Compare commits

..

1 Commits

Author SHA1 Message Date
e8da4c5ce9 Hide API KEY 2025-12-13 23:17:51 +07:00
38 changed files with 1674 additions and 5359 deletions

View File

@ -6,6 +6,14 @@
<entry key="Firebase Crashlytics"> <entry key="Firebase Crashlytics">
<value> <value>
<InsightsFilterSettings> <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="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="THIRTY_DAYS" /> <option name="timeIntervalDays" value="THIRTY_DAYS" />
<option name="visibilityType" value="ALL" /> <option name="visibilityType" value="ALL" />

View File

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

View File

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

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

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

@ -1,76 +0,0 @@
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,191 +7,118 @@
* Fazri Abdurrahman * Fazri Abdurrahman
# **Version 1.0.0 Initial Release** # **Version 1.0.0 Initial Release**
## **Sprint 1: Struktur Dasar Aplikasi** ## **Sprint 1: Struktur Dasar Aplikasi**
### **Struktur & Navigation** * **Implementasi struktur navigasi dasar aplikasi** - Setup navigation system
* **Setup navigation system** - Implementasi routing antar halaman (Beranda, Arsip, Sampah) * **Pembuatan menu drawer untuk navigasi screen** - Drawer dengan list menu (Beranda, Berbintang, Arsip, Sampah)
* **Menu Drawer** - Navigation drawer dengan list menu (Beranda, Berbintang, Arsip, Sampah) * **Pembuatan screen Arsip dan Sampah** - Layout dan UI untuk Archive & Trash
* **Bottom Navigation** - Home & AI Helper tabs dengan icon navigation * **Implementasi routing antar halaman** - Navigation flow (Beranda, Arsip, Sampah)
* **Top App Bar** - Menu hamburger dan search icon dengan Material3 styling * **Penambahan Bottom Navigation** - Home & AI Helper tabs
* **Screen Architecture** - Pembuatan screen Arsip, Sampah, Berbintang, AI Helper * **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?"
### **Theme & Styling** ## **Sprint 2: Project Restructuring, Fitur Search, Delete Kategori**
* **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
### **Category Management** * **Fitur search beranda** - Cari kategori berdasarkan nama
* **Category Model** - Data class dengan gradient colors dan timestamp * **Search filtering real-time** - Kategori otomatis filter saat mengetik
* **Category Dialog** - Form tambah/edit kategori dengan nama + gradient picker * **Delete kategori dengan UI** - Tombol X di top-right corner setiap kategori
* **Category Card** - Design dengan icon folder, nama, jumlah catatan, gradient background * **Confirmation dialog untuk delete** - Prevent accidental deletion dengan warning message
* **Staggered Grid Layout** - 2 kolom responsive dengan LazyVerticalStaggeredGrid * **Search di kategori** - Cari catatan berdasarkan judul & isi (case-insensitive)
* **Category Actions** - Menu dropdown (⋮) untuk edit dan delete kategori * **Search empty state** - Tampilkan pesan "Tidak ada hasil" saat search kosong
* **Empty State** - Pesan "Buat kategori pertama Anda" dengan icon * **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
### **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** ## **Fitur Utama Aplikasi**
@ -214,19 +141,17 @@
--- ---
## **Features for Sprint 3 v1.1.0** ## **Planned Features (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 { android {
namespace = "com.example.notesai" namespace = "com.example.notesai"
compileSdk = 35 compileSdk = 34
defaultConfig { defaultConfig {
applicationId = "com.example.notesai" applicationId = "com.example.notesai"
@ -64,12 +64,6 @@ dependencies {
implementation("androidx.datastore:datastore-preferences:1.0.0") implementation("androidx.datastore:datastore-preferences:1.0.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
implementation("com.google.ai.client.generativeai:generativeai:0.9.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) // Untuk integrasi Gemini AI (optional - uncomment jika sudah ada API key)
// implementation("com.google.ai.client.generativeai:generativeai:0.1.2") // implementation("com.google.ai.client.generativeai:generativeai:0.1.2")

View File

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

View File

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

View File

@ -1,5 +1,5 @@
package com.example.notesai.config package com.example.notesai.config
object APIKey { object APIKey {
const val GEMINI_API_KEY = "AIzaSyBzC64RXsNtSERlts_FSd8HXKEpkLdT7-8" const val GEMINI_API_KEY = "MY_GEMINI_KEY"
} }

View File

@ -11,7 +11,6 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.example.notesai.data.model.Note import com.example.notesai.data.model.Note
import com.example.notesai.data.model.Category import com.example.notesai.data.model.Category
import com.example.notesai.data.model.ChatHistory
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -29,8 +28,7 @@ data class SerializableCategory(
val name: String, val name: String,
val gradientStart: Long, val gradientStart: Long,
val gradientEnd: Long, val gradientEnd: Long,
val timestamp: Long, val timestamp: Long
val isDeleted: Boolean = false
) )
@Serializable @Serializable
@ -38,20 +36,17 @@ data class SerializableNote(
val id: String, val id: String,
val categoryId: String, val categoryId: String,
val title: String, val title: String,
val description: String = "", val content: String,
val content: String = "",
val timestamp: Long, val timestamp: Long,
val isArchived: Boolean = false, val isArchived: Boolean,
val isDeleted: Boolean = false, val isDeleted: Boolean,
val isPinned: Boolean = false val isPinned: Boolean
) )
class DataStoreManager(private val context: Context) { class DataStoreManager(private val context: Context) {
companion object { companion object {
val CATEGORIES_KEY = stringPreferencesKey("categories") val CATEGORIES_KEY = stringPreferencesKey("categories")
val NOTES_KEY = stringPreferencesKey("notes") val NOTES_KEY = stringPreferencesKey("notes")
val CHAT_HISTORY_KEY = stringPreferencesKey("chat_history")
val THEME_KEY = stringPreferencesKey("theme") // "dark" or "light"
} }
private val json = Json { private val json = Json {
@ -71,17 +66,9 @@ class DataStoreManager(private val context: Context) {
val jsonString = preferences[CATEGORIES_KEY] ?: "[]" val jsonString = preferences[CATEGORIES_KEY] ?: "[]"
try { try {
json.decodeFromString<List<SerializableCategory>>(jsonString).map { json.decodeFromString<List<SerializableCategory>>(jsonString).map {
Category( Category(it.id, it.name, it.gradientStart, it.gradientEnd, it.timestamp)
id = it.id,
name = it.name,
gradientStart = it.gradientStart,
gradientEnd = it.gradientEnd,
timestamp = it.timestamp,
isDeleted = it.isDeleted
)
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace()
emptyList() emptyList()
} }
} }
@ -99,40 +86,17 @@ class DataStoreManager(private val context: Context) {
try { try {
json.decodeFromString<List<SerializableNote>>(jsonString).map { json.decodeFromString<List<SerializableNote>>(jsonString).map {
Note( Note(
id = it.id, it.id,
categoryId = it.categoryId, it.categoryId,
title = it.title, it.title,
description = it.description, it.content,
content = it.content, it.timestamp,
timestamp = it.timestamp, it.isArchived,
isPinned = it.isPinned, it.isDeleted,
isArchived = it.isArchived, it.isPinned
isDeleted = it.isDeleted
) )
} }
} catch (e: Exception) { } 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() emptyList()
} }
} }
@ -141,14 +105,7 @@ class DataStoreManager(private val context: Context) {
try { try {
context.dataStore.edit { preferences -> context.dataStore.edit { preferences ->
val serializable = categories.map { val serializable = categories.map {
SerializableCategory( SerializableCategory(it.id, it.name, it.gradientStart, it.gradientEnd, it.timestamp)
id = it.id,
name = it.name,
gradientStart = it.gradientStart,
gradientEnd = it.gradientEnd,
timestamp = it.timestamp,
isDeleted = it.isDeleted
)
} }
preferences[CATEGORIES_KEY] = json.encodeToString(serializable) preferences[CATEGORIES_KEY] = json.encodeToString(serializable)
} }
@ -161,17 +118,7 @@ class DataStoreManager(private val context: Context) {
try { try {
context.dataStore.edit { preferences -> context.dataStore.edit { preferences ->
val serializable = notes.map { val serializable = notes.map {
SerializableNote( SerializableNote(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
)
} }
preferences[NOTES_KEY] = json.encodeToString(serializable) preferences[NOTES_KEY] = json.encodeToString(serializable)
} }
@ -179,97 +126,4 @@ class DataStoreManager(private val context: Context) {
e.printStackTrace() 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,14 +1,13 @@
// File: data/model/Category.kt
package com.example.notesai.data.model package com.example.notesai.data.model
import kotlinx.serialization.Serializable
import java.util.UUID import java.util.UUID
@Serializable
data class Category( data class Category(
val id: String = UUID.randomUUID().toString(), val id: String = UUID.randomUUID().toString(),
val name: String, val name: String,
val gradientStart: Long, val gradientStart: Long,
val gradientEnd: Long, val gradientEnd: Long,
val timestamp: Long = System.currentTimeMillis(), val timestamp: Long = System.currentTimeMillis(),
val isDeleted: Boolean = false // Support soft delete val isDeleted: Boolean = false // TAMBAHKAN INI
) )

View File

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

View File

@ -1,352 +1,177 @@
package com.example.notesai.presentation.components package com.example.notesai.presentation.components
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.shape.CircleShape 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.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.outlined.* import androidx.compose.material.icons.filled.Create
import androidx.compose.material3.* import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.* 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.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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 @Composable
fun DrawerMenu( fun DrawerMenu(
currentScreen: String, currentScreen: String,
isDarkTheme: Boolean,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onItemClick: (String) -> Unit, onItemClick: (String) -> Unit
onThemeToggle: () -> Unit
) { ) {
// Backdrop with blur effect
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(AppColors.Overlay) .statusBarsPadding() // Padding untuk status bar
.background(Color.Black.copy(alpha = 0.5f))
.clickable( .clickable(
onClick = onDismiss, onClick = onDismiss,
indication = null, indication = null,
interactionSource = remember { MutableInteractionSource() } interactionSource = remember { MutableInteractionSource() }
) )
) { ) {
// Drawer Content Card(
Surface(
modifier = Modifier modifier = Modifier
.fillMaxHeight() .fillMaxHeight()
.width(280.dp) .width(250.dp)
.align(Alignment.CenterStart) .align(Alignment.CenterStart)
.clickable( .clickable(
onClick = {}, onClick = {},
indication = null, indication = null,
interactionSource = remember { MutableInteractionSource() } interactionSource = remember { MutableInteractionSource() }
), ),
color = AppColors.Surface, shape = RoundedCornerShape(topEnd = 0.dp, bottomEnd = 0.dp),
shadowElevation = Constants.Elevation.ExtraLarge.dp colors = CardDefaults.cardColors(
containerColor = Color(0xFF1E293B)
),
elevation = CardDefaults.cardElevation(defaultElevation = 16.dp)
) { ) {
Column( Column(modifier = Modifier.fillMaxSize()) {
modifier = Modifier.fillMaxSize() // Header Drawer dengan tombol close
) {
// Header - Minimalist
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background( .background(
brush = Brush.verticalGradient( brush = Brush.verticalGradient(
colors = listOf( colors = listOf(Color(0xFF6366F1), Color(0xFFA855F7))
AppColors.Primary.copy(alpha = 0.15f),
Color.Transparent
)
) )
) )
.padding(Constants.Spacing.ExtraLarge.dp) .padding(24.dp)
) { ) {
Column { Row(
// App Icon with subtle background modifier = Modifier.fillMaxWidth(),
Box( horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier verticalAlignment = Alignment.CenterVertically
.size(56.dp) ) {
.background( Column {
color = AppColors.Primary.copy(alpha = 0.2f),
shape = RoundedCornerShape(Constants.Radius.Medium.dp)
),
contentAlignment = Alignment.Center
) {
Icon( Icon(
Icons.Default.Create, Icons.Default.Create,
contentDescription = null, contentDescription = null,
tint = AppColors.Primary, tint = Color.White,
modifier = Modifier.size(32.dp) 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)
) )
} }
Spacer(modifier = Modifier.height(Constants.Spacing.Medium.dp)) // // Tombol Close
// IconButton(
Text( // onClick = onDismiss,
Constants.APP_NAME, // modifier = Modifier
style = MaterialTheme.typography.headlineMedium, // .size(40.dp)
color = AppColors.OnBackground, // .background(
fontWeight = FontWeight.Bold, // Color.White.copy(alpha = 0.2f),
fontSize = 24.sp // shape = CircleShape
) // )
// ) {
Spacer(modifier = Modifier.height(Constants.Spacing.ExtraSmall.dp)) // Icon(
// Icons.Default.Close,
Text( // contentDescription = "Tutup Menu",
"Smart Note Taking", // tint = Color.White,
style = MaterialTheme.typography.bodyMedium, // modifier = Modifier.size(24.dp)
color = AppColors.OnSurfaceVariant, // )
fontSize = 14.sp // }
)
} }
} }
Spacer(modifier = Modifier.height(Constants.Spacing.Large.dp)) Spacer(modifier = Modifier.height(16.dp))
// Menu Items // Menu Items
DrawerMenuItem( MenuItem(
icon = if (currentScreen == "main") Icons.Filled.Home else Icons.Outlined.Home, icon = Icons.Default.Home,
text = "Beranda", text = "Beranda",
isSelected = currentScreen == "main", isSelected = currentScreen == "main"
onClick = { onItemClick("main") } ) { onItemClick("main") }
)
DrawerMenuItem( MenuItem(
icon = if (currentScreen == "starred") Icons.Filled.Star else Icons.Outlined.StarBorder, icon = Icons.Default.Star,
text = "Berbintang", text = "Berbintang",
isSelected = currentScreen == "starred", isSelected = currentScreen == "starred"
onClick = { onItemClick("starred") } ) { onItemClick("starred") }
)
DrawerMenuItem( MenuItem(
icon = if (currentScreen == "archive") Icons.Filled.Archive else Icons.Outlined.Archive, icon = Icons.Default.Archive,
text = "Arsip", text = "Arsip",
isSelected = currentScreen == "archive", isSelected = currentScreen == "archive"
onClick = { onItemClick("archive") } ) { onItemClick("archive") }
)
DrawerMenuItem( MenuItem(
icon = if (currentScreen == "trash") Icons.Filled.Delete else Icons.Outlined.Delete, icon = Icons.Default.Delete,
text = "Sampah", text = "Sampah",
isSelected = currentScreen == "trash", isSelected = currentScreen == "trash"
onClick = { onItemClick("trash") } ) { 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)) Spacer(modifier = Modifier.weight(1f))
// Footer - Version info // Footer
HorizontalDivider( Divider(
color = AppColors.Divider, color = Color.White.copy(alpha = 0.1f),
modifier = Modifier.padding(horizontal = Constants.Spacing.Medium.dp) modifier = Modifier.padding(horizontal = 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( Text(
"Tema Aplikasi", text = "Version 1.0.0",
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, style = MaterialTheme.typography.bodySmall,
color = AppColors.OnSurfaceTertiary, color = Color.White.copy(alpha = 0.5f),
fontSize = 12.sp modifier = Modifier.padding(16.dp)
) )
} }
} }
// 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,33 +1,31 @@
package com.example.notesai.presentation.components package com.example.notesai.presentation.components
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.shape.CircleShape 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.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material3.BottomAppBar
import androidx.compose.material.icons.outlined.Home import androidx.compose.material3.Icon
import androidx.compose.material.icons.outlined.StarBorder import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.* import androidx.compose.material3.Text
import androidx.compose.runtime.* import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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 @Composable
fun ModernBottomBar( fun ModernBottomBar(
@ -35,125 +33,70 @@ fun ModernBottomBar(
onHomeClick: () -> Unit, onHomeClick: () -> Unit,
onAIClick: () -> Unit onAIClick: () -> Unit
) { ) {
// Floating Bottom Bar with Glassmorphism BottomAppBar(
Box( containerColor = Color.Transparent,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .shadow(8.dp, RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp))
.padding(horizontal = 16.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)
)
) { ) {
Surface( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.shadow( .padding(horizontal = 16.dp),
elevation = Constants.Elevation.Large.dp, horizontalArrangement = Arrangement.SpaceEvenly,
shape = RoundedCornerShape(Constants.Radius.ExtraLarge.dp) verticalAlignment = Alignment.CenterVertically
),
color = AppColors.SurfaceElevated,
shape = RoundedCornerShape(Constants.Radius.ExtraLarge.dp)
) { ) {
Row( Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .weight(1f)
.padding(vertical = 8.dp, horizontal = 24.dp), .clickable(onClick = onHomeClick)
horizontalArrangement = Arrangement.SpaceEvenly, .padding(vertical = 8.dp)
verticalAlignment = Alignment.CenterVertically
) { ) {
// Home Button Icon(
BottomBarItem( Icons.Default.Home,
selected = currentScreen == "main", contentDescription = "Home",
onClick = onHomeClick, tint = if (currentScreen == "main") Color(0xFF6366F1) else Color(0xFF94A3B8),
icon = if (currentScreen == "main") Icons.Filled.Home else Icons.Outlined.Home, modifier = Modifier.size(24.dp)
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
)
}
// AI Button Column(
BottomBarItem( horizontalAlignment = Alignment.CenterHorizontally,
selected = currentScreen == "ai", modifier = Modifier
onClick = onAIClick, .weight(1f)
icon = if (currentScreen == "ai") Icons.Filled.AutoAwesome else Icons.Outlined.AutoAwesome, .clickable(onClick = onAIClick)
label = "AI Helper" .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
) )
} }
} }
} }
} }
@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,28 +1,42 @@
package com.example.notesai.presentation.components package com.example.notesai.presentation.components
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.CircleShape 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.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close 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.Menu
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.* 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.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.example.notesai.util.AppColors
import com.example.notesai.util.Constants
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -36,144 +50,58 @@ fun ModernTopBar(
onSearchQueryChange: (String) -> Unit, onSearchQueryChange: (String) -> Unit,
showSearch: Boolean showSearch: Boolean
) { ) {
// Floating Top Bar with same style as Bottom Bar TopAppBar(
Box( title = {
modifier = Modifier if (showSearch) {
.fillMaxWidth() TextField(
.padding(horizontal = 16.dp, vertical = 8.dp) value = searchQuery,
) { onValueChange = onSearchQueryChange,
Surface( placeholder = { Text("Cari catatan...", color = Color.White.copy(0.6f)) },
modifier = Modifier colors = TextFieldDefaults.colors(
.fillMaxWidth() focusedContainerColor = Color.Transparent,
.shadow( unfocusedContainerColor = Color.Transparent,
elevation = Constants.Elevation.Large.dp, focusedTextColor = Color.White,
shape = RoundedCornerShape(Constants.Radius.ExtraLarge.dp) unfocusedTextColor = Color.White,
), cursorColor = Color.White,
color = AppColors.SurfaceElevated, focusedIndicatorColor = Color.Transparent,
shape = RoundedCornerShape(Constants.Radius.ExtraLarge.dp) unfocusedIndicatorColor = Color.Transparent
) { ),
// Smooth transition for search bar modifier = Modifier.fillMaxWidth()
AnimatedContent( )
targetState = showSearch, } else {
transitionSpec = { Text(
fadeIn(animationSpec = tween(Constants.ANIMATION_DURATION_MEDIUM)) togetherWith title,
fadeOut(animationSpec = tween(Constants.ANIMATION_DURATION_MEDIUM)) fontWeight = FontWeight.Bold,
}, fontSize = 22.sp
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)
)
}
}
}
} }
} },
} 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
),
modifier = Modifier
.background(
brush = Brush.horizontalGradient(
colors = listOf(Color(0xFF6366F1), Color(0xFFA855F7))
)
)
)
} }

View File

@ -1,192 +1,163 @@
// File: presentation/dialogs/CategoryDialog.kt package com.example.notesai.presentation.dialogs
package com.example.notesai.presentation.dialogs
import androidx.compose.animation.* import androidx.compose.foundation.background
import androidx.compose.animation.core.* import androidx.compose.foundation.clickable
import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.Row
import androidx.compose.material.icons.Icons import androidx.compose.foundation.layout.Spacer
import androidx.compose.material.icons.filled.Check import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.material3.* import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.* import androidx.compose.foundation.layout.height
import androidx.compose.ui.Alignment import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip import androidx.compose.material.icons.Icons
import androidx.compose.ui.graphics.Brush import androidx.compose.material.icons.filled.Check
import androidx.compose.ui.graphics.Color import androidx.compose.material3.AlertDialog
import androidx.compose.ui.text.font.FontWeight import androidx.compose.material3.Button
import androidx.compose.ui.unit.dp import androidx.compose.material3.ButtonDefaults
import androidx.compose.ui.unit.sp import androidx.compose.material3.Icon
import com.example.notesai.util.AppColors import androidx.compose.material3.MaterialTheme
import com.example.notesai.util.Constants 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
@Composable @Composable
fun CategoryDialog( fun CategoryDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: (String, Long, Long) -> Unit onSave: (String, Long, Long) -> Unit
) { ) {
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
var selectedGradient by remember { mutableStateOf(0) } var selectedGradient by remember { mutableStateOf(0) }
AlertDialog( val gradients = listOf(
onDismissRequest = onDismiss, Pair(0xFF6366F1L, 0xFFA855F7L),
containerColor = AppColors.Surface, Pair(0xFFEC4899L, 0xFFF59E0BL),
shape = RoundedCornerShape(20.dp), Pair(0xFF8B5CF6L, 0xFFEC4899L),
title = { Pair(0xFF06B6D4L, 0xFF3B82F6L),
Text( Pair(0xFF10B981L, 0xFF059669L),
"Buat Kategori Baru", Pair(0xFFF59E0BL, 0xFFEF4444L),
color = AppColors.OnBackground, Pair(0xFF6366F1L, 0xFF8B5CF6L),
fontWeight = FontWeight.Bold, Pair(0xFFEF4444L, 0xFFDC2626L)
fontSize = 20.sp )
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)
) )
},
text = { Spacer(modifier = Modifier.height(20.dp))
Column( Text(
verticalArrangement = Arrangement.spacedBy(20.dp) "Pilih Gradient:",
) { style = MaterialTheme.typography.bodyMedium,
// Input Nama color = Color.White,
OutlinedTextField( fontWeight = FontWeight.SemiBold
value = name, )
onValueChange = { name = it },
label = { Spacer(modifier = Modifier.height(12.dp))
Text(
"Nama Kategori", gradients.chunked(4).forEach { row ->
color = AppColors.OnSurfaceVariant Row(
)
},
placeholder = {
Text(
"Contoh: Pekerjaan, Personal",
color = AppColors.OnSurfaceTertiary,
fontSize = 14.sp
)
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors( horizontalArrangement = Arrangement.spacedBy(8.dp)
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)
) { ) {
Text( row.forEachIndexed { index, gradient ->
"Pilih Warna:", val globalIndex = gradients.indexOf(gradient)
style = MaterialTheme.typography.bodyMedium, Box(
color = AppColors.OnSurface, modifier = Modifier
fontWeight = FontWeight.SemiBold, .weight(1f)
fontSize = 14.sp .aspectRatio(1f)
) .clip(RoundedCornerShape(12.dp))
.background(
Constants.CategoryColors.chunked(4).forEach { row -> brush = Brush.linearGradient(
Row( colors = listOf(
modifier = Modifier.fillMaxWidth(), Color(gradient.first),
horizontalArrangement = Arrangement.spacedBy(8.dp) Color(gradient.second)
) {
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 )
) { .clickable { selectedGradient = globalIndex },
// Check icon dengan animation contentAlignment = Alignment.Center
this@Row.AnimatedVisibility( ) {
visible = isSelected, if (selectedGradient == globalIndex) {
enter = scaleIn() + fadeIn(), Icon(
exit = scaleOut() + fadeOut() Icons.Default.Check,
) { contentDescription = null,
Surface( tint = Color.White,
color = Color.White.copy(alpha = 0.9f), modifier = Modifier.size(24.dp)
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,19 +1,31 @@
package com.example.notesai.presentation.dialogs package com.example.notesai.presentation.dialogs
import androidx.compose.foundation.layout.* 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.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material3.AlertDialog
import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.Button
import androidx.compose.material3.* import androidx.compose.material3.ButtonDefaults
import androidx.compose.runtime.* 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.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.notesai.data.model.Note import com.example.notesai.data.model.Note
import com.example.notesai.util.AppColors
import com.example.notesai.util.Constants
@Composable @Composable
fun NoteDialog( fun NoteDialog(
@ -23,190 +35,89 @@ fun NoteDialog(
onDelete: (() -> Unit)? onDelete: (() -> Unit)?
) { ) {
var title by remember { mutableStateOf(note?.title ?: "") } var title by remember { mutableStateOf(note?.title ?: "") }
var description by remember { mutableStateOf(note?.description ?: "") } var content by remember { mutableStateOf(note?.content ?: "") }
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( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
containerColor = AppColors.Surface, containerColor = Color(0xFF1E293B),
shape = RoundedCornerShape(20.dp),
title = { title = {
Text( Text(
if (note == null) "Catatan Baru" else "Edit Catatan", if (note == null) "Catatan Baru" else "Edit Catatan",
color = AppColors.OnBackground, color = Color.White,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold
fontSize = 20.sp
) )
}, },
text = { text = {
Column( Column {
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Title Input
OutlinedTextField( OutlinedTextField(
value = title, value = title,
onValueChange = { title = it }, onValueChange = { title = it },
label = { label = { Text("Judul", color = Color(0xFF94A3B8)) },
Text(
"Judul",
color = AppColors.OnSurfaceVariant
)
},
placeholder = {
Text(
"Masukkan judul catatan",
color = AppColors.OnSurfaceTertiary,
fontSize = 14.sp
)
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedTextColor = AppColors.OnBackground, focusedTextColor = Color.White,
unfocusedTextColor = AppColors.OnSurface, unfocusedTextColor = Color.White,
focusedContainerColor = AppColors.SurfaceVariant, focusedContainerColor = Color(0xFF334155),
unfocusedContainerColor = AppColors.SurfaceVariant, unfocusedContainerColor = Color(0xFF334155),
cursorColor = AppColors.Primary, cursorColor = Color(0xFFA855F7),
focusedBorderColor = AppColors.Primary, focusedIndicatorColor = Color(0xFFA855F7),
unfocusedBorderColor = Color.Transparent unfocusedIndicatorColor = Color(0xFF64748B)
), ),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp)
singleLine = true
) )
// Description Input Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField( OutlinedTextField(
value = description, value = content,
onValueChange = { description = it }, onValueChange = { content = it },
label = { label = { Text("Isi Catatan", color = Color(0xFF94A3B8)) },
Text(
"Deskripsi",
color = AppColors.OnSurfaceVariant
)
},
placeholder = {
Text(
"Tambahkan deskripsi singkat...",
color = AppColors.OnSurfaceTertiary,
fontSize = 14.sp
)
},
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 120.dp, max = 200.dp), .height(200.dp),
colors = OutlinedTextFieldDefaults.colors( maxLines = 10,
focusedTextColor = AppColors.OnBackground, colors = TextFieldDefaults.colors(
unfocusedTextColor = AppColors.OnSurface, focusedTextColor = Color.White,
focusedContainerColor = AppColors.SurfaceVariant, unfocusedTextColor = Color.White,
unfocusedContainerColor = AppColors.SurfaceVariant, focusedContainerColor = Color(0xFF334155),
cursorColor = AppColors.Primary, unfocusedContainerColor = Color(0xFF334155),
focusedBorderColor = AppColors.Primary, cursorColor = Color(0xFFA855F7),
unfocusedBorderColor = Color.Transparent focusedIndicatorColor = Color(0xFFA855F7),
unfocusedIndicatorColor = Color(0xFF64748B)
), ),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp)
maxLines = 8
) )
} }
}, },
confirmButton = { confirmButton = {
Row( Row {
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Delete button (if editing)
if (onDelete != null) { if (onDelete != null) {
IconButton( TextButton(onClick = onDelete) {
onClick = { showDeleteConfirm = true }, Text("Hapus", color = Color(0xFFEF4444), fontWeight = FontWeight.Bold)
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( Button(
onClick = { onClick = { if (title.isNotBlank()) onSave(title, content) },
if (title.isNotBlank()) {
onSave(title, description)
}
},
enabled = title.isNotBlank(), enabled = title.isNotBlank(),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = AppColors.Primary, containerColor = Color.Transparent
disabledContainerColor = AppColors.Primary.copy(alpha = 0.5f)
), ),
shape = RoundedCornerShape(12.dp), modifier = Modifier.background(
modifier = Modifier.height(48.dp) brush = Brush.linearGradient(
) { colors = listOf(Color(0xFF6366F1), Color(0xFFA855F7))
Text( ),
"Simpan", shape = RoundedCornerShape(8.dp)
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 15.sp
) )
) {
Text("Simpan", color = Color.White, fontWeight = FontWeight.Bold)
} }
} }
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Batal", color = Color(0xFF94A3B8))
}
} }
) )
} }

View File

@ -1,52 +1,71 @@
package com.example.notesai.presentation.screens.ai package com.example.notesai.presentation.screens.ai
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* 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.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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.* import androidx.compose.material.icons.filled.Folder
import androidx.compose.runtime.* import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import com.example.notesai.data.model.Note
import com.example.notesai.data.model.ChatMessage
import com.example.notesai.data.model.Category
import com.example.notesai.config.APIKey import com.example.notesai.config.APIKey
import com.example.notesai.data.local.DataStoreManager import com.example.notesai.presentation.screens.ai.components.ChatBubble
import com.example.notesai.data.model.* import com.example.notesai.presentation.screens.ai.components.CompactStatItem
import com.example.notesai.util.Constants import com.example.notesai.presentation.screens.ai.components.SuggestionChip
import com.example.notesai.util.Constants.AppColors.Divider
import com.google.ai.client.generativeai.GenerativeModel import com.google.ai.client.generativeai.GenerativeModel
import com.google.ai.client.generativeai.type.generationConfig import com.google.ai.client.generativeai.type.generationConfig
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.* import kotlin.collections.plus
import com.example.notesai.presentation.screens.ai.components.ChatBubble
import com.example.notesai.presentation.screens.ai.components.ChatHistoryDrawer
import com.example.notesai.presentation.screens.ai.components.CompactStatItem
import com.example.notesai.presentation.screens.ai.components.SuggestionChip
import com.example.notesai.util.AppColors
private const val MAX_CHAT_TITLE_LENGTH = 30
private fun String.toSafeChatPreview(maxLength: Int = MAX_CHAT_TITLE_LENGTH): String {
return if (this.length > maxLength) {
this.take(maxLength).trim() + "..."
} else {
this.trim()
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -54,27 +73,20 @@ fun AIHelperScreen(
categories: List<Category>, categories: List<Category>,
notes: List<Note> notes: List<Note>
) { ) {
val context = LocalContext.current
val dataStoreManager = remember { DataStoreManager(context) }
var prompt by remember { mutableStateOf("") } var prompt by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) }
var selectedCategory by remember { mutableStateOf<Category?>(null) } var selectedCategory by remember { mutableStateOf<Category?>(null) }
var showCategoryDropdown by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") } var errorMessage by remember { mutableStateOf("") }
var chatMessages by remember { mutableStateOf(listOf<ChatMessage>()) } var chatMessages by remember { mutableStateOf(listOf<ChatMessage>()) }
var showCopiedMessage by remember { mutableStateOf(false) } var showCopiedMessage by remember { mutableStateOf(false) }
var copiedMessageId by remember { mutableStateOf("") } var copiedMessageId by remember { mutableStateOf("") }
var showHistoryDrawer by remember { mutableStateOf(false) }
var currentChatId by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
// Load chat histories // Inisialisasi Gemini Model
val chatHistories by dataStoreManager.chatHistoryFlow.collectAsState(initial = emptyList())
// Inisialisasi Gemini Model
val generativeModel = remember { val generativeModel = remember {
GenerativeModel( GenerativeModel(
modelName = "gemini-2.5-flash", modelName = "gemini-2.5-flash",
@ -97,480 +109,420 @@ fun AIHelperScreen(
} }
} }
// Function to save chat history Column(
fun saveChatHistory() { modifier = Modifier.Companion
if (chatMessages.isNotEmpty()) { .fillMaxSize()
scope.launch { .background(MaterialTheme.colorScheme.background)
val lastMessage = chatMessages.lastOrNull()?.message ?: "" ) {
val preview = lastMessage.toSafeChatPreview() // Header
Card(
val chatHistory = ChatHistory( modifier = Modifier.Companion.fillMaxWidth(),
id = currentChatId ?: UUID.randomUUID().toString(), colors = CardDefaults.cardColors(containerColor = Color.Companion.Transparent),
categoryId = selectedCategory?.id, shape = RoundedCornerShape(0.dp)
categoryName = selectedCategory?.name ?: "Semua Kategori", ) {
messages = chatMessages.map { it.toSerializable() }, Box(
lastMessagePreview = preview, modifier = Modifier.Companion
customTitle = null, .fillMaxWidth()
timestamp = System.currentTimeMillis() .background(
) brush = Brush.Companion.linearGradient(
colors = listOf(Color(0xFF6366F1), Color(0xFFA855F7))
dataStoreManager.addChatHistory(chatHistory) )
currentChatId = chatHistory.id )
.padding(20.dp)
) {
Column {
Row(verticalAlignment = Alignment.Companion.CenterVertically) {
Icon(
Icons.Default.Star,
contentDescription = null,
tint = Color(0xFFFBBF24),
modifier = Modifier.Companion.size(28.dp)
)
Spacer(modifier = Modifier.Companion.width(12.dp))
Column {
Text(
"AI Helper",
style = MaterialTheme.typography.titleLarge,
color = Color.Companion.White,
fontWeight = FontWeight.Companion.Bold
)
Text(
"Powered by Gemini AI",
style = MaterialTheme.typography.bodySmall,
color = Color.Companion.White.copy(0.8f)
)
}
}
}
} }
} }
}
// Function to load chat history // Category Selector & Stats - Compact Version
fun loadChatHistory(history: ChatHistory) {
chatMessages = history.messages.map { it.toChatMessage() }
currentChatId = history.id
selectedCategory = categories.find { it.id == history.categoryId }
showHistoryDrawer = false
}
// Function to start new chat
fun startNewChat() {
chatMessages = emptyList()
currentChatId = null
errorMessage = ""
showHistoryDrawer = false
}
Box(modifier = Modifier.fillMaxSize()) {
Column( Column(
modifier = Modifier modifier = Modifier.Companion
.fillMaxSize() .fillMaxWidth()
.background(AppColors.Background) .background(MaterialTheme.colorScheme.background)
.padding(16.dp)
) { ) {
// Top Bar with History Button & Stats // Category Selector
Surface( Box {
color = AppColors.Surface, Card(
shadowElevation = 2.dp modifier = Modifier.Companion
) {
Column(
modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp) .clickable { showCategoryDropdown = !showCategoryDropdown },
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E293B)),
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp)
) { ) {
// Top Row - Menu & New Chat
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.Companion
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.Companion.CenterVertically
) { ) {
// History Drawer Button Row(verticalAlignment = Alignment.Companion.CenterVertically) {
IconButton(
onClick = { showHistoryDrawer = true },
modifier = Modifier
.size(40.dp)
.background(
AppColors.Primary.copy(alpha = 0.1f),
CircleShape
)
) {
Icon( Icon(
Icons.Default.Menu, Icons.Default.Folder,
contentDescription = "Menu",
tint = AppColors.Primary,
modifier = Modifier.size(20.dp)
)
}
// Category Badge
Surface(
color = AppColors.Primary.copy(alpha = 0.1f),
shape = RoundedCornerShape(Constants.Radius.Medium.dp)
) {
Row(
modifier = Modifier.padding(
horizontal = Constants.Spacing.Medium.dp,
vertical = Constants.Spacing.Small.dp
),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Folder,
contentDescription = null,
tint = AppColors.Primary,
modifier = Modifier.size(16.dp)
)
Text(
selectedCategory?.name ?: "Semua Kategori",
color = AppColors.Primary,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold
)
}
}
// New Chat Button
if (chatMessages.isNotEmpty()) {
Button(
onClick = { startNewChat() },
colors = ButtonDefaults.buttonColors(
containerColor = AppColors.Primary
),
shape = RoundedCornerShape(Constants.Radius.Medium.dp),
contentPadding = PaddingValues(
horizontal = Constants.Spacing.Medium.dp,
vertical = Constants.Spacing.Small.dp
)
) {
Icon(
Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
"Baru",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Stats - Compact
val filteredNotes = if (selectedCategory != null) {
notes.filter { it.categoryId == selectedCategory!!.id && !it.isArchived }
} else {
notes.filter { !it.isArchived }
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
CompactStatItem(
icon = Icons.Default.Description,
value = filteredNotes.size.toString(),
label = "Catatan"
)
CompactStatItem(
icon = Icons.Default.Star,
value = filteredNotes.count { it.isPinned }.toString(),
label = "Dipasang"
)
CompactStatItem(
icon = Icons.Default.Folder,
value = categories.size.toString(),
label = "Kategori"
)
}
}
}
HorizontalDivider(color = AppColors.Divider)
// Chat Area
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) {
if (chatMessages.isEmpty()) {
// Welcome State
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(80.dp)
.background(
color = AppColors.Primary.copy(alpha = 0.1f),
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.AutoAwesome,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(40.dp), tint = Color(0xFF6366F1),
tint = AppColors.Primary modifier = Modifier.Companion.size(20.dp)
) )
} Spacer(modifier = Modifier.Companion.width(8.dp))
Spacer(modifier = Modifier.height(24.dp))
Text(
"AI Assistant",
style = MaterialTheme.typography.headlineMedium,
color = AppColors.OnBackground,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Tanyakan apa saja tentang catatan Anda",
style = MaterialTheme.typography.bodyLarge,
color = AppColors.OnSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(32.dp))
// Suggestion Chips
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(0.85f)
) {
Text( Text(
"Contoh pertanyaan:", selectedCategory?.name ?: "Semua Kategori",
style = MaterialTheme.typography.labelMedium, color = Color.Companion.White,
color = AppColors.OnSurfaceTertiary style = MaterialTheme.typography.bodyMedium
) )
SuggestionChip("Analisis catatan saya") { prompt = it }
SuggestionChip("Buat ringkasan") { prompt = it }
SuggestionChip("Berikan saran organisasi") { prompt = it }
} }
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
tint = Color(0xFF94A3B8)
)
} }
} else { }
// Chat Messages
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 100.dp)
) {
chatMessages.forEach { message ->
ChatBubble(
message = message,
onCopy = {
clipboardManager.setText(AnnotatedString(message.message))
copiedMessageId = message.id
showCopiedMessage = true
scope.launch {
delay(2000)
showCopiedMessage = false
}
},
showCopied = showCopiedMessage && copiedMessageId == message.id
)
Spacer(modifier = Modifier.height(12.dp))
}
// Loading Indicator DropdownMenu(
if (isLoading) { expanded = showCategoryDropdown,
Row( onDismissRequest = { showCategoryDropdown = false },
modifier = Modifier modifier = Modifier.Companion
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp), .background(Color(0xFF1E293B))
horizontalArrangement = Arrangement.Start ) {
) { DropdownMenuItem(
Surface( text = { Text("Semua Kategori", color = Color.Companion.White) },
color = AppColors.SurfaceVariant, onClick = {
shape = RoundedCornerShape(16.dp) selectedCategory = null
) { showCategoryDropdown = false
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = AppColors.Primary,
strokeWidth = 2.dp
)
Text(
"AI sedang berpikir...",
color = AppColors.OnSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
} }
)
// Error Message categories.forEach { category ->
if (errorMessage.isNotEmpty()) { DropdownMenuItem(
Surface( text = { Text(category.name, color = Color.Companion.White) },
modifier = Modifier.fillMaxWidth(), onClick = {
color = AppColors.Error.copy(alpha = 0.1f), selectedCategory = category
shape = RoundedCornerShape(12.dp) showCategoryDropdown = false
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = AppColors.Error,
modifier = Modifier.size(20.dp)
)
Text(
errorMessage,
color = AppColors.Error,
style = MaterialTheme.typography.bodySmall
)
}
} }
} )
} }
} }
} }
// Input Area - Minimalist // Stats - Compact
Surface( Spacer(modifier = Modifier.Companion.height(12.dp))
color = AppColors.Surface, val filteredNotes = if (selectedCategory != null) {
shadowElevation = 8.dp, notes.filter { it.categoryId == selectedCategory!!.id && !it.isArchived }
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp) } else {
notes.filter { !it.isArchived }
}
Row(
modifier = Modifier.Companion.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) { ) {
Row( CompactStatItem(
modifier = Modifier label = "Total",
.fillMaxWidth() value = filteredNotes.size.toString(),
.padding(16.dp), color = Color(0xFF6366F1)
verticalAlignment = Alignment.Bottom, )
horizontalArrangement = Arrangement.spacedBy(12.dp) CompactStatItem(
label = "Dipasang",
value = filteredNotes.count { it.isPinned }.toString(),
color = Color(0xFFFBBF24)
)
CompactStatItem(
label = "Kategori",
value = categories.size.toString(),
color = Color(0xFFA855F7)
)
}
}
Divider(color = Color(0xFF334155), thickness = 1.dp)
// Chat Area
Column(
modifier = Modifier.Companion
.weight(1f)
.fillMaxWidth()
) {
if (chatMessages.isEmpty()) {
// Welcome State
Column(
modifier = Modifier.Companion
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.Companion.CenterHorizontally,
verticalArrangement = Arrangement.Center
) { ) {
OutlinedTextField( Icon(
value = prompt, Icons.Default.Star,
onValueChange = { prompt = it }, contentDescription = null,
placeholder = { modifier = Modifier.Companion.size(64.dp),
Text( tint = Color(0xFF6366F1).copy(0.5f)
"Ketik pesan...", )
color = AppColors.OnSurfaceTertiary Spacer(modifier = Modifier.Companion.height(16.dp))
) Text(
}, "Mulai Percakapan",
modifier = Modifier style = MaterialTheme.typography.titleLarge,
.weight(1f) color = Color.Companion.White,
.heightIn(min = 48.dp, max = 120.dp), fontWeight = FontWeight.Companion.Bold
colors = OutlinedTextFieldDefaults.colors( )
focusedTextColor = AppColors.OnBackground, Spacer(modifier = Modifier.Companion.height(8.dp))
unfocusedTextColor = AppColors.OnSurface, Text(
focusedContainerColor = AppColors.SurfaceVariant, "Tanyakan apa saja tentang catatan Anda",
unfocusedContainerColor = AppColors.SurfaceVariant, style = MaterialTheme.typography.bodyMedium,
cursorColor = AppColors.Primary, color = Color(0xFF94A3B8),
focusedBorderColor = AppColors.Primary, textAlign = TextAlign.Companion.Center
unfocusedBorderColor = Color.Transparent
),
shape = RoundedCornerShape(24.dp),
maxLines = 4
) )
// Send Button Spacer(modifier = Modifier.Companion.height(24.dp))
FloatingActionButton(
onClick = { // Suggestion Chips
if (prompt.isNotBlank() && !isLoading) { Column(
horizontalAlignment = Alignment.Companion.Start,
modifier = Modifier.Companion.fillMaxWidth(0.8f)
) {
Text(
"Contoh pertanyaan:",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF64748B),
modifier = Modifier.Companion.padding(bottom = 8.dp)
)
SuggestionChip("Analisis catatan saya", onSelect = { prompt = it })
SuggestionChip("Buat ringkasan", onSelect = { prompt = it })
SuggestionChip("Berikan saran organisasi", onSelect = { prompt = it })
}
}
} else {
// Chat Messages
Column(
modifier = Modifier.Companion
.fillMaxSize()
.verticalScroll(scrollState)
.padding(16.dp)
) {
chatMessages.forEach { message ->
ChatBubble(
message = message,
onCopy = {
clipboardManager.setText(AnnotatedString(message.message))
copiedMessageId = message.id
showCopiedMessage = true
scope.launch { scope.launch {
chatMessages = chatMessages + ChatMessage( delay(2000)
message = prompt, showCopiedMessage = false
isUser = true }
},
showCopied = showCopiedMessage && copiedMessageId == message.id
)
Spacer(modifier = Modifier.Companion.height(12.dp))
}
// Loading Indicator
if (isLoading) {
Row(
modifier = Modifier.Companion
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.Start
) {
Card(
colors = CardDefaults.cardColors(
containerColor = Color(0xFF1E293B)
),
shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp)
) {
Row(
modifier = Modifier.Companion.padding(16.dp),
verticalAlignment = Alignment.Companion.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.Companion.size(20.dp),
color = Color(0xFF6366F1),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.Companion.width(12.dp))
Text(
"AI sedang berpikir...",
color = Color(0xFF94A3B8),
style = MaterialTheme.typography.bodyMedium
) )
val userPrompt = prompt
prompt = ""
isLoading = true
errorMessage = ""
try {
val filteredNotes = if (selectedCategory != null) {
notes.filter { it.categoryId == selectedCategory!!.id && !it.isArchived }
} else {
notes.filter { !it.isArchived }
}
val notesContext = buildString {
appendLine("Data catatan pengguna:")
appendLine("Total catatan: ${filteredNotes.size}")
appendLine("Kategori: ${selectedCategory?.name ?: "Semua"}")
appendLine()
appendLine("Daftar catatan:")
filteredNotes.take(10).forEach { note ->
appendLine("- Judul: ${note.title}")
appendLine(" Isi: ${note.content.take(100)}")
appendLine()
}
}
val fullPrompt = "$notesContext\n\nPertanyaan: $userPrompt\n\nBerikan jawaban dalam bahasa Indonesia yang natural dan membantu."
val result = generativeModel.generateContent(fullPrompt)
val response = result.text ?: "Tidak ada respons dari AI"
chatMessages = chatMessages + ChatMessage(
message = response,
isUser = false
)
// Auto-save chat history
saveChatHistory()
} catch (e: Exception) {
// Better error handling
errorMessage = when {
e.message?.contains("quota", ignoreCase = true) == true ->
"⚠️ Kuota API habis. Silakan coba lagi nanti atau hubungi developer."
e.message?.contains("404", ignoreCase = true) == true ||
e.message?.contains("not found", ignoreCase = true) == true ->
"⚠️ Model AI tidak ditemukan. Silakan hubungi developer."
e.message?.contains("401", ignoreCase = true) == true ||
e.message?.contains("API key", ignoreCase = true) == true ->
"⚠️ API key tidak valid. Silakan hubungi developer."
e.message?.contains("timeout", ignoreCase = true) == true ->
"⚠️ Koneksi timeout. Periksa koneksi internet Anda."
e.message?.contains("network", ignoreCase = true) == true ->
"⚠️ Tidak ada koneksi internet. Silakan periksa koneksi Anda."
else ->
"⚠️ Terjadi kesalahan: ${e.message?.take(100) ?: "Unknown error"}"
}
} finally {
isLoading = false
}
} }
} }
}, }
containerColor = AppColors.Primary,
modifier = Modifier.size(48.dp)
) {
Icon(
Icons.Default.Send,
contentDescription = "Send",
tint = Color.White,
modifier = Modifier.size(20.dp)
)
} }
// Error Message
if (errorMessage.isNotEmpty()) {
Card(
modifier = Modifier.Companion.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = Color(0xFFEF4444).copy(0.2f)
),
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier.Companion.padding(12.dp),
verticalAlignment = Alignment.Companion.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = Color(0xFFEF4444),
modifier = Modifier.Companion.size(20.dp)
)
Spacer(modifier = Modifier.Companion.width(8.dp))
Text(
errorMessage,
color = Color(0xFFEF4444),
style = MaterialTheme.typography.bodySmall
)
}
}
}
Spacer(modifier = Modifier.Companion.height(80.dp))
} }
} }
} }
// Chat History Drawer // Input Area
AnimatedVisibility( Card(
visible = showHistoryDrawer, modifier = Modifier.Companion.fillMaxWidth(),
enter = fadeIn() + slideInHorizontally(), colors = CardDefaults.cardColors(
exit = fadeOut() + slideOutHorizontally() containerColor = Color(0xFF1E293B)
),
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) { ) {
ChatHistoryDrawer( Row(
chatHistories = chatHistories, modifier = Modifier.Companion
categories = categories, .fillMaxWidth()
notes = notes, .padding(16.dp),
selectedCategory = selectedCategory, verticalAlignment = Alignment.Companion.Bottom
onDismiss = { showHistoryDrawer = false }, ) {
onHistoryClick = { loadChatHistory(it) }, OutlinedTextField(
onDeleteHistory = { historyId -> value = prompt,
scope.launch { onValueChange = { prompt = it },
dataStoreManager.deleteChatHistory(historyId) placeholder = {
} Text(
}, "Ketik pesan...",
onCategorySelected = { category -> color = Color(0xFF64748B)
selectedCategory = category )
}, },
onNewChat = { startNewChat() }, modifier = Modifier.Companion
onEditHistoryTitle = { historyId, newTitle -> .weight(1f)
scope.launch { .heightIn(min = 48.dp, max = 120.dp),
dataStoreManager.updateChatHistoryTitle(historyId, newTitle) colors = TextFieldDefaults.colors(
} focusedTextColor = Color.Companion.White,
unfocusedTextColor = Color.Companion.White,
focusedContainerColor = Color(0xFF334155),
unfocusedContainerColor = Color(0xFF334155),
cursorColor = Color(0xFFA855F7),
focusedIndicatorColor = Color(0xFF6366F1),
unfocusedIndicatorColor = Color(0xFF475569)
),
shape = androidx.compose.foundation.shape.RoundedCornerShape(24.dp),
maxLines = 4
)
Spacer(modifier = Modifier.Companion.width(12.dp))
// Send Button
FloatingActionButton(
onClick = {
if (prompt.isNotBlank() && !isLoading) {
scope.launch {
// Add user message
chatMessages = chatMessages + ChatMessage(
message = prompt,
isUser = true
)
val userPrompt = prompt
prompt = ""
isLoading = true
errorMessage = ""
try {
val filteredNotes = if (selectedCategory != null) {
notes.filter { it.categoryId == selectedCategory!!.id && !it.isArchived }
} else {
notes.filter { !it.isArchived }
}
val notesContext = buildString {
appendLine("Data catatan pengguna:")
appendLine("Total catatan: ${filteredNotes.size}")
appendLine("Kategori: ${selectedCategory?.name ?: "Semua"}")
appendLine()
appendLine("Daftar catatan:")
filteredNotes.take(10).forEach { note ->
appendLine("- Judul: ${note.title}")
appendLine(" Isi: ${note.content.take(100)}")
appendLine()
}
}
val fullPrompt =
"$notesContext\n\nPertanyaan: $userPrompt\n\nBerikan jawaban dalam bahasa Indonesia yang natural dan membantu."
val result = generativeModel.generateContent(fullPrompt)
val response = result.text ?: "Tidak ada respons dari AI"
// Add AI response
chatMessages = chatMessages + ChatMessage(
message = response,
isUser = false
)
} catch (e: Exception) {
errorMessage = "Error: ${e.message}"
} finally {
isLoading = false
}
}
}
},
containerColor = Color.Companion.Transparent,
modifier = Modifier.Companion
.size(48.dp)
.background(
brush = Brush.Companion.linearGradient(
colors = listOf(Color(0xFF6366F1), Color(0xFFA855F7))
),
shape = CircleShape
)
) {
Icon(
Icons.Default.Send,
contentDescription = "Send",
tint = Color.Companion.White,
modifier = Modifier.Companion.size(24.dp)
)
} }
) }
} }
} }
} }

View File

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

View File

@ -1,15 +1,11 @@
package com.example.notesai.presentation.screens.ai.components package com.example.notesai.presentation.screens.ai.components
import androidx.compose.foundation.background 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -18,46 +14,29 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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 @Composable
fun CompactStatItem( fun CompactStatItem(label: String, value: String, color: Color) {
icon: androidx.compose.ui.graphics.vector.ImageVector,
value: String,
label: String
) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier modifier = Modifier
.background( .background(
color = AppColors.SurfaceVariant, color = Color(0xFF1E293B),
shape = RoundedCornerShape(8.dp) shape = RoundedCornerShape(8.dp)
) )
.padding(horizontal = 12.dp, vertical = 8.dp) .padding(horizontal = 12.dp, vertical = 8.dp)
) { ) {
Icon( Text(
icon, value,
contentDescription = null, style = MaterialTheme.typography.titleMedium,
tint = AppColors.Primary, color = color,
modifier = Modifier.size(16.dp) fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.width(4.dp))
Text(
label,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF94A3B8)
) )
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,7 +1,6 @@
package com.example.notesai.presentation.screens.ai.components package com.example.notesai.presentation.screens.ai.components
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -9,49 +8,44 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lightbulb
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp 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 @Composable
fun SuggestionChip( fun SuggestionChip(text: String, onSelect: (String) -> Unit) {
text: String, Card(
onSelect: (String) -> Unit modifier = Modifier
) { .padding(vertical = 4.dp)
Surface( .clickable { onSelect(text) },
onClick = { onSelect(text) }, colors = CardDefaults.cardColors(
color = AppColors.SurfaceVariant, containerColor = Color(0xFF1E293B)
),
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp)
) { ) {
Row( Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically
horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Icon( Icon(
Icons.Default.Lightbulb, Icons.Default.Star,
contentDescription = null, contentDescription = null,
tint = AppColors.Primary, tint = Color(0xFF6366F1),
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text, text,
color = AppColors.OnSurface, color = Color.White,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium
fontSize = 14.sp
) )
} }
} }

View File

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

View File

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

View File

@ -1,278 +1,128 @@
// File: presentation/screens/main/components/NoteCard.kt
package com.example.notesai.presentation.screens.main.components package com.example.notesai.presentation.screens.main.components
import androidx.compose.animation.core.*
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.* 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.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.StarBorder import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.material3.* import androidx.compose.material3.Card
import androidx.compose.runtime.* 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.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.example.notesai.data.model.Note 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.text.SimpleDateFormat
import java.util.* import java.util.Date
import java.util.Locale
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun NoteCard( fun NoteCard(
note: Note, note: Note,
onClick: () -> Unit, onClick: () -> Unit,
onPinClick: () -> Unit, onPinClick: () -> Unit
onEdit: () -> Unit = {},
onDelete: () -> Unit = {}
) { ) {
val dateFormat = SimpleDateFormat("dd MMM, HH:mm", Locale("id", "ID")) 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( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.scale(scale) .combinedClickable(
.combinedClickable(onClick = onClick), onClick = onClick,
shape = RoundedCornerShape(Constants.Radius.Large.dp), ),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = AppColors.SurfaceVariant containerColor = Color(0xFF1E293B)
), ),
elevation = CardDefaults.cardElevation( elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
defaultElevation = Constants.Elevation.Small.dp
)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(Constants.Spacing.Large.dp) .padding(16.dp)
) { ) {
// Header: Title + Actions (Vertical)
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top verticalAlignment = Alignment.Top
) { ) {
// Title - takes most space // Judul
Text( Text(
note.title, note.title,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = AppColors.OnBackground, color = Color.White,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis
fontSize = 18.sp
) )
IconButton(
// Vertical Actions Stack onClick = onPinClick,
Column( modifier = Modifier.size(24.dp)
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(0.dp)
) { ) {
// Menu Button Icon(
Box { if (note.isPinned) Icons.Filled.Star else Icons.Outlined.StarBorder,
IconButton( contentDescription = "Pin",
onClick = { showMenu = true }, tint = if (note.isPinned) Color(0xFFFBBF24) else Color.Gray,
modifier = Modifier.size(28.dp) modifier = Modifier.size(18.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 Preview // Deskripsi
if (note.description.isNotEmpty()) { if (note.content.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(
note.description, text = "Deskripsi",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.labelSmall,
maxLines = 3, color = Color(0xFF94A3B8),
overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.SemiBold
color = AppColors.OnSurfaceVariant,
lineHeight = 20.sp,
fontSize = 14.sp
) )
} else { Spacer(modifier = Modifier.height(4.dp))
// Tampilkan placeholder jika deskripsi kosong
Spacer(modifier = Modifier.height(12.dp))
Text( Text(
"Tidak ada deskripsi", note.content,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = AppColors.OnSurfaceVariant.copy(alpha = 0.5f), maxLines = 4,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic, overflow = TextOverflow.Ellipsis,
fontSize = 14.sp color = Color(0xFFCBD5E1),
lineHeight = 20.sp
) )
} }
Spacer(modifier = Modifier.height(16.dp))
// Divider
HorizontalDivider(
color = AppColors.Divider,
thickness = 1.dp
)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Footer: Timestamp HorizontalDivider(
Row( color = Color(0xFF334155),
modifier = Modifier.fillMaxWidth(), thickness = 1.dp
horizontalArrangement = Arrangement.SpaceBetween, )
verticalAlignment = Alignment.CenterVertically
) { Spacer(modifier = Modifier.height(8.dp))
Text(
dateFormat.format(Date(note.timestamp)), // Timestamp
style = MaterialTheme.typography.bodySmall, Text(
color = AppColors.OnSurfaceTertiary, dateFormat.format(Date(note.timestamp)),
fontSize = 12.sp style = MaterialTheme.typography.bodySmall,
) color = Color(0xFF64748B)
} )
} }
} }
} }

View File

@ -1,46 +1,50 @@
package com.example.notesai.presentation.screens.note package com.example.notesai.presentation.screens.note
import androidx.compose.foundation.layout.* 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.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.* 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.outlined.StarBorder import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.material3.* import androidx.compose.material3.AlertDialog
import androidx.compose.runtime.* import androidx.compose.material3.Divider
import androidx.compose.ui.Alignment 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.ui.Modifier 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.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.text.font.FontWeight
import androidx.compose.ui.unit.* import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.notesai.data.model.Note import com.example.notesai.data.model.Note
import com.example.notesai.presentation.screens.note.components.DraggableMiniMarkdownToolbar
import com.example.notesai.presentation.screens.note.editor.RichEditorState
import com.example.notesai.util.MarkdownParser
import com.example.notesai.util.MarkdownSerializer
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.Date
import kotlin.math.roundToInt import java.util.Locale
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun EditableFullScreenNoteView( fun EditableFullScreenNoteView(
note: Note, note: Note,
@ -51,245 +55,196 @@ fun EditableFullScreenNoteView(
onPinToggle: () -> Unit onPinToggle: () -> Unit
) { ) {
var title by remember { mutableStateOf(note.title) } var title by remember { mutableStateOf(note.title) }
var isContentFocused by remember { mutableStateOf(false) } 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")) }
val editorState = remember(note.id) { // Dialog Konfirmasi Arsip
RichEditorState( if (showArchiveDialog) {
AnnotatedStringSerializer.fromJson(note.content) AlertDialog(
) onDismissRequest = { showArchiveDialog = false },
} title = {
Text(
text = "Arsipkan Catatan?",
val focusRequester = remember { FocusRequester() } style = MaterialTheme.typography.titleLarge,
val bringIntoViewRequester = remember { BringIntoViewRequester() } fontWeight = FontWeight.Bold
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
)
}
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)
}
}
) )
}, },
contentWindowInsets = WindowInsets(0) text = {
) { 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( Text(
"Terakhir diubah: ${dateFormat.format(Date(note.timestamp))}", text = "Catatan ini akan dipindahkan ke arsip. Anda masih bisa mengaksesnya nanti.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodyMedium
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f)
) )
},
HorizontalDivider(Modifier.padding(vertical = 20.dp)) confirmButton = {
TextButton(
// ✅ FIX UTAMA: set cursorBrush agar insertion cursor muncul onClick = {
BasicTextField( if (title.isNotBlank()) {
value = editorState.value, onSave(title, content)
onValueChange = {
editorState.onValueChange(it)
scope.launch {
bringIntoViewRequester.bringIntoView()
}
},
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()
} }
onArchive()
showArchiveDialog = false
} }
) ) {
Text("Arsipkan", color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(180.dp)) }
},
dismissButton = {
TextButton(onClick = { showArchiveDialog = false }) {
Text("Batal", color = MaterialTheme.colorScheme.onSurface)
}
} }
} )
}
if (isContentFocused) { // Dialog Konfirmasi Hapus
DraggableMiniMarkdownToolbar( if (showDeleteDialog) {
modifier = Modifier AlertDialog(
.align(Alignment.TopStart) onDismissRequest = { showDeleteDialog = false },
.offset { title = {
val maxX = Text(
(screenWidthPx - toolbarSizePx.width - marginPx) text = "Hapus Catatan?",
.coerceAtLeast(marginPx) 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 maxY = Scaffold(
(screenHeightPx - imeBottomPx - toolbarSizePx.height) containerColor = MaterialTheme.colorScheme.background,
.coerceAtLeast(marginPx) topBar = {
TopAppBar(
IntOffset( title = { },
toolbarOffset.x.coerceIn(marginPx, maxX).roundToInt(), navigationIcon = {
toolbarOffset.y.coerceIn(marginPx, maxY).roundToInt() IconButton(onClick = {
if (title.isNotBlank()) {
onSave(title, content)
}
onBack()
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Kembali", tint = MaterialTheme.colorScheme.onBackground)
}
},
actions = {
IconButton(onClick = {
if (title.isNotBlank()) {
onSave(title, content)
}
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
) )
} }
.onSizeChanged { IconButton(onClick = { showArchiveDialog = true }) {
toolbarSizePx = androidx.compose.ui.geometry.Size( Icon(Icons.Default.Archive, contentDescription = "Arsipkan", tint = MaterialTheme.colorScheme.onSurface)
it.width.toFloat(), }
it.height.toFloat() IconButton(onClick = { showDeleteDialog = true }) {
) Icon(Icons.Default.Delete, contentDescription = "Hapus", tint = MaterialTheme.colorScheme.onSurface)
}, }
isBoldActive = editorState.isBoldActive(),
isItalicActive = editorState.isItalicActive(),
isUnderlineActive = editorState.isUnderlineActive(),
onDrag = ::moveToolbar,
onBold = {
ensureFocus()
editorState.toggleBold()
}, },
onItalic = { colors = TopAppBarDefaults.topAppBarColors(
ensureFocus() containerColor = Color.Transparent
editorState.toggleItalic() )
},
onUnderline = { ensureFocus(); editorState.toggleUnderline() },
onHeading = { ensureFocus(); editorState.setHeading(1) }, // sementara H1
onBullet = { ensureFocus(); editorState.toggleBulletList() }
) )
} }
) { 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)
)
},
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = Color(0xFFA855F7)
),
modifier = Modifier.fillMaxWidth()
)
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

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

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

View File

@ -1,66 +0,0 @@
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,14 +1,12 @@
// File: util/Constants.kt
package com.example.notesai.util 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 import androidx.compose.ui.graphics.Color
object Constants { object Constants {
// App Info // App Info
const val APP_NAME = "NotesAI" const val APP_NAME = "AI Notes"
const val APP_VERSION = "1.1.0" const val APP_VERSION = "1.0.0"
// DataStore // DataStore
const val DATASTORE_NAME = "notes_prefs" const val DATASTORE_NAME = "notes_prefs"
@ -19,175 +17,37 @@ object Constants {
const val MAX_CHAT_PREVIEW_LINES = 2 const val MAX_CHAT_PREVIEW_LINES = 2
const val GRID_COLUMNS = 2 const val GRID_COLUMNS = 2
// DARK THEME COLORS // Gradients
object DarkColors { val GRADIENT_PRESETS = listOf(
val Background = Color(0xFF0A0A0A) Pair(0xFF6366F1L, 0xFFA855F7L),
val Surface = Color(0xFF141414) Pair(0xFFEC4899L, 0xFFF59E0BL),
val SurfaceVariant = Color(0xFF1E1E1E) Pair(0xFF8B5CF6L, 0xFFEC4899L),
val SurfaceElevated = Color(0xFF252525) Pair(0xFF06B6D4L, 0xFF3B82F6L),
val Primary = Color(0xFF3B82F6) Pair(0xFF10B981L, 0xFF059669L),
val PrimaryVariant = Color(0xFF60A5FA) Pair(0xFFF59E0BL, 0xFFEF4444L),
val PrimaryContainer = Color(0xFF1E3A8A) Pair(0xFF6366F1L, 0xFF8B5CF6L),
val Secondary = Color(0xFF8B5CF6) Pair(0xFFEF4444L, 0xFFDC2626L)
val SecondaryVariant = Color(0xFFA78BFA) )
val OnBackground = Color(0xFFFFFFFF)
val OnSurface = Color(0xFFE5E5E5) // Colors
val OnSurfaceVariant = Color(0xFF9CA3AF) object AppColors {
val OnSurfaceTertiary = Color(0xFF6B7280) 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)
val Success = Color(0xFF10B981) val Success = Color(0xFF10B981)
val Error = Color(0xFFEF4444) val Error = Color(0xFFEF4444)
val Warning = Color(0xFFFBBF24) val Warning = Color(0xFFFBBF24)
val Info = Color(0xFF3B82F6) val TextSecondary = Color(0xFF94A3B8)
val Border = Color(0xFF2A2A2A) val TextTertiary = Color(0xFF64748B)
val Divider = Color(0xFF1F1F1F) val Divider = Color(0xFF334155)
val Overlay = Color(0xFF000000).copy(alpha = 0.5f)
val Shadow = Color(0xFF000000).copy(alpha = 0.3f)
} }
// LIGHT THEME COLORS // Animation
object LightColors { const val ANIMATION_DURATION = 300
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_IN_DURATION = 200
const val FADE_OUT_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

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

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

@ -1,289 +0,0 @@
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,15 +9,6 @@ appcompat = "1.6.1"
material = "1.10.0" material = "1.10.0"
activity = "1.8.0" activity = "1.8.0"
constraintlayout = "2.1.4" 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] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -28,15 +19,6 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version
material = { group = "com.google.android.material", name = "material", version.ref = "material" } material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }