Compare commits

...

25 Commits

Author SHA1 Message Date
f4847ced63 Dokumentasi perubahan Sprint 4 Sementara 2025-12-19 02:10:21 +07:00
7f5e2fd28d Serializer untuk AnnotatedString agar Markdown tampilannya tetap pada Notes setelah di save 2025-12-19 01:58:17 +07:00
7be456d7cb Menambah Underlined, Heading, dan Bullet List selain Bold dan Italic 2025-12-19 00:43:54 +07:00
c0bbd3e54f Fitur Markdown (RichEditorState) pada catatan dan penyeesuaian Selection Handle 2025-12-19 00:29:13 +07:00
b8d9a71664 Edit Title dan Filter Chat History 2025-12-18 21:26:50 +07:00
5503d53881 Edit Title dan Filter Chat History 2025-12-18 21:26:38 +07:00
74e1a720cd Penyesuaian UI/UX Chat History 2025-12-18 20:42:15 +07:00
75033bc671 Dokumentasi Perubahan 2025-12-18 19:54:49 +07:00
05b7a2a71b Menambahkan Fitur Edit dan Hapus pada NoteCard.kt 2025-12-18 16:59:20 +07:00
7a67943800 Menyesuaikan UI/UX Design pada StarredNotesScreen.kt dan TopBar pada seluruh komponen Halaman 2025-12-18 16:58:33 +07:00
b264f87b14 Memperbaiki Bug tidak tersimpan 2025-12-18 15:09:39 +07:00
79f7e33a5a Mencoba Respon AI dengan MarkdownText.kt 2025-12-18 14:20:51 +07:00
41a1e8268a Mencoba MarkdownText.kt 2025-12-18 14:09:46 +07:00
520da1f66a Coba 2025-12-18 11:38:33 +07:00
da93991ef3 Menambahkan Dark/Light theme toggle 2025-12-18 11:28:56 +07:00
1b5e79166c Menambahkan Dark/Light theme toggle 2025-12-18 11:25:34 +07:00
0876c82abc Coba 2025-12-18 11:09:53 +07:00
2121682dd4 History Chat AI berdasarkan Catatan yang ada didalam kategori dalam bentuk Drawer Menu di AI Helper 2025-12-18 10:13:07 +07:00
2037d32766 check 2025-12-17 22:58:57 +07:00
7a15d1d9e1 Menyesuaikan Desain NoteDialog.kt dan CategoryDialog.kt 2025-12-17 22:16:23 +07:00
8c3994e317 Menyesuaikan Desain NoteDialog.kt dan CategoryDialog.kt 2025-12-17 21:47:56 +07:00
900bf8b7ff Mengganti Preview deskripsi Note Card dan Note Dialog 2025-12-17 21:35:13 +07:00
de163a09f8 Hide API KEY 2025-12-13 23:17:21 +07:00
80774b58ea Mengubah Warna dan menyesuaikan UI/UX Halaman AI Helper 2025-12-13 23:15:24 +07:00
0f0ac6b8f3 Mengubah Warna dan menyesuaikan UI/UX Beranda 2025-12-13 23:14:52 +07:00
37 changed files with 5392 additions and 1707 deletions

View File

@ -6,14 +6,6 @@
<entry key="Firebase Crashlytics">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="PLACEHOLDER" />
<option name="mobileSdkAppId" value="" />
<option name="projectId" value="" />
<option name="projectNumber" value="" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="THIRTY_DAYS" />
<option name="visibilityType" value="ALL" />

View File

@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-12-13T07:41:36.634314200Z">
<DropdownSelection timestamp="2025-12-18T02:27:11.898714800Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=RR8T103A6JZ" />
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\Fazri Abdurrahman\.android\avd\Medium_Tablet.avd" />
</handle>
</Target>
</DropdownSelection>

View File

@ -0,0 +1,76 @@
kotlin version: 2.0.21
error message: org.jetbrains.kotlin.util.FileAnalysisException: While analysing C:/Users/Fazri Abdurrahman/AndroidStudioProjects/NotesAI/app/src/main/java/com/example/notesai/MainActivity.kt:40:5: java.lang.IllegalArgumentException: source must not be null
at org.jetbrains.kotlin.util.AnalysisExceptionsKt.wrapIntoFileAnalysisExceptionIfNeeded(AnalysisExceptions.kt:57)
at org.jetbrains.kotlin.fir.FirCliExceptionHandler.handleExceptionOnFileAnalysis(Utils.kt:249)
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:46)
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.resolveAndCheckFir(firUtils.kt:77)
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.buildResolveAndCheckFirViaLightTree(firUtils.kt:88)
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModuleToAnalyzedFir(jvmCompilerPipeline.kt:319)
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModulesUsingFrontendIrAndLightTree(jvmCompilerPipeline.kt:118)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:148)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:464)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:73)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:506)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:301)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:129)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92)
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
at java.base/java.lang.Thread.run(Unknown Source)
Caused by: java.lang.IllegalArgumentException: source must not be null
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.requireNotNull(KtDiagnosticReportHelpers.kt:68)
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn(KtDiagnosticReportHelpers.kt:39)
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn$default(KtDiagnosticReportHelpers.kt:31)
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkSourceElement(FirIncompatibleClassExpressionChecker.kt:50)
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkType$checkers(FirIncompatibleClassExpressionChecker.kt:42)
at org.jetbrains.kotlin.fir.analysis.checkers.type.FirIncompatibleClassTypeChecker.check(FirIncompatibleClassTypeChecker.kt:17)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.check(TypeCheckersDiagnosticComponent.kt:81)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:53)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:19)
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
at org.jetbrains.kotlin.fir.analysis.collectors.CheckerRunningDiagnosticCollectorVisitor.checkElement(CheckerRunningDiagnosticCollectorVisitor.kt:24)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:248)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
at org.jetbrains.kotlin.fir.declarations.impl.FirSimpleFunctionImpl.acceptChildren(FirSimpleFunctionImpl.kt:63)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:118)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirSimpleFunction.accept(FirSimpleFunction.kt:51)
at org.jetbrains.kotlin.fir.declarations.impl.FirRegularClassImpl.acceptChildren(FirRegularClassImpl.kt:63)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitClassAndChildren(AbstractDiagnosticCollectorVisitor.kt:87)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:92)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirRegularClass.accept(FirRegularClass.kt:48)
at org.jetbrains.kotlin.fir.declarations.impl.FirFileImpl.acceptChildren(FirFileImpl.kt:57)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:1151)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirFile.accept(FirFile.kt:42)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollector.collectDiagnostics(AbstractDiagnosticCollector.kt:36)
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:34)
... 33 more

View File

@ -0,0 +1,76 @@
kotlin version: 2.0.21
error message: org.jetbrains.kotlin.util.FileAnalysisException: While analysing C:/Users/Fazri Abdurrahman/AndroidStudioProjects/NotesAI/app/src/main/java/com/example/notesai/MainActivity.kt:40:5: java.lang.IllegalArgumentException: source must not be null
at org.jetbrains.kotlin.util.AnalysisExceptionsKt.wrapIntoFileAnalysisExceptionIfNeeded(AnalysisExceptions.kt:57)
at org.jetbrains.kotlin.fir.FirCliExceptionHandler.handleExceptionOnFileAnalysis(Utils.kt:249)
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:46)
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.resolveAndCheckFir(firUtils.kt:77)
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.buildResolveAndCheckFirViaLightTree(firUtils.kt:88)
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModuleToAnalyzedFir(jvmCompilerPipeline.kt:319)
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModulesUsingFrontendIrAndLightTree(jvmCompilerPipeline.kt:118)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:148)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:464)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:73)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:506)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:301)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:129)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92)
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
at java.base/java.lang.Thread.run(Unknown Source)
Caused by: java.lang.IllegalArgumentException: source must not be null
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.requireNotNull(KtDiagnosticReportHelpers.kt:68)
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn(KtDiagnosticReportHelpers.kt:39)
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn$default(KtDiagnosticReportHelpers.kt:31)
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkSourceElement(FirIncompatibleClassExpressionChecker.kt:50)
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkType$checkers(FirIncompatibleClassExpressionChecker.kt:42)
at org.jetbrains.kotlin.fir.analysis.checkers.type.FirIncompatibleClassTypeChecker.check(FirIncompatibleClassTypeChecker.kt:17)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.check(TypeCheckersDiagnosticComponent.kt:81)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:53)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:19)
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
at org.jetbrains.kotlin.fir.analysis.collectors.CheckerRunningDiagnosticCollectorVisitor.checkElement(CheckerRunningDiagnosticCollectorVisitor.kt:24)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:248)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
at org.jetbrains.kotlin.fir.declarations.impl.FirSimpleFunctionImpl.acceptChildren(FirSimpleFunctionImpl.kt:63)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:118)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirSimpleFunction.accept(FirSimpleFunction.kt:51)
at org.jetbrains.kotlin.fir.declarations.impl.FirRegularClassImpl.acceptChildren(FirRegularClassImpl.kt:63)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitClassAndChildren(AbstractDiagnosticCollectorVisitor.kt:87)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:92)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirRegularClass.accept(FirRegularClass.kt:48)
at org.jetbrains.kotlin.fir.declarations.impl.FirFileImpl.acceptChildren(FirFileImpl.kt:57)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:1151)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirFile.accept(FirFile.kt:42)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollector.collectDiagnostics(AbstractDiagnosticCollector.kt:36)
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:34)
... 33 more

View File

@ -0,0 +1,76 @@
kotlin version: 2.0.21
error message: org.jetbrains.kotlin.util.FileAnalysisException: While analysing C:/Users/Fazri Abdurrahman/AndroidStudioProjects/NotesAI/app/src/main/java/com/example/notesai/MainActivity.kt:40:5: java.lang.IllegalArgumentException: source must not be null
at org.jetbrains.kotlin.util.AnalysisExceptionsKt.wrapIntoFileAnalysisExceptionIfNeeded(AnalysisExceptions.kt:57)
at org.jetbrains.kotlin.fir.FirCliExceptionHandler.handleExceptionOnFileAnalysis(Utils.kt:249)
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:46)
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.resolveAndCheckFir(firUtils.kt:77)
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.buildResolveAndCheckFirViaLightTree(firUtils.kt:88)
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModuleToAnalyzedFir(jvmCompilerPipeline.kt:319)
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModulesUsingFrontendIrAndLightTree(jvmCompilerPipeline.kt:118)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:148)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:464)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:73)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:506)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:301)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:129)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92)
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
at java.base/java.lang.Thread.run(Unknown Source)
Caused by: java.lang.IllegalArgumentException: source must not be null
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.requireNotNull(KtDiagnosticReportHelpers.kt:68)
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn(KtDiagnosticReportHelpers.kt:39)
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn$default(KtDiagnosticReportHelpers.kt:31)
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkSourceElement(FirIncompatibleClassExpressionChecker.kt:50)
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkType$checkers(FirIncompatibleClassExpressionChecker.kt:42)
at org.jetbrains.kotlin.fir.analysis.checkers.type.FirIncompatibleClassTypeChecker.check(FirIncompatibleClassTypeChecker.kt:17)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.check(TypeCheckersDiagnosticComponent.kt:81)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:53)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:19)
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
at org.jetbrains.kotlin.fir.analysis.collectors.CheckerRunningDiagnosticCollectorVisitor.checkElement(CheckerRunningDiagnosticCollectorVisitor.kt:24)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:248)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
at org.jetbrains.kotlin.fir.declarations.impl.FirSimpleFunctionImpl.acceptChildren(FirSimpleFunctionImpl.kt:63)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:118)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirSimpleFunction.accept(FirSimpleFunction.kt:51)
at org.jetbrains.kotlin.fir.declarations.impl.FirRegularClassImpl.acceptChildren(FirRegularClassImpl.kt:63)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitClassAndChildren(AbstractDiagnosticCollectorVisitor.kt:87)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:92)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirRegularClass.accept(FirRegularClass.kt:48)
at org.jetbrains.kotlin.fir.declarations.impl.FirFileImpl.acceptChildren(FirFileImpl.kt:57)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:1151)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirFile.accept(FirFile.kt:42)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollector.collectDiagnostics(AbstractDiagnosticCollector.kt:36)
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:34)
... 33 more

View File

@ -0,0 +1,76 @@
kotlin version: 2.0.21
error message: org.jetbrains.kotlin.util.FileAnalysisException: While analysing C:/Users/Fazri Abdurrahman/AndroidStudioProjects/NotesAI/app/src/main/java/com/example/notesai/MainActivity.kt:40:5: java.lang.IllegalArgumentException: source must not be null
at org.jetbrains.kotlin.util.AnalysisExceptionsKt.wrapIntoFileAnalysisExceptionIfNeeded(AnalysisExceptions.kt:57)
at org.jetbrains.kotlin.fir.FirCliExceptionHandler.handleExceptionOnFileAnalysis(Utils.kt:249)
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:46)
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.resolveAndCheckFir(firUtils.kt:77)
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.buildResolveAndCheckFirViaLightTree(firUtils.kt:88)
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModuleToAnalyzedFir(jvmCompilerPipeline.kt:319)
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModulesUsingFrontendIrAndLightTree(jvmCompilerPipeline.kt:118)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:148)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:464)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:73)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:506)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:301)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:129)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92)
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
at java.base/java.lang.Thread.run(Unknown Source)
Caused by: java.lang.IllegalArgumentException: source must not be null
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.requireNotNull(KtDiagnosticReportHelpers.kt:68)
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn(KtDiagnosticReportHelpers.kt:39)
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn$default(KtDiagnosticReportHelpers.kt:31)
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkSourceElement(FirIncompatibleClassExpressionChecker.kt:50)
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkType$checkers(FirIncompatibleClassExpressionChecker.kt:42)
at org.jetbrains.kotlin.fir.analysis.checkers.type.FirIncompatibleClassTypeChecker.check(FirIncompatibleClassTypeChecker.kt:17)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.check(TypeCheckersDiagnosticComponent.kt:81)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:53)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:19)
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
at org.jetbrains.kotlin.fir.analysis.collectors.CheckerRunningDiagnosticCollectorVisitor.checkElement(CheckerRunningDiagnosticCollectorVisitor.kt:24)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:248)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
at org.jetbrains.kotlin.fir.declarations.impl.FirSimpleFunctionImpl.acceptChildren(FirSimpleFunctionImpl.kt:63)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:118)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirSimpleFunction.accept(FirSimpleFunction.kt:51)
at org.jetbrains.kotlin.fir.declarations.impl.FirRegularClassImpl.acceptChildren(FirRegularClassImpl.kt:63)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitClassAndChildren(AbstractDiagnosticCollectorVisitor.kt:87)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:92)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirRegularClass.accept(FirRegularClass.kt:48)
at org.jetbrains.kotlin.fir.declarations.impl.FirFileImpl.acceptChildren(FirFileImpl.kt:57)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:1151)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirFile.accept(FirFile.kt:42)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollector.collectDiagnostics(AbstractDiagnosticCollector.kt:36)
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:34)
... 33 more

311
Readme.md
View File

@ -7,118 +7,191 @@
* Fazri Abdurrahman
# **Version 1.0.0 Initial Release**
## **Sprint 1: Struktur Dasar Aplikasi**
* **Implementasi struktur navigasi dasar aplikasi** - Setup navigation system
* **Pembuatan menu drawer untuk navigasi screen** - Drawer dengan list menu (Beranda, Berbintang, Arsip, Sampah)
* **Pembuatan screen Arsip dan Sampah** - Layout dan UI untuk Archive & Trash
* **Implementasi routing antar halaman** - Navigation flow (Beranda, Arsip, Sampah)
* **Penambahan Bottom Navigation** - Home & AI Helper tabs
* **Penambahan Top App Bar** - Menu hamburger dan search icon
* **Setup Material3 dengan Dark Theme** - Color scheme dark mode
* **Implementasi color scheme & gradient header** - Primary/Secondary colors dengan gradient
* **Pembuatan data class** - Category, Note, ChatMessage models
* **Implementasi sistem kategori pada halaman beranda** - Category management system
* **Pembuatan dialog tambah kategori** - Form dengan nama + gradient picker
* **Penambahan validasi input form kategori** - Prevent empty category name
* **Tampilan kategori Staggered Grid** - 2 kolom responsive layout
* **Category Card design** - Ikon folder, nama, jumlah catatan, gradient background
* **Empty state kategori** - Pesan "Buat kategori pertama Anda"
* **Implementasi LazyVerticalStaggeredGrid** - Compose grid layout
* **Gradient preset 8 warna** - Pre-defined color combinations
* **Manajemen state kategori** - Remember state untuk categories list
* **Implementasi pembuatan dan pengeditan catatan** - Note CRUD operations
* **Dialog catatan** - Form dengan judul, isi, simpan, batal, hapus
* **Note Card design** - Judul, preview, timestamp, pin icon
* **Fitur pin untuk catatan penting** - Toggle pin/unpin functionality
* **Full-screen editable note view** - Editor dengan auto-save
* **Fitur arsip, hapus, dan pin** - Actions di full-screen mode
* **Fitur search catatan** - Filter berdasarkan judul + isi
* **Sorting catatan** - Berdasarkan pin & timestamp (descending)
* **Implementasi custom TextField** - Styled text input fields
* **Date formatter utility** - Format timestamp ke readable format
* **Edit in-place full-screen note** - Direct editing tanpa dialog
* **Pembuatan screen AI Helper** - Layout untuk chat dengan AI
* **Header AI dengan ikon bintang** - Badge "Powered by Gemini AI"
* **Category selector** - Dropdown untuk filter konteks AI
* **Statistik ringkas** - Total catatan, pinned, jumlah kategori
* **Welcome state AI** - Icon + greeting message
* **Suggestion chips** - Quick question templates
* **Input area multiline** - TextField dengan tombol kirim gradient
* **Auto-scroll chat** - Scroll ke bottom dengan LaunchedEffect
* **State management chat messages** - List of ChatMessage
* **Integrasi Gemini 2.5 Flash API** - Setup API connection
* **Prompt engineering** - Context dari data catatan user
* **Chat bubble user & AI** - Different styling untuk user/AI
* **Copy-to-clipboard** - Copy jawaban AI ke clipboard
* **Loading indicator** - Circular progress saat AI processing
* **Error message informatif** - Display error dengan jelas
* **Timestamp pada setiap pesan** - Format HH:mm
* **Filter catatan berdasarkan kategori** - Context untuk AI berdasarkan selected category
* **Pembatasan 10 catatan terbaru** - Optimasi token usage
* **Implementasi Google AI SDK** - Configuration (temperature, topK, topP, maxOutputTokens)
* **Context builder** - String builder untuk kategori & catatan
* **API calls dengan coroutine** - Async operations menggunakan launch
* **Refinement warna & gradient** - Polish color palette
* **Smooth animations** - Drawer slide, FAB scale, transitions
* **Peningkatan shadow dan elevation** - Card depth visual
* **Konsistensi spacing dan padding** - 8dp, 12dp, 16dp, 20dp standards
* **Peningkatan desain Card** - Rounded corners (12dp, 16dp, 20dp)
* **Optimasi readability teks** - Font sizes dan line heights
* **Visual feedback** - Click ripples, copy confirmation, loading states
* **Empty state improvements** - Icon + pesan yang lebih jelas
* **Perbaikan error messages** - Dengan ikon dan warna merah
* **State hoisting** - Optimasi recomposition
* **Perbaikan smooth scroll** - Keyboard handling di chat
* **Implementasi DataStore** - Preferences DataStore untuk persistence
* **Auto-save dengan debounce** - 500ms delay sebelum save
* **Persistence data penuh** - Data tetap ada setelah app ditutup
* **Error handling DataStore** - Try-catch untuk I/O operations
* **Flow-based data loading** - Collect dari Flow dengan LaunchedEffect
* **Implementasi DataStoreManager** - Class dengan categoriesFlow & notesFlow
* **Try-catch semua operasi I/O** - Comprehensive error handling
* **Optimasi lifecycle data** - Proper state management
* **Halaman Catatan Berbintang** - StarredNotesScreen dengan filter isPinned
* **Ikon bintang untuk pesan** - Star icon pada note cards
* **Konfirmasi Arsip** - AlertDialog "Arsipkan Catatan?"
* **Konfirmasi Hapus** - AlertDialog "Hapus Catatan?"
### **Struktur & Navigation**
* **Setup navigation system** - Implementasi routing antar halaman (Beranda, Arsip, Sampah)
* **Menu Drawer** - Navigation drawer dengan list menu (Beranda, Berbintang, Arsip, Sampah)
* **Bottom Navigation** - Home & AI Helper tabs dengan icon navigation
* **Top App Bar** - Menu hamburger dan search icon dengan Material3 styling
* **Screen Architecture** - Pembuatan screen Arsip, Sampah, Berbintang, AI Helper
## **Sprint 2: Project Restructuring, Fitur Search, Delete Kategori**
### **Theme & Styling**
* **Material3 Dark Theme** - Setup color scheme dengan dark mode default
* **Color System** - Primary/Secondary colors dengan gradient presets (8 kombinasi warna)
* **Consistent Design** - Rounded corners (12dp, 16dp, 20dp), shadow, elevation
* **Smooth Animations** - Drawer slide, FAB scale, card transitions dengan spring animations
* **Typography System** - Optimasi font sizes dan line heights untuk readability
* **Fitur search beranda** - Cari kategori berdasarkan nama
* **Search filtering real-time** - Kategori otomatis filter saat mengetik
* **Delete kategori dengan UI** - Tombol X di top-right corner setiap kategori
* **Confirmation dialog untuk delete** - Prevent accidental deletion dengan warning message
* **Search di kategori** - Cari catatan berdasarkan judul & isi (case-insensitive)
* **Search empty state** - Tampilkan pesan "Tidak ada hasil" saat search kosong
* **Gradle optimization** - Cleanup dependencies yang tidak diperlukan
* **Hilangkan Fitur Tahan Untuk Hapus**
* **Project restructuring** - Migrasi dari 3 file monolith ke Clean Architecture
* **Data layer separation** - Pisahkan Category, Note, ChatMessage ke `data/model/`
* **DataStore refactoring** - Pindahkan DataStoreManager ke `data/local/` dengan PreferencesKeys
* **Component extraction** - Pisahkan MainScreen, CategoryCard, NoteCard ke folder terpisah
* **Utilities creation** - Buat Constants.kt, DateFormatter.kt, Extensions.kt
* **SerializableModels dengan extension functions** - Konversi model lebih clean
* **Import optimization** - Update semua import ke package structure baru
* **Menu dropdown kategori** - Icon titik tiga (⋮) untuk edit & delete
* **Edit kategori feature** - Dialog untuk ubah nama dan gradient kategori
* **Pre-filled edit form** - Auto-select gradient yang sedang dipakai
* **Soft delete implementation** - Pindahkan ke trash (bukan hapus permanen)
* **Trash system dengan kategori** - Tampilkan kategori & note yang dihapus
* **TrashCategoryCard component** - Card khusus untuk kategori di trash
* **Restore kategori feature** - Pulihkan kategori beserta semua note
* **Delete permanen kategori** - Hapus kategori dan note secara permanent
* **Counter display di trash** - Jumlah kategori dan note terhapus
* **Category model extension** - Tambah field `isDeleted` untuk soft delete
* **Global category filter** - Filter `!isDeleted` di semua screen
* **Gradient preview di trash** - Kategori tetap tampilkan gradient (opacity)
* **Dialog konfirmasi delete permanent** - Warning untuk tindakan irreversible
* **Runtime error debugging** - Fix NotImplementedError & FATAL EXCEPTION
* **Google Play Services error handling** - Handle GMS error untuk Gemini AI
* **HorizontalDivider migration** - Ganti deprecated Divider component
* **Migration guide documentation** - Panduan lengkap step-by-step migrasi
* **Debugging documentation** - Guide untuk troubleshoot common issues
### **Category Management**
* **Category Model** - Data class dengan gradient colors dan timestamp
* **Category Dialog** - Form tambah/edit kategori dengan nama + gradient picker
* **Category Card** - Design dengan icon folder, nama, jumlah catatan, gradient background
* **Staggered Grid Layout** - 2 kolom responsive dengan LazyVerticalStaggeredGrid
* **Category Actions** - Menu dropdown (⋮) untuk edit dan delete kategori
* **Empty State** - Pesan "Buat kategori pertama Anda" dengan icon
### **Note Management**
* **Note Model** - Data class dengan title, content, timestamp, isPinned, isArchived
* **Note Dialog** - Form dengan judul, deskripsi, simpan, batal, hapus
* **Note Card** - Preview dengan judul, deskripsi, timestamp, pin icon
* **Full-screen Editor** - Editable note view dengan auto-save dan actions
* **Pin Feature** - Toggle pin/unpin untuk catatan penting dengan sorting priority
* **Archive & Delete** - Actions untuk arsip dan soft delete notes
* **Search Functionality** - Real-time search berdasarkan judul dan isi (case-insensitive)
* **Smart Sorting** - Berdasarkan pin status dan timestamp (descending)
### **AI Assistant**
* **Gemini AI Integration** - Setup Google Generative AI SDK dengan gemini-2.5-flash
* **AI Helper Screen** - Layout chat interface dengan header dan statistics
* **Category Context Selector** - Dropdown untuk filter konteks AI berdasarkan kategori
* **Statistics Display** - Total catatan, pinned notes, jumlah kategori
* **Chat Interface** - User & AI bubble dengan different styling dan timestamp
* **Prompt Engineering** - Context builder dengan data catatan user (max 10 terbaru)
* **Suggestion Chips** - Quick question templates untuk user guidance
* **Copy to Clipboard** - Copy jawaban AI dengan confirmation feedback
* **Loading & Error States** - Circular progress indicator dan error messages
* **API Configuration** - Temperature 0.8, topK 40, topP 0.95, maxOutputTokens 4096
* **Auto-scroll Chat** - Scroll ke bottom otomatis dengan LaunchedEffect
### **Data Persistence**
* **DataStore Implementation** - Preferences DataStore untuk local storage
* **DataStoreManager Class** - Centralized dengan categoriesFlow & notesFlow
* **Auto-save dengan Debounce** - 500ms delay untuk optimize I/O operations
* **Flow-based Loading** - Reactive data loading dengan Flow collection
* **Error Handling** - Try-catch untuk semua I/O operations
* **Serializable Models** - JSON serialization dengan extension functions
### **UI/UX Enhancements**
* **Visual Feedback** - Click ripples, copy confirmation, loading states
* **Empty States** - Icon + descriptive messages untuk setiap screen
* **Confirmation Dialogs** - AlertDialog untuk arsip, hapus, delete actions
* **Search Empty State** - "Tidak ada hasil" message saat search kosong
* **Custom TextField** - Styled text input dengan consistent design
* **Date Formatter Utility** - Format timestamp ke readable Indonesian format
---
## **Sprint 2: Project Restructuring & Advanced Features**
### **Project Architecture**
* **Clean Architecture Migration** - Dari 3 file monolith ke modular structure
* **Data Layer Separation** - Models ke `data/model/` (Category, Note, ChatMessage)
* **Local Storage Layer** - DataStoreManager ke `data/local/` dengan PreferencesKeys
* **Component Extraction** - Screen components ke folder terpisah (main, starred, archive, trash)
* **Utilities Creation** - Constants.kt, DateFormatter.kt, Extensions.kt untuk reusability
* **Import Optimization** - Update semua import sesuai package structure baru
### **Search & Filter**
* **Beranda Search** - Real-time search kategori berdasarkan nama
* **Category Notes Search** - Search catatan di dalam kategori (judul & isi)
* **Search Filtering** - Live filtering saat user mengetik
* **Search Empty State** - Descriptive message dengan alternative suggestions
### **Category Features**
* **Edit Category** - Dialog untuk ubah nama dan gradient dengan pre-filled form
* **Delete Category** - Menu dropdown dengan confirmation dialog
* **Category Actions Menu** - Icon ⋮ untuk access edit & delete options
* **Gradient Preview** - Visual preview saat edit kategori
### **Trash System**
* **Soft Delete Implementation** - isDeleted flag untuk Category dan Note
* **Trash Screen** - Tampilkan kategori & notes yang terhapus
* **TrashCategoryCard Component** - Card khusus dengan restore & delete permanent actions
* **Restore Feature** - Pulihkan kategori beserta semua notes di dalamnya
* **Permanent Delete** - Hapus kategori dan notes secara irreversible dengan confirmation
* **Counter Display** - Jumlah items terhapus di trash
* **Global Filter** - Filter `!isDeleted` di semua screen untuk hide deleted items
### **Bug Fixes & Optimization**
* **Runtime Error Debugging** - Fix NotImplementedError & FATAL EXCEPTION issues
* **Google Play Services Handling** - Error handling untuk GMS dependencies
* **Component Migration** - Update deprecated Divider ke HorizontalDivider
* **Gradle Optimization** - Cleanup unnecessary dependencies
* **State Management** - Proper state hoisting dan recomposition optimization
### **Documentation**
* **Migration Guide** - Step-by-step panduan untuk project restructuring
* **Debugging Guide** - Troubleshooting common issues dan error handling
---
# **Version 1.1.0 AI Helper Screen Enhancement & UI Refinement**
## **Sprint 3: AI Helper Screen Features & Modern UI Redesign**
### **AI Assistant Enhancements**
* **History Chat AI dengan Drawer Menu** - Riwayat percakapan AI tersimpan permanen, dikelompokkan per kategori
* **Chat History Management** - Load previous chat, delete history, start new chat dengan auto-save
* **Markdown Parser untuk AI Response** - Support bold, italic, code blocks, headers, lists, quotes, links
* **Improved Error Handling** - User-friendly error messages untuk quota, network, API issues
* **Gemini Model Update** - Switch ke gemini-1.5-flash untuk stabilitas optimal
### **Theme System**
* **Dark/Light Theme Toggle** - Dual theme dengan persistent storage di drawer menu
* **Reactive Color System** - Dynamic color switching untuk semua components
* **Complete Color Palette** - DarkColors dan LightColors objects untuk consistency
### **UI/UX Modernization**
* **Floating Design System** - TopBar dan BottomBar dengan floating style, rounded corners, shadow
* **Consistent Component Style** - Unified design language dengan CircleShape buttons
* **Optimized Layouts** - Better spacing dan vertical action stack untuk maximize content space
### **Data & Navigation**
* **Note Edit & Delete from Card** - Menu dropdown pada NoteCard untuk quick actions
* **Race Condition Fix** - Guard flags dan lifecycle-aware auto-save untuk data persistence
* **Simplified Navigation** - Unified drawer menu, remove redundant back buttons
* **Extended DataStore** - Support chat history, theme preference, improved error handling
---
## **Sprint 4: Rich Text Editor Core Features & AI Chat History UI/UX Improvements**
### **Rich Text Editing**
* **Hybrid Rich Text Editor (WYSIWYG)** Edit teks dengan format langsung tanpa syntax markdown terlihat
* **Bold, Italic, Underline** Formatting bersifat toggle dan tetap aktif sampai dimatikan
* **Heading & Bullet List** Support heading (H1H3) dan bullet list tanpa konflik antar format
* **Undo / Redo** Riwayat perubahan editor terintegrasi
### **Floating Toolbar**
* **Draggable Mini Toolbar** Toolbar dapat dipindahkan bebas oleh user
* **Active State Indicator** Icon toolbar menandakan format aktif (Bold, Italic, dll)
* **Minimal UI** Toolbar kecil agar tidak mengganggu area pengetikan
* **Keyboard-Aware Positioning** Posisi toolbar menyesuaikan saat keyboard muncul
### **Cursor & Editing Stability**
* **Stable Cursor & Selection** Insertion point dan selection handle akurat saat mengetik
* **IME & Keyboard Safe** Editor tetap stabil saat keyboard resize / rotate
* **Auto Bring-Into-View** Cursor selalu terlihat saat mengetik di area bawah layar
### **Data Persistence**
* **Format Tersimpan Permanen** Rich text tidak hilang setelah save atau reopen
* **Auto Save Lifecycle-Aware** Catatan otomatis tersimpan saat app background / keluar
* **Markdown Compatibility** Support import & export markdown secara aman
### **Chat History Enhancements**
* **Compact Modern Design** - Item lebih kecil dengan horizontal layout dan 30 karakter limit
* **Search & Filter System** - Real-time search dengan category dropdown filtering
* **Date Grouping** - Auto-group: "Hari Ini", "Kemarin", "Minggu Ini", "Lebih Lama"
* **Edit Title with Markdown** - Custom title support: **bold**, *italic*, `code`, ~~strike~~
* **Context Menu** - Three-dot menu (⋮) untuk Edit dan Delete actions
* **Live Preview** - Real-time markdown preview saat edit title
### **Technical Updates**
* **ChatHistory Model** - Added `customTitle: String?` field
* **DataStore Integration** - New `updateChatHistoryTitle()` function
* **Smart Truncation** - Auto-truncate preview ke 30 char dengan `toSafeChatPreview()`
* **Markdown Parser** - Inline markdown rendering untuk titles dengan proper styling
* **Character Counter** - Visual feedback dengan color indicator (Gray → Primary → Red)
### **User Experience**
* **Better Empty States** - Informative UI untuk empty search dan no history
* **Smooth Animations** - Slide transitions untuk dialogs
* **Input Validation** - Max 30 char dengan real-time blocking
* **Focus Management** - Seamless editing experience dengan auto-focus
> Rich Text Editor butuh dikembangkan lagi lebih advance
---
## **Fitur Utama Aplikasi**
@ -141,17 +214,19 @@
---
## **Planned Features (v1.1.0)**
## **Features for Sprint 3 v1.1.0**
* History Chat AI berdasarkan Catatan yang ada didalam kategori dalam bentuk Drawer Menu di AI Helper (ok)
* Mengganti Preview Deskripsi NoteCard dan NoteDialog (ok)
* Dark/Light theme toggle (ok)
* Markdown Parser (ok)
* Backup & restore data
* Tags untuk catatan
* Rich text editor
* Dark theme toggle
* Multi-language support
* AI Agent Catatan
* Fungsi AI (Summary berdasarkan catatan, Upload File)
* Markdown Parser
* Opsi memilih kategori dan catatan
* Penyesuaian User Interface dan User Experience
---
## **Features for Sprint 4 v1.1.0**
* Penyesuaian UI/UX History Chat AI (ok)
* Rich text editor (ok - Pengembangan Lanjutan)
* AI Agent Catatan
* Fungsi AI (Upload File)
* Fitur Sematkan Category, otomatis paling atas

View File

@ -7,7 +7,7 @@ plugins {
android {
namespace = "com.example.notesai"
compileSdk = 34
compileSdk = 35
defaultConfig {
applicationId = "com.example.notesai"
@ -64,6 +64,12 @@ dependencies {
implementation("androidx.datastore:datastore-preferences:1.0.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
implementation("com.google.ai.client.generativeai:generativeai:0.9.0")
implementation(libs.androidx.ui.text)
implementation(libs.androidx.material3)
implementation(libs.androidx.animation.core)
implementation(libs.androidx.glance)
implementation(libs.androidx.animation)
implementation(libs.androidx.ui.graphics)
// Untuk integrasi Gemini AI (optional - uncomment jika sudah ada API key)
// implementation("com.google.ai.client.generativeai:generativeai:0.1.2")

View File

@ -16,6 +16,7 @@
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:windowSoftInputMode="adjustResize"
android:exported="true"
android:theme="@style/Theme.NotesAI">
<intent-filter>

View File

@ -1,24 +1,22 @@
package com.example.notesai
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import java.util.UUID
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import com.example.notesai.data.local.DataStoreManager
import com.example.notesai.presentation.components.DrawerMenu
@ -34,8 +32,9 @@ import com.example.notesai.presentation.screens.starred.StarredNotesScreen
import com.example.notesai.presentation.screens.trash.TrashScreen
import com.example.notesai.data.model.Note
import com.example.notesai.data.model.Category
import com.example.notesai.util.updateWhere
import kotlinx.coroutines.delay
import com.example.notesai.util.AppColors
import com.example.notesai.util.Constants
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@ -43,14 +42,41 @@ class MainActivity : ComponentActivity() {
setContent {
MaterialTheme(
colorScheme = darkColorScheme(
primary = Color(0xFF6366F1),
secondary = Color(0xFFA855F7),
background = Color(0xFF0F172A),
surface = Color(0xFF1E293B),
primary = AppColors.Primary,
onPrimary = Color.White,
primaryContainer = AppColors.PrimaryContainer,
onPrimaryContainer = Color.White,
secondary = AppColors.Secondary,
onSecondary = Color.White,
onBackground = Color(0xFFE2E8F0),
onSurface = Color(0xFFE2E8F0)
secondaryContainer = AppColors.SecondaryVariant,
onSecondaryContainer = Color.White,
background = AppColors.Background,
onBackground = AppColors.OnBackground,
surface = AppColors.Surface,
onSurface = AppColors.OnSurface,
surfaceVariant = AppColors.SurfaceVariant,
onSurfaceVariant = AppColors.OnSurfaceVariant,
error = AppColors.Error,
onError = Color.White,
outline = AppColors.Border,
outlineVariant = AppColors.Divider
),
typography = Typography(
displayLarge = MaterialTheme.typography.displayLarge.copy(
fontWeight = FontWeight.Bold
),
headlineLarge = MaterialTheme.typography.headlineLarge.copy(
fontWeight = FontWeight.Bold
),
titleLarge = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.SemiBold
),
bodyLarge = MaterialTheme.typography.bodyLarge.copy(
lineHeight = 24.sp
),
bodyMedium = MaterialTheme.typography.bodyMedium.copy(
lineHeight = 20.sp
)
)
) {
Surface(
@ -70,6 +96,7 @@ fun NotesApp() {
val context = LocalContext.current
val dataStoreManager = remember { DataStoreManager(context) }
val scope = rememberCoroutineScope()
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
var categories by remember { mutableStateOf(listOf<Category>()) }
var notes by remember { mutableStateOf(listOf<Note>()) }
@ -83,72 +110,98 @@ fun NotesApp() {
var showSearch by remember { mutableStateOf(false) }
var showFullScreenNote by remember { mutableStateOf(false) }
var fullScreenNote by remember { mutableStateOf<Note?>(null) }
var isDarkTheme by remember { mutableStateOf(true) }
// Load data dari DataStore
// Guard flags to prevent race conditions
var isDataLoaded by remember { mutableStateOf(false) }
// Load theme preference
LaunchedEffect(Unit) {
try {
dataStoreManager.categoriesFlow.collect { loadedCategories ->
dataStoreManager.themeFlow.collect { theme ->
isDarkTheme = theme == "dark"
AppColors.setTheme(isDarkTheme)
}
}
// Load categories ONCE
LaunchedEffect(Unit) {
dataStoreManager.categoriesFlow.collect { loadedCategories ->
if (!isDataLoaded) {
android.util.Log.d("NotesApp", "Loading ${loadedCategories.size} categories")
categories = loadedCategories
}
} catch (e: Exception) {
e.printStackTrace()
}
}
// Load notes ONCE
LaunchedEffect(Unit) {
try {
dataStoreManager.notesFlow.collect { loadedNotes ->
dataStoreManager.notesFlow.collect { loadedNotes ->
if (!isDataLoaded) {
android.util.Log.d("NotesApp", "Loading ${loadedNotes.size} notes")
notes = loadedNotes
isDataLoaded = true // Mark as loaded
}
} catch (e: Exception) {
e.printStackTrace()
}
}
// Simpan categories dengan debounce
LaunchedEffect(categories.size) {
if (categories.isNotEmpty()) {
delay(500)
try {
// Save categories when changed
LaunchedEffect(categories) {
if (isDataLoaded && categories.isNotEmpty()) {
android.util.Log.d("NotesApp", "Saving ${categories.size} categories")
scope.launch {
dataStoreManager.saveCategories(categories)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
// Simpan notes dengan debounce
LaunchedEffect(notes.size) {
if (notes.isNotEmpty()) {
delay(500)
try {
// Save notes when changed
LaunchedEffect(notes) {
if (isDataLoaded && notes.isNotEmpty()) {
android.util.Log.d("NotesApp", "Saving ${notes.size} notes")
scope.launch {
dataStoreManager.saveNotes(notes)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
// Save on lifecycle events
DisposableEffect(lifecycleOwner) {
val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_PAUSE ||
event == androidx.lifecycle.Lifecycle.Event.ON_STOP) {
android.util.Log.d("NotesApp", "Lifecycle ${event.name}: Saving data")
scope.launch {
if (categories.isNotEmpty()) {
dataStoreManager.saveCategories(categories)
}
if (notes.isNotEmpty()) {
dataStoreManager.saveNotes(notes)
}
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
Box(modifier = Modifier.fillMaxSize()) {
Scaffold(
containerColor = AppColors.Background,
topBar = {
if (!showFullScreenNote) {
if (!showFullScreenNote && currentScreen != "ai") {
ModernTopBar(
title = when(currentScreen) {
"main" -> if (selectedCategory != null) selectedCategory!!.name else "AI Notes"
"ai" -> "AI Helper"
"starred" -> "Berbintang"
"archive" -> "Arsip"
"trash" -> "Sampah"
else -> "AI Notes"
},
showBackButton = (selectedCategory != null && currentScreen == "main") || currentScreen == "ai" || currentScreen == "starred",
showBackButton = (selectedCategory != null && currentScreen == "main"),
onBackClick = {
if (currentScreen == "ai" || currentScreen == "starred") {
currentScreen = "main"
} else {
selectedCategory = null
}
selectedCategory = null
},
onMenuClick = { drawerState = !drawerState },
onSearchClick = { showSearch = !showSearch },
@ -161,7 +214,12 @@ fun NotesApp() {
floatingActionButton = {
AnimatedVisibility(
visible = currentScreen == "main" && !showFullScreenNote,
enter = scaleIn() + fadeIn(),
enter = scaleIn(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
) + fadeIn(),
exit = scaleOut() + fadeOut()
) {
FloatingActionButton(
@ -173,20 +231,18 @@ fun NotesApp() {
showCategoryDialog = true
}
},
containerColor = Color.Transparent,
modifier = Modifier
.shadow(8.dp, CircleShape)
.background(
brush = Brush.linearGradient(
colors = listOf(Color(0xFF6366F1), Color(0xFFA855F7))
),
shape = CircleShape
)
containerColor = AppColors.Primary,
contentColor = Color.White,
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 8.dp,
pressedElevation = 12.dp
),
modifier = Modifier.size(64.dp)
) {
Icon(
Icons.Default.Add,
contentDescription = if (selectedCategory != null) "Tambah Catatan" else "Tambah Kategori",
tint = Color.White
modifier = Modifier.size(28.dp)
)
}
}
@ -255,7 +311,7 @@ fun NotesApp() {
) {
when (currentScreen) {
"main" -> MainScreen(
categories = categories.filter { !it.isDeleted }, // TAMBAHKAN FILTER INI
categories = categories.filter { !it.isDeleted },
notes = notes,
selectedCategory = selectedCategory,
searchQuery = searchQuery,
@ -271,12 +327,10 @@ fun NotesApp() {
}
},
onCategoryDelete = { category ->
// UBAH: Jangan filter, tapi set isDeleted = true
categories = categories.map {
if (it.id == category.id) it.copy(isDeleted = true)
else it
}
// Note di dalam kategori juga di-delete
notes = notes.map {
if (it.categoryId == category.id) it.copy(isDeleted = true)
else it
@ -296,12 +350,22 @@ fun NotesApp() {
it
}
}
},
onNoteEdit = { note ->
editingNote = note
showNoteDialog = true
},
onNoteDelete = { note ->
notes = notes.map {
if (it.id == note.id) it.copy(isDeleted = true)
else it
}
}
)
"trash" -> TrashScreen(
notes = notes.filter { it.isDeleted },
categories = categories, // Pass semua categories (sudah ada yang isDeleted)
categories = categories,
onRestoreNote = { note ->
notes = notes.map {
if (it.id == note.id) it.copy(isDeleted = false, isArchived = false)
@ -312,34 +376,28 @@ fun NotesApp() {
notes = notes.filter { it.id != note.id }
},
onRestoreCategory = { category ->
// Restore kategori
categories = categories.map {
if (it.id == category.id) it.copy(isDeleted = false)
else it
}
// Restore semua note di dalam kategori
notes = notes.map {
if (it.categoryId == category.id) it.copy(isDeleted = false, isArchived = false)
else it
}
},
onDeleteCategoryPermanent = { category ->
// Hapus kategori permanen
categories = categories.filter { it.id != category.id }
// Hapus semua note di dalam kategori permanen
notes = notes.filter { it.categoryId != category.id }
}
)
"starred" -> StarredNotesScreen(
notes = notes,
categories = categories.filter { !it.isDeleted }, // FILTER
categories = categories.filter { !it.isDeleted },
onNoteClick = { note ->
fullScreenNote = note
showFullScreenNote = true
},
onMenuClick = { drawerState = true },
onBack = { currentScreen = "main" },
onUnpin = { note ->
notes = notes.map {
if (it.id == note.id) it.copy(isPinned = false)
@ -350,7 +408,7 @@ fun NotesApp() {
"archive" -> ArchiveScreen(
notes = notes.filter { it.isArchived && !it.isDeleted },
categories = categories.filter { !it.isDeleted }, // FILTER
categories = categories.filter { !it.isDeleted },
onRestore = { note ->
notes = notes.map {
if (it.id == note.id) it.copy(isArchived = false)
@ -366,7 +424,7 @@ fun NotesApp() {
)
"ai" -> AIHelperScreen(
categories = categories.filter { !it.isDeleted }, // FILTER
categories = categories.filter { !it.isDeleted },
notes = notes.filter { !it.isDeleted }
)
}
@ -395,13 +453,13 @@ fun NotesApp() {
showNoteDialog = false
editingNote = null
},
onSave = { title, content ->
onSave = { title, description ->
if (editingNote != null) {
notes = notes.map {
if (it.id == editingNote!!.id)
it.copy(
title = title,
content = content,
description = description,
timestamp = System.currentTimeMillis()
)
else it
@ -410,7 +468,8 @@ fun NotesApp() {
notes = notes + Note(
categoryId = selectedCategory!!.id,
title = title,
content = content
description = description,
content = ""
)
}
showNoteDialog = false
@ -431,7 +490,7 @@ fun NotesApp() {
}
}
// Drawer with Animation - DI LUAR SCAFFOLD agar di atas semua
// Drawer with Animation
AnimatedVisibility(
visible = drawerState,
enter = fadeIn() + slideInHorizontally(
@ -440,10 +499,11 @@ fun NotesApp() {
exit = fadeOut() + slideOutHorizontally(
targetOffsetX = { -it }
),
modifier = Modifier.zIndex(100f) // Z-index tinggi
modifier = Modifier.zIndex(100f)
) {
DrawerMenu(
currentScreen = currentScreen,
isDarkTheme = isDarkTheme,
onDismiss = { drawerState = false },
onItemClick = { screen ->
currentScreen = screen
@ -451,9 +511,15 @@ fun NotesApp() {
drawerState = false
showSearch = false
searchQuery = ""
},
onThemeToggle = {
isDarkTheme = !isDarkTheme
AppColors.setTheme(isDarkTheme)
scope.launch {
dataStoreManager.saveTheme(if (isDarkTheme) "dark" else "light")
}
}
)
}
}
}
}

View File

@ -11,6 +11,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.example.notesai.data.model.Note
import com.example.notesai.data.model.Category
import com.example.notesai.data.model.ChatHistory
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
@ -28,7 +29,8 @@ data class SerializableCategory(
val name: String,
val gradientStart: Long,
val gradientEnd: Long,
val timestamp: Long
val timestamp: Long,
val isDeleted: Boolean = false
)
@Serializable
@ -36,17 +38,20 @@ data class SerializableNote(
val id: String,
val categoryId: String,
val title: String,
val content: String,
val description: String = "",
val content: String = "",
val timestamp: Long,
val isArchived: Boolean,
val isDeleted: Boolean,
val isPinned: Boolean
val isArchived: Boolean = false,
val isDeleted: Boolean = false,
val isPinned: Boolean = false
)
class DataStoreManager(private val context: Context) {
companion object {
val CATEGORIES_KEY = stringPreferencesKey("categories")
val NOTES_KEY = stringPreferencesKey("notes")
val CHAT_HISTORY_KEY = stringPreferencesKey("chat_history")
val THEME_KEY = stringPreferencesKey("theme") // "dark" or "light"
}
private val json = Json {
@ -66,9 +71,17 @@ class DataStoreManager(private val context: Context) {
val jsonString = preferences[CATEGORIES_KEY] ?: "[]"
try {
json.decodeFromString<List<SerializableCategory>>(jsonString).map {
Category(it.id, it.name, it.gradientStart, it.gradientEnd, it.timestamp)
Category(
id = it.id,
name = it.name,
gradientStart = it.gradientStart,
gradientEnd = it.gradientEnd,
timestamp = it.timestamp,
isDeleted = it.isDeleted
)
}
} catch (e: Exception) {
e.printStackTrace()
emptyList()
}
}
@ -86,17 +99,40 @@ class DataStoreManager(private val context: Context) {
try {
json.decodeFromString<List<SerializableNote>>(jsonString).map {
Note(
it.id,
it.categoryId,
it.title,
it.content,
it.timestamp,
it.isArchived,
it.isDeleted,
it.isPinned
id = it.id,
categoryId = it.categoryId,
title = it.title,
description = it.description,
content = it.content,
timestamp = it.timestamp,
isPinned = it.isPinned,
isArchived = it.isArchived,
isDeleted = it.isDeleted
)
}
} catch (e: Exception) {
e.printStackTrace()
emptyList()
}
}
// Chat History Flow
val chatHistoryFlow: Flow<List<ChatHistory>> = context.dataStore.data
.catch { exception ->
if (exception is IOException) {
emit(emptyPreferences())
} else {
throw exception
}
}
.map { preferences ->
val jsonString = preferences[CHAT_HISTORY_KEY] ?: "[]"
try {
json.decodeFromString<List<ChatHistory>>(jsonString)
.filter { !it.isDeleted }
.sortedByDescending { it.timestamp }
} catch (e: Exception) {
e.printStackTrace()
emptyList()
}
}
@ -105,7 +141,14 @@ class DataStoreManager(private val context: Context) {
try {
context.dataStore.edit { preferences ->
val serializable = categories.map {
SerializableCategory(it.id, it.name, it.gradientStart, it.gradientEnd, it.timestamp)
SerializableCategory(
id = it.id,
name = it.name,
gradientStart = it.gradientStart,
gradientEnd = it.gradientEnd,
timestamp = it.timestamp,
isDeleted = it.isDeleted
)
}
preferences[CATEGORIES_KEY] = json.encodeToString(serializable)
}
@ -118,7 +161,17 @@ class DataStoreManager(private val context: Context) {
try {
context.dataStore.edit { preferences ->
val serializable = notes.map {
SerializableNote(it.id, it.categoryId, it.title, it.content, it.timestamp, it.isArchived, it.isDeleted, it.isPinned)
SerializableNote(
id = it.id,
categoryId = it.categoryId,
title = it.title,
description = it.description,
content = it.content,
timestamp = it.timestamp,
isPinned = it.isPinned,
isArchived = it.isArchived,
isDeleted = it.isDeleted
)
}
preferences[NOTES_KEY] = json.encodeToString(serializable)
}
@ -126,4 +179,97 @@ class DataStoreManager(private val context: Context) {
e.printStackTrace()
}
}
// Save Chat History
suspend fun saveChatHistory(chatHistoryList: List<ChatHistory>) {
try {
context.dataStore.edit { preferences ->
preferences[CHAT_HISTORY_KEY] = json.encodeToString(chatHistoryList)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
// Add new chat history
suspend fun addChatHistory(chatHistory: ChatHistory) {
try {
context.dataStore.edit { preferences ->
val jsonString = preferences[CHAT_HISTORY_KEY] ?: "[]"
val currentList = json.decodeFromString<List<ChatHistory>>(jsonString).toMutableList()
// Check if already exists, update instead
val existingIndex = currentList.indexOfFirst { it.id == chatHistory.id }
if (existingIndex != -1) {
currentList[existingIndex] = chatHistory
} else {
currentList.add(0, chatHistory) // Add to beginning
}
preferences[CHAT_HISTORY_KEY] = json.encodeToString(currentList)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
// Delete chat history (soft delete)
suspend fun deleteChatHistory(historyId: String) {
try {
context.dataStore.edit { preferences ->
val jsonString = preferences[CHAT_HISTORY_KEY] ?: "[]"
val currentList = json.decodeFromString<List<ChatHistory>>(jsonString).toMutableList()
val updatedList = currentList.map {
if (it.id == historyId) it.copy(isDeleted = true) else it
}
preferences[CHAT_HISTORY_KEY] = json.encodeToString(updatedList)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
// NEW: Update chat history title
suspend fun updateChatHistoryTitle(historyId: String, newTitle: String) {
try {
context.dataStore.edit { preferences ->
val jsonString = preferences[CHAT_HISTORY_KEY] ?: "[]"
val currentList = json.decodeFromString<List<ChatHistory>>(jsonString).toMutableList()
val updatedList = currentList.map {
if (it.id == historyId) {
it.copy(customTitle = newTitle)
} else {
it
}
}
preferences[CHAT_HISTORY_KEY] = json.encodeToString(updatedList)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
// Theme Preference Flow
val themeFlow: Flow<String> = context.dataStore.data
.catch { exception ->
if (exception is IOException) {
emit(emptyPreferences())
} else {
throw exception
}
}
.map { preferences ->
preferences[THEME_KEY] ?: "dark" // Default dark theme
}
// Save Theme Preference
suspend fun saveTheme(theme: String) {
try {
context.dataStore.edit { preferences ->
preferences[THEME_KEY] = theme
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}

View File

@ -1,13 +1,14 @@
// File: data/model/Category.kt
package com.example.notesai.data.model
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class Category(
val id: String = UUID.randomUUID().toString(),
val name: String,
val gradientStart: Long,
val gradientEnd: Long,
val timestamp: Long = System.currentTimeMillis(),
val isDeleted: Boolean = false // TAMBAHKAN INI
val isDeleted: Boolean = false // Support soft delete
)

View File

@ -0,0 +1,40 @@
package com.example.notesai.data.model
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class ChatHistory(
val id: String = UUID.randomUUID().toString(),
val categoryId: String?, // null berarti "Semua Kategori"
val categoryName: String, // Untuk display
val messages: List<SerializableChatMessage>,
val lastMessagePreview: String, // Preview pesan terakhir
val customTitle: String? = null, // Custom title yang di-edit user (support markdown)
val timestamp: Long = System.currentTimeMillis(),
val isDeleted: Boolean = false
)
@Serializable
data class SerializableChatMessage(
val id: String,
val message: String,
val isUser: Boolean,
val timestamp: Long
)
// Extension function untuk convert ChatMessage ke SerializableChatMessage
fun ChatMessage.toSerializable() = SerializableChatMessage(
id = id,
message = message,
isUser = isUser,
timestamp = timestamp
)
// Extension function untuk convert SerializableChatMessage ke ChatMessage
fun SerializableChatMessage.toChatMessage() = ChatMessage(
id = id,
message = message,
isUser = isUser,
timestamp = timestamp
)

View File

@ -1,15 +1,20 @@
// File: data/model/Note.kt
package com.example.notesai.data.model
import android.annotation.SuppressLint
//noinspection UnsafeOptInUsageError
import kotlinx.serialization.Serializable
import java.util.UUID
@SuppressLint("UnsafeOptInUsageError")
@Serializable
data class Note(
val id: String = UUID.randomUUID().toString(),
val categoryId: String,
val title: String,
val content: String,
val description: String = "", // Field baru untuk preview di NoteCard
val content: String = "", // Konten lengkap untuk EditableFullScreenNoteView
val timestamp: Long = System.currentTimeMillis(),
val isPinned: Boolean = false,
val isArchived: Boolean = false,
val isDeleted: Boolean = false,
val isPinned: Boolean = false
val isDeleted: Boolean = false
)

View File

@ -1,177 +1,352 @@
package com.example.notesai.presentation.components
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
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.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.notesai.util.Constants
import com.example.notesai.util.AppColors
@Composable
fun DrawerMenu(
currentScreen: String,
isDarkTheme: Boolean,
onDismiss: () -> Unit,
onItemClick: (String) -> Unit
onItemClick: (String) -> Unit,
onThemeToggle: () -> Unit
) {
// Backdrop with blur effect
Box(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding() // Padding untuk status bar
.background(Color.Black.copy(alpha = 0.5f))
.background(AppColors.Overlay)
.clickable(
onClick = onDismiss,
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
) {
Card(
// Drawer Content
Surface(
modifier = Modifier
.fillMaxHeight()
.width(250.dp)
.width(280.dp)
.align(Alignment.CenterStart)
.clickable(
onClick = {},
indication = null,
interactionSource = remember { MutableInteractionSource() }
),
shape = RoundedCornerShape(topEnd = 0.dp, bottomEnd = 0.dp),
colors = CardDefaults.cardColors(
containerColor = Color(0xFF1E293B)
),
elevation = CardDefaults.cardElevation(defaultElevation = 16.dp)
color = AppColors.Surface,
shadowElevation = Constants.Elevation.ExtraLarge.dp
) {
Column(modifier = Modifier.fillMaxSize()) {
// Header Drawer dengan tombol close
Column(
modifier = Modifier.fillMaxSize()
) {
// Header - Minimalist
Box(
modifier = Modifier
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(Color(0xFF6366F1), Color(0xFFA855F7))
colors = listOf(
AppColors.Primary.copy(alpha = 0.15f),
Color.Transparent
)
)
)
.padding(24.dp)
.padding(Constants.Spacing.ExtraLarge.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Column {
// App Icon with subtle background
Box(
modifier = Modifier
.size(56.dp)
.background(
color = AppColors.Primary.copy(alpha = 0.2f),
shape = RoundedCornerShape(Constants.Radius.Medium.dp)
),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Create,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(36.dp)
)
Spacer(modifier = Modifier.height(12.dp))
Text(
"AI Notes",
style = MaterialTheme.typography.headlineMedium,
color = Color.White,
fontWeight = FontWeight.Bold
)
Text(
"Smart & Modern",
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(0.8f)
tint = AppColors.Primary,
modifier = Modifier.size(32.dp)
)
}
// // Tombol Close
// IconButton(
// onClick = onDismiss,
// modifier = Modifier
// .size(40.dp)
// .background(
// Color.White.copy(alpha = 0.2f),
// shape = CircleShape
// )
// ) {
// Icon(
// Icons.Default.Close,
// contentDescription = "Tutup Menu",
// tint = Color.White,
// modifier = Modifier.size(24.dp)
// )
// }
Spacer(modifier = Modifier.height(Constants.Spacing.Medium.dp))
Text(
Constants.APP_NAME,
style = MaterialTheme.typography.headlineMedium,
color = AppColors.OnBackground,
fontWeight = FontWeight.Bold,
fontSize = 24.sp
)
Spacer(modifier = Modifier.height(Constants.Spacing.ExtraSmall.dp))
Text(
"Smart Note Taking",
style = MaterialTheme.typography.bodyMedium,
color = AppColors.OnSurfaceVariant,
fontSize = 14.sp
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(Constants.Spacing.Large.dp))
// Menu Items
MenuItem(
icon = Icons.Default.Home,
DrawerMenuItem(
icon = if (currentScreen == "main") Icons.Filled.Home else Icons.Outlined.Home,
text = "Beranda",
isSelected = currentScreen == "main"
) { onItemClick("main") }
isSelected = currentScreen == "main",
onClick = { onItemClick("main") }
)
MenuItem(
icon = Icons.Default.Star,
DrawerMenuItem(
icon = if (currentScreen == "starred") Icons.Filled.Star else Icons.Outlined.StarBorder,
text = "Berbintang",
isSelected = currentScreen == "starred"
) { onItemClick("starred") }
isSelected = currentScreen == "starred",
onClick = { onItemClick("starred") }
)
MenuItem(
icon = Icons.Default.Archive,
DrawerMenuItem(
icon = if (currentScreen == "archive") Icons.Filled.Archive else Icons.Outlined.Archive,
text = "Arsip",
isSelected = currentScreen == "archive"
) { onItemClick("archive") }
isSelected = currentScreen == "archive",
onClick = { onItemClick("archive") }
)
MenuItem(
icon = Icons.Default.Delete,
DrawerMenuItem(
icon = if (currentScreen == "trash") Icons.Filled.Delete else Icons.Outlined.Delete,
text = "Sampah",
isSelected = currentScreen == "trash"
) { onItemClick("trash") }
isSelected = currentScreen == "trash",
onClick = { onItemClick("trash") }
)
Spacer(modifier = Modifier.height(Constants.Spacing.Medium.dp))
HorizontalDivider(
color = AppColors.Divider,
modifier = Modifier.padding(horizontal = Constants.Spacing.Medium.dp)
)
Spacer(modifier = Modifier.height(Constants.Spacing.Medium.dp))
// Theme Toggle
ThemeToggleItem(
isDarkTheme = isDarkTheme,
onToggle = onThemeToggle
)
Spacer(modifier = Modifier.weight(1f))
// Footer
Divider(
color = Color.White.copy(alpha = 0.1f),
modifier = Modifier.padding(horizontal = 16.dp)
// Footer - Version info
HorizontalDivider(
color = AppColors.Divider,
modifier = Modifier.padding(horizontal = Constants.Spacing.Medium.dp)
)
Text(
text = "Version 1.0.0",
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.5f),
modifier = Modifier.padding(16.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(Constants.Spacing.Medium.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Version ${Constants.APP_VERSION}",
style = MaterialTheme.typography.bodySmall,
color = AppColors.OnSurfaceTertiary,
fontSize = 12.sp
)
// Powered by badge
Surface(
color = AppColors.Primary.copy(alpha = 0.1f),
shape = RoundedCornerShape(Constants.Radius.Small.dp)
) {
Row(
modifier = Modifier.padding(horizontal = Constants.Spacing.Small.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
Icons.Default.AutoAwesome,
contentDescription = null,
tint = AppColors.Primary,
modifier = Modifier.size(12.dp)
)
Text(
"AI",
color = AppColors.Primary,
fontSize = 11.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
}
}
@Composable
private fun ThemeToggleItem(
isDarkTheme: Boolean,
onToggle: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Constants.Spacing.Medium.dp)
.clip(RoundedCornerShape(Constants.Radius.Medium.dp))
.clickable(onClick = onToggle)
.background(AppColors.SurfaceVariant)
.padding(horizontal = Constants.Spacing.Medium.dp, vertical = Constants.Spacing.Medium.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Constants.Spacing.Medium.dp)
) {
// Icon with background
Box(
modifier = Modifier
.size(40.dp)
.background(
color = AppColors.Primary.copy(alpha = 0.2f),
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
if (isDarkTheme) Icons.Default.DarkMode else Icons.Default.LightMode,
contentDescription = null,
tint = AppColors.Primary,
modifier = Modifier.size(20.dp)
)
}
// Text
Column {
Text(
"Tema Aplikasi",
style = MaterialTheme.typography.bodyLarge,
color = AppColors.OnSurface,
fontWeight = FontWeight.SemiBold,
fontSize = 15.sp
)
Text(
if (isDarkTheme) "Mode Gelap" else "Mode Terang",
style = MaterialTheme.typography.bodySmall,
color = AppColors.OnSurfaceTertiary,
fontSize = 12.sp
)
}
}
// Toggle Switch
Switch(
checked = isDarkTheme,
onCheckedChange = { onToggle() },
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = AppColors.Primary,
uncheckedThumbColor = Color.White,
uncheckedTrackColor = AppColors.Border
)
)
}
}
@Composable
private fun DrawerMenuItem(
icon: ImageVector,
text: String,
isSelected: Boolean,
onClick: () -> Unit
) {
// Scale animation
val scale by animateFloatAsState(
targetValue = if (isSelected) 1.02f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
),
label = "scale"
)
Row(
modifier = Modifier
.fillMaxWidth()
.scale(scale)
.clip(RoundedCornerShape(Constants.Radius.Medium.dp))
.clickable(onClick = onClick)
.background(
color = if (isSelected)
AppColors.Primary.copy(alpha = 0.1f)
else
Color.Transparent
)
.padding(horizontal = Constants.Spacing.Medium.dp, vertical = Constants.Spacing.Medium.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Icon with background
Box(
modifier = Modifier
.size(40.dp)
.background(
color = if (isSelected)
AppColors.Primary.copy(alpha = 0.2f)
else
AppColors.SurfaceVariant,
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
icon,
contentDescription = null,
tint = if (isSelected) AppColors.Primary else AppColors.OnSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
Spacer(modifier = Modifier.width(Constants.Spacing.Medium.dp))
// Text
Text(
text,
style = MaterialTheme.typography.bodyLarge,
color = if (isSelected) AppColors.Primary else AppColors.OnSurface,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
fontSize = 15.sp
)
}
}

View File

@ -1,31 +1,33 @@
package com.example.notesai.presentation.components
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.StarBorder
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.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.notesai.util.AppColors
import com.example.notesai.util.Constants
@Composable
fun ModernBottomBar(
@ -33,70 +35,125 @@ fun ModernBottomBar(
onHomeClick: () -> Unit,
onAIClick: () -> Unit
) {
BottomAppBar(
containerColor = Color.Transparent,
// Floating Bottom Bar with Glassmorphism
Box(
modifier = Modifier
.shadow(8.dp, RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp))
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color(0xFF1E293B).copy(0.95f),
Color(0xFF334155).copy(0.95f)
)
),
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)
)
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Row(
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
.shadow(
elevation = Constants.Elevation.Large.dp,
shape = RoundedCornerShape(Constants.Radius.ExtraLarge.dp)
),
color = AppColors.SurfaceElevated,
shape = RoundedCornerShape(Constants.Radius.ExtraLarge.dp)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
Row(
modifier = Modifier
.weight(1f)
.clickable(onClick = onHomeClick)
.padding(vertical = 8.dp)
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 24.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Home,
contentDescription = "Home",
tint = if (currentScreen == "main") Color(0xFF6366F1) else Color(0xFF94A3B8),
modifier = Modifier.size(24.dp)
// Home Button
BottomBarItem(
selected = currentScreen == "main",
onClick = onHomeClick,
icon = if (currentScreen == "main") Icons.Filled.Home else Icons.Outlined.Home,
label = "Beranda"
)
Spacer(modifier = Modifier.height(4.dp))
Text(
"Home",
color = if (currentScreen == "main") Color(0xFF6366F1) else Color(0xFF94A3B8),
style = MaterialTheme.typography.bodySmall,
fontWeight = if (currentScreen == "main") FontWeight.Bold else FontWeight.Normal
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.weight(1f)
.clickable(onClick = onAIClick)
.padding(vertical = 8.dp)
) {
Icon(
Icons.Default.Star,
contentDescription = "AI Helper",
tint = if (currentScreen == "ai") Color(0xFFFBBF24) else Color(0xFF94A3B8),
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
"AI Helper",
color = if (currentScreen == "ai") Color(0xFFFBBF24) else Color(0xFF94A3B8),
style = MaterialTheme.typography.bodySmall,
fontWeight = if (currentScreen == "ai") FontWeight.Bold else FontWeight.Normal
// AI Button
BottomBarItem(
selected = currentScreen == "ai",
onClick = onAIClick,
icon = if (currentScreen == "ai") Icons.Filled.AutoAwesome else Icons.Outlined.AutoAwesome,
label = "AI Helper"
)
}
}
}
}
@Composable
private fun BottomBarItem(
selected: Boolean,
onClick: () -> Unit,
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String
) {
// Scale animation
val scale by animateFloatAsState(
targetValue = if (selected) 1.1f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "scale"
)
// Color animation
val iconColor by animateColorAsState(
targetValue = if (selected) AppColors.Primary else AppColors.OnSurfaceVariant,
animationSpec = tween(Constants.ANIMATION_DURATION_MEDIUM),
label = "color"
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.clickable(
onClick = onClick,
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
.padding(horizontal = 24.dp, vertical = 8.dp)
) {
// Icon with background indicator
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(48.dp)
.scale(scale)
) {
// Background indicator
if (selected) {
Box(
modifier = Modifier
.size(40.dp)
.background(
color = AppColors.Primary.copy(alpha = 0.15f),
shape = CircleShape
)
)
}
Icon(
icon,
contentDescription = label,
tint = iconColor,
modifier = Modifier.size(24.dp)
)
}
// Label with fade animation
AnimatedVisibility(
visible = selected,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Spacer(modifier = Modifier.height(4.dp))
Text(
label,
color = AppColors.Primary,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
fontSize = 12.sp
)
}
}
}

View File

@ -1,42 +1,28 @@
package com.example.notesai.presentation.components
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.*
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.filled.Close
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.notesai.util.AppColors
import com.example.notesai.util.Constants
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -50,58 +36,144 @@ fun ModernTopBar(
onSearchQueryChange: (String) -> Unit,
showSearch: Boolean
) {
TopAppBar(
title = {
if (showSearch) {
TextField(
value = searchQuery,
onValueChange = onSearchQueryChange,
placeholder = { Text("Cari catatan...", color = Color.White.copy(0.6f)) },
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
cursorColor = Color.White,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
modifier = Modifier.fillMaxWidth()
)
} else {
Text(
title,
fontWeight = FontWeight.Bold,
fontSize = 22.sp
)
}
},
navigationIcon = {
IconButton(onClick = if (showBackButton) onBackClick else onMenuClick) {
Icon(
if (showBackButton) Icons.AutoMirrored.Filled.ArrowBack else Icons.Default.Menu,
contentDescription = null,
tint = Color.White
)
}
},
actions = {
IconButton(onClick = onSearchClick) {
Icon(
if (showSearch) Icons.Default.Close else Icons.Default.Search,
contentDescription = "Search",
tint = Color.White
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
),
// Floating Top Bar with same style as Bottom Bar
Box(
modifier = Modifier
.background(
brush = Brush.horizontalGradient(
colors = listOf(Color(0xFF6366F1), Color(0xFFA855F7))
)
)
)
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.shadow(
elevation = Constants.Elevation.Large.dp,
shape = RoundedCornerShape(Constants.Radius.ExtraLarge.dp)
),
color = AppColors.SurfaceElevated,
shape = RoundedCornerShape(Constants.Radius.ExtraLarge.dp)
) {
// Smooth transition for search bar
AnimatedContent(
targetState = showSearch,
transitionSpec = {
fadeIn(animationSpec = tween(Constants.ANIMATION_DURATION_MEDIUM)) togetherWith
fadeOut(animationSpec = tween(Constants.ANIMATION_DURATION_MEDIUM))
},
label = "topbar"
) { isSearching ->
if (isSearching) {
// Search Mode
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Search TextField
OutlinedTextField(
value = searchQuery,
onValueChange = onSearchQueryChange,
placeholder = {
Text(
"Cari...",
color = AppColors.OnSurfaceVariant,
fontSize = 15.sp
)
},
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = AppColors.SurfaceVariant,
unfocusedContainerColor = AppColors.SurfaceVariant,
focusedTextColor = AppColors.OnBackground,
unfocusedTextColor = AppColors.OnSurface,
cursorColor = AppColors.Primary,
focusedBorderColor = AppColors.Primary,
unfocusedBorderColor = Color.Transparent
),
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp),
shape = RoundedCornerShape(Constants.Radius.Large.dp),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 15.sp)
)
// Close Search Button
IconButton(
onClick = {
onSearchQueryChange("")
onSearchClick()
},
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(AppColors.SurfaceVariant)
) {
Icon(
Icons.Default.Close,
contentDescription = "Close Search",
tint = AppColors.OnSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
}
} else {
// Normal Mode
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
// Back/Menu Button
IconButton(
onClick = if (showBackButton) onBackClick else onMenuClick,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(
if (showBackButton) AppColors.SurfaceVariant
else AppColors.Primary.copy(alpha = 0.1f)
)
) {
Icon(
if (showBackButton) Icons.AutoMirrored.Filled.ArrowBack else Icons.Default.Menu,
contentDescription = if (showBackButton) "Back" else "Menu",
tint = if (showBackButton) AppColors.OnSurface else AppColors.Primary,
modifier = Modifier.size(20.dp)
)
}
// Title
Text(
title,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
color = AppColors.OnBackground,
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp)
)
// Search Button
IconButton(
onClick = onSearchClick,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(AppColors.SurfaceVariant)
) {
Icon(
Icons.Default.Search,
contentDescription = "Search",
tint = AppColors.OnSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
}
}
}
}
}
}

View File

@ -1,163 +1,192 @@
package com.example.notesai.presentation.dialogs
// File: presentation/dialogs/CategoryDialog.kt
package com.example.notesai.presentation.dialogs
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.notesai.util.AppColors
import com.example.notesai.util.Constants
@Composable
fun CategoryDialog(
onDismiss: () -> Unit,
onSave: (String, Long, Long) -> Unit
) {
var name by remember { mutableStateOf("") }
var selectedGradient by remember { mutableStateOf(0) }
@Composable
fun CategoryDialog(
onDismiss: () -> Unit,
onSave: (String, Long, Long) -> Unit
) {
var name by remember { mutableStateOf("") }
var selectedGradient by remember { mutableStateOf(0) }
val gradients = listOf(
Pair(0xFF6366F1L, 0xFFA855F7L),
Pair(0xFFEC4899L, 0xFFF59E0BL),
Pair(0xFF8B5CF6L, 0xFFEC4899L),
Pair(0xFF06B6D4L, 0xFF3B82F6L),
Pair(0xFF10B981L, 0xFF059669L),
Pair(0xFFF59E0BL, 0xFFEF4444L),
Pair(0xFF6366F1L, 0xFF8B5CF6L),
Pair(0xFFEF4444L, 0xFFDC2626L)
)
AlertDialog(
onDismissRequest = onDismiss,
containerColor = Color(0xFF1E293B),
title = {
Text(
"Buat Kategori Baru",
color = Color.White,
fontWeight = FontWeight.Bold
)
},
text = {
Column {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Nama Kategori", color = Color(0xFF94A3B8)) },
modifier = Modifier.fillMaxWidth(),
colors = TextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedContainerColor = Color(0xFF334155),
unfocusedContainerColor = Color(0xFF334155),
cursorColor = Color(0xFFA855F7),
focusedIndicatorColor = Color(0xFFA855F7),
unfocusedIndicatorColor = Color(0xFF64748B)
),
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(20.dp))
AlertDialog(
onDismissRequest = onDismiss,
containerColor = AppColors.Surface,
shape = RoundedCornerShape(20.dp),
title = {
Text(
"Pilih Gradient:",
style = MaterialTheme.typography.bodyMedium,
color = Color.White,
fontWeight = FontWeight.SemiBold
"Buat Kategori Baru",
color = AppColors.OnBackground,
fontWeight = FontWeight.Bold,
fontSize = 20.sp
)
Spacer(modifier = Modifier.height(12.dp))
gradients.chunked(4).forEach { row ->
Row(
},
text = {
Column(
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
// Input Nama
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = {
Text(
"Nama Kategori",
color = AppColors.OnSurfaceVariant
)
},
placeholder = {
Text(
"Contoh: Pekerjaan, Personal",
color = AppColors.OnSurfaceTertiary,
fontSize = 14.sp
)
},
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.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(12.dp),
singleLine = true
)
// Gradient Selector
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
row.forEachIndexed { index, gradient ->
val globalIndex = gradients.indexOf(gradient)
Box(
modifier = Modifier
.weight(1f)
.aspectRatio(1f)
.clip(RoundedCornerShape(12.dp))
.background(
brush = Brush.linearGradient(
colors = listOf(
Color(gradient.first),
Color(gradient.second)
)
)
)
.clickable { selectedGradient = globalIndex },
contentAlignment = Alignment.Center
Text(
"Pilih Warna:",
style = MaterialTheme.typography.bodyMedium,
color = AppColors.OnSurface,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp
)
Constants.CategoryColors.chunked(4).forEach { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (selectedGradient == globalIndex) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp)
row.forEach { gradient ->
val globalIndex = Constants.CategoryColors.indexOf(gradient)
val isSelected = selectedGradient == globalIndex
// Scale animation
val scale by animateFloatAsState(
targetValue = if (isSelected) 1.1f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
),
label = "scale"
)
Box(
modifier = Modifier
.weight(1f)
.aspectRatio(1f)
.clip(RoundedCornerShape(12.dp))
.background(
brush = Brush.linearGradient(
colors = listOf(
Color(gradient.first),
Color(gradient.second)
)
)
)
.clickable { selectedGradient = globalIndex },
contentAlignment = Alignment.Center
) {
// Check icon dengan animation
this@Row.AnimatedVisibility(
visible = isSelected,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
Surface(
color = Color.White.copy(alpha = 0.9f),
shape = RoundedCornerShape(8.dp)
) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = Color(gradient.first),
modifier = Modifier
.padding(6.dp)
.size(20.dp)
)
}
}
}
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
},
confirmButton = {
Button(
onClick = {
if (name.isNotBlank()) {
val gradient = Constants.CategoryColors[selectedGradient]
onSave(name, gradient.first, gradient.second)
}
},
enabled = name.isNotBlank(),
colors = ButtonDefaults.buttonColors(
containerColor = AppColors.Primary,
disabledContainerColor = AppColors.Primary.copy(alpha = 0.5f)
),
shape = RoundedCornerShape(12.dp),
modifier = Modifier.height(48.dp)
) {
Text(
"Simpan",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 15.sp
)
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.height(48.dp)
) {
Text(
"Batal",
color = AppColors.OnSurfaceVariant,
fontSize = 15.sp
)
}
}
},
confirmButton = {
Button(
onClick = {
if (name.isNotBlank()) {
val gradient = gradients[selectedGradient]
onSave(name, gradient.first, gradient.second)
}
},
enabled = name.isNotBlank(),
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent
),
modifier = Modifier.background(
brush = Brush.linearGradient(
colors = listOf(Color(0xFF6366F1), Color(0xFFA855F7))
),
shape = RoundedCornerShape(8.dp)
)
) {
Text("Simpan", color = Color.White, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Batal", color = Color(0xFF94A3B8))
}
}
)
}
)
}

View File

@ -1,31 +1,19 @@
package com.example.notesai.presentation.dialogs
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.notesai.data.model.Note
import com.example.notesai.util.AppColors
import com.example.notesai.util.Constants
@Composable
fun NoteDialog(
@ -35,89 +23,190 @@ fun NoteDialog(
onDelete: (() -> Unit)?
) {
var title by remember { mutableStateOf(note?.title ?: "") }
var content by remember { mutableStateOf(note?.content ?: "") }
var description by remember { mutableStateOf(note?.description ?: "") }
var showDeleteConfirm by remember { mutableStateOf(false) }
// Delete confirmation dialog
if (showDeleteConfirm) {
AlertDialog(
onDismissRequest = { showDeleteConfirm = false },
containerColor = AppColors.Surface,
shape = RoundedCornerShape(20.dp),
title = {
Text(
"Hapus Catatan?",
color = AppColors.OnBackground,
fontWeight = FontWeight.Bold
)
},
text = {
Text(
"Catatan ini akan dipindahkan ke sampah.",
color = AppColors.OnSurfaceVariant
)
},
confirmButton = {
Button(
onClick = {
onDelete?.invoke()
showDeleteConfirm = false
},
colors = ButtonDefaults.buttonColors(
containerColor = AppColors.Error
),
shape = RoundedCornerShape(12.dp)
) {
Text("Hapus", color = Color.White, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(
onClick = { showDeleteConfirm = false },
shape = RoundedCornerShape(12.dp)
) {
Text("Batal", color = AppColors.OnSurfaceVariant)
}
}
)
}
AlertDialog(
onDismissRequest = onDismiss,
containerColor = Color(0xFF1E293B),
containerColor = AppColors.Surface,
shape = RoundedCornerShape(20.dp),
title = {
Text(
if (note == null) "Catatan Baru" else "Edit Catatan",
color = Color.White,
fontWeight = FontWeight.Bold
color = AppColors.OnBackground,
fontWeight = FontWeight.Bold,
fontSize = 20.sp
)
},
text = {
Column {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Title Input
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Judul", color = Color(0xFF94A3B8)) },
label = {
Text(
"Judul",
color = AppColors.OnSurfaceVariant
)
},
placeholder = {
Text(
"Masukkan judul catatan",
color = AppColors.OnSurfaceTertiary,
fontSize = 14.sp
)
},
modifier = Modifier.fillMaxWidth(),
colors = TextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedContainerColor = Color(0xFF334155),
unfocusedContainerColor = Color(0xFF334155),
cursorColor = Color(0xFFA855F7),
focusedIndicatorColor = Color(0xFFA855F7),
unfocusedIndicatorColor = Color(0xFF64748B)
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(12.dp)
shape = RoundedCornerShape(12.dp),
singleLine = true
)
Spacer(modifier = Modifier.height(12.dp))
// Description Input
OutlinedTextField(
value = content,
onValueChange = { content = it },
label = { Text("Isi Catatan", color = Color(0xFF94A3B8)) },
value = description,
onValueChange = { description = it },
label = {
Text(
"Deskripsi",
color = AppColors.OnSurfaceVariant
)
},
placeholder = {
Text(
"Tambahkan deskripsi singkat...",
color = AppColors.OnSurfaceTertiary,
fontSize = 14.sp
)
},
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
maxLines = 10,
colors = TextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedContainerColor = Color(0xFF334155),
unfocusedContainerColor = Color(0xFF334155),
cursorColor = Color(0xFFA855F7),
focusedIndicatorColor = Color(0xFFA855F7),
unfocusedIndicatorColor = Color(0xFF64748B)
.heightIn(min = 120.dp, max = 200.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(12.dp)
shape = RoundedCornerShape(12.dp),
maxLines = 8
)
}
},
confirmButton = {
Row {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Delete button (if editing)
if (onDelete != null) {
TextButton(onClick = onDelete) {
Text("Hapus", color = Color(0xFFEF4444), fontWeight = FontWeight.Bold)
IconButton(
onClick = { showDeleteConfirm = true },
modifier = Modifier.size(48.dp)
) {
Icon(
Icons.Default.Delete,
contentDescription = "Hapus",
tint = AppColors.Error
)
}
Spacer(modifier = Modifier.width(8.dp))
}
Spacer(modifier = Modifier.weight(1f))
// Cancel button
TextButton(
onClick = onDismiss,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.height(48.dp)
) {
Text(
"Batal",
color = AppColors.OnSurfaceVariant,
fontSize = 15.sp
)
}
// Save button
Button(
onClick = { if (title.isNotBlank()) onSave(title, content) },
onClick = {
if (title.isNotBlank()) {
onSave(title, description)
}
},
enabled = title.isNotBlank(),
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent
containerColor = AppColors.Primary,
disabledContainerColor = AppColors.Primary.copy(alpha = 0.5f)
),
modifier = Modifier.background(
brush = Brush.linearGradient(
colors = listOf(Color(0xFF6366F1), Color(0xFFA855F7))
),
shape = RoundedCornerShape(8.dp)
)
shape = RoundedCornerShape(12.dp),
modifier = Modifier.height(48.dp)
) {
Text("Simpan", color = Color.White, fontWeight = FontWeight.Bold)
Text(
"Simpan",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 15.sp
)
}
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Batal", color = Color(0xFF94A3B8))
}
}
)
}

View File

@ -1,32 +1,28 @@
package com.example.notesai.presentation.screens.ai.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.SmartToy
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.notesai.data.model.ChatMessage
import com.example.notesai.util.MarkdownText
import com.example.notesai.util.AppColors
import com.example.notesai.util.Constants
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.*
@Composable
fun ChatBubble(
@ -36,41 +32,46 @@ fun ChatBubble(
) {
val dateFormat = remember { SimpleDateFormat("HH:mm", Locale("id", "ID")) }
Row(
Column(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (message.isUser) Arrangement.End else Arrangement.Start
horizontalAlignment = if (message.isUser) Alignment.End else Alignment.Start
) {
if (!message.isUser) {
// Ganti ikon bintang dengan ikon robot/sparkles
Icon(
Icons.Default.AutoAwesome, // Atau bisa diganti dengan ikon lain seperti AutoAwesome
contentDescription = null,
tint = Color(0xFF6366F1), // Warna ungu/biru untuk AI
modifier = Modifier
.size(32.dp)
.padding(end = 8.dp)
)
}
Column(
modifier = Modifier.fillMaxWidth(0.85f),
horizontalAlignment = if (message.isUser) Alignment.End else Alignment.Start
) {
Card(
colors = CardDefaults.cardColors(
containerColor = if (message.isUser)
Color(0xFF6366F1)
else
Color(0xFF1E293B)
),
if (message.isUser) {
// User Message
Surface(
color = AppColors.Primary,
shape = RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
bottomStart = if (message.isUser) 16.dp else 4.dp,
bottomEnd = if (message.isUser) 4.dp else 16.dp
)
topStart = Constants.Radius.Large.dp,
topEnd = Constants.Radius.Large.dp,
bottomStart = Constants.Radius.Large.dp,
bottomEnd = 4.dp
),
shadowElevation = 2.dp,
modifier = Modifier.widthIn(max = 320.dp)
) {
Column(modifier = Modifier.padding(12.dp)) {
Column(
modifier = Modifier.padding(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Person,
contentDescription = null,
tint = Color.White.copy(alpha = 0.9f),
modifier = Modifier.size(16.dp)
)
Text(
"Anda",
color = Color.White.copy(alpha = 0.9f),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.height(6.dp))
Text(
message.message,
color = Color.White,
@ -78,42 +79,92 @@ fun ChatBubble(
lineHeight = 20.sp
)
Spacer(modifier = Modifier.height(4.dp))
Text(
dateFormat.format(Date(message.timestamp)),
color = Color.White.copy(alpha = 0.7f),
fontSize = 11.sp
)
}
}
} else {
// AI Message with Markdown
Surface(
color = AppColors.SurfaceVariant,
shape = RoundedCornerShape(
topStart = Constants.Radius.Large.dp,
topEnd = Constants.Radius.Large.dp,
bottomStart = 4.dp,
bottomEnd = Constants.Radius.Large.dp
),
shadowElevation = 2.dp,
modifier = Modifier.widthIn(max = 320.dp)
) {
Column(
modifier = Modifier.padding(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
dateFormat.format(Date(message.timestamp)),
color = Color.White.copy(0.6f),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp)
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.SmartToy,
contentDescription = null,
tint = AppColors.Primary,
modifier = Modifier.size(16.dp)
)
Text(
"AI Assistant",
color = AppColors.OnSurfaceVariant,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold
)
}
if (!message.isUser) {
IconButton(
onClick = onCopy,
modifier = Modifier.size(32.dp)
) {
// Copy Button
IconButton(
onClick = onCopy,
modifier = Modifier.size(28.dp)
) {
AnimatedContent(
targetState = showCopied,
transitionSpec = {
fadeIn() + scaleIn() togetherWith fadeOut() + scaleOut()
},
label = "copy_icon"
) { copied ->
Icon(
Icons.Default.ContentCopy,
contentDescription = "Copy",
tint = Color.White.copy(0.7f),
if (copied) Icons.Default.Check else Icons.Default.ContentCopy,
contentDescription = if (copied) "Copied" else "Copy",
tint = if (copied) AppColors.Success else AppColors.OnSurfaceVariant,
modifier = Modifier.size(16.dp)
)
}
}
}
}
}
if (showCopied && !message.isUser) {
Text(
"✓ Disalin",
color = Color(0xFF10B981),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp, start = 8.dp)
)
Spacer(modifier = Modifier.height(8.dp))
// Use MarkdownText for AI responses
MarkdownText(
markdown = message.message,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(4.dp))
Text(
dateFormat.format(Date(message.timestamp)),
color = AppColors.OnSurfaceTertiary,
fontSize = 11.sp
)
}
}
}
}

View File

@ -1,11 +1,15 @@
package com.example.notesai.presentation.screens.ai.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -14,29 +18,46 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.notesai.util.AppColors
import com.example.notesai.util.Constants
@Composable
fun CompactStatItem(label: String, value: String, color: Color) {
fun CompactStatItem(
icon: androidx.compose.ui.graphics.vector.ImageVector,
value: String,
label: String
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.background(
color = Color(0xFF1E293B),
color = AppColors.SurfaceVariant,
shape = RoundedCornerShape(8.dp)
)
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Text(
value,
style = MaterialTheme.typography.titleMedium,
color = color,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.width(4.dp))
Text(
label,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF94A3B8)
Icon(
icon,
contentDescription = null,
tint = AppColors.Primary,
modifier = Modifier.size(16.dp)
)
Column {
Text(
value,
style = MaterialTheme.typography.titleMedium,
color = AppColors.OnBackground,
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
Text(
label,
style = MaterialTheme.typography.bodySmall,
color = AppColors.OnSurfaceTertiary,
fontSize = 11.sp
)
}
}
}

View File

@ -1,6 +1,7 @@
package com.example.notesai.presentation.screens.ai.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
@ -8,44 +9,49 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lightbulb
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.notesai.util.AppColors
import com.example.notesai.util.Constants
@Composable
fun SuggestionChip(text: String, onSelect: (String) -> Unit) {
Card(
modifier = Modifier
.padding(vertical = 4.dp)
.clickable { onSelect(text) },
colors = CardDefaults.cardColors(
containerColor = Color(0xFF1E293B)
),
fun SuggestionChip(
text: String,
onSelect: (String) -> Unit
) {
Surface(
onClick = { onSelect(text) },
color = AppColors.SurfaceVariant,
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Star,
Icons.Default.Lightbulb,
contentDescription = null,
tint = Color(0xFF6366F1),
tint = AppColors.Primary,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text,
color = Color.White,
style = MaterialTheme.typography.bodyMedium
color = AppColors.OnSurface,
style = MaterialTheme.typography.bodyMedium,
fontSize = 14.sp
)
}
}

View File

@ -1,4 +1,3 @@
// File: presentation/screens/main/MainScreen.kt
package com.example.notesai.presentation.screens.main
import androidx.compose.foundation.layout.*
@ -28,7 +27,9 @@ fun MainScreen(
onNoteClick: (Note) -> Unit,
onPinToggle: (Note) -> Unit,
onCategoryDelete: (Category) -> Unit,
onCategoryEdit: (Category, String, Long, Long) -> Unit // Parameter baru
onCategoryEdit: (Category, String, Long, Long) -> Unit,
onNoteEdit: (Note) -> Unit = {},
onNoteDelete: (Note) -> Unit = {}
) {
Column(modifier = Modifier.fillMaxSize()) {
if (selectedCategory == null) {
@ -58,7 +59,12 @@ fun MainScreen(
} else {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2),
contentPadding = PaddingValues(16.dp),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 16.dp,
bottom = 100.dp // Extra space untuk floating bottom bar
),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalItemSpacing = 12.dp,
modifier = Modifier.fillMaxSize()
@ -102,7 +108,12 @@ fun MainScreen(
} else {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2),
contentPadding = PaddingValues(16.dp),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 16.dp,
bottom = 100.dp // Extra space untuk floating bottom bar
),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalItemSpacing = 12.dp,
modifier = Modifier.fillMaxSize()
@ -111,7 +122,9 @@ fun MainScreen(
NoteCard(
note = note,
onClick = { onNoteClick(note) },
onPinClick = { onPinToggle(note) }
onPinClick = { onPinToggle(note) },
onEdit = { onNoteEdit(note) },
onDelete = { onNoteDelete(note) }
)
}
}

View File

@ -1,21 +1,29 @@
// File: presentation/screens/main/components/CategoryCard.kt
package com.example.notesai.presentation.screens.main.components
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.material.icons.outlined.FolderOpen
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.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.notesai.data.model.Category
import com.example.notesai.util.AppColors
import com.example.notesai.util.Constants
@Composable
fun CategoryCard(
@ -28,16 +36,33 @@ fun CategoryCard(
var showDeleteConfirm by remember { mutableStateOf(false) }
var showEditDialog by remember { mutableStateOf(false) }
var showMenu by remember { mutableStateOf(false) }
var isPressed by remember { mutableStateOf(false) }
// Smooth scale animation
val scale by animateFloatAsState(
targetValue = if (isPressed) 0.95f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "scale"
)
// Delete Confirmation Dialog
if (showDeleteConfirm) {
AlertDialog(
onDismissRequest = { showDeleteConfirm = false },
title = { Text("Pindahkan ke Sampah?", color = Color.White) },
title = {
Text(
"Pindahkan ke Sampah?",
color = AppColors.OnBackground,
fontWeight = FontWeight.Bold
)
},
text = {
Text(
"Kategori '${category.name}' dan semua catatan di dalamnya akan dipindahkan ke sampah.",
color = Color.White
"Kategori '${category.name}' dan $noteCount catatan di dalamnya akan dipindahkan ke sampah.",
color = AppColors.OnSurfaceVariant
)
},
confirmButton = {
@ -47,23 +72,19 @@ fun CategoryCard(
showDeleteConfirm = false
},
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFEF4444)
containerColor = AppColors.Error
)
) {
Text("Hapus", color = Color.White)
}
},
dismissButton = {
Button(
onClick = { showDeleteConfirm = false },
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF64748B)
)
) {
Text("Batal", color = Color.White)
TextButton(onClick = { showDeleteConfirm = false }) {
Text("Batal", color = AppColors.OnSurfaceVariant)
}
},
containerColor = Color(0xFF1E293B)
containerColor = AppColors.Surface,
shape = RoundedCornerShape(Constants.Radius.Large.dp)
)
}
@ -79,13 +100,19 @@ fun CategoryCard(
)
}
// Main Card - Minimalist Design
Card(
modifier = Modifier
.fillMaxWidth()
.scale(scale)
.clickable(onClick = onClick),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
shape = RoundedCornerShape(Constants.Radius.Large.dp),
colors = CardDefaults.cardColors(
containerColor = AppColors.Surface
),
elevation = CardDefaults.cardElevation(
defaultElevation = Constants.Elevation.Small.dp
)
) {
Box(
modifier = Modifier
@ -93,94 +120,146 @@ fun CategoryCard(
.background(
brush = Brush.linearGradient(
colors = listOf(
Color(category.gradientStart),
Color(category.gradientEnd)
Color(category.gradientStart).copy(alpha = 0.1f),
Color(category.gradientEnd).copy(alpha = 0.05f)
)
)
)
.padding(20.dp)
) {
Column {
Icon(
Icons.Default.Folder,
contentDescription = null,
tint = Color.White.copy(0.9f),
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.height(12.dp))
Text(
category.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = Color.White
)
Spacer(modifier = Modifier.height(4.dp))
Text(
"$noteCount catatan",
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(0.8f)
)
}
// Menu Button (Titik Tiga)
Box(
modifier = Modifier.align(Alignment.TopEnd)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(Constants.Spacing.Large.dp)
) {
IconButton(
onClick = { showMenu = true }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Icon(
Icons.Default.MoreVert,
contentDescription = "Menu",
tint = Color.White.copy(0.9f)
)
// Icon dengan gradient accent
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(Constants.Radius.Medium.dp))
.background(
brush = Brush.linearGradient(
colors = listOf(
Color(category.gradientStart).copy(alpha = 0.2f),
Color(category.gradientEnd).copy(alpha = 0.1f)
)
)
),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Outlined.FolderOpen,
contentDescription = null,
tint = Color(category.gradientStart),
modifier = Modifier.size(24.dp)
)
}
// Menu Button
Box {
IconButton(
onClick = { showMenu = true },
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.MoreVert,
contentDescription = "Menu",
tint = AppColors.OnSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
modifier = Modifier.background(AppColors.SurfaceElevated)
) {
DropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Constants.Spacing.Small.dp)
) {
Icon(
Icons.Default.Edit,
contentDescription = null,
tint = AppColors.Primary,
modifier = Modifier.size(18.dp)
)
Text(
"Edit Kategori",
color = AppColors.OnSurface,
fontSize = 14.sp
)
}
},
onClick = {
showMenu = false
showEditDialog = true
}
)
DropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Constants.Spacing.Small.dp)
) {
Icon(
Icons.Default.Delete,
contentDescription = null,
tint = AppColors.Error,
modifier = Modifier.size(18.dp)
)
Text(
"Pindah ke Sampah",
color = AppColors.OnSurface,
fontSize = 14.sp
)
}
},
onClick = {
showMenu = false
showDeleteConfirm = true
}
)
}
}
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
modifier = Modifier.background(Color(0xFF1E293B))
) {
DropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Edit,
contentDescription = null,
tint = Color(0xFF6366F1),
modifier = Modifier.size(20.dp)
)
Text("Edit Kategori", color = Color.White)
}
},
onClick = {
showMenu = false
showEditDialog = true
}
)
Spacer(modifier = Modifier.height(Constants.Spacing.Medium.dp))
DropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Delete,
contentDescription = null,
tint = Color(0xFFEF4444),
modifier = Modifier.size(20.dp)
)
Text("Pindah ke Sampah", color = Color.White)
}
},
onClick = {
showMenu = false
showDeleteConfirm = true
}
// Category Name
Text(
category.name,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = AppColors.OnBackground,
fontSize = 18.sp
)
Spacer(modifier = Modifier.height(Constants.Spacing.ExtraSmall.dp))
// Note Count dengan subtle styling
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
Icons.Default.Description,
contentDescription = null,
tint = AppColors.OnSurfaceTertiary,
modifier = Modifier.size(14.dp)
)
Text(
"$noteCount catatan",
style = MaterialTheme.typography.bodySmall,
color = AppColors.OnSurfaceTertiary,
fontSize = 13.sp
)
}
}
@ -194,31 +273,23 @@ fun EditCategoryDialog(
onDismiss: () -> Unit,
onSave: (String, Long, Long) -> Unit
) {
val gradients = listOf(
Pair(0xFF6366F1L, 0xFFA855F7L),
Pair(0xFFEC4899L, 0xFFF59E0BL),
Pair(0xFF8B5CF6L, 0xFFEC4899L),
Pair(0xFF06B6D4L, 0xFF3B82F6L),
Pair(0xFF10B981L, 0xFF059669L),
Pair(0xFFF59E0BL, 0xFFEF4444L),
Pair(0xFF6366F1L, 0xFF8B5CF6L),
Pair(0xFFEF4444L, 0xFFDC2626L)
)
var name by remember { mutableStateOf(category.name) }
var selectedGradient by remember {
mutableStateOf(
gradients.indexOfFirst {
Constants.CategoryColors.indexOfFirst {
it.first == category.gradientStart && it.second == category.gradientEnd
}.takeIf { it >= 0 } ?: 0
)
}
AlertDialog(
onDismissRequest = onDismiss,
containerColor = Color(0xFF1E293B),
containerColor = AppColors.Surface,
shape = RoundedCornerShape(Constants.Radius.Large.dp),
title = {
Text(
"Edit Kategori",
color = Color.White,
color = AppColors.OnBackground,
fontWeight = FontWeight.Bold
)
},
@ -227,42 +298,51 @@ fun EditCategoryDialog(
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Nama Kategori", color = Color(0xFF94A3B8)) },
label = {
Text(
"Nama Kategori",
color = AppColors.OnSurfaceVariant
)
},
modifier = Modifier.fillMaxWidth(),
colors = TextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedContainerColor = Color(0xFF334155),
unfocusedContainerColor = Color(0xFF334155),
cursorColor = Color(0xFFA855F7),
focusedIndicatorColor = Color(0xFFA855F7),
unfocusedIndicatorColor = Color(0xFF64748B)
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = AppColors.OnBackground,
unfocusedTextColor = AppColors.OnSurface,
focusedContainerColor = AppColors.SurfaceVariant,
unfocusedContainerColor = AppColors.SurfaceVariant,
cursorColor = AppColors.Primary,
focusedBorderColor = AppColors.Primary,
unfocusedBorderColor = AppColors.Border
),
shape = RoundedCornerShape(12.dp)
shape = RoundedCornerShape(Constants.Radius.Medium.dp)
)
Spacer(modifier = Modifier.height(20.dp))
Text(
"Pilih Gradient:",
"Pilih Warna:",
style = MaterialTheme.typography.bodyMedium,
color = Color.White,
fontWeight = FontWeight.SemiBold
color = AppColors.OnSurface,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp
)
Spacer(modifier = Modifier.height(12.dp))
gradients.chunked(4).forEach { row ->
Constants.CategoryColors.chunked(4).forEach { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
row.forEachIndexed { _, gradient ->
val globalIndex = gradients.indexOf(gradient)
val globalIndex = Constants.CategoryColors.indexOf(gradient)
val isSelected = selectedGradient == globalIndex
Box(
modifier = Modifier
.weight(1f)
.aspectRatio(1f)
.clip(RoundedCornerShape(12.dp))
.clip(RoundedCornerShape(Constants.Radius.Medium.dp))
.background(
brush = Brush.linearGradient(
colors = listOf(
@ -274,7 +354,11 @@ fun EditCategoryDialog(
.clickable { selectedGradient = globalIndex },
contentAlignment = Alignment.Center
) {
if (selectedGradient == globalIndex) {
this@Row.AnimatedVisibility(
visible = isSelected,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
Icon(
Icons.Default.Check,
contentDescription = null,
@ -293,19 +377,13 @@ fun EditCategoryDialog(
Button(
onClick = {
if (name.isNotBlank()) {
val gradient = gradients[selectedGradient]
val gradient = Constants.CategoryColors[selectedGradient]
onSave(name, gradient.first, gradient.second)
}
},
enabled = name.isNotBlank(),
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent
),
modifier = Modifier.background(
brush = Brush.linearGradient(
colors = listOf(Color(0xFF6366F1), Color(0xFFA855F7))
),
shape = RoundedCornerShape(8.dp)
containerColor = AppColors.Primary
)
) {
Text("Simpan", color = Color.White, fontWeight = FontWeight.Bold)
@ -313,7 +391,7 @@ fun EditCategoryDialog(
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Batal", color = Color(0xFF94A3B8))
Text("Batal", color = AppColors.OnSurfaceVariant)
}
}
)

View File

@ -1,128 +1,278 @@
// File: presentation/screens/main/components/NoteCard.kt
package com.example.notesai.presentation.screens.main.components
import androidx.compose.animation.core.*
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.notesai.data.model.Note
import com.example.notesai.util.AppColors
import com.example.notesai.util.Constants
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.*
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NoteCard(
note: Note,
onClick: () -> Unit,
onPinClick: () -> Unit
onPinClick: () -> Unit,
onEdit: () -> Unit = {},
onDelete: () -> Unit = {}
) {
val dateFormat = SimpleDateFormat("dd MMM, HH:mm", Locale("id", "ID"))
var showMenu by remember { mutableStateOf(false) }
var showDeleteConfirm by remember { mutableStateOf(false) }
// Scale animation on press
var isPressed by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isPressed) 0.95f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "scale"
)
// Delete Confirmation Dialog
if (showDeleteConfirm) {
AlertDialog(
onDismissRequest = { showDeleteConfirm = false },
icon = {
Icon(
Icons.Default.DeleteForever,
contentDescription = null,
tint = AppColors.Error
)
},
title = {
Text(
"Pindahkan ke Sampah?",
color = AppColors.OnBackground,
fontWeight = FontWeight.Bold
)
},
text = {
Text(
"Catatan '${note.title}' akan dipindahkan ke sampah.",
color = AppColors.OnSurfaceVariant
)
},
confirmButton = {
Button(
onClick = {
onDelete()
showDeleteConfirm = false
},
colors = ButtonDefaults.buttonColors(
containerColor = AppColors.Error
)
) {
Text("Hapus", color = Color.White)
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirm = false }) {
Text("Batal", color = AppColors.OnSurfaceVariant)
}
},
containerColor = AppColors.Surface,
shape = RoundedCornerShape(Constants.Radius.Large.dp)
)
}
Card(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = onClick,
),
shape = RoundedCornerShape(16.dp),
.scale(scale)
.combinedClickable(onClick = onClick),
shape = RoundedCornerShape(Constants.Radius.Large.dp),
colors = CardDefaults.cardColors(
containerColor = Color(0xFF1E293B)
containerColor = AppColors.SurfaceVariant
),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
elevation = CardDefaults.cardElevation(
defaultElevation = Constants.Elevation.Small.dp
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.padding(Constants.Spacing.Large.dp)
) {
// Header: Title + Actions (Vertical)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
// Judul
// Title - takes most space
Text(
note.title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = Color.White,
color = AppColors.OnBackground,
modifier = Modifier.weight(1f),
maxLines = 2,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
fontSize = 18.sp
)
IconButton(
onClick = onPinClick,
modifier = Modifier.size(24.dp)
// Vertical Actions Stack
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
Icon(
if (note.isPinned) Icons.Filled.Star else Icons.Outlined.StarBorder,
contentDescription = "Pin",
tint = if (note.isPinned) Color(0xFFFBBF24) else Color.Gray,
modifier = Modifier.size(18.dp)
)
// Menu Button
Box {
IconButton(
onClick = { showMenu = true },
modifier = Modifier.size(28.dp)
) {
Icon(
Icons.Default.MoreVert,
contentDescription = "Menu",
tint = AppColors.OnSurfaceVariant,
modifier = Modifier.size(18.dp)
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
modifier = Modifier.background(AppColors.SurfaceElevated)
) {
DropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Constants.Spacing.Small.dp)
) {
Icon(
Icons.Default.Edit,
contentDescription = null,
tint = AppColors.Primary,
modifier = Modifier.size(18.dp)
)
Text(
"Edit Catatan",
color = AppColors.OnSurface,
fontSize = 14.sp
)
}
},
onClick = {
showMenu = false
onEdit()
}
)
DropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Constants.Spacing.Small.dp)
) {
Icon(
Icons.Default.Delete,
contentDescription = null,
tint = AppColors.Error,
modifier = Modifier.size(18.dp)
)
Text(
"Pindah ke Sampah",
color = AppColors.OnSurface,
fontSize = 14.sp
)
}
},
onClick = {
showMenu = false
showDeleteConfirm = true
}
)
}
}
// Pin Button
IconButton(
onClick = onPinClick,
modifier = Modifier.size(28.dp)
) {
Icon(
if (note.isPinned) Icons.Filled.Star else Icons.Outlined.StarBorder,
contentDescription = "Pin",
tint = if (note.isPinned) AppColors.Warning else AppColors.OnSurfaceVariant,
modifier = Modifier.size(18.dp)
)
}
}
}
// Deskripsi
if (note.content.isNotEmpty()) {
// Deskripsi Preview
if (note.description.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Deskripsi",
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF94A3B8),
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
note.content,
note.description,
style = MaterialTheme.typography.bodyMedium,
maxLines = 4,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
color = Color(0xFFCBD5E1),
lineHeight = 20.sp
color = AppColors.OnSurfaceVariant,
lineHeight = 20.sp,
fontSize = 14.sp
)
} else {
// Tampilkan placeholder jika deskripsi kosong
Spacer(modifier = Modifier.height(12.dp))
Text(
"Tidak ada deskripsi",
style = MaterialTheme.typography.bodyMedium,
color = AppColors.OnSurfaceVariant.copy(alpha = 0.5f),
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
fontSize = 14.sp
)
}
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(16.dp))
// Divider
HorizontalDivider(
color = Color(0xFF334155),
color = AppColors.Divider,
thickness = 1.dp
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(12.dp))
// Timestamp
Text(
dateFormat.format(Date(note.timestamp)),
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF64748B)
)
// Footer: Timestamp
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
dateFormat.format(Date(note.timestamp)),
style = MaterialTheme.typography.bodySmall,
color = AppColors.OnSurfaceTertiary,
fontSize = 12.sp
)
}
}
}
}

View File

@ -1,50 +1,46 @@
package com.example.notesai.presentation.screens.note
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.layout.imeNestedScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor // ✅ ADD
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.*
import com.example.notesai.data.model.Note
import com.example.notesai.presentation.screens.note.components.DraggableMiniMarkdownToolbar
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 java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.*
import kotlin.math.roundToInt
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun EditableFullScreenNoteView(
note: Note,
@ -55,196 +51,245 @@ fun EditableFullScreenNoteView(
onPinToggle: () -> Unit
) {
var title by remember { mutableStateOf(note.title) }
var content by remember { mutableStateOf(note.content) }
var showArchiveDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
val dateFormat = remember { SimpleDateFormat("dd MMMM yyyy, HH:mm", Locale("id", "ID")) }
var isContentFocused by remember { mutableStateOf(false) }
// Dialog Konfirmasi Arsip
if (showArchiveDialog) {
AlertDialog(
onDismissRequest = { showArchiveDialog = false },
title = {
Text(
text = "Arsipkan Catatan?",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
},
text = {
Text(
text = "Catatan ini akan dipindahkan ke arsip. Anda masih bisa mengaksesnya nanti.",
style = MaterialTheme.typography.bodyMedium
)
},
confirmButton = {
TextButton(
onClick = {
if (title.isNotBlank()) {
onSave(title, content)
}
onArchive()
showArchiveDialog = false
}
) {
Text("Arsipkan", color = MaterialTheme.colorScheme.primary)
}
},
dismissButton = {
TextButton(onClick = { showArchiveDialog = false }) {
Text("Batal", color = MaterialTheme.colorScheme.onSurface)
}
}
val editorState = remember(note.id) {
RichEditorState(
AnnotatedStringSerializer.fromJson(note.content)
)
}
// Dialog Konfirmasi Hapus
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = {
Text(
text = "Hapus Catatan?",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
},
text = {
Text(
text = "Catatan ini akan dihapus permanen dan tidak bisa dikembalikan.",
style = MaterialTheme.typography.bodyMedium
)
},
confirmButton = {
TextButton(
onClick = {
onDelete()
showDeleteDialog = false
}
) {
Text("Hapus", color = Color(0xFFEF4444))
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("Batal", color = MaterialTheme.colorScheme.onSurface)
}
val focusRequester = remember { FocusRequester() }
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val scrollState = rememberScrollState()
val scope = rememberCoroutineScope()
val keyboard = LocalSoftwareKeyboardController.current
fun ensureFocus() {
focusRequester.requestFocus()
keyboard?.show()
}
fun saveNote() {
if (title.isNotBlank()) {
onSave(
title,
AnnotatedStringSerializer.toJson(editorState.value.annotatedString)
)
}
}
// 🔥 AUTO SAVE SAAT APP BACKGROUND / KELUAR
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) {
saveNote()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
val dateFormat = remember {
SimpleDateFormat("dd MMMM yyyy, HH:mm", Locale("id", "ID"))
}
val density = LocalDensity.current
val config = LocalConfiguration.current
val screenWidthPx = with(density) { config.screenWidthDp.dp.toPx() }
val screenHeightPx = with(density) { config.screenHeightDp.dp.toPx() }
val marginPx = with(density) { 16.dp.toPx() }
val imeBottomPx = with(density) {
WindowInsets.ime.getBottom(this).toFloat()
}
var toolbarSizePx by remember {
mutableStateOf(androidx.compose.ui.geometry.Size.Zero)
}
var toolbarOffset by remember {
mutableStateOf(Offset(marginPx, screenHeightPx * 0.6f))
}
fun moveToolbar(dx: Float, dy: Float) {
toolbarOffset = toolbarOffset.copy(
x = toolbarOffset.x + dx,
y = toolbarOffset.y + dy
)
}
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
TopAppBar(
title = { },
navigationIcon = {
IconButton(onClick = {
if (title.isNotBlank()) {
onSave(title, content)
Box(modifier = Modifier.fillMaxSize()) {
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = {
saveNote()
onBack()
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, null)
}
},
title = {},
actions = {
IconButton(onClick = {
saveNote()
onPinToggle()
}) {
Icon(
if (note.isPinned) Icons.Filled.Star
else Icons.Outlined.StarBorder,
null
)
}
IconButton(onClick = onArchive) {
Icon(Icons.Default.Archive, null)
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, null)
}
onBack()
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Kembali", tint = MaterialTheme.colorScheme.onBackground)
}
},
actions = {
IconButton(onClick = {
if (title.isNotBlank()) {
onSave(title, content)
)
},
contentWindowInsets = WindowInsets(0)
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.imeNestedScroll()
.verticalScroll(scrollState)
.padding(horizontal = 20.dp)
.padding(
bottom = WindowInsets.ime
.asPaddingValues()
.calculateBottomPadding()
)
) {
TextField(
value = title,
onValueChange = { title = it },
textStyle = MaterialTheme.typography.headlineLarge.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
),
placeholder = { Text("Judul") },
modifier = Modifier.fillMaxWidth(),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
)
)
Spacer(Modifier.height(12.dp))
Text(
"Terakhir diubah: ${dateFormat.format(Date(note.timestamp))}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f)
)
HorizontalDivider(Modifier.padding(vertical = 20.dp))
// ✅ FIX UTAMA: set cursorBrush agar insertion cursor muncul
BasicTextField(
value = editorState.value,
onValueChange = {
editorState.onValueChange(it)
scope.launch {
bringIntoViewRequester.bringIntoView()
}
onPinToggle()
}) {
Icon(
if (note.isPinned) Icons.Filled.Star else Icons.Outlined.StarBorder,
contentDescription = "Pin Catatan",
tint = if (note.isPinned) Color(0xFFFBBF24) else MaterialTheme.colorScheme.onSurface
},
cursorBrush = SolidColor(Color(0xFFA855F7)), // ✅ ADD THIS
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onBackground,
lineHeight = 28.sp
),
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 400.dp)
.focusRequester(focusRequester)
.bringIntoViewRequester(bringIntoViewRequester)
.onFocusChanged {
isContentFocused = it.isFocused
if (it.isFocused) {
scope.launch { bringIntoViewRequester.bringIntoView() }
}
},
decorationBox = { innerTextField ->
Box {
if (editorState.value.text.isEmpty()) {
Text(
"Mulai menulis...",
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.4f)
)
}
innerTextField()
}
}
)
Spacer(Modifier.height(180.dp))
}
}
if (isContentFocused) {
DraggableMiniMarkdownToolbar(
modifier = Modifier
.align(Alignment.TopStart)
.offset {
val maxX =
(screenWidthPx - toolbarSizePx.width - marginPx)
.coerceAtLeast(marginPx)
val maxY =
(screenHeightPx - imeBottomPx - toolbarSizePx.height)
.coerceAtLeast(marginPx)
IntOffset(
toolbarOffset.x.coerceIn(marginPx, maxX).roundToInt(),
toolbarOffset.y.coerceIn(marginPx, maxY).roundToInt()
)
}
IconButton(onClick = { showArchiveDialog = true }) {
Icon(Icons.Default.Archive, contentDescription = "Arsipkan", tint = MaterialTheme.colorScheme.onSurface)
}
IconButton(onClick = { showDeleteDialog = true }) {
Icon(Icons.Default.Delete, contentDescription = "Hapus", tint = MaterialTheme.colorScheme.onSurface)
}
.onSizeChanged {
toolbarSizePx = androidx.compose.ui.geometry.Size(
it.width.toFloat(),
it.height.toFloat()
)
},
isBoldActive = editorState.isBoldActive(),
isItalicActive = editorState.isItalicActive(),
isUnderlineActive = editorState.isUnderlineActive(),
onDrag = ::moveToolbar,
onBold = {
ensureFocus()
editorState.toggleBold()
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
) {
TextField(
value = title,
onValueChange = { title = it },
textStyle = MaterialTheme.typography.headlineLarge.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
),
placeholder = {
Text(
"Judul",
style = MaterialTheme.typography.headlineLarge,
color = Color(0xFF64748B)
)
onItalic = {
ensureFocus()
editorState.toggleItalic()
},
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = Color(0xFFA855F7)
),
modifier = Modifier.fillMaxWidth()
onUnderline = { ensureFocus(); editorState.toggleUnderline() },
onHeading = { ensureFocus(); editorState.setHeading(1) }, // sementara H1
onBullet = { ensureFocus(); editorState.toggleBulletList() }
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Terakhir diubah: ${dateFormat.format(Date(note.timestamp))}",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF64748B)
)
Divider(
modifier = Modifier.padding(vertical = 20.dp),
color = MaterialTheme.colorScheme.surface
)
TextField(
value = content,
onValueChange = { content = it },
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface,
lineHeight = 28.sp
),
placeholder = {
Text(
"Mulai menulis...",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF64748B)
)
},
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = Color(0xFFA855F7)
),
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 400.dp)
)
Spacer(modifier = Modifier.height(100.dp))
}
}
}
}

View File

@ -0,0 +1,131 @@
package com.example.notesai.presentation.screens.note.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
@Composable
fun DraggableMiniMarkdownToolbar(
modifier: Modifier = Modifier,
onDrag: (dx: Float, dy: Float) -> Unit,
// STATE
isBoldActive: Boolean,
isItalicActive: Boolean,
isUnderlineActive: Boolean,
// ACTIONS
onBold: () -> Unit,
onItalic: () -> Unit,
onHeading: () -> Unit,
onUnderline: () -> Unit,
onBullet: () -> Unit
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
shadowElevation = 6.dp
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
// 🔹 DRAG HANDLE
Box(
modifier = Modifier
.size(36.dp)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
onDrag(dragAmount.x, dragAmount.y)
}
},
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.DragIndicator,
contentDescription = "Drag",
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f),
modifier = Modifier.size(18.dp)
)
}
ToolbarIcon(
icon = Icons.Default.FormatBold,
isActive = isBoldActive,
onClick = onBold
)
ToolbarIcon(
icon = Icons.Default.FormatItalic,
isActive = isItalicActive,
onClick = onItalic
)
ToolbarIcon(
icon = Icons.Default.FormatUnderlined,
isActive = isUnderlineActive,
onClick = onUnderline
)
ToolbarIcon(
icon = Icons.Default.Title,
onClick = onHeading
)
ToolbarIcon(
icon = Icons.Default.FormatListBulleted,
onClick = onBullet
)
}
}
}
@Composable
private fun ToolbarIcon(
icon: androidx.compose.ui.graphics.vector.ImageVector,
onClick: () -> Unit,
isActive: Boolean = false
) {
val activeBg = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
val activeColor = MaterialTheme.colorScheme.primary
Box(
modifier = Modifier
.size(36.dp)
.background(
color = if (isActive) activeBg else androidx.compose.ui.graphics.Color.Transparent,
shape = RoundedCornerShape(10.dp)
),
contentAlignment = Alignment.Center
) {
IconButton(
onClick = onClick,
modifier = Modifier.size(36.dp)
) {
Icon(
icon,
contentDescription = null,
tint = if (isActive) activeColor else MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(18.dp)
)
}
}
}

View File

@ -0,0 +1,434 @@
package com.example.notesai.presentation.screens.note.editor
import androidx.compose.runtime.*
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.sp
@Stable
class RichEditorState(initial: AnnotatedString) {
var value by mutableStateOf(
TextFieldValue(
annotatedString = initial,
selection = TextRange(initial.length)
)
)
/* =====================
UNDO / REDO
===================== */
private val undoStack = mutableStateListOf<TextFieldValue>()
private val redoStack = mutableStateListOf<TextFieldValue>()
private fun snapshot() {
undoStack.add(value)
redoStack.clear()
}
fun canUndo() = undoStack.isNotEmpty()
fun canRedo() = redoStack.isNotEmpty()
fun undo() {
if (!canUndo()) return
redoStack.add(value)
value = undoStack.removeLast()
}
fun redo() {
if (!canRedo()) return
undoStack.add(value)
value = redoStack.removeLast()
}
/* =====================
STICKY TYPING STYLE
===================== */
private val activeStyles = mutableStateListOf<SpanStyle>()
/* =====================
VALUE CHANGE (KEY)
===================== */
fun onValueChange(newValue: TextFieldValue) {
val old = value
// cursor/selection change only
if (newValue.text == old.text) {
value = old.copy(
selection = newValue.selection,
composition = newValue.composition
)
return
}
snapshot()
// 1) build new annotated string by preserving old spans
val built = buildPreservingSpans(old, newValue)
// 2) auto-convert markdown patterns around cursor
val converted = autoConvertMarkdown(built)
value = converted
}
private fun buildPreservingSpans(old: TextFieldValue, newValue: TextFieldValue): TextFieldValue {
val builder = AnnotatedString.Builder(newValue.text)
// copy old spans (clamped)
old.annotatedString.spanStyles.forEach { r ->
val s = r.start.coerceIn(0, newValue.text.length)
val e = r.end.coerceIn(0, newValue.text.length)
if (s < e) builder.addStyle(r.item, s, e)
}
// apply sticky styles to newly inserted char (simple heuristic)
val insertPos = newValue.selection.start - 1
if (insertPos >= 0 && insertPos < newValue.text.length) {
activeStyles.forEach { st ->
builder.addStyle(st, insertPos, insertPos + 1)
}
}
return TextFieldValue(
annotatedString = builder.toAnnotatedString(),
selection = newValue.selection,
composition = newValue.composition
)
}
/* =====================
TOOLBAR TOGGLES
===================== */
fun toggleBold() = toggleStyle(SpanStyle(fontWeight = FontWeight.Bold))
fun toggleItalic() = toggleStyle(SpanStyle(fontStyle = FontStyle.Italic))
fun toggleUnderline() = toggleStyle(SpanStyle(textDecoration = TextDecoration.Underline))
private fun toggleStyle(style: SpanStyle) {
val sel = value.selection.normalized()
snapshot()
if (!sel.collapsed) applyStyleToSelection(style)
else toggleTypingStyle(style)
}
private fun toggleTypingStyle(style: SpanStyle) {
val idx = activeStyles.indexOfFirst { it.hasSameStyle(style) }
if (idx >= 0) activeStyles.removeAt(idx) else activeStyles.add(style)
}
private fun applyStyleToSelection(style: SpanStyle) {
val sel = value.selection.normalized()
val start = sel.start
val end = sel.end
if (start >= end) return
val builder = AnnotatedString.Builder(value.text)
value.annotatedString.spanStyles.forEach { r ->
val overlap = r.start < end && r.end > start
val same = r.item.hasSameStyle(style)
if (!(overlap && same)) builder.addStyle(r.item, r.start, r.end)
}
builder.addStyle(style, start, end)
value = value.copy(
annotatedString = builder.toAnnotatedString(),
selection = TextRange(end)
)
}
/* =====================
HEADING / BULLET (for toolbar)
===================== */
fun setHeading(level: Int) {
snapshot()
val sel = value.selection
val text = value.text
val lineStart = text.lastIndexOf('\n', (sel.start - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }
val lineEnd = text.indexOf('\n', sel.start).let { if (it == -1) text.length else it }
val size = when (level) {
1 -> 28.sp
2 -> 22.sp
3 -> 18.sp
else -> return
}
val builder = AnnotatedString.Builder(text)
value.annotatedString.spanStyles.forEach { r -> builder.addStyle(r.item, r.start, r.end) }
builder.addStyle(SpanStyle(fontSize = size, fontWeight = FontWeight.Bold), lineStart, lineEnd)
value = value.copy(annotatedString = builder.toAnnotatedString())
}
fun toggleBulletList() {
snapshot()
val sel = value.selection
val text = value.text
val prefix = ""
val lineStart = text.lastIndexOf('\n', (sel.start - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }
val isBullet = text.startsWith(prefix, startIndex = lineStart)
if (isBullet) {
replaceTextPreserveSpans(
start = lineStart,
end = lineStart + prefix.length,
replacement = "",
newCursor = (sel.start - prefix.length).coerceAtLeast(lineStart)
)
} else {
replaceTextPreserveSpans(
start = lineStart,
end = lineStart,
replacement = prefix,
newCursor = sel.start + prefix.length
)
}
}
/* =====================
AUTO-CONVERT MARKDOWN (LEVEL 3)
===================== */
private fun autoConvertMarkdown(v: TextFieldValue): TextFieldValue {
var cur = v
// order matters: bold before italic
cur = convertBold(cur)
cur = convertItalic(cur)
cur = convertHeading(cur)
cur = convertDashBullet(cur)
return cur
}
// **word** -> bold(word), remove ** **
private fun convertBold(v: TextFieldValue): TextFieldValue {
val text = v.text
val cursor = v.selection.start
if (cursor < 2) return v
if (!(text.getOrNull(cursor - 1) == '*' && text.getOrNull(cursor - 2) == '*')) return v
val startMarker = text.lastIndexOf("**", startIndex = cursor - 3)
if (startMarker == -1) return v
val contentStart = startMarker + 2
val contentEnd = cursor - 2
if (contentEnd <= contentStart) return v
if (text.substring(contentStart, contentEnd).contains('\n')) return v
// remove end marker then start marker (preserve spans)
var out = v
out = replaceTextPreserveSpansLocal(out, cursor - 2, cursor, "")
out = replaceTextPreserveSpansLocal(out, startMarker, startMarker + 2, "")
// after removing start marker, content shifts -2
val newStart = startMarker
val newEnd = contentEnd - 2
out = addStylePreserve(out, SpanStyle(fontWeight = FontWeight.Bold), newStart, newEnd)
// cursor shifts back 4 chars total
out = out.copy(selection = TextRange((cursor - 4).coerceAtLeast(newEnd)))
return out
}
// *word* -> italic(word), remove * *
private fun convertItalic(v: TextFieldValue): TextFieldValue {
val text = v.text
val cursor = v.selection.start
if (cursor < 1) return v
// avoid triggering on bold closing (**)
if (text.getOrNull(cursor - 1) != '*') return v
if (text.getOrNull(cursor - 2) == '*') return v
val startMarker = text.lastIndexOf('*', startIndex = cursor - 2)
if (startMarker == -1) return v
// avoid ** as start
if (text.getOrNull(startMarker - 1) == '*') return v
val contentStart = startMarker + 1
val contentEnd = cursor - 1
if (contentEnd <= contentStart) return v
if (text.substring(contentStart, contentEnd).contains('\n')) return v
var out = v
out = replaceTextPreserveSpansLocal(out, cursor - 1, cursor, "")
out = replaceTextPreserveSpansLocal(out, startMarker, startMarker + 1, "")
val newStart = startMarker
val newEnd = contentEnd - 1
out = addStylePreserve(out, SpanStyle(fontStyle = FontStyle.Italic), newStart, newEnd)
out = out.copy(selection = TextRange((cursor - 2).coerceAtLeast(newEnd)))
return out
}
// "# " / "## " / "### " at line start -> heading + remove markers
private fun convertHeading(v: TextFieldValue): TextFieldValue {
val text = v.text
val cursor = v.selection.start
val lineStart = text.lastIndexOf('\n', (cursor - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }
val lineEnd = text.indexOf('\n', cursor).let { if (it == -1) text.length else it }
val linePrefix = text.substring(lineStart, minOf(lineStart + 4, text.length))
val level = when {
linePrefix.startsWith("### ") -> 3
linePrefix.startsWith("## ") -> 2
linePrefix.startsWith("# ") -> 1
else -> return v
}
// only trigger when user just typed the space after #'s OR when cursor is still on same line early
val removeLen = when (level) {
1 -> 2
2 -> 3
else -> 4
}
val triggerPos = lineStart + removeLen
if (cursor < triggerPos) return v
var out = v
out = replaceTextPreserveSpansLocal(out, lineStart, lineStart + removeLen, "")
// apply heading style to the line content
val newLineStart = lineStart
val newLineEnd = (lineEnd - removeLen).coerceAtLeast(newLineStart)
val size = when (level) {
1 -> 28.sp
2 -> 22.sp
else -> 18.sp
}
out = addStylePreserve(
out,
SpanStyle(fontSize = size, fontWeight = FontWeight.Bold),
newLineStart,
newLineEnd
)
// shift cursor back by removedLen
out = out.copy(selection = TextRange((cursor - removeLen).coerceAtLeast(newLineStart)))
return out
}
// "- " at line start -> "• "
private fun convertDashBullet(v: TextFieldValue): TextFieldValue {
val text = v.text
val cursor = v.selection.start
val lineStart = text.lastIndexOf('\n', (cursor - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }
val prefix = "- "
if (!text.startsWith(prefix, startIndex = lineStart)) return v
// only trigger when user already typed "- "
if (cursor < lineStart + 2) return v
var out = v
out = replaceTextPreserveSpansLocal(out, lineStart, lineStart + 2, "")
// cursor stays same length (2)
return out
}
/* =====================
TOOLBAR STATE
===================== */
fun isBoldActive() = isStyleActive(fontWeight = FontWeight.Bold)
fun isItalicActive() = isStyleActive(fontStyle = FontStyle.Italic)
fun isUnderlineActive() = isStyleActive(decoration = TextDecoration.Underline)
private fun isStyleActive(
fontWeight: FontWeight? = null,
fontStyle: FontStyle? = null,
decoration: TextDecoration? = null
): Boolean {
val sel = value.selection
if (!sel.collapsed) {
return value.annotatedString.spanStyles.any {
it.start <= sel.start &&
it.end >= sel.end &&
(fontWeight == null || it.item.fontWeight == fontWeight) &&
(fontStyle == null || it.item.fontStyle == fontStyle) &&
(decoration == null || it.item.textDecoration == decoration)
}
}
return activeStyles.any {
(fontWeight == null || it.fontWeight == fontWeight) &&
(fontStyle == null || it.fontStyle == fontStyle) &&
(decoration == null || it.textDecoration == decoration)
}
}
/* =====================
INTERNAL: text replace while preserving spans
===================== */
private fun replaceTextPreserveSpans(start: Int, end: Int, replacement: String, newCursor: Int) {
value = replaceTextPreserveSpansLocal(value, start, end, replacement)
.copy(selection = TextRange(newCursor))
}
private fun replaceTextPreserveSpansLocal(
v: TextFieldValue,
start: Int,
end: Int,
replacement: String
): TextFieldValue {
val oldText = v.text
val s = start.coerceIn(0, oldText.length)
val e = end.coerceIn(0, oldText.length)
if (s > e) return v
val newText = oldText.substring(0, s) + replacement + oldText.substring(e)
val delta = replacement.length - (e - s)
val b = AnnotatedString.Builder(newText)
v.annotatedString.spanStyles.forEach { r ->
var rs = r.start
var re = r.end
// adjust spans
when {
re <= s -> Unit
rs >= e -> { rs += delta; re += delta }
rs < s && re > e -> re += delta
rs < s && re in (s + 1)..e -> re = s
rs in s until e && re > e -> { rs = s + replacement.length; re += delta }
else -> return@forEach
}
rs = rs.coerceIn(0, newText.length)
re = re.coerceIn(0, newText.length)
if (rs < re) b.addStyle(r.item, rs, re)
}
return v.copy(annotatedString = b.toAnnotatedString())
}
private fun addStylePreserve(v: TextFieldValue, style: SpanStyle, start: Int, end: Int): TextFieldValue {
val s = start.coerceIn(0, v.text.length)
val e = end.coerceIn(0, v.text.length)
if (s >= e) return v
val b = AnnotatedString.Builder(v.text)
v.annotatedString.spanStyles.forEach { r -> b.addStyle(r.item, r.start, r.end) }
b.addStyle(style, s, e)
return v.copy(annotatedString = b.toAnnotatedString())
}
}
/* =====================
HELPERS
===================== */
private fun SpanStyle.hasSameStyle(other: SpanStyle): Boolean =
fontWeight == other.fontWeight &&
fontStyle == other.fontStyle &&
textDecoration == other.textDecoration
private fun TextRange.normalized(): TextRange =
if (start <= end) this else TextRange(end, start)

View File

@ -1,13 +1,16 @@
package com.example.notesai.presentation.screens.starred
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.notesai.presentation.components.EmptyState
import com.example.notesai.presentation.screens.starred.components.StarredNoteCard
@ -20,31 +23,37 @@ fun StarredNotesScreen(
notes: List<Note>,
categories: List<Category>,
onNoteClick: (Note) -> Unit,
onMenuClick: () -> Unit,
onBack: () -> Unit,
onUnpin: (Note) -> Unit
) {
val starredNotes = notes.filter { it.isPinned && !it.isArchived && !it.isDeleted }
.sortedByDescending { it.timestamp }
if (starredNotes.isEmpty()) {
EmptyState(
icon = Icons.Default.Star,
message = "Belum ada catatan berbintang",
subtitle = "Catatan yang ditandai berbintang akan muncul di sini"
)
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(starredNotes) { note ->
val category = categories.find { it.id == note.categoryId }
StarredNoteCard(
note = note,
categoryName = category?.name ?: "Unknown",
onClick = { onNoteClick(note) },
onUnpin = { onUnpin(note) }
)
Column(modifier = Modifier.fillMaxSize()) {
if (starredNotes.isEmpty()) {
EmptyState(
icon = Icons.Default.Star,
message = "Belum ada catatan berbintang",
subtitle = "Catatan yang ditandai berbintang akan muncul di sini"
)
} else {
LazyColumn(
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 16.dp,
bottom = 100.dp // Extra space untuk bottom bar
),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(starredNotes) { note ->
val category = categories.find { it.id == note.categoryId }
StarredNoteCard(
note = note,
categoryName = category?.name ?: "Unknown",
onClick = { onNoteClick(note) },
onUnpin = { onUnpin(note) }
)
}
}
}
}

View File

@ -0,0 +1,66 @@
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
@Serializable
data class SpanDto(
val start: Int,
val end: Int,
val bold: Boolean = false,
val italic: Boolean = false,
val underline: Boolean = false
)
@Serializable
data class RichTextDto(
val text: String,
val spans: List<SpanDto>
)
object AnnotatedStringSerializer {
fun toJson(value: AnnotatedString): String {
val spans = value.spanStyles.map {
SpanDto(
start = it.start,
end = it.end,
bold = it.item.fontWeight != null,
italic = it.item.fontStyle != null,
underline = it.item.textDecoration != null
)
}
return Json.encodeToString(
RichTextDto(
text = value.text,
spans = spans
)
)
}
fun fromJson(json: String): AnnotatedString {
return try {
val dto = Json.decodeFromString<RichTextDto>(json)
val builder = AnnotatedString.Builder(dto.text)
dto.spans.forEach {
builder.addStyle(
SpanStyle(
fontWeight = if (it.bold) androidx.compose.ui.text.font.FontWeight.Bold else null,
fontStyle = if (it.italic) androidx.compose.ui.text.font.FontStyle.Italic else null,
textDecoration = if (it.underline) androidx.compose.ui.text.style.TextDecoration.Underline else null
),
it.start,
it.end
)
}
builder.toAnnotatedString()
} catch (e: Exception) {
AnnotatedString(json) // fallback plain
}
}
}

View File

@ -1,12 +1,14 @@
// File: util/Constants.kt
package com.example.notesai.util
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
object Constants {
// App Info
const val APP_NAME = "AI Notes"
const val APP_VERSION = "1.0.0"
const val APP_NAME = "NotesAI"
const val APP_VERSION = "1.1.0"
// DataStore
const val DATASTORE_NAME = "notes_prefs"
@ -17,37 +19,175 @@ object Constants {
const val MAX_CHAT_PREVIEW_LINES = 2
const val GRID_COLUMNS = 2
// Gradients
val GRADIENT_PRESETS = listOf(
Pair(0xFF6366F1L, 0xFFA855F7L),
Pair(0xFFEC4899L, 0xFFF59E0BL),
Pair(0xFF8B5CF6L, 0xFFEC4899L),
Pair(0xFF06B6D4L, 0xFF3B82F6L),
Pair(0xFF10B981L, 0xFF059669L),
Pair(0xFFF59E0BL, 0xFFEF4444L),
Pair(0xFF6366F1L, 0xFF8B5CF6L),
Pair(0xFFEF4444L, 0xFFDC2626L)
)
// Colors
object AppColors {
val Primary = Color(0xFF6366F1)
val Secondary = Color(0xFFA855F7)
val Background = Color(0xFF0F172A)
val Surface = Color(0xFF1E293B)
val SurfaceVariant = Color(0xFF334155)
val OnBackground = Color(0xFFE2E8F0)
val OnSurface = Color(0xFFE2E8F0)
// DARK THEME COLORS
object DarkColors {
val Background = Color(0xFF0A0A0A)
val Surface = Color(0xFF141414)
val SurfaceVariant = Color(0xFF1E1E1E)
val SurfaceElevated = Color(0xFF252525)
val Primary = Color(0xFF3B82F6)
val PrimaryVariant = Color(0xFF60A5FA)
val PrimaryContainer = Color(0xFF1E3A8A)
val Secondary = Color(0xFF8B5CF6)
val SecondaryVariant = Color(0xFFA78BFA)
val OnBackground = Color(0xFFFFFFFF)
val OnSurface = Color(0xFFE5E5E5)
val OnSurfaceVariant = Color(0xFF9CA3AF)
val OnSurfaceTertiary = Color(0xFF6B7280)
val Success = Color(0xFF10B981)
val Error = Color(0xFFEF4444)
val Warning = Color(0xFFFBBF24)
val TextSecondary = Color(0xFF94A3B8)
val TextTertiary = Color(0xFF64748B)
val Divider = Color(0xFF334155)
val Info = Color(0xFF3B82F6)
val Border = Color(0xFF2A2A2A)
val Divider = Color(0xFF1F1F1F)
val Overlay = Color(0xFF000000).copy(alpha = 0.5f)
val Shadow = Color(0xFF000000).copy(alpha = 0.3f)
}
// Animation
const val ANIMATION_DURATION = 300
// LIGHT THEME COLORS
object LightColors {
val Background = Color(0xFFF8F9FA)
val Surface = Color(0xFFFFFFFF)
val SurfaceVariant = Color(0xFFF1F3F5)
val SurfaceElevated = Color(0xFFFFFFFF)
val Primary = Color(0xFF3B82F6)
val PrimaryVariant = Color(0xFF2563EB)
val PrimaryContainer = Color(0xFFDCEEFF)
val Secondary = Color(0xFF8B5CF6)
val SecondaryVariant = Color(0xFF7C3AED)
val OnBackground = Color(0xFF1F2937)
val OnSurface = Color(0xFF374151)
val OnSurfaceVariant = Color(0xFF6B7280)
val OnSurfaceTertiary = Color(0xFF9CA3AF)
val Success = Color(0xFF10B981)
val Error = Color(0xFFEF4444)
val Warning = Color(0xFFA16207)
val Info = Color(0xFF3B82F6)
val Border = Color(0xFFE5E7EB)
val Divider = Color(0xFFF3F4F6)
val Overlay = Color(0xFF000000).copy(alpha = 0.3f)
val Shadow = Color(0xFF000000).copy(alpha = 0.1f)
}
// Category Colors - Same for both themes
val CategoryColors = listOf(
Pair(0xFF3B82F6L, 0xFF60A5FAL), // Blue
Pair(0xFF8B5CF6L, 0xFFA78BFAL), // Purple
Pair(0xFF10B981L, 0xFF34D399L), // Green
Pair(0xFFF59E0BL, 0xFFFBBF24L), // Amber
Pair(0xFFEF4444L, 0xFFF87171L), // Red
Pair(0xFF06B6D4L, 0xFF22D3EEL), // Cyan
Pair(0xFFEC4899L, 0xFFF472B6L), // Pink
Pair(0xFF6366F1L, 0xFF818CF8L) // Indigo
)
// Animation Durations
const val ANIMATION_DURATION_SHORT = 150
const val ANIMATION_DURATION_MEDIUM = 300
const val ANIMATION_DURATION_LONG = 500
const val FADE_IN_DURATION = 200
const val FADE_OUT_DURATION = 200
// Spacing System
object Spacing {
const val ExtraSmall = 4
const val Small = 8
const val Medium = 16
const val Large = 24
const val ExtraLarge = 32
const val XXLarge = 48
}
// Corner Radius
object Radius {
const val Small = 8
const val Medium = 12
const val Large = 16
const val ExtraLarge = 20
const val Round = 999
}
// Elevation
object Elevation {
const val None = 0
const val Small = 2
const val Medium = 4
const val Large = 8
const val ExtraLarge = 16
}
}
// REACTIVE APP COLORS - Using Compose State
object AppColors {
// Internal state
private var _isDark by mutableStateOf(true)
// Public setter
fun setTheme(isDark: Boolean) {
_isDark = isDark
}
// All colors are now reactive via mutableStateOf
val Background: Color
get() = if (_isDark) Constants.DarkColors.Background else Constants.LightColors.Background
val Surface: Color
get() = if (_isDark) Constants.DarkColors.Surface else Constants.LightColors.Surface
val SurfaceVariant: Color
get() = if (_isDark) Constants.DarkColors.SurfaceVariant else Constants.LightColors.SurfaceVariant
val SurfaceElevated: Color
get() = if (_isDark) Constants.DarkColors.SurfaceElevated else Constants.LightColors.SurfaceElevated
val Primary: Color
get() = if (_isDark) Constants.DarkColors.Primary else Constants.LightColors.Primary
val PrimaryVariant: Color
get() = if (_isDark) Constants.DarkColors.PrimaryVariant else Constants.LightColors.PrimaryVariant
val PrimaryContainer: Color
get() = if (_isDark) Constants.DarkColors.PrimaryContainer else Constants.LightColors.PrimaryContainer
val Secondary: Color
get() = if (_isDark) Constants.DarkColors.Secondary else Constants.LightColors.Secondary
val SecondaryVariant: Color
get() = if (_isDark) Constants.DarkColors.SecondaryVariant else Constants.LightColors.SecondaryVariant
val OnBackground: Color
get() = if (_isDark) Constants.DarkColors.OnBackground else Constants.LightColors.OnBackground
val OnSurface: Color
get() = if (_isDark) Constants.DarkColors.OnSurface else Constants.LightColors.OnSurface
val OnSurfaceVariant: Color
get() = if (_isDark) Constants.DarkColors.OnSurfaceVariant else Constants.LightColors.OnSurfaceVariant
val OnSurfaceTertiary: Color
get() = if (_isDark) Constants.DarkColors.OnSurfaceTertiary else Constants.LightColors.OnSurfaceTertiary
val Success: Color
get() = if (_isDark) Constants.DarkColors.Success else Constants.LightColors.Success
val Error: Color
get() = if (_isDark) Constants.DarkColors.Error else Constants.LightColors.Error
val Warning: Color
get() = if (_isDark) Constants.DarkColors.Warning else Constants.LightColors.Warning
val Info: Color
get() = if (_isDark) Constants.DarkColors.Info else Constants.LightColors.Info
val Border: Color
get() = if (_isDark) Constants.DarkColors.Border else Constants.LightColors.Border
val Divider: Color
get() = if (_isDark) Constants.DarkColors.Divider else Constants.LightColors.Divider
val Overlay: Color
get() = if (_isDark) Constants.DarkColors.Overlay else Constants.LightColors.Overlay
val Shadow: Color
get() = if (_isDark) Constants.DarkColors.Shadow else Constants.LightColors.Shadow
}

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

View File

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

View File

@ -0,0 +1,289 @@
package com.example.notesai.util
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.notesai.util.AppColors
import com.example.notesai.util.Constants
@Composable
fun MarkdownText(
markdown: String,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
parseMarkdown(markdown).forEach { block ->
when (block) {
is MarkdownBlock.Paragraph -> {
Text(
text = buildAnnotatedString {
appendInlineMarkdown(block.content)
},
style = MaterialTheme.typography.bodyMedium,
color = AppColors.OnSurface,
lineHeight = 24.sp
)
}
is MarkdownBlock.Header -> {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = block.content,
style = when (block.level) {
1 -> MaterialTheme.typography.headlineLarge
2 -> MaterialTheme.typography.headlineMedium
3 -> MaterialTheme.typography.headlineSmall
else -> MaterialTheme.typography.titleLarge
},
color = AppColors.OnBackground,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
}
is MarkdownBlock.CodeBlock -> {
Surface(
modifier = Modifier.fillMaxWidth(),
color = AppColors.SurfaceVariant,
shape = RoundedCornerShape(Constants.Radius.Medium.dp)
) {
Text(
text = block.content,
modifier = Modifier.padding(12.dp),
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall,
color = AppColors.OnSurface,
fontSize = 13.sp
)
}
}
is MarkdownBlock.ListItem -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = if (block.isOrdered) "${block.number}." else "",
color = AppColors.Primary,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.width(20.dp)
)
Text(
text = buildAnnotatedString {
appendInlineMarkdown(block.content)
},
style = MaterialTheme.typography.bodyMedium,
color = AppColors.OnSurface,
modifier = Modifier.weight(1f)
)
}
}
is MarkdownBlock.Quote -> {
Surface(
modifier = Modifier.fillMaxWidth(),
color = AppColors.Primary.copy(alpha = 0.1f),
shape = RoundedCornerShape(Constants.Radius.Medium.dp)
) {
Row {
Spacer(
modifier = Modifier
.width(4.dp)
.fillMaxHeight()
.background(AppColors.Primary)
)
Text(
text = buildAnnotatedString {
appendInlineMarkdown(block.content)
},
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodyMedium,
color = AppColors.OnSurface,
fontStyle = FontStyle.Italic
)
}
}
}
}
}
}
}
// Markdown Block Types
sealed class MarkdownBlock {
data class Paragraph(val content: String) : MarkdownBlock()
data class Header(val level: Int, val content: String) : MarkdownBlock()
data class CodeBlock(val content: String, val language: String? = null) : MarkdownBlock()
data class ListItem(val content: String, val isOrdered: Boolean, val number: Int = 0) : MarkdownBlock()
data class Quote(val content: String) : MarkdownBlock()
}
// Parse markdown into blocks
fun parseMarkdown(text: String): List<MarkdownBlock> {
val blocks = mutableListOf<MarkdownBlock>()
val lines = text.lines()
var i = 0
while (i < lines.size) {
val line = lines[i]
when {
// Code block
line.trimStart().startsWith("```") -> {
val language = line.trimStart().removePrefix("```").trim()
val codeLines = mutableListOf<String>()
i++
while (i < lines.size && !lines[i].trimStart().startsWith("```")) {
codeLines.add(lines[i])
i++
}
blocks.add(MarkdownBlock.CodeBlock(codeLines.joinToString("\n"), language.ifEmpty { null }))
i++
}
// Header
line.trimStart().startsWith("#") -> {
val level = line.takeWhile { it == '#' }.length
val content = line.removePrefix("#".repeat(level)).trim()
blocks.add(MarkdownBlock.Header(level, content))
i++
}
// Quote
line.trimStart().startsWith(">") -> {
val content = line.trimStart().removePrefix(">").trim()
blocks.add(MarkdownBlock.Quote(content))
i++
}
// Unordered list
line.trimStart().matches(Regex("^[-*+]\\s+.*")) -> {
val content = line.trimStart().replaceFirst(Regex("^[-*+]\\s+"), "")
blocks.add(MarkdownBlock.ListItem(content, false))
i++
}
// Ordered list
line.trimStart().matches(Regex("^\\d+\\.\\s+.*")) -> {
val number = line.trimStart().takeWhile { it.isDigit() }.toIntOrNull() ?: 1
val content = line.trimStart().replaceFirst(Regex("^\\d+\\.\\s+"), "")
blocks.add(MarkdownBlock.ListItem(content, true, number))
i++
}
// Empty line - skip
line.isBlank() -> {
i++
}
// Paragraph
else -> {
val paragraphLines = mutableListOf<String>()
while (i < lines.size && lines[i].isNotBlank() &&
!lines[i].trimStart().startsWith("#") &&
!lines[i].trimStart().startsWith(">") &&
!lines[i].trimStart().startsWith("```") &&
!lines[i].trimStart().matches(Regex("^[-*+]\\s+.*")) &&
!lines[i].trimStart().matches(Regex("^\\d+\\.\\s+.*"))) {
paragraphLines.add(lines[i])
i++
}
if (paragraphLines.isNotEmpty()) {
blocks.add(MarkdownBlock.Paragraph(paragraphLines.joinToString(" ")))
}
}
}
}
return blocks
}
// Parse inline markdown (bold, italic, code, links)
fun AnnotatedString.Builder.appendInlineMarkdown(text: String) {
var currentIndex = 0
val inlinePatterns = listOf(
// Bold with **
Regex("""\*\*(.+?)\*\*""") to { content: String ->
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(content)
}
},
// Bold with __
Regex("""__(.+?)__""") to { content: String ->
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(content)
}
},
// Italic with *
Regex("""\*(.+?)\*""") to { content: String ->
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
append(content)
}
},
// Italic with _
Regex("""_(.+?)_""") to { content: String ->
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
append(content)
}
},
// Inline code
Regex("""`(.+?)`""") to { content: String ->
withStyle(
SpanStyle(
fontFamily = FontFamily.Monospace,
background = Color(0xFF1E1E1E),
color = Color(0xFFE5E5E5)
)
) {
append(" $content ")
}
},
// Links [text](url)
Regex("""\[(.+?)]\((.+?)\)""") to { content: String ->
withStyle(
SpanStyle(
color = Color(0xFF3B82F6),
textDecoration = TextDecoration.Underline
)
) {
append(content)
}
}
)
var remainingText = text
val matches = mutableListOf<Triple<Int, Int, (String) -> Unit>>()
// Find all matches
inlinePatterns.forEach { (regex, styleApplier) ->
regex.findAll(remainingText).forEach { match ->
val content = match.groupValues[1]
matches.add(Triple(match.range.first, match.range.last + 1) { styleApplier(content) })
}
}
// Sort matches by start position
val sortedMatches = matches.sortedBy { it.first }
// Apply styles
var lastIndex = 0
sortedMatches.forEach { (start, end, applier) ->
if (start >= lastIndex) {
// Append text before match
append(remainingText.substring(lastIndex, start))
// Apply style
applier("")
lastIndex = end
}
}
// Append remaining text
if (lastIndex < remainingText.length) {
append(remainingText.substring(lastIndex))
}
}

View File

@ -9,6 +9,15 @@ appcompat = "1.6.1"
material = "1.10.0"
activity = "1.8.0"
constraintlayout = "2.1.4"
uiText = "1.10.0"
material3 = "1.4.0"
animationCore = "1.10.0"
firebaseAnnotations = "17.0.0"
firebaseFirestoreKtx = "26.0.2"
uiGraphics = "1.10.0"
roomCompiler = "2.8.4"
glance = "1.1.1"
animation = "1.10.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -19,6 +28,15 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
androidx-ui-text = { group = "androidx.compose.ui", name = "ui-text", version.ref = "uiText" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
androidx-animation-core = { group = "androidx.compose.animation", name = "animation-core", version.ref = "animationCore" }
firebase-annotations = { group = "com.google.firebase", name = "firebase-annotations", version.ref = "firebaseAnnotations" }
firebase-firestore-ktx = { group = "com.google.firebase", name = "firebase-firestore-ktx", version.ref = "firebaseFirestoreKtx" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "uiGraphics" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "roomCompiler" }
androidx-glance = { group = "androidx.glance", name = "glance", version.ref = "glance" }
androidx-animation = { group = "androidx.compose.animation", name = "animation", version.ref = "animation" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }