Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4847ced63 | |||
| 7f5e2fd28d | |||
| 7be456d7cb | |||
| c0bbd3e54f | |||
| b8d9a71664 | |||
| 5503d53881 | |||
| 74e1a720cd | |||
| 75033bc671 | |||
| 05b7a2a71b | |||
| 7a67943800 | |||
| b264f87b14 | |||
| 79f7e33a5a | |||
| 41a1e8268a | |||
| 520da1f66a | |||
| da93991ef3 | |||
| 1b5e79166c | |||
| 0876c82abc | |||
| 2121682dd4 | |||
| 2037d32766 | |||
| 7a15d1d9e1 | |||
| 8c3994e317 | |||
| 900bf8b7ff | |||
| de163a09f8 | |||
| 80774b58ea | |||
| 0f0ac6b8f3 |
8
.idea/appInsightsSettings.xml
generated
8
.idea/appInsightsSettings.xml
generated
@ -6,14 +6,6 @@
|
|||||||
<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" />
|
||||||
|
|||||||
4
.idea/deploymentTargetSelector.xml
generated
4
.idea/deploymentTargetSelector.xml
generated
@ -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-13T07:41:36.634314200Z">
|
<DropdownSelection timestamp="2025-12-18T02:27:11.898714800Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=RR8T103A6JZ" />
|
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\Fazri Abdurrahman\.android\avd\Medium_Tablet.avd" />
|
||||||
</handle>
|
</handle>
|
||||||
</Target>
|
</Target>
|
||||||
</DropdownSelection>
|
</DropdownSelection>
|
||||||
|
|||||||
76
.kotlin/errors/errors-1766030638995.log
Normal file
76
.kotlin/errors/errors-1766030638995.log
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
kotlin version: 2.0.21
|
||||||
|
error message: org.jetbrains.kotlin.util.FileAnalysisException: While analysing C:/Users/Fazri Abdurrahman/AndroidStudioProjects/NotesAI/app/src/main/java/com/example/notesai/MainActivity.kt:40:5: java.lang.IllegalArgumentException: source must not be null
|
||||||
|
at org.jetbrains.kotlin.util.AnalysisExceptionsKt.wrapIntoFileAnalysisExceptionIfNeeded(AnalysisExceptions.kt:57)
|
||||||
|
at org.jetbrains.kotlin.fir.FirCliExceptionHandler.handleExceptionOnFileAnalysis(Utils.kt:249)
|
||||||
|
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:46)
|
||||||
|
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.resolveAndCheckFir(firUtils.kt:77)
|
||||||
|
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.buildResolveAndCheckFirViaLightTree(firUtils.kt:88)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModuleToAnalyzedFir(jvmCompilerPipeline.kt:319)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModulesUsingFrontendIrAndLightTree(jvmCompilerPipeline.kt:118)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:148)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
|
||||||
|
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
|
||||||
|
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
|
||||||
|
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:464)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:73)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:506)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:301)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:129)
|
||||||
|
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675)
|
||||||
|
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92)
|
||||||
|
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660)
|
||||||
|
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
|
||||||
|
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
||||||
|
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
|
||||||
|
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
|
||||||
|
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
|
||||||
|
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
|
||||||
|
at java.base/java.lang.Thread.run(Unknown Source)
|
||||||
|
Caused by: java.lang.IllegalArgumentException: source must not be null
|
||||||
|
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.requireNotNull(KtDiagnosticReportHelpers.kt:68)
|
||||||
|
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn(KtDiagnosticReportHelpers.kt:39)
|
||||||
|
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn$default(KtDiagnosticReportHelpers.kt:31)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkSourceElement(FirIncompatibleClassExpressionChecker.kt:50)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkType$checkers(FirIncompatibleClassExpressionChecker.kt:42)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.checkers.type.FirIncompatibleClassTypeChecker.check(FirIncompatibleClassTypeChecker.kt:17)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.check(TypeCheckersDiagnosticComponent.kt:81)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:53)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:19)
|
||||||
|
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.CheckerRunningDiagnosticCollectorVisitor.checkElement(CheckerRunningDiagnosticCollectorVisitor.kt:24)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:248)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:30)
|
||||||
|
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.impl.FirSimpleFunctionImpl.acceptChildren(FirSimpleFunctionImpl.kt:63)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:118)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:30)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.FirSimpleFunction.accept(FirSimpleFunction.kt:51)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.impl.FirRegularClassImpl.acceptChildren(FirRegularClassImpl.kt:63)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitClassAndChildren(AbstractDiagnosticCollectorVisitor.kt:87)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:92)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:30)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.FirRegularClass.accept(FirRegularClass.kt:48)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.impl.FirFileImpl.acceptChildren(FirFileImpl.kt:57)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:1151)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:30)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.FirFile.accept(FirFile.kt:42)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollector.collectDiagnostics(AbstractDiagnosticCollector.kt:36)
|
||||||
|
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:34)
|
||||||
|
... 33 more
|
||||||
|
|
||||||
|
|
||||||
76
.kotlin/errors/errors-1766030769153.log
Normal file
76
.kotlin/errors/errors-1766030769153.log
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
kotlin version: 2.0.21
|
||||||
|
error message: org.jetbrains.kotlin.util.FileAnalysisException: While analysing C:/Users/Fazri Abdurrahman/AndroidStudioProjects/NotesAI/app/src/main/java/com/example/notesai/MainActivity.kt:40:5: java.lang.IllegalArgumentException: source must not be null
|
||||||
|
at org.jetbrains.kotlin.util.AnalysisExceptionsKt.wrapIntoFileAnalysisExceptionIfNeeded(AnalysisExceptions.kt:57)
|
||||||
|
at org.jetbrains.kotlin.fir.FirCliExceptionHandler.handleExceptionOnFileAnalysis(Utils.kt:249)
|
||||||
|
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:46)
|
||||||
|
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.resolveAndCheckFir(firUtils.kt:77)
|
||||||
|
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.buildResolveAndCheckFirViaLightTree(firUtils.kt:88)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModuleToAnalyzedFir(jvmCompilerPipeline.kt:319)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModulesUsingFrontendIrAndLightTree(jvmCompilerPipeline.kt:118)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:148)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
|
||||||
|
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
|
||||||
|
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
|
||||||
|
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:464)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:73)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:506)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:301)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:129)
|
||||||
|
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675)
|
||||||
|
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92)
|
||||||
|
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660)
|
||||||
|
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
|
||||||
|
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
||||||
|
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
|
||||||
|
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
|
||||||
|
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
|
||||||
|
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
|
||||||
|
at java.base/java.lang.Thread.run(Unknown Source)
|
||||||
|
Caused by: java.lang.IllegalArgumentException: source must not be null
|
||||||
|
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.requireNotNull(KtDiagnosticReportHelpers.kt:68)
|
||||||
|
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn(KtDiagnosticReportHelpers.kt:39)
|
||||||
|
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn$default(KtDiagnosticReportHelpers.kt:31)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkSourceElement(FirIncompatibleClassExpressionChecker.kt:50)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkType$checkers(FirIncompatibleClassExpressionChecker.kt:42)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.checkers.type.FirIncompatibleClassTypeChecker.check(FirIncompatibleClassTypeChecker.kt:17)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.check(TypeCheckersDiagnosticComponent.kt:81)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:53)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:19)
|
||||||
|
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.CheckerRunningDiagnosticCollectorVisitor.checkElement(CheckerRunningDiagnosticCollectorVisitor.kt:24)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:248)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:30)
|
||||||
|
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.impl.FirSimpleFunctionImpl.acceptChildren(FirSimpleFunctionImpl.kt:63)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:118)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:30)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.FirSimpleFunction.accept(FirSimpleFunction.kt:51)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.impl.FirRegularClassImpl.acceptChildren(FirRegularClassImpl.kt:63)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitClassAndChildren(AbstractDiagnosticCollectorVisitor.kt:87)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:92)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:30)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.FirRegularClass.accept(FirRegularClass.kt:48)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.impl.FirFileImpl.acceptChildren(FirFileImpl.kt:57)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:1151)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:30)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.FirFile.accept(FirFile.kt:42)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollector.collectDiagnostics(AbstractDiagnosticCollector.kt:36)
|
||||||
|
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:34)
|
||||||
|
... 33 more
|
||||||
|
|
||||||
|
|
||||||
76
.kotlin/errors/errors-1766030880930.log
Normal file
76
.kotlin/errors/errors-1766030880930.log
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
kotlin version: 2.0.21
|
||||||
|
error message: org.jetbrains.kotlin.util.FileAnalysisException: While analysing C:/Users/Fazri Abdurrahman/AndroidStudioProjects/NotesAI/app/src/main/java/com/example/notesai/MainActivity.kt:40:5: java.lang.IllegalArgumentException: source must not be null
|
||||||
|
at org.jetbrains.kotlin.util.AnalysisExceptionsKt.wrapIntoFileAnalysisExceptionIfNeeded(AnalysisExceptions.kt:57)
|
||||||
|
at org.jetbrains.kotlin.fir.FirCliExceptionHandler.handleExceptionOnFileAnalysis(Utils.kt:249)
|
||||||
|
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:46)
|
||||||
|
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.resolveAndCheckFir(firUtils.kt:77)
|
||||||
|
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.buildResolveAndCheckFirViaLightTree(firUtils.kt:88)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModuleToAnalyzedFir(jvmCompilerPipeline.kt:319)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModulesUsingFrontendIrAndLightTree(jvmCompilerPipeline.kt:118)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:148)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
|
||||||
|
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
|
||||||
|
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
|
||||||
|
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:464)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:73)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:506)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:301)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:129)
|
||||||
|
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675)
|
||||||
|
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92)
|
||||||
|
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660)
|
||||||
|
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
|
||||||
|
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
||||||
|
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
|
||||||
|
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
|
||||||
|
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
|
||||||
|
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
|
||||||
|
at java.base/java.lang.Thread.run(Unknown Source)
|
||||||
|
Caused by: java.lang.IllegalArgumentException: source must not be null
|
||||||
|
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.requireNotNull(KtDiagnosticReportHelpers.kt:68)
|
||||||
|
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn(KtDiagnosticReportHelpers.kt:39)
|
||||||
|
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn$default(KtDiagnosticReportHelpers.kt:31)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkSourceElement(FirIncompatibleClassExpressionChecker.kt:50)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkType$checkers(FirIncompatibleClassExpressionChecker.kt:42)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.checkers.type.FirIncompatibleClassTypeChecker.check(FirIncompatibleClassTypeChecker.kt:17)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.check(TypeCheckersDiagnosticComponent.kt:81)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:53)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:19)
|
||||||
|
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.CheckerRunningDiagnosticCollectorVisitor.checkElement(CheckerRunningDiagnosticCollectorVisitor.kt:24)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:248)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:30)
|
||||||
|
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.impl.FirSimpleFunctionImpl.acceptChildren(FirSimpleFunctionImpl.kt:63)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:118)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:30)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.FirSimpleFunction.accept(FirSimpleFunction.kt:51)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.impl.FirRegularClassImpl.acceptChildren(FirRegularClassImpl.kt:63)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitClassAndChildren(AbstractDiagnosticCollectorVisitor.kt:87)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:92)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:30)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.FirRegularClass.accept(FirRegularClass.kt:48)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.impl.FirFileImpl.acceptChildren(FirFileImpl.kt:57)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:1151)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:30)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.FirFile.accept(FirFile.kt:42)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollector.collectDiagnostics(AbstractDiagnosticCollector.kt:36)
|
||||||
|
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:34)
|
||||||
|
... 33 more
|
||||||
|
|
||||||
|
|
||||||
76
.kotlin/errors/errors-1766031015881.log
Normal file
76
.kotlin/errors/errors-1766031015881.log
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
kotlin version: 2.0.21
|
||||||
|
error message: org.jetbrains.kotlin.util.FileAnalysisException: While analysing C:/Users/Fazri Abdurrahman/AndroidStudioProjects/NotesAI/app/src/main/java/com/example/notesai/MainActivity.kt:40:5: java.lang.IllegalArgumentException: source must not be null
|
||||||
|
at org.jetbrains.kotlin.util.AnalysisExceptionsKt.wrapIntoFileAnalysisExceptionIfNeeded(AnalysisExceptions.kt:57)
|
||||||
|
at org.jetbrains.kotlin.fir.FirCliExceptionHandler.handleExceptionOnFileAnalysis(Utils.kt:249)
|
||||||
|
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:46)
|
||||||
|
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.resolveAndCheckFir(firUtils.kt:77)
|
||||||
|
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.buildResolveAndCheckFirViaLightTree(firUtils.kt:88)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModuleToAnalyzedFir(jvmCompilerPipeline.kt:319)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModulesUsingFrontendIrAndLightTree(jvmCompilerPipeline.kt:118)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:148)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
|
||||||
|
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
|
||||||
|
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
|
||||||
|
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:464)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:73)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:506)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:301)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:129)
|
||||||
|
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675)
|
||||||
|
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92)
|
||||||
|
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660)
|
||||||
|
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
|
||||||
|
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
||||||
|
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
|
||||||
|
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
|
||||||
|
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
|
||||||
|
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
|
||||||
|
at java.base/java.lang.Thread.run(Unknown Source)
|
||||||
|
Caused by: java.lang.IllegalArgumentException: source must not be null
|
||||||
|
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.requireNotNull(KtDiagnosticReportHelpers.kt:68)
|
||||||
|
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn(KtDiagnosticReportHelpers.kt:39)
|
||||||
|
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn$default(KtDiagnosticReportHelpers.kt:31)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkSourceElement(FirIncompatibleClassExpressionChecker.kt:50)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkType$checkers(FirIncompatibleClassExpressionChecker.kt:42)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.checkers.type.FirIncompatibleClassTypeChecker.check(FirIncompatibleClassTypeChecker.kt:17)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.check(TypeCheckersDiagnosticComponent.kt:81)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:53)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:19)
|
||||||
|
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.CheckerRunningDiagnosticCollectorVisitor.checkElement(CheckerRunningDiagnosticCollectorVisitor.kt:24)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:248)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:30)
|
||||||
|
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.impl.FirSimpleFunctionImpl.acceptChildren(FirSimpleFunctionImpl.kt:63)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:118)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:30)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.FirSimpleFunction.accept(FirSimpleFunction.kt:51)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.impl.FirRegularClassImpl.acceptChildren(FirRegularClassImpl.kt:63)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitClassAndChildren(AbstractDiagnosticCollectorVisitor.kt:87)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:92)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:30)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.FirRegularClass.accept(FirRegularClass.kt:48)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.impl.FirFileImpl.acceptChildren(FirFileImpl.kt:57)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:1151)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:30)
|
||||||
|
at org.jetbrains.kotlin.fir.declarations.FirFile.accept(FirFile.kt:42)
|
||||||
|
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollector.collectDiagnostics(AbstractDiagnosticCollector.kt:36)
|
||||||
|
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:34)
|
||||||
|
... 33 more
|
||||||
|
|
||||||
|
|
||||||
311
Readme.md
311
Readme.md
@ -7,118 +7,191 @@
|
|||||||
* 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**
|
||||||
|
|
||||||
* **Implementasi struktur navigasi dasar aplikasi** - Setup navigation system
|
### **Struktur & Navigation**
|
||||||
* **Pembuatan menu drawer untuk navigasi screen** - Drawer dengan list menu (Beranda, Berbintang, Arsip, Sampah)
|
* **Setup navigation system** - Implementasi routing antar halaman (Beranda, Arsip, Sampah)
|
||||||
* **Pembuatan screen Arsip dan Sampah** - Layout dan UI untuk Archive & Trash
|
* **Menu Drawer** - Navigation drawer dengan list menu (Beranda, Berbintang, Arsip, Sampah)
|
||||||
* **Implementasi routing antar halaman** - Navigation flow (Beranda, Arsip, Sampah)
|
* **Bottom Navigation** - Home & AI Helper tabs dengan icon navigation
|
||||||
* **Penambahan Bottom Navigation** - Home & AI Helper tabs
|
* **Top App Bar** - Menu hamburger dan search icon dengan Material3 styling
|
||||||
* **Penambahan Top App Bar** - Menu hamburger dan search icon
|
* **Screen Architecture** - Pembuatan screen Arsip, Sampah, Berbintang, AI Helper
|
||||||
* **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?"
|
|
||||||
|
|
||||||
## **Sprint 2: Project Restructuring, Fitur Search, Delete Kategori**
|
### **Theme & Styling**
|
||||||
|
* **Material3 Dark Theme** - Setup color scheme dengan dark mode default
|
||||||
|
* **Color System** - Primary/Secondary colors dengan gradient presets (8 kombinasi warna)
|
||||||
|
* **Consistent Design** - Rounded corners (12dp, 16dp, 20dp), shadow, elevation
|
||||||
|
* **Smooth Animations** - Drawer slide, FAB scale, card transitions dengan spring animations
|
||||||
|
* **Typography System** - Optimasi font sizes dan line heights untuk readability
|
||||||
|
|
||||||
* **Fitur search beranda** - Cari kategori berdasarkan nama
|
### **Category Management**
|
||||||
* **Search filtering real-time** - Kategori otomatis filter saat mengetik
|
* **Category Model** - Data class dengan gradient colors dan timestamp
|
||||||
* **Delete kategori dengan UI** - Tombol X di top-right corner setiap kategori
|
* **Category Dialog** - Form tambah/edit kategori dengan nama + gradient picker
|
||||||
* **Confirmation dialog untuk delete** - Prevent accidental deletion dengan warning message
|
* **Category Card** - Design dengan icon folder, nama, jumlah catatan, gradient background
|
||||||
* **Search di kategori** - Cari catatan berdasarkan judul & isi (case-insensitive)
|
* **Staggered Grid Layout** - 2 kolom responsive dengan LazyVerticalStaggeredGrid
|
||||||
* **Search empty state** - Tampilkan pesan "Tidak ada hasil" saat search kosong
|
* **Category Actions** - Menu dropdown (⋮) untuk edit dan delete kategori
|
||||||
* **Gradle optimization** - Cleanup dependencies yang tidak diperlukan
|
* **Empty State** - Pesan "Buat kategori pertama Anda" dengan icon
|
||||||
* **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 (H1–H3) 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**
|
||||||
@ -141,17 +214,19 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## **Planned Features (v1.1.0)**
|
## **Features for Sprint 3 v1.1.0**
|
||||||
|
|
||||||
|
* History Chat AI berdasarkan Catatan yang ada didalam kategori dalam bentuk Drawer Menu di AI Helper (ok)
|
||||||
|
* Mengganti Preview Deskripsi NoteCard dan NoteDialog (ok)
|
||||||
|
* Dark/Light theme toggle (ok)
|
||||||
|
* Markdown Parser (ok)
|
||||||
|
|
||||||
* Backup & restore data
|
|
||||||
* Tags untuk catatan
|
|
||||||
* Rich text editor
|
|
||||||
* Dark theme toggle
|
|
||||||
* Multi-language support
|
|
||||||
* AI Agent Catatan
|
|
||||||
* Fungsi AI (Summary berdasarkan catatan, Upload File)
|
|
||||||
* Markdown Parser
|
|
||||||
* Opsi memilih kategori dan catatan
|
|
||||||
* Penyesuaian User Interface dan User Experience
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## **Features for Sprint 4 v1.1.0**
|
||||||
|
|
||||||
|
* Penyesuaian UI/UX History Chat AI (ok)
|
||||||
|
* Rich text editor (ok - Pengembangan Lanjutan)
|
||||||
|
* AI Agent Catatan
|
||||||
|
* Fungsi AI (Upload File)
|
||||||
|
* Fitur Sematkan Category, otomatis paling atas
|
||||||
|
|||||||
@ -7,7 +7,7 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.notesai"
|
namespace = "com.example.notesai"
|
||||||
compileSdk = 34
|
compileSdk = 35
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.example.notesai"
|
applicationId = "com.example.notesai"
|
||||||
@ -64,6 +64,12 @@ 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")
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
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>
|
||||||
|
|||||||
@ -1,24 +1,22 @@
|
|||||||
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.foundation.background
|
import androidx.compose.animation.core.Spring
|
||||||
|
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.unit.dp
|
|
||||||
import java.util.UUID
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.zIndex
|
import 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
|
||||||
@ -34,8 +32,9 @@ 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.updateWhere
|
import com.example.notesai.util.AppColors
|
||||||
import kotlinx.coroutines.delay
|
import com.example.notesai.util.Constants
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@ -43,14 +42,41 @@ class MainActivity : ComponentActivity() {
|
|||||||
setContent {
|
setContent {
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = darkColorScheme(
|
colorScheme = darkColorScheme(
|
||||||
primary = Color(0xFF6366F1),
|
primary = AppColors.Primary,
|
||||||
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,
|
||||||
onBackground = Color(0xFFE2E8F0),
|
secondaryContainer = AppColors.SecondaryVariant,
|
||||||
onSurface = Color(0xFFE2E8F0)
|
onSecondaryContainer = Color.White,
|
||||||
|
background = AppColors.Background,
|
||||||
|
onBackground = AppColors.OnBackground,
|
||||||
|
surface = AppColors.Surface,
|
||||||
|
onSurface = AppColors.OnSurface,
|
||||||
|
surfaceVariant = AppColors.SurfaceVariant,
|
||||||
|
onSurfaceVariant = AppColors.OnSurfaceVariant,
|
||||||
|
error = AppColors.Error,
|
||||||
|
onError = Color.White,
|
||||||
|
outline = AppColors.Border,
|
||||||
|
outlineVariant = AppColors.Divider
|
||||||
|
),
|
||||||
|
typography = Typography(
|
||||||
|
displayLarge = MaterialTheme.typography.displayLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
),
|
||||||
|
headlineLarge = MaterialTheme.typography.headlineLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
),
|
||||||
|
titleLarge = MaterialTheme.typography.titleLarge.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
),
|
||||||
|
bodyLarge = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
lineHeight = 24.sp
|
||||||
|
),
|
||||||
|
bodyMedium = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
lineHeight = 20.sp
|
||||||
|
)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
@ -70,6 +96,7 @@ 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>()) }
|
||||||
@ -83,72 +110,98 @@ 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) }
|
||||||
|
|
||||||
// Load data dari DataStore
|
// Guard flags to prevent race conditions
|
||||||
|
var isDataLoaded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Load theme preference
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
try {
|
dataStoreManager.themeFlow.collect { theme ->
|
||||||
dataStoreManager.categoriesFlow.collect { loadedCategories ->
|
isDarkTheme = theme == "dark"
|
||||||
|
AppColors.setTheme(isDarkTheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load categories ONCE
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
dataStoreManager.categoriesFlow.collect { loadedCategories ->
|
||||||
|
if (!isDataLoaded) {
|
||||||
|
android.util.Log.d("NotesApp", "Loading ${loadedCategories.size} categories")
|
||||||
categories = loadedCategories
|
categories = loadedCategories
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load notes ONCE
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
try {
|
dataStoreManager.notesFlow.collect { loadedNotes ->
|
||||||
dataStoreManager.notesFlow.collect { loadedNotes ->
|
if (!isDataLoaded) {
|
||||||
|
android.util.Log.d("NotesApp", "Loading ${loadedNotes.size} notes")
|
||||||
notes = loadedNotes
|
notes = loadedNotes
|
||||||
|
isDataLoaded = true // Mark as loaded
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simpan categories dengan debounce
|
// Save categories when changed
|
||||||
LaunchedEffect(categories.size) {
|
LaunchedEffect(categories) {
|
||||||
if (categories.isNotEmpty()) {
|
if (isDataLoaded && categories.isNotEmpty()) {
|
||||||
delay(500)
|
android.util.Log.d("NotesApp", "Saving ${categories.size} categories")
|
||||||
try {
|
scope.launch {
|
||||||
dataStoreManager.saveCategories(categories)
|
dataStoreManager.saveCategories(categories)
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simpan notes dengan debounce
|
// Save notes when changed
|
||||||
LaunchedEffect(notes.size) {
|
LaunchedEffect(notes) {
|
||||||
if (notes.isNotEmpty()) {
|
if (isDataLoaded && notes.isNotEmpty()) {
|
||||||
delay(500)
|
android.util.Log.d("NotesApp", "Saving ${notes.size} notes")
|
||||||
try {
|
scope.launch {
|
||||||
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) {
|
if (!showFullScreenNote && currentScreen != "ai") {
|
||||||
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") || currentScreen == "ai" || currentScreen == "starred",
|
showBackButton = (selectedCategory != null && currentScreen == "main"),
|
||||||
onBackClick = {
|
onBackClick = {
|
||||||
if (currentScreen == "ai" || currentScreen == "starred") {
|
selectedCategory = null
|
||||||
currentScreen = "main"
|
|
||||||
} else {
|
|
||||||
selectedCategory = null
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onMenuClick = { drawerState = !drawerState },
|
onMenuClick = { drawerState = !drawerState },
|
||||||
onSearchClick = { showSearch = !showSearch },
|
onSearchClick = { showSearch = !showSearch },
|
||||||
@ -161,7 +214,12 @@ fun NotesApp() {
|
|||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = currentScreen == "main" && !showFullScreenNote,
|
visible = currentScreen == "main" && !showFullScreenNote,
|
||||||
enter = scaleIn() + fadeIn(),
|
enter = scaleIn(
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessLow
|
||||||
|
)
|
||||||
|
) + fadeIn(),
|
||||||
exit = scaleOut() + fadeOut()
|
exit = scaleOut() + fadeOut()
|
||||||
) {
|
) {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
@ -173,20 +231,18 @@ fun NotesApp() {
|
|||||||
showCategoryDialog = true
|
showCategoryDialog = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
containerColor = Color.Transparent,
|
containerColor = AppColors.Primary,
|
||||||
modifier = Modifier
|
contentColor = Color.White,
|
||||||
.shadow(8.dp, CircleShape)
|
elevation = FloatingActionButtonDefaults.elevation(
|
||||||
.background(
|
defaultElevation = 8.dp,
|
||||||
brush = Brush.linearGradient(
|
pressedElevation = 12.dp
|
||||||
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",
|
||||||
tint = Color.White
|
modifier = Modifier.size(28.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -255,7 +311,7 @@ fun NotesApp() {
|
|||||||
) {
|
) {
|
||||||
when (currentScreen) {
|
when (currentScreen) {
|
||||||
"main" -> MainScreen(
|
"main" -> MainScreen(
|
||||||
categories = categories.filter { !it.isDeleted }, // TAMBAHKAN FILTER INI
|
categories = categories.filter { !it.isDeleted },
|
||||||
notes = notes,
|
notes = notes,
|
||||||
selectedCategory = selectedCategory,
|
selectedCategory = selectedCategory,
|
||||||
searchQuery = searchQuery,
|
searchQuery = searchQuery,
|
||||||
@ -271,12 +327,10 @@ 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
|
||||||
@ -296,12 +350,22 @@ 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, // Pass semua categories (sudah ada yang isDeleted)
|
categories = categories,
|
||||||
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)
|
||||||
@ -312,34 +376,28 @@ 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 }, // FILTER
|
categories = categories.filter { !it.isDeleted },
|
||||||
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)
|
||||||
@ -350,7 +408,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 }, // FILTER
|
categories = categories.filter { !it.isDeleted },
|
||||||
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)
|
||||||
@ -366,7 +424,7 @@ fun NotesApp() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
"ai" -> AIHelperScreen(
|
"ai" -> AIHelperScreen(
|
||||||
categories = categories.filter { !it.isDeleted }, // FILTER
|
categories = categories.filter { !it.isDeleted },
|
||||||
notes = notes.filter { !it.isDeleted }
|
notes = notes.filter { !it.isDeleted }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -395,13 +453,13 @@ fun NotesApp() {
|
|||||||
showNoteDialog = false
|
showNoteDialog = false
|
||||||
editingNote = null
|
editingNote = null
|
||||||
},
|
},
|
||||||
onSave = { title, content ->
|
onSave = { title, description ->
|
||||||
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,
|
||||||
content = content,
|
description = description,
|
||||||
timestamp = System.currentTimeMillis()
|
timestamp = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
else it
|
else it
|
||||||
@ -410,7 +468,8 @@ fun NotesApp() {
|
|||||||
notes = notes + Note(
|
notes = notes + Note(
|
||||||
categoryId = selectedCategory!!.id,
|
categoryId = selectedCategory!!.id,
|
||||||
title = title,
|
title = title,
|
||||||
content = content
|
description = description,
|
||||||
|
content = ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
showNoteDialog = false
|
showNoteDialog = false
|
||||||
@ -431,7 +490,7 @@ fun NotesApp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drawer with Animation - DI LUAR SCAFFOLD agar di atas semua
|
// Drawer with Animation
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = drawerState,
|
visible = drawerState,
|
||||||
enter = fadeIn() + slideInHorizontally(
|
enter = fadeIn() + slideInHorizontally(
|
||||||
@ -440,10 +499,11 @@ fun NotesApp() {
|
|||||||
exit = fadeOut() + slideOutHorizontally(
|
exit = fadeOut() + slideOutHorizontally(
|
||||||
targetOffsetX = { -it }
|
targetOffsetX = { -it }
|
||||||
),
|
),
|
||||||
modifier = Modifier.zIndex(100f) // Z-index tinggi
|
modifier = Modifier.zIndex(100f)
|
||||||
) {
|
) {
|
||||||
DrawerMenu(
|
DrawerMenu(
|
||||||
currentScreen = currentScreen,
|
currentScreen = currentScreen,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
onDismiss = { drawerState = false },
|
onDismiss = { drawerState = false },
|
||||||
onItemClick = { screen ->
|
onItemClick = { screen ->
|
||||||
currentScreen = screen
|
currentScreen = screen
|
||||||
@ -451,9 +511,15 @@ 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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ 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
|
||||||
@ -28,7 +29,8 @@ 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
|
||||||
@ -36,17 +38,20 @@ data class SerializableNote(
|
|||||||
val id: String,
|
val id: String,
|
||||||
val categoryId: String,
|
val categoryId: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val content: String,
|
val description: String = "",
|
||||||
|
val content: String = "",
|
||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
val isArchived: Boolean,
|
val isArchived: Boolean = false,
|
||||||
val isDeleted: Boolean,
|
val isDeleted: Boolean = false,
|
||||||
val isPinned: Boolean
|
val isPinned: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
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 {
|
||||||
@ -66,9 +71,17 @@ 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(it.id, it.name, it.gradientStart, it.gradientEnd, it.timestamp)
|
Category(
|
||||||
|
id = it.id,
|
||||||
|
name = it.name,
|
||||||
|
gradientStart = it.gradientStart,
|
||||||
|
gradientEnd = it.gradientEnd,
|
||||||
|
timestamp = it.timestamp,
|
||||||
|
isDeleted = it.isDeleted
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -86,17 +99,40 @@ class DataStoreManager(private val context: Context) {
|
|||||||
try {
|
try {
|
||||||
json.decodeFromString<List<SerializableNote>>(jsonString).map {
|
json.decodeFromString<List<SerializableNote>>(jsonString).map {
|
||||||
Note(
|
Note(
|
||||||
it.id,
|
id = it.id,
|
||||||
it.categoryId,
|
categoryId = it.categoryId,
|
||||||
it.title,
|
title = it.title,
|
||||||
it.content,
|
description = it.description,
|
||||||
it.timestamp,
|
content = it.content,
|
||||||
it.isArchived,
|
timestamp = it.timestamp,
|
||||||
it.isDeleted,
|
isPinned = it.isPinned,
|
||||||
it.isPinned
|
isArchived = it.isArchived,
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -105,7 +141,14 @@ 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(it.id, it.name, it.gradientStart, it.gradientEnd, it.timestamp)
|
SerializableCategory(
|
||||||
|
id = it.id,
|
||||||
|
name = it.name,
|
||||||
|
gradientStart = it.gradientStart,
|
||||||
|
gradientEnd = it.gradientEnd,
|
||||||
|
timestamp = it.timestamp,
|
||||||
|
isDeleted = it.isDeleted
|
||||||
|
)
|
||||||
}
|
}
|
||||||
preferences[CATEGORIES_KEY] = json.encodeToString(serializable)
|
preferences[CATEGORIES_KEY] = json.encodeToString(serializable)
|
||||||
}
|
}
|
||||||
@ -118,7 +161,17 @@ 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(it.id, it.categoryId, it.title, it.content, it.timestamp, it.isArchived, it.isDeleted, it.isPinned)
|
SerializableNote(
|
||||||
|
id = it.id,
|
||||||
|
categoryId = it.categoryId,
|
||||||
|
title = it.title,
|
||||||
|
description = it.description,
|
||||||
|
content = it.content,
|
||||||
|
timestamp = it.timestamp,
|
||||||
|
isPinned = it.isPinned,
|
||||||
|
isArchived = it.isArchived,
|
||||||
|
isDeleted = it.isDeleted
|
||||||
|
)
|
||||||
}
|
}
|
||||||
preferences[NOTES_KEY] = json.encodeToString(serializable)
|
preferences[NOTES_KEY] = json.encodeToString(serializable)
|
||||||
}
|
}
|
||||||
@ -126,4 +179,97 @@ 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,13 +1,14 @@
|
|||||||
// 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 // TAMBAHKAN INI
|
val isDeleted: Boolean = false // Support soft delete
|
||||||
)
|
)
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
package com.example.notesai.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChatHistory(
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
val categoryId: String?, // null berarti "Semua Kategori"
|
||||||
|
val categoryName: String, // Untuk display
|
||||||
|
val messages: List<SerializableChatMessage>,
|
||||||
|
val lastMessagePreview: String, // Preview pesan terakhir
|
||||||
|
val customTitle: String? = null, // Custom title yang di-edit user (support markdown)
|
||||||
|
val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
val isDeleted: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SerializableChatMessage(
|
||||||
|
val id: String,
|
||||||
|
val message: String,
|
||||||
|
val isUser: Boolean,
|
||||||
|
val timestamp: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
// Extension function untuk convert ChatMessage ke SerializableChatMessage
|
||||||
|
fun ChatMessage.toSerializable() = SerializableChatMessage(
|
||||||
|
id = id,
|
||||||
|
message = message,
|
||||||
|
isUser = isUser,
|
||||||
|
timestamp = timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
// Extension function untuk convert SerializableChatMessage ke ChatMessage
|
||||||
|
fun SerializableChatMessage.toChatMessage() = ChatMessage(
|
||||||
|
id = id,
|
||||||
|
message = message,
|
||||||
|
isUser = isUser,
|
||||||
|
timestamp = timestamp
|
||||||
|
)
|
||||||
@ -1,15 +1,20 @@
|
|||||||
// 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 content: String,
|
val description: String = "", // Field baru untuk preview di NoteCard
|
||||||
|
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
|
|
||||||
)
|
)
|
||||||
@ -1,177 +1,352 @@
|
|||||||
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.Arrangement
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
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.Archive
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material.icons.filled.Create
|
import androidx.compose.material.icons.outlined.*
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material.icons.filled.Home
|
import androidx.compose.runtime.*
|
||||||
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()
|
||||||
.statusBarsPadding() // Padding untuk status bar
|
.background(AppColors.Overlay)
|
||||||
.background(Color.Black.copy(alpha = 0.5f))
|
|
||||||
.clickable(
|
.clickable(
|
||||||
onClick = onDismiss,
|
onClick = onDismiss,
|
||||||
indication = null,
|
indication = null,
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Card(
|
// Drawer Content
|
||||||
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.width(250.dp)
|
.width(280.dp)
|
||||||
.align(Alignment.CenterStart)
|
.align(Alignment.CenterStart)
|
||||||
.clickable(
|
.clickable(
|
||||||
onClick = {},
|
onClick = {},
|
||||||
indication = null,
|
indication = null,
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(topEnd = 0.dp, bottomEnd = 0.dp),
|
color = AppColors.Surface,
|
||||||
colors = CardDefaults.cardColors(
|
shadowElevation = Constants.Elevation.ExtraLarge.dp
|
||||||
containerColor = Color(0xFF1E293B)
|
|
||||||
),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 16.dp)
|
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(
|
||||||
// Header Drawer dengan tombol close
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
// Header - Minimalist
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(
|
.background(
|
||||||
brush = Brush.verticalGradient(
|
brush = Brush.verticalGradient(
|
||||||
colors = listOf(Color(0xFF6366F1), Color(0xFFA855F7))
|
colors = listOf(
|
||||||
|
AppColors.Primary.copy(alpha = 0.15f),
|
||||||
|
Color.Transparent
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.padding(24.dp)
|
.padding(Constants.Spacing.ExtraLarge.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Column {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
// App Icon with subtle background
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
Box(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
modifier = Modifier
|
||||||
) {
|
.size(56.dp)
|
||||||
Column {
|
.background(
|
||||||
|
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 = Color.White,
|
tint = AppColors.Primary,
|
||||||
modifier = Modifier.size(36.dp)
|
modifier = Modifier.size(32.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)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// // Tombol Close
|
Spacer(modifier = Modifier.height(Constants.Spacing.Medium.dp))
|
||||||
// IconButton(
|
|
||||||
// onClick = onDismiss,
|
Text(
|
||||||
// modifier = Modifier
|
Constants.APP_NAME,
|
||||||
// .size(40.dp)
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
// .background(
|
color = AppColors.OnBackground,
|
||||||
// Color.White.copy(alpha = 0.2f),
|
fontWeight = FontWeight.Bold,
|
||||||
// shape = CircleShape
|
fontSize = 24.sp
|
||||||
// )
|
)
|
||||||
// ) {
|
|
||||||
// Icon(
|
Spacer(modifier = Modifier.height(Constants.Spacing.ExtraSmall.dp))
|
||||||
// Icons.Default.Close,
|
|
||||||
// contentDescription = "Tutup Menu",
|
Text(
|
||||||
// tint = Color.White,
|
"Smart Note Taking",
|
||||||
// modifier = Modifier.size(24.dp)
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
// )
|
color = AppColors.OnSurfaceVariant,
|
||||||
// }
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(Constants.Spacing.Large.dp))
|
||||||
|
|
||||||
// Menu Items
|
// Menu Items
|
||||||
MenuItem(
|
DrawerMenuItem(
|
||||||
icon = Icons.Default.Home,
|
icon = if (currentScreen == "main") Icons.Filled.Home else Icons.Outlined.Home,
|
||||||
text = "Beranda",
|
text = "Beranda",
|
||||||
isSelected = currentScreen == "main"
|
isSelected = currentScreen == "main",
|
||||||
) { onItemClick("main") }
|
onClick = { onItemClick("main") }
|
||||||
|
)
|
||||||
|
|
||||||
MenuItem(
|
DrawerMenuItem(
|
||||||
icon = Icons.Default.Star,
|
icon = if (currentScreen == "starred") Icons.Filled.Star else Icons.Outlined.StarBorder,
|
||||||
text = "Berbintang",
|
text = "Berbintang",
|
||||||
isSelected = currentScreen == "starred"
|
isSelected = currentScreen == "starred",
|
||||||
) { onItemClick("starred") }
|
onClick = { onItemClick("starred") }
|
||||||
|
)
|
||||||
|
|
||||||
MenuItem(
|
DrawerMenuItem(
|
||||||
icon = Icons.Default.Archive,
|
icon = if (currentScreen == "archive") Icons.Filled.Archive else Icons.Outlined.Archive,
|
||||||
text = "Arsip",
|
text = "Arsip",
|
||||||
isSelected = currentScreen == "archive"
|
isSelected = currentScreen == "archive",
|
||||||
) { onItemClick("archive") }
|
onClick = { onItemClick("archive") }
|
||||||
|
)
|
||||||
|
|
||||||
MenuItem(
|
DrawerMenuItem(
|
||||||
icon = Icons.Default.Delete,
|
icon = if (currentScreen == "trash") Icons.Filled.Delete else Icons.Outlined.Delete,
|
||||||
text = "Sampah",
|
text = "Sampah",
|
||||||
isSelected = currentScreen == "trash"
|
isSelected = currentScreen == "trash",
|
||||||
) { onItemClick("trash") }
|
onClick = { onItemClick("trash") }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(Constants.Spacing.Medium.dp))
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
color = AppColors.Divider,
|
||||||
|
modifier = Modifier.padding(horizontal = Constants.Spacing.Medium.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(Constants.Spacing.Medium.dp))
|
||||||
|
|
||||||
|
// Theme Toggle
|
||||||
|
ThemeToggleItem(
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
onToggle = onThemeToggle
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
// Footer
|
// Footer - Version info
|
||||||
Divider(
|
HorizontalDivider(
|
||||||
color = Color.White.copy(alpha = 0.1f),
|
color = AppColors.Divider,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp)
|
modifier = Modifier.padding(horizontal = Constants.Spacing.Medium.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Row(
|
||||||
text = "Version 1.0.0",
|
modifier = Modifier
|
||||||
style = MaterialTheme.typography.bodySmall,
|
.fillMaxWidth()
|
||||||
color = Color.White.copy(alpha = 0.5f),
|
.padding(Constants.Spacing.Medium.dp),
|
||||||
modifier = Modifier.padding(16.dp)
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
)
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Version ${Constants.APP_VERSION}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = AppColors.OnSurfaceTertiary,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
// Powered by badge
|
||||||
|
Surface(
|
||||||
|
color = AppColors.Primary.copy(alpha = 0.1f),
|
||||||
|
shape = RoundedCornerShape(Constants.Radius.Small.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = Constants.Spacing.Small.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.AutoAwesome,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = AppColors.Primary,
|
||||||
|
modifier = Modifier.size(12.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"AI",
|
||||||
|
color = AppColors.Primary,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ThemeToggleItem(
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
onToggle: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = Constants.Spacing.Medium.dp)
|
||||||
|
.clip(RoundedCornerShape(Constants.Radius.Medium.dp))
|
||||||
|
.clickable(onClick = onToggle)
|
||||||
|
.background(AppColors.SurfaceVariant)
|
||||||
|
.padding(horizontal = Constants.Spacing.Medium.dp, vertical = Constants.Spacing.Medium.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Constants.Spacing.Medium.dp)
|
||||||
|
) {
|
||||||
|
// Icon with background
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.background(
|
||||||
|
color = AppColors.Primary.copy(alpha = 0.2f),
|
||||||
|
shape = CircleShape
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (isDarkTheme) Icons.Default.DarkMode else Icons.Default.LightMode,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = AppColors.Primary,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"Tema Aplikasi",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = AppColors.OnSurface,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 15.sp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (isDarkTheme) "Mode Gelap" else "Mode Terang",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = AppColors.OnSurfaceTertiary,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle Switch
|
||||||
|
Switch(
|
||||||
|
checked = isDarkTheme,
|
||||||
|
onCheckedChange = { onToggle() },
|
||||||
|
colors = SwitchDefaults.colors(
|
||||||
|
checkedThumbColor = Color.White,
|
||||||
|
checkedTrackColor = AppColors.Primary,
|
||||||
|
uncheckedThumbColor = Color.White,
|
||||||
|
uncheckedTrackColor = AppColors.Border
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DrawerMenuItem(
|
||||||
|
icon: ImageVector,
|
||||||
|
text: String,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
// Scale animation
|
||||||
|
val scale by animateFloatAsState(
|
||||||
|
targetValue = if (isSelected) 1.02f else 1f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessMedium
|
||||||
|
),
|
||||||
|
label = "scale"
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.scale(scale)
|
||||||
|
.clip(RoundedCornerShape(Constants.Radius.Medium.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.background(
|
||||||
|
color = if (isSelected)
|
||||||
|
AppColors.Primary.copy(alpha = 0.1f)
|
||||||
|
else
|
||||||
|
Color.Transparent
|
||||||
|
)
|
||||||
|
.padding(horizontal = Constants.Spacing.Medium.dp, vertical = Constants.Spacing.Medium.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Icon with background
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.background(
|
||||||
|
color = if (isSelected)
|
||||||
|
AppColors.Primary.copy(alpha = 0.2f)
|
||||||
|
else
|
||||||
|
AppColors.SurfaceVariant,
|
||||||
|
shape = CircleShape
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (isSelected) AppColors.Primary else AppColors.OnSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(Constants.Spacing.Medium.dp))
|
||||||
|
|
||||||
|
// Text
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = if (isSelected) AppColors.Primary else AppColors.OnSurface,
|
||||||
|
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
|
fontSize = 15.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,31 +1,33 @@
|
|||||||
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.layout.Arrangement
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
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.material3.BottomAppBar
|
import androidx.compose.material.icons.outlined.AutoAwesome
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material.icons.outlined.Home
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material.icons.outlined.StarBorder
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
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.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(
|
||||||
@ -33,70 +35,125 @@ fun ModernBottomBar(
|
|||||||
onHomeClick: () -> Unit,
|
onHomeClick: () -> Unit,
|
||||||
onAIClick: () -> Unit
|
onAIClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
BottomAppBar(
|
// Floating Bottom Bar with Glassmorphism
|
||||||
containerColor = Color.Transparent,
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.shadow(8.dp, RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp))
|
.fillMaxWidth()
|
||||||
.background(
|
.padding(horizontal = 16.dp)
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color(0xFF1E293B).copy(0.95f),
|
|
||||||
Color(0xFF334155).copy(0.95f)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp),
|
.shadow(
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
elevation = Constants.Elevation.Large.dp,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
shape = RoundedCornerShape(Constants.Radius.ExtraLarge.dp)
|
||||||
|
),
|
||||||
|
color = AppColors.SurfaceElevated,
|
||||||
|
shape = RoundedCornerShape(Constants.Radius.ExtraLarge.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Row(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onHomeClick)
|
.padding(vertical = 8.dp, horizontal = 24.dp),
|
||||||
.padding(vertical = 8.dp)
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
// Home Button
|
||||||
Icons.Default.Home,
|
BottomBarItem(
|
||||||
contentDescription = "Home",
|
selected = currentScreen == "main",
|
||||||
tint = if (currentScreen == "main") Color(0xFF6366F1) else Color(0xFF94A3B8),
|
onClick = onHomeClick,
|
||||||
modifier = Modifier.size(24.dp)
|
icon = if (currentScreen == "main") Icons.Filled.Home else Icons.Outlined.Home,
|
||||||
|
label = "Beranda"
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
"Home",
|
|
||||||
color = if (currentScreen == "main") Color(0xFF6366F1) else Color(0xFF94A3B8),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
fontWeight = if (currentScreen == "main") FontWeight.Bold else FontWeight.Normal
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
// AI Button
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
BottomBarItem(
|
||||||
modifier = Modifier
|
selected = currentScreen == "ai",
|
||||||
.weight(1f)
|
onClick = onAIClick,
|
||||||
.clickable(onClick = onAIClick)
|
icon = if (currentScreen == "ai") Icons.Filled.AutoAwesome else Icons.Outlined.AutoAwesome,
|
||||||
.padding(vertical = 8.dp)
|
label = "AI Helper"
|
||||||
) {
|
|
||||||
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,42 +1,28 @@
|
|||||||
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.layout.*
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
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.material.icons.filled.Star
|
import androidx.compose.material3.*
|
||||||
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
|
||||||
@ -50,58 +36,144 @@ fun ModernTopBar(
|
|||||||
onSearchQueryChange: (String) -> Unit,
|
onSearchQueryChange: (String) -> Unit,
|
||||||
showSearch: Boolean
|
showSearch: Boolean
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
// Floating Top Bar with same style as Bottom Bar
|
||||||
title = {
|
Box(
|
||||||
if (showSearch) {
|
|
||||||
TextField(
|
|
||||||
value = searchQuery,
|
|
||||||
onValueChange = onSearchQueryChange,
|
|
||||||
placeholder = { Text("Cari catatan...", color = Color.White.copy(0.6f)) },
|
|
||||||
colors = TextFieldDefaults.colors(
|
|
||||||
focusedContainerColor = Color.Transparent,
|
|
||||||
unfocusedContainerColor = Color.Transparent,
|
|
||||||
focusedTextColor = Color.White,
|
|
||||||
unfocusedTextColor = Color.White,
|
|
||||||
cursorColor = Color.White,
|
|
||||||
focusedIndicatorColor = Color.Transparent,
|
|
||||||
unfocusedIndicatorColor = Color.Transparent
|
|
||||||
),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 22.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = if (showBackButton) onBackClick else onMenuClick) {
|
|
||||||
Icon(
|
|
||||||
if (showBackButton) Icons.AutoMirrored.Filled.ArrowBack else Icons.Default.Menu,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color.White
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
IconButton(onClick = onSearchClick) {
|
|
||||||
Icon(
|
|
||||||
if (showSearch) Icons.Default.Close else Icons.Default.Search,
|
|
||||||
contentDescription = "Search",
|
|
||||||
tint = Color.White
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = Color.Transparent
|
|
||||||
),
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(
|
.fillMaxWidth()
|
||||||
brush = Brush.horizontalGradient(
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
colors = listOf(Color(0xFF6366F1), Color(0xFFA855F7))
|
) {
|
||||||
)
|
Surface(
|
||||||
)
|
modifier = Modifier
|
||||||
)
|
.fillMaxWidth()
|
||||||
|
.shadow(
|
||||||
|
elevation = Constants.Elevation.Large.dp,
|
||||||
|
shape = RoundedCornerShape(Constants.Radius.ExtraLarge.dp)
|
||||||
|
),
|
||||||
|
color = AppColors.SurfaceElevated,
|
||||||
|
shape = RoundedCornerShape(Constants.Radius.ExtraLarge.dp)
|
||||||
|
) {
|
||||||
|
// Smooth transition for search bar
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = showSearch,
|
||||||
|
transitionSpec = {
|
||||||
|
fadeIn(animationSpec = tween(Constants.ANIMATION_DURATION_MEDIUM)) togetherWith
|
||||||
|
fadeOut(animationSpec = tween(Constants.ANIMATION_DURATION_MEDIUM))
|
||||||
|
},
|
||||||
|
label = "topbar"
|
||||||
|
) { isSearching ->
|
||||||
|
if (isSearching) {
|
||||||
|
// Search Mode
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
// Search TextField
|
||||||
|
OutlinedTextField(
|
||||||
|
value = searchQuery,
|
||||||
|
onValueChange = onSearchQueryChange,
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
"Cari...",
|
||||||
|
color = AppColors.OnSurfaceVariant,
|
||||||
|
fontSize = 15.sp
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = AppColors.SurfaceVariant,
|
||||||
|
unfocusedContainerColor = AppColors.SurfaceVariant,
|
||||||
|
focusedTextColor = AppColors.OnBackground,
|
||||||
|
unfocusedTextColor = AppColors.OnSurface,
|
||||||
|
cursorColor = AppColors.Primary,
|
||||||
|
focusedBorderColor = AppColors.Primary,
|
||||||
|
unfocusedBorderColor = Color.Transparent
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.heightIn(min = 48.dp),
|
||||||
|
shape = RoundedCornerShape(Constants.Radius.Large.dp),
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = LocalTextStyle.current.copy(fontSize = 15.sp)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Close Search Button
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
onSearchQueryChange("")
|
||||||
|
onSearchClick()
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(AppColors.SurfaceVariant)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = "Close Search",
|
||||||
|
tint = AppColors.OnSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal Mode
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
// Back/Menu Button
|
||||||
|
IconButton(
|
||||||
|
onClick = if (showBackButton) onBackClick else onMenuClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
if (showBackButton) AppColors.SurfaceVariant
|
||||||
|
else AppColors.Primary.copy(alpha = 0.1f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (showBackButton) Icons.AutoMirrored.Filled.ArrowBack else Icons.Default.Menu,
|
||||||
|
contentDescription = if (showBackButton) "Back" else "Menu",
|
||||||
|
tint = if (showBackButton) AppColors.OnSurface else AppColors.Primary,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
color = AppColors.OnBackground,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Search Button
|
||||||
|
IconButton(
|
||||||
|
onClick = onSearchClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(AppColors.SurfaceVariant)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Search,
|
||||||
|
contentDescription = "Search",
|
||||||
|
tint = AppColors.OnSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,163 +1,192 @@
|
|||||||
package com.example.notesai.presentation.dialogs
|
// File: presentation/dialogs/CategoryDialog.kt
|
||||||
|
package com.example.notesai.presentation.dialogs
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.material3.MaterialTheme
|
import com.example.notesai.util.AppColors
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import com.example.notesai.util.Constants
|
||||||
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) }
|
||||||
|
|
||||||
val gradients = listOf(
|
AlertDialog(
|
||||||
Pair(0xFF6366F1L, 0xFFA855F7L),
|
onDismissRequest = onDismiss,
|
||||||
Pair(0xFFEC4899L, 0xFFF59E0BL),
|
containerColor = AppColors.Surface,
|
||||||
Pair(0xFF8B5CF6L, 0xFFEC4899L),
|
shape = RoundedCornerShape(20.dp),
|
||||||
Pair(0xFF06B6D4L, 0xFF3B82F6L),
|
title = {
|
||||||
Pair(0xFF10B981L, 0xFF059669L),
|
|
||||||
Pair(0xFFF59E0BL, 0xFFEF4444L),
|
|
||||||
Pair(0xFF6366F1L, 0xFF8B5CF6L),
|
|
||||||
Pair(0xFFEF4444L, 0xFFDC2626L)
|
|
||||||
)
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
containerColor = Color(0xFF1E293B),
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
"Buat Kategori Baru",
|
|
||||||
color = Color.White,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = name,
|
|
||||||
onValueChange = { name = it },
|
|
||||||
label = { Text("Nama Kategori", color = Color(0xFF94A3B8)) },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = TextFieldDefaults.colors(
|
|
||||||
focusedTextColor = Color.White,
|
|
||||||
unfocusedTextColor = Color.White,
|
|
||||||
focusedContainerColor = Color(0xFF334155),
|
|
||||||
unfocusedContainerColor = Color(0xFF334155),
|
|
||||||
cursorColor = Color(0xFFA855F7),
|
|
||||||
focusedIndicatorColor = Color(0xFFA855F7),
|
|
||||||
unfocusedIndicatorColor = Color(0xFF64748B)
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
|
||||||
Text(
|
Text(
|
||||||
"Pilih Gradient:",
|
"Buat Kategori Baru",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
color = AppColors.OnBackground,
|
||||||
color = Color.White,
|
fontWeight = FontWeight.Bold,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontSize = 20.sp
|
||||||
)
|
)
|
||||||
|
},
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
text = {
|
||||||
|
Column(
|
||||||
gradients.chunked(4).forEach { row ->
|
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||||
Row(
|
) {
|
||||||
|
// Input Nama
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
"Nama Kategori",
|
||||||
|
color = AppColors.OnSurfaceVariant
|
||||||
|
)
|
||||||
|
},
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
"Contoh: Pekerjaan, Personal",
|
||||||
|
color = AppColors.OnSurfaceTertiary,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedTextColor = AppColors.OnBackground,
|
||||||
|
unfocusedTextColor = AppColors.OnSurface,
|
||||||
|
focusedContainerColor = AppColors.SurfaceVariant,
|
||||||
|
unfocusedContainerColor = AppColors.SurfaceVariant,
|
||||||
|
cursorColor = AppColors.Primary,
|
||||||
|
focusedBorderColor = AppColors.Primary,
|
||||||
|
unfocusedBorderColor = Color.Transparent
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Gradient Selector
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
row.forEachIndexed { index, gradient ->
|
Text(
|
||||||
val globalIndex = gradients.indexOf(gradient)
|
"Pilih Warna:",
|
||||||
Box(
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier
|
color = AppColors.OnSurface,
|
||||||
.weight(1f)
|
fontWeight = FontWeight.SemiBold,
|
||||||
.aspectRatio(1f)
|
fontSize = 14.sp
|
||||||
.clip(RoundedCornerShape(12.dp))
|
)
|
||||||
.background(
|
|
||||||
brush = Brush.linearGradient(
|
Constants.CategoryColors.chunked(4).forEach { row ->
|
||||||
colors = listOf(
|
Row(
|
||||||
Color(gradient.first),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Color(gradient.second)
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.clickable { selectedGradient = globalIndex },
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
if (selectedGradient == globalIndex) {
|
row.forEach { gradient ->
|
||||||
Icon(
|
val globalIndex = Constants.CategoryColors.indexOf(gradient)
|
||||||
Icons.Default.Check,
|
val isSelected = selectedGradient == globalIndex
|
||||||
contentDescription = null,
|
|
||||||
tint = Color.White,
|
// Scale animation
|
||||||
modifier = Modifier.size(24.dp)
|
val scale by animateFloatAsState(
|
||||||
|
targetValue = if (isSelected) 1.1f else 1f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessMedium
|
||||||
|
),
|
||||||
|
label = "scale"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(
|
||||||
|
brush = Brush.linearGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color(gradient.first),
|
||||||
|
Color(gradient.second)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.clickable { selectedGradient = globalIndex },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
// Check icon dengan animation
|
||||||
|
this@Row.AnimatedVisibility(
|
||||||
|
visible = isSelected,
|
||||||
|
enter = scaleIn() + fadeIn(),
|
||||||
|
exit = scaleOut() + fadeOut()
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = Color.White.copy(alpha = 0.9f),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(gradient.first),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(6.dp)
|
||||||
|
.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (name.isNotBlank()) {
|
||||||
|
val gradient = Constants.CategoryColors[selectedGradient]
|
||||||
|
onSave(name, gradient.first, gradient.second)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = name.isNotBlank(),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = AppColors.Primary,
|
||||||
|
disabledContainerColor = AppColors.Primary.copy(alpha = 0.5f)
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier.height(48.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Simpan",
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 15.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier.height(48.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Batal",
|
||||||
|
color = AppColors.OnSurfaceVariant,
|
||||||
|
fontSize = 15.sp
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
)
|
||||||
confirmButton = {
|
}
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
if (name.isNotBlank()) {
|
|
||||||
val gradient = gradients[selectedGradient]
|
|
||||||
onSave(name, gradient.first, gradient.second)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = name.isNotBlank(),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = Color.Transparent
|
|
||||||
),
|
|
||||||
modifier = Modifier.background(
|
|
||||||
brush = Brush.linearGradient(
|
|
||||||
colors = listOf(Color(0xFF6366F1), Color(0xFFA855F7))
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text("Simpan", color = Color.White, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text("Batal", color = Color(0xFF94A3B8))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,31 +1,19 @@
|
|||||||
package com.example.notesai.presentation.dialogs
|
package com.example.notesai.presentation.dialogs
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.layout.*
|
||||||
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.material3.AlertDialog
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.runtime.*
|
||||||
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(
|
||||||
@ -35,89 +23,190 @@ fun NoteDialog(
|
|||||||
onDelete: (() -> Unit)?
|
onDelete: (() -> Unit)?
|
||||||
) {
|
) {
|
||||||
var title by remember { mutableStateOf(note?.title ?: "") }
|
var title by remember { mutableStateOf(note?.title ?: "") }
|
||||||
var content by remember { mutableStateOf(note?.content ?: "") }
|
var description by remember { mutableStateOf(note?.description ?: "") }
|
||||||
|
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Delete confirmation dialog
|
||||||
|
if (showDeleteConfirm) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDeleteConfirm = false },
|
||||||
|
containerColor = AppColors.Surface,
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
"Hapus Catatan?",
|
||||||
|
color = AppColors.OnBackground,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"Catatan ini akan dipindahkan ke sampah.",
|
||||||
|
color = AppColors.OnSurfaceVariant
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onDelete?.invoke()
|
||||||
|
showDeleteConfirm = false
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = AppColors.Error
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Text("Hapus", color = Color.White, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { showDeleteConfirm = false },
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Text("Batal", color = AppColors.OnSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
containerColor = Color(0xFF1E293B),
|
containerColor = AppColors.Surface,
|
||||||
|
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 = Color.White,
|
color = AppColors.OnBackground,
|
||||||
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 = { Text("Judul", color = Color(0xFF94A3B8)) },
|
label = {
|
||||||
|
Text(
|
||||||
|
"Judul",
|
||||||
|
color = AppColors.OnSurfaceVariant
|
||||||
|
)
|
||||||
|
},
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
"Masukkan judul catatan",
|
||||||
|
color = AppColors.OnSurfaceTertiary,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = TextFieldDefaults.colors(
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
focusedTextColor = Color.White,
|
focusedTextColor = AppColors.OnBackground,
|
||||||
unfocusedTextColor = Color.White,
|
unfocusedTextColor = AppColors.OnSurface,
|
||||||
focusedContainerColor = Color(0xFF334155),
|
focusedContainerColor = AppColors.SurfaceVariant,
|
||||||
unfocusedContainerColor = Color(0xFF334155),
|
unfocusedContainerColor = AppColors.SurfaceVariant,
|
||||||
cursorColor = Color(0xFFA855F7),
|
cursorColor = AppColors.Primary,
|
||||||
focusedIndicatorColor = Color(0xFFA855F7),
|
focusedBorderColor = AppColors.Primary,
|
||||||
unfocusedIndicatorColor = Color(0xFF64748B)
|
unfocusedBorderColor = Color.Transparent
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
singleLine = true
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
// Description Input
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = content,
|
value = description,
|
||||||
onValueChange = { content = it },
|
onValueChange = { description = it },
|
||||||
label = { Text("Isi Catatan", color = Color(0xFF94A3B8)) },
|
label = {
|
||||||
|
Text(
|
||||||
|
"Deskripsi",
|
||||||
|
color = AppColors.OnSurfaceVariant
|
||||||
|
)
|
||||||
|
},
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
"Tambahkan deskripsi singkat...",
|
||||||
|
color = AppColors.OnSurfaceTertiary,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(200.dp),
|
.heightIn(min = 120.dp, max = 200.dp),
|
||||||
maxLines = 10,
|
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),
|
||||||
|
maxLines = 8
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
Row {
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
// Delete button (if editing)
|
||||||
if (onDelete != null) {
|
if (onDelete != null) {
|
||||||
TextButton(onClick = onDelete) {
|
IconButton(
|
||||||
Text("Hapus", color = Color(0xFFEF4444), fontWeight = FontWeight.Bold)
|
onClick = { showDeleteConfirm = true },
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = "Hapus",
|
||||||
|
tint = AppColors.Error
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
// Cancel button
|
||||||
|
TextButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier.height(48.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Batal",
|
||||||
|
color = AppColors.OnSurfaceVariant,
|
||||||
|
fontSize = 15.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save button
|
||||||
Button(
|
Button(
|
||||||
onClick = { if (title.isNotBlank()) onSave(title, content) },
|
onClick = {
|
||||||
|
if (title.isNotBlank()) {
|
||||||
|
onSave(title, description)
|
||||||
|
}
|
||||||
|
},
|
||||||
enabled = title.isNotBlank(),
|
enabled = title.isNotBlank(),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = Color.Transparent
|
containerColor = AppColors.Primary,
|
||||||
|
disabledContainerColor = AppColors.Primary.copy(alpha = 0.5f)
|
||||||
),
|
),
|
||||||
modifier = Modifier.background(
|
shape = RoundedCornerShape(12.dp),
|
||||||
brush = Brush.linearGradient(
|
modifier = Modifier.height(48.dp)
|
||||||
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,
|
||||||
|
fontSize = 15.sp
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text("Batal", color = Color(0xFF94A3B8))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,32 +1,28 @@
|
|||||||
package com.example.notesai.presentation.screens.ai.components
|
package com.example.notesai.presentation.screens.ai.components
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.*
|
||||||
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.AutoAwesome
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material.icons.filled.SmartToy
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.runtime.*
|
||||||
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.Date
|
import java.util.*
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatBubble(
|
fun ChatBubble(
|
||||||
@ -36,41 +32,46 @@ fun ChatBubble(
|
|||||||
) {
|
) {
|
||||||
val dateFormat = remember { SimpleDateFormat("HH:mm", Locale("id", "ID")) }
|
val dateFormat = remember { SimpleDateFormat("HH:mm", Locale("id", "ID")) }
|
||||||
|
|
||||||
Row(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = if (message.isUser) Arrangement.End else Arrangement.Start
|
horizontalAlignment = if (message.isUser) Alignment.End else Alignment.Start
|
||||||
) {
|
) {
|
||||||
if (!message.isUser) {
|
if (message.isUser) {
|
||||||
// Ganti ikon bintang dengan ikon robot/sparkles
|
// User Message
|
||||||
Icon(
|
Surface(
|
||||||
Icons.Default.AutoAwesome, // Atau bisa diganti dengan ikon lain seperti AutoAwesome
|
color = AppColors.Primary,
|
||||||
contentDescription = null,
|
|
||||||
tint = Color(0xFF6366F1), // Warna ungu/biru untuk AI
|
|
||||||
modifier = Modifier
|
|
||||||
.size(32.dp)
|
|
||||||
.padding(end = 8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth(0.85f),
|
|
||||||
horizontalAlignment = if (message.isUser) Alignment.End else Alignment.Start
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = if (message.isUser)
|
|
||||||
Color(0xFF6366F1)
|
|
||||||
else
|
|
||||||
Color(0xFF1E293B)
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(
|
shape = RoundedCornerShape(
|
||||||
topStart = 16.dp,
|
topStart = Constants.Radius.Large.dp,
|
||||||
topEnd = 16.dp,
|
topEnd = Constants.Radius.Large.dp,
|
||||||
bottomStart = if (message.isUser) 16.dp else 4.dp,
|
bottomStart = Constants.Radius.Large.dp,
|
||||||
bottomEnd = if (message.isUser) 4.dp else 16.dp
|
bottomEnd = 4.dp
|
||||||
)
|
),
|
||||||
|
shadowElevation = 2.dp,
|
||||||
|
modifier = Modifier.widthIn(max = 320.dp)
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(12.dp)) {
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Person,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White.copy(alpha = 0.9f),
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Anda",
|
||||||
|
color = Color.White.copy(alpha = 0.9f),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
message.message,
|
message.message,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
@ -78,42 +79,92 @@ 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
|
||||||
) {
|
) {
|
||||||
Text(
|
Row(
|
||||||
dateFormat.format(Date(message.timestamp)),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
color = Color.White.copy(0.6f),
|
verticalAlignment = Alignment.CenterVertically
|
||||||
style = MaterialTheme.typography.bodySmall,
|
) {
|
||||||
modifier = Modifier.padding(top = 4.dp)
|
Icon(
|
||||||
)
|
Icons.Default.SmartToy,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = AppColors.Primary,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"AI Assistant",
|
||||||
|
color = AppColors.OnSurfaceVariant,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!message.isUser) {
|
// Copy Button
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onCopy,
|
onClick = onCopy,
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier.size(28.dp)
|
||||||
) {
|
) {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = showCopied,
|
||||||
|
transitionSpec = {
|
||||||
|
fadeIn() + scaleIn() togetherWith fadeOut() + scaleOut()
|
||||||
|
},
|
||||||
|
label = "copy_icon"
|
||||||
|
) { copied ->
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.ContentCopy,
|
if (copied) Icons.Default.Check else Icons.Default.ContentCopy,
|
||||||
contentDescription = "Copy",
|
contentDescription = if (copied) "Copied" else "Copy",
|
||||||
tint = Color.White.copy(0.7f),
|
tint = if (copied) AppColors.Success else AppColors.OnSurfaceVariant,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showCopied && !message.isUser) {
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
|
||||||
"✓ Disalin",
|
// Use MarkdownText for AI responses
|
||||||
color = Color(0xFF10B981),
|
MarkdownText(
|
||||||
style = MaterialTheme.typography.bodySmall,
|
markdown = message.message,
|
||||||
modifier = Modifier.padding(top = 4.dp, start = 8.dp)
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
dateFormat.format(Date(message.timestamp)),
|
||||||
|
color = AppColors.OnSurfaceTertiary,
|
||||||
|
fontSize = 11.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,11 +1,15 @@
|
|||||||
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
|
||||||
@ -14,29 +18,46 @@ 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(label: String, value: String, color: Color) {
|
fun CompactStatItem(
|
||||||
|
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 = Color(0xFF1E293B),
|
color = AppColors.SurfaceVariant,
|
||||||
shape = RoundedCornerShape(8.dp)
|
shape = RoundedCornerShape(8.dp)
|
||||||
)
|
)
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Icon(
|
||||||
value,
|
icon,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
contentDescription = null,
|
||||||
color = color,
|
tint = AppColors.Primary,
|
||||||
fontWeight = FontWeight.Bold
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
|
||||||
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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
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
|
||||||
@ -8,44 +9,49 @@ 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(text: String, onSelect: (String) -> Unit) {
|
fun SuggestionChip(
|
||||||
Card(
|
text: String,
|
||||||
modifier = Modifier
|
onSelect: (String) -> Unit
|
||||||
.padding(vertical = 4.dp)
|
) {
|
||||||
.clickable { onSelect(text) },
|
Surface(
|
||||||
colors = CardDefaults.cardColors(
|
onClick = { onSelect(text) },
|
||||||
containerColor = Color(0xFF1E293B)
|
color = AppColors.SurfaceVariant,
|
||||||
),
|
|
||||||
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.Star,
|
Icons.Default.Lightbulb,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = Color(0xFF6366F1),
|
tint = AppColors.Primary,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
Text(
|
||||||
text,
|
text,
|
||||||
color = Color.White,
|
color = AppColors.OnSurface,
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontSize = 14.sp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
// 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.*
|
||||||
@ -28,7 +27,9 @@ 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 // Parameter baru
|
onCategoryEdit: (Category, String, Long, Long) -> Unit,
|
||||||
|
onNoteEdit: (Note) -> Unit = {},
|
||||||
|
onNoteDelete: (Note) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
if (selectedCategory == null) {
|
if (selectedCategory == null) {
|
||||||
@ -58,7 +59,12 @@ fun MainScreen(
|
|||||||
} else {
|
} else {
|
||||||
LazyVerticalStaggeredGrid(
|
LazyVerticalStaggeredGrid(
|
||||||
columns = StaggeredGridCells.Fixed(2),
|
columns = StaggeredGridCells.Fixed(2),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(
|
||||||
|
start = 16.dp,
|
||||||
|
end = 16.dp,
|
||||||
|
top = 16.dp,
|
||||||
|
bottom = 100.dp // Extra space untuk floating bottom bar
|
||||||
|
),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalItemSpacing = 12.dp,
|
verticalItemSpacing = 12.dp,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
@ -102,7 +108,12 @@ fun MainScreen(
|
|||||||
} else {
|
} else {
|
||||||
LazyVerticalStaggeredGrid(
|
LazyVerticalStaggeredGrid(
|
||||||
columns = StaggeredGridCells.Fixed(2),
|
columns = StaggeredGridCells.Fixed(2),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(
|
||||||
|
start = 16.dp,
|
||||||
|
end = 16.dp,
|
||||||
|
top = 16.dp,
|
||||||
|
bottom = 100.dp // Extra space untuk floating bottom bar
|
||||||
|
),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalItemSpacing = 12.dp,
|
verticalItemSpacing = 12.dp,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
@ -111,7 +122,9 @@ 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) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,29 @@
|
|||||||
|
// 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(
|
||||||
@ -28,16 +36,33 @@ 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 = { Text("Pindahkan ke Sampah?", color = Color.White) },
|
title = {
|
||||||
|
Text(
|
||||||
|
"Pindahkan ke Sampah?",
|
||||||
|
color = AppColors.OnBackground,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
},
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
"Kategori '${category.name}' dan semua catatan di dalamnya akan dipindahkan ke sampah.",
|
"Kategori '${category.name}' dan $noteCount catatan di dalamnya akan dipindahkan ke sampah.",
|
||||||
color = Color.White
|
color = AppColors.OnSurfaceVariant
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
@ -47,23 +72,19 @@ fun CategoryCard(
|
|||||||
showDeleteConfirm = false
|
showDeleteConfirm = false
|
||||||
},
|
},
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = Color(0xFFEF4444)
|
containerColor = AppColors.Error
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Text("Hapus", color = Color.White)
|
Text("Hapus", color = Color.White)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
Button(
|
TextButton(onClick = { showDeleteConfirm = false }) {
|
||||||
onClick = { showDeleteConfirm = false },
|
Text("Batal", color = AppColors.OnSurfaceVariant)
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = Color(0xFF64748B)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text("Batal", color = Color.White)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
containerColor = Color(0xFF1E293B)
|
containerColor = AppColors.Surface,
|
||||||
|
shape = RoundedCornerShape(Constants.Radius.Large.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,13 +100,19 @@ fun CategoryCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Main Card - Minimalist Design
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.scale(scale)
|
||||||
.clickable(onClick = onClick),
|
.clickable(onClick = onClick),
|
||||||
shape = RoundedCornerShape(20.dp),
|
shape = RoundedCornerShape(Constants.Radius.Large.dp),
|
||||||
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
|
colors = CardDefaults.cardColors(
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
containerColor = AppColors.Surface
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(
|
||||||
|
defaultElevation = Constants.Elevation.Small.dp
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -93,94 +120,146 @@ fun CategoryCard(
|
|||||||
.background(
|
.background(
|
||||||
brush = Brush.linearGradient(
|
brush = Brush.linearGradient(
|
||||||
colors = listOf(
|
colors = listOf(
|
||||||
Color(category.gradientStart),
|
Color(category.gradientStart).copy(alpha = 0.1f),
|
||||||
Color(category.gradientEnd)
|
Color(category.gradientEnd).copy(alpha = 0.05f)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.padding(20.dp)
|
|
||||||
) {
|
) {
|
||||||
Column {
|
Column(
|
||||||
Icon(
|
modifier = Modifier
|
||||||
Icons.Default.Folder,
|
.fillMaxWidth()
|
||||||
contentDescription = null,
|
.padding(Constants.Spacing.Large.dp)
|
||||||
tint = Color.White.copy(0.9f),
|
|
||||||
modifier = Modifier.size(32.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Text(
|
|
||||||
category.name,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = Color.White
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
"$noteCount catatan",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = Color.White.copy(0.8f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Menu Button (Titik Tiga)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.align(Alignment.TopEnd)
|
|
||||||
) {
|
) {
|
||||||
IconButton(
|
Row(
|
||||||
onClick = { showMenu = true }
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.Top
|
||||||
) {
|
) {
|
||||||
Icon(
|
// Icon dengan gradient accent
|
||||||
Icons.Default.MoreVert,
|
Box(
|
||||||
contentDescription = "Menu",
|
modifier = Modifier
|
||||||
tint = Color.White.copy(0.9f)
|
.size(48.dp)
|
||||||
)
|
.clip(RoundedCornerShape(Constants.Radius.Medium.dp))
|
||||||
|
.background(
|
||||||
|
brush = Brush.linearGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color(category.gradientStart).copy(alpha = 0.2f),
|
||||||
|
Color(category.gradientEnd).copy(alpha = 0.1f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.FolderOpen,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(category.gradientStart),
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Menu Button
|
||||||
|
Box {
|
||||||
|
IconButton(
|
||||||
|
onClick = { showMenu = true },
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.MoreVert,
|
||||||
|
contentDescription = "Menu",
|
||||||
|
tint = AppColors.OnSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showMenu,
|
||||||
|
onDismissRequest = { showMenu = false },
|
||||||
|
modifier = Modifier.background(AppColors.SurfaceElevated)
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Constants.Spacing.Small.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Edit,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = AppColors.Primary,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Edit Kategori",
|
||||||
|
color = AppColors.OnSurface,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
showEditDialog = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Constants.Spacing.Small.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = AppColors.Error,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Pindah ke Sampah",
|
||||||
|
color = AppColors.OnSurface,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
showDeleteConfirm = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DropdownMenu(
|
Spacer(modifier = Modifier.height(Constants.Spacing.Medium.dp))
|
||||||
expanded = showMenu,
|
|
||||||
onDismissRequest = { showMenu = false },
|
|
||||||
modifier = Modifier.background(Color(0xFF1E293B))
|
|
||||||
) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Edit,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color(0xFF6366F1),
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
Text("Edit Kategori", color = Color.White)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
showMenu = false
|
|
||||||
showEditDialog = true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
DropdownMenuItem(
|
// Category Name
|
||||||
text = {
|
Text(
|
||||||
Row(
|
category.name,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
fontWeight = FontWeight.Bold,
|
||||||
) {
|
color = AppColors.OnBackground,
|
||||||
Icon(
|
fontSize = 18.sp
|
||||||
Icons.Default.Delete,
|
)
|
||||||
contentDescription = null,
|
|
||||||
tint = Color(0xFFEF4444),
|
Spacer(modifier = Modifier.height(Constants.Spacing.ExtraSmall.dp))
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
// Note Count dengan subtle styling
|
||||||
Text("Pindah ke Sampah", color = Color.White)
|
Row(
|
||||||
}
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
},
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
onClick = {
|
) {
|
||||||
showMenu = false
|
Icon(
|
||||||
showDeleteConfirm = true
|
Icons.Default.Description,
|
||||||
}
|
contentDescription = null,
|
||||||
|
tint = AppColors.OnSurfaceTertiary,
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"$noteCount catatan",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = AppColors.OnSurfaceTertiary,
|
||||||
|
fontSize = 13.sp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -194,31 +273,23 @@ fun EditCategoryDialog(
|
|||||||
onDismiss: () -> Unit,
|
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(
|
||||||
gradients.indexOfFirst {
|
Constants.CategoryColors.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 = Color(0xFF1E293B),
|
containerColor = AppColors.Surface,
|
||||||
|
shape = RoundedCornerShape(Constants.Radius.Large.dp),
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
"Edit Kategori",
|
"Edit Kategori",
|
||||||
color = Color.White,
|
color = AppColors.OnBackground,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@ -227,42 +298,51 @@ fun EditCategoryDialog(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
label = { Text("Nama Kategori", color = Color(0xFF94A3B8)) },
|
label = {
|
||||||
|
Text(
|
||||||
|
"Nama Kategori",
|
||||||
|
color = AppColors.OnSurfaceVariant
|
||||||
|
)
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = TextFieldDefaults.colors(
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
focusedTextColor = Color.White,
|
focusedTextColor = AppColors.OnBackground,
|
||||||
unfocusedTextColor = Color.White,
|
unfocusedTextColor = AppColors.OnSurface,
|
||||||
focusedContainerColor = Color(0xFF334155),
|
focusedContainerColor = AppColors.SurfaceVariant,
|
||||||
unfocusedContainerColor = Color(0xFF334155),
|
unfocusedContainerColor = AppColors.SurfaceVariant,
|
||||||
cursorColor = Color(0xFFA855F7),
|
cursorColor = AppColors.Primary,
|
||||||
focusedIndicatorColor = Color(0xFFA855F7),
|
focusedBorderColor = AppColors.Primary,
|
||||||
unfocusedIndicatorColor = Color(0xFF64748B)
|
unfocusedBorderColor = AppColors.Border
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(Constants.Radius.Medium.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Pilih Gradient:",
|
"Pilih Warna:",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = Color.White,
|
color = AppColors.OnSurface,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 14.sp
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
gradients.chunked(4).forEach { row ->
|
Constants.CategoryColors.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 = gradients.indexOf(gradient)
|
val globalIndex = Constants.CategoryColors.indexOf(gradient)
|
||||||
|
val isSelected = selectedGradient == globalIndex
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.aspectRatio(1f)
|
.aspectRatio(1f)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(Constants.Radius.Medium.dp))
|
||||||
.background(
|
.background(
|
||||||
brush = Brush.linearGradient(
|
brush = Brush.linearGradient(
|
||||||
colors = listOf(
|
colors = listOf(
|
||||||
@ -274,7 +354,11 @@ fun EditCategoryDialog(
|
|||||||
.clickable { selectedGradient = globalIndex },
|
.clickable { selectedGradient = globalIndex },
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
if (selectedGradient == globalIndex) {
|
this@Row.AnimatedVisibility(
|
||||||
|
visible = isSelected,
|
||||||
|
enter = scaleIn() + fadeIn(),
|
||||||
|
exit = scaleOut() + fadeOut()
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Check,
|
Icons.Default.Check,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@ -293,19 +377,13 @@ fun EditCategoryDialog(
|
|||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (name.isNotBlank()) {
|
if (name.isNotBlank()) {
|
||||||
val gradient = gradients[selectedGradient]
|
val gradient = Constants.CategoryColors[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 = Color.Transparent
|
containerColor = AppColors.Primary
|
||||||
),
|
|
||||||
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)
|
||||||
@ -313,7 +391,7 @@ fun EditCategoryDialog(
|
|||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = onDismiss) {
|
TextButton(onClick = onDismiss) {
|
||||||
Text("Batal", color = Color(0xFF94A3B8))
|
Text("Batal", color = AppColors.OnSurfaceVariant)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,128 +1,278 @@
|
|||||||
|
// 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.Arrangement
|
import androidx.compose.foundation.layout.*
|
||||||
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.Star
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material.icons.outlined.StarBorder
|
import androidx.compose.material.icons.outlined.StarBorder
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.runtime.*
|
||||||
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.Date
|
import java.util.*
|
||||||
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()
|
||||||
.combinedClickable(
|
.scale(scale)
|
||||||
onClick = onClick,
|
.combinedClickable(onClick = onClick),
|
||||||
),
|
shape = RoundedCornerShape(Constants.Radius.Large.dp),
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = Color(0xFF1E293B)
|
containerColor = AppColors.SurfaceVariant
|
||||||
),
|
),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
elevation = CardDefaults.cardElevation(
|
||||||
|
defaultElevation = Constants.Elevation.Small.dp
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp)
|
.padding(Constants.Spacing.Large.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
|
||||||
) {
|
) {
|
||||||
// Judul
|
// Title - takes most space
|
||||||
Text(
|
Text(
|
||||||
note.title,
|
note.title,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = Color.White,
|
color = AppColors.OnBackground,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
fontSize = 18.sp
|
||||||
)
|
)
|
||||||
IconButton(
|
|
||||||
onClick = onPinClick,
|
// Vertical Actions Stack
|
||||||
modifier = Modifier.size(24.dp)
|
Column(
|
||||||
|
horizontalAlignment = Alignment.End,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(0.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
// Menu Button
|
||||||
if (note.isPinned) Icons.Filled.Star else Icons.Outlined.StarBorder,
|
Box {
|
||||||
contentDescription = "Pin",
|
IconButton(
|
||||||
tint = if (note.isPinned) Color(0xFFFBBF24) else Color.Gray,
|
onClick = { showMenu = true },
|
||||||
modifier = Modifier.size(18.dp)
|
modifier = Modifier.size(28.dp)
|
||||||
)
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.MoreVert,
|
||||||
|
contentDescription = "Menu",
|
||||||
|
tint = AppColors.OnSurfaceVariant,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showMenu,
|
||||||
|
onDismissRequest = { showMenu = false },
|
||||||
|
modifier = Modifier.background(AppColors.SurfaceElevated)
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Constants.Spacing.Small.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Edit,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = AppColors.Primary,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Edit Catatan",
|
||||||
|
color = AppColors.OnSurface,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
onEdit()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Constants.Spacing.Small.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = AppColors.Error,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Pindah ke Sampah",
|
||||||
|
color = AppColors.OnSurface,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
showDeleteConfirm = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pin Button
|
||||||
|
IconButton(
|
||||||
|
onClick = onPinClick,
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (note.isPinned) Icons.Filled.Star else Icons.Outlined.StarBorder,
|
||||||
|
contentDescription = "Pin",
|
||||||
|
tint = if (note.isPinned) AppColors.Warning else AppColors.OnSurfaceVariant,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deskripsi
|
// Deskripsi Preview
|
||||||
if (note.content.isNotEmpty()) {
|
if (note.description.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Deskripsi",
|
note.description,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = Color(0xFF94A3B8),
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
note.content,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
maxLines = 4,
|
maxLines = 3,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = Color(0xFFCBD5E1),
|
color = AppColors.OnSurfaceVariant,
|
||||||
lineHeight = 20.sp
|
lineHeight = 20.sp,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Tampilkan placeholder jika deskripsi kosong
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"Tidak ada deskripsi",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = AppColors.OnSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
|
||||||
|
fontSize = 14.sp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Divider
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
color = Color(0xFF334155),
|
color = AppColors.Divider,
|
||||||
thickness = 1.dp
|
thickness = 1.dp
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
// Timestamp
|
// Footer: Timestamp
|
||||||
Text(
|
Row(
|
||||||
dateFormat.format(Date(note.timestamp)),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
color = Color(0xFF64748B)
|
verticalAlignment = Alignment.CenterVertically
|
||||||
)
|
) {
|
||||||
|
Text(
|
||||||
|
dateFormat.format(Date(note.timestamp)),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = AppColors.OnSurfaceTertiary,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,50 +1,46 @@
|
|||||||
package com.example.notesai.presentation.screens.note
|
package com.example.notesai.presentation.screens.note
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.*
|
||||||
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.Archive
|
import androidx.compose.material.icons.filled.*
|
||||||
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.AlertDialog
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material3.Divider
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.ui.Alignment
|
||||||
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.dp
|
import androidx.compose.ui.unit.*
|
||||||
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.Date
|
import java.util.*
|
||||||
import java.util.Locale
|
import kotlin.math.roundToInt
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun EditableFullScreenNoteView(
|
fun EditableFullScreenNoteView(
|
||||||
note: Note,
|
note: Note,
|
||||||
@ -55,196 +51,245 @@ fun EditableFullScreenNoteView(
|
|||||||
onPinToggle: () -> Unit
|
onPinToggle: () -> Unit
|
||||||
) {
|
) {
|
||||||
var title by remember { mutableStateOf(note.title) }
|
var title by remember { mutableStateOf(note.title) }
|
||||||
var content by remember { mutableStateOf(note.content) }
|
var isContentFocused by remember { mutableStateOf(false) }
|
||||||
var showArchiveDialog by remember { mutableStateOf(false) }
|
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
|
||||||
val dateFormat = remember { SimpleDateFormat("dd MMMM yyyy, HH:mm", Locale("id", "ID")) }
|
|
||||||
|
|
||||||
// Dialog Konfirmasi Arsip
|
val editorState = remember(note.id) {
|
||||||
if (showArchiveDialog) {
|
RichEditorState(
|
||||||
AlertDialog(
|
AnnotatedStringSerializer.fromJson(note.content)
|
||||||
onDismissRequest = { showArchiveDialog = false },
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = "Arsipkan Catatan?",
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
text = "Catatan ini akan dipindahkan ke arsip. Anda masih bisa mengaksesnya nanti.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
if (title.isNotBlank()) {
|
|
||||||
onSave(title, content)
|
|
||||||
}
|
|
||||||
onArchive()
|
|
||||||
showArchiveDialog = false
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text("Arsipkan", color = MaterialTheme.colorScheme.primary)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showArchiveDialog = false }) {
|
|
||||||
Text("Batal", color = MaterialTheme.colorScheme.onSurface)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dialog Konfirmasi Hapus
|
|
||||||
if (showDeleteDialog) {
|
val focusRequester = remember { FocusRequester() }
|
||||||
AlertDialog(
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
onDismissRequest = { showDeleteDialog = false },
|
val scrollState = rememberScrollState()
|
||||||
title = {
|
val scope = rememberCoroutineScope()
|
||||||
Text(
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
text = "Hapus Catatan?",
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
fun ensureFocus() {
|
||||||
fontWeight = FontWeight.Bold
|
focusRequester.requestFocus()
|
||||||
)
|
keyboard?.show()
|
||||||
},
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
fun saveNote() {
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
if (title.isNotBlank()) {
|
||||||
topBar = {
|
onSave(
|
||||||
TopAppBar(
|
title,
|
||||||
title = { },
|
AnnotatedStringSerializer.toJson(editorState.value.annotatedString)
|
||||||
navigationIcon = {
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
IconButton(onClick = { showArchiveDialog = true }) {
|
|
||||||
Icon(Icons.Default.Archive, contentDescription = "Arsipkan", tint = MaterialTheme.colorScheme.onSurface)
|
|
||||||
}
|
|
||||||
IconButton(onClick = { showDeleteDialog = true }) {
|
|
||||||
Icon(Icons.Default.Delete, contentDescription = "Hapus", tint = MaterialTheme.colorScheme.onSurface)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = Color.Transparent
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { padding ->
|
}
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(padding)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = 20.dp)
|
|
||||||
) {
|
|
||||||
TextField(
|
|
||||||
value = title,
|
|
||||||
onValueChange = { title = it },
|
|
||||||
textStyle = MaterialTheme.typography.headlineLarge.copy(
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground
|
|
||||||
),
|
|
||||||
placeholder = {
|
|
||||||
Text(
|
|
||||||
"Judul",
|
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
|
||||||
color = Color(0xFF64748B)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
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))
|
// 🔥 AUTO SAVE SAAT APP BACKGROUND / KELUAR
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
||||||
Text(
|
DisposableEffect(lifecycleOwner) {
|
||||||
text = "Terakhir diubah: ${dateFormat.format(Date(note.timestamp))}",
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
style = MaterialTheme.typography.bodySmall,
|
if (event == Lifecycle.Event.ON_STOP) {
|
||||||
color = Color(0xFF64748B)
|
saveNote()
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Divider(
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
modifier = Modifier.padding(vertical = 20.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surface
|
|
||||||
)
|
|
||||||
|
|
||||||
TextField(
|
onDispose {
|
||||||
value = content,
|
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||||
onValueChange = { content = it },
|
}
|
||||||
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
}
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
lineHeight = 28.sp
|
|
||||||
),
|
val dateFormat = remember {
|
||||||
placeholder = {
|
SimpleDateFormat("dd MMMM yyyy, HH:mm", Locale("id", "ID"))
|
||||||
Text(
|
}
|
||||||
"Mulai menulis...",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
val density = LocalDensity.current
|
||||||
color = Color(0xFF64748B)
|
val config = LocalConfiguration.current
|
||||||
)
|
|
||||||
},
|
val screenWidthPx = with(density) { config.screenWidthDp.dp.toPx() }
|
||||||
colors = TextFieldDefaults.colors(
|
val screenHeightPx = with(density) { config.screenHeightDp.dp.toPx() }
|
||||||
focusedContainerColor = Color.Transparent,
|
val marginPx = with(density) { 16.dp.toPx() }
|
||||||
unfocusedContainerColor = Color.Transparent,
|
|
||||||
focusedIndicatorColor = Color.Transparent,
|
val imeBottomPx = with(density) {
|
||||||
unfocusedIndicatorColor = Color.Transparent,
|
WindowInsets.ime.getBottom(this).toFloat()
|
||||||
cursorColor = Color(0xFFA855F7)
|
}
|
||||||
),
|
|
||||||
|
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)
|
||||||
|
) { paddingValues ->
|
||||||
|
|
||||||
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxSize()
|
||||||
.heightIn(min = 400.dp)
|
.padding(paddingValues)
|
||||||
)
|
.imeNestedScroll()
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
.padding(horizontal = 20.dp)
|
||||||
|
.padding(
|
||||||
|
bottom = WindowInsets.ime
|
||||||
|
.asPaddingValues()
|
||||||
|
.calculateBottomPadding()
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(100.dp))
|
TextField(
|
||||||
|
value = title,
|
||||||
|
onValueChange = { title = it },
|
||||||
|
textStyle = MaterialTheme.typography.headlineLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground
|
||||||
|
),
|
||||||
|
placeholder = { Text("Judul") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"Terakhir diubah: ${dateFormat.format(Date(note.timestamp))}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(Modifier.padding(vertical = 20.dp))
|
||||||
|
|
||||||
|
// ✅ FIX UTAMA: set cursorBrush agar insertion cursor muncul
|
||||||
|
BasicTextField(
|
||||||
|
value = editorState.value,
|
||||||
|
onValueChange = {
|
||||||
|
editorState.onValueChange(it)
|
||||||
|
scope.launch {
|
||||||
|
bringIntoViewRequester.bringIntoView()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cursorBrush = SolidColor(Color(0xFFA855F7)), // ✅ ADD THIS
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
lineHeight = 28.sp
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.defaultMinSize(minHeight = 400.dp)
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.bringIntoViewRequester(bringIntoViewRequester)
|
||||||
|
.onFocusChanged {
|
||||||
|
isContentFocused = it.isFocused
|
||||||
|
if (it.isFocused) {
|
||||||
|
scope.launch { bringIntoViewRequester.bringIntoView() }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
decorationBox = { innerTextField ->
|
||||||
|
Box {
|
||||||
|
if (editorState.value.text.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"Mulai menulis...",
|
||||||
|
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.4f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerTextField()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(180.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isContentFocused) {
|
||||||
|
DraggableMiniMarkdownToolbar(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.offset {
|
||||||
|
val maxX =
|
||||||
|
(screenWidthPx - toolbarSizePx.width - marginPx)
|
||||||
|
.coerceAtLeast(marginPx)
|
||||||
|
|
||||||
|
val maxY =
|
||||||
|
(screenHeightPx - imeBottomPx - toolbarSizePx.height)
|
||||||
|
.coerceAtLeast(marginPx)
|
||||||
|
|
||||||
|
IntOffset(
|
||||||
|
toolbarOffset.x.coerceIn(marginPx, maxX).roundToInt(),
|
||||||
|
toolbarOffset.y.coerceIn(marginPx, maxY).roundToInt()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onSizeChanged {
|
||||||
|
toolbarSizePx = androidx.compose.ui.geometry.Size(
|
||||||
|
it.width.toFloat(),
|
||||||
|
it.height.toFloat()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
isBoldActive = editorState.isBoldActive(),
|
||||||
|
isItalicActive = editorState.isItalicActive(),
|
||||||
|
isUnderlineActive = editorState.isUnderlineActive(),
|
||||||
|
|
||||||
|
onDrag = ::moveToolbar,
|
||||||
|
onBold = {
|
||||||
|
ensureFocus()
|
||||||
|
editorState.toggleBold()
|
||||||
|
},
|
||||||
|
onItalic = {
|
||||||
|
ensureFocus()
|
||||||
|
editorState.toggleItalic()
|
||||||
|
},
|
||||||
|
onUnderline = { ensureFocus(); editorState.toggleUnderline() },
|
||||||
|
onHeading = { ensureFocus(); editorState.setHeading(1) }, // sementara H1
|
||||||
|
onBullet = { ensureFocus(); editorState.toggleBulletList() }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
package com.example.notesai.presentation.screens.note.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DraggableMiniMarkdownToolbar(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onDrag: (dx: Float, dy: Float) -> Unit,
|
||||||
|
|
||||||
|
// STATE
|
||||||
|
isBoldActive: Boolean,
|
||||||
|
isItalicActive: Boolean,
|
||||||
|
isUnderlineActive: Boolean,
|
||||||
|
|
||||||
|
// ACTIONS
|
||||||
|
onBold: () -> Unit,
|
||||||
|
onItalic: () -> Unit,
|
||||||
|
onHeading: () -> Unit,
|
||||||
|
onUnderline: () -> Unit,
|
||||||
|
onBullet: () -> Unit
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier,
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
|
||||||
|
shadowElevation = 6.dp
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
|
// 🔹 DRAG HANDLE
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectDragGestures { change, dragAmount ->
|
||||||
|
change.consume()
|
||||||
|
onDrag(dragAmount.x, dragAmount.y)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.DragIndicator,
|
||||||
|
contentDescription = "Drag",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f),
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarIcon(
|
||||||
|
icon = Icons.Default.FormatBold,
|
||||||
|
isActive = isBoldActive,
|
||||||
|
onClick = onBold
|
||||||
|
)
|
||||||
|
|
||||||
|
ToolbarIcon(
|
||||||
|
icon = Icons.Default.FormatItalic,
|
||||||
|
isActive = isItalicActive,
|
||||||
|
onClick = onItalic
|
||||||
|
)
|
||||||
|
|
||||||
|
ToolbarIcon(
|
||||||
|
icon = Icons.Default.FormatUnderlined,
|
||||||
|
isActive = isUnderlineActive,
|
||||||
|
onClick = onUnderline
|
||||||
|
)
|
||||||
|
|
||||||
|
ToolbarIcon(
|
||||||
|
icon = Icons.Default.Title,
|
||||||
|
onClick = onHeading
|
||||||
|
)
|
||||||
|
|
||||||
|
ToolbarIcon(
|
||||||
|
icon = Icons.Default.FormatListBulleted,
|
||||||
|
onClick = onBullet
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ToolbarIcon(
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
isActive: Boolean = false
|
||||||
|
) {
|
||||||
|
val activeBg = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
|
||||||
|
val activeColor = MaterialTheme.colorScheme.primary
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.background(
|
||||||
|
color = if (isActive) activeBg else androidx.compose.ui.graphics.Color.Transparent,
|
||||||
|
shape = RoundedCornerShape(10.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (isActive) activeColor else MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,434 @@
|
|||||||
|
package com.example.notesai.presentation.screens.note.editor
|
||||||
|
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.text.*
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
class RichEditorState(initial: AnnotatedString) {
|
||||||
|
|
||||||
|
var value by mutableStateOf(
|
||||||
|
TextFieldValue(
|
||||||
|
annotatedString = initial,
|
||||||
|
selection = TextRange(initial.length)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
UNDO / REDO
|
||||||
|
===================== */
|
||||||
|
private val undoStack = mutableStateListOf<TextFieldValue>()
|
||||||
|
private val redoStack = mutableStateListOf<TextFieldValue>()
|
||||||
|
|
||||||
|
private fun snapshot() {
|
||||||
|
undoStack.add(value)
|
||||||
|
redoStack.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun canUndo() = undoStack.isNotEmpty()
|
||||||
|
fun canRedo() = redoStack.isNotEmpty()
|
||||||
|
|
||||||
|
fun undo() {
|
||||||
|
if (!canUndo()) return
|
||||||
|
redoStack.add(value)
|
||||||
|
value = undoStack.removeLast()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun redo() {
|
||||||
|
if (!canRedo()) return
|
||||||
|
undoStack.add(value)
|
||||||
|
value = redoStack.removeLast()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
STICKY TYPING STYLE
|
||||||
|
===================== */
|
||||||
|
private val activeStyles = mutableStateListOf<SpanStyle>()
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
VALUE CHANGE (KEY)
|
||||||
|
===================== */
|
||||||
|
fun onValueChange(newValue: TextFieldValue) {
|
||||||
|
val old = value
|
||||||
|
|
||||||
|
// cursor/selection change only
|
||||||
|
if (newValue.text == old.text) {
|
||||||
|
value = old.copy(
|
||||||
|
selection = newValue.selection,
|
||||||
|
composition = newValue.composition
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot()
|
||||||
|
|
||||||
|
// 1) build new annotated string by preserving old spans
|
||||||
|
val built = buildPreservingSpans(old, newValue)
|
||||||
|
|
||||||
|
// 2) auto-convert markdown patterns around cursor
|
||||||
|
val converted = autoConvertMarkdown(built)
|
||||||
|
|
||||||
|
value = converted
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildPreservingSpans(old: TextFieldValue, newValue: TextFieldValue): TextFieldValue {
|
||||||
|
val builder = AnnotatedString.Builder(newValue.text)
|
||||||
|
|
||||||
|
// copy old spans (clamped)
|
||||||
|
old.annotatedString.spanStyles.forEach { r ->
|
||||||
|
val s = r.start.coerceIn(0, newValue.text.length)
|
||||||
|
val e = r.end.coerceIn(0, newValue.text.length)
|
||||||
|
if (s < e) builder.addStyle(r.item, s, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply sticky styles to newly inserted char (simple heuristic)
|
||||||
|
val insertPos = newValue.selection.start - 1
|
||||||
|
if (insertPos >= 0 && insertPos < newValue.text.length) {
|
||||||
|
activeStyles.forEach { st ->
|
||||||
|
builder.addStyle(st, insertPos, insertPos + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextFieldValue(
|
||||||
|
annotatedString = builder.toAnnotatedString(),
|
||||||
|
selection = newValue.selection,
|
||||||
|
composition = newValue.composition
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
TOOLBAR TOGGLES
|
||||||
|
===================== */
|
||||||
|
fun toggleBold() = toggleStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||||
|
fun toggleItalic() = toggleStyle(SpanStyle(fontStyle = FontStyle.Italic))
|
||||||
|
fun toggleUnderline() = toggleStyle(SpanStyle(textDecoration = TextDecoration.Underline))
|
||||||
|
|
||||||
|
private fun toggleStyle(style: SpanStyle) {
|
||||||
|
val sel = value.selection.normalized()
|
||||||
|
snapshot()
|
||||||
|
|
||||||
|
if (!sel.collapsed) applyStyleToSelection(style)
|
||||||
|
else toggleTypingStyle(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toggleTypingStyle(style: SpanStyle) {
|
||||||
|
val idx = activeStyles.indexOfFirst { it.hasSameStyle(style) }
|
||||||
|
if (idx >= 0) activeStyles.removeAt(idx) else activeStyles.add(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyStyleToSelection(style: SpanStyle) {
|
||||||
|
val sel = value.selection.normalized()
|
||||||
|
val start = sel.start
|
||||||
|
val end = sel.end
|
||||||
|
if (start >= end) return
|
||||||
|
|
||||||
|
val builder = AnnotatedString.Builder(value.text)
|
||||||
|
|
||||||
|
value.annotatedString.spanStyles.forEach { r ->
|
||||||
|
val overlap = r.start < end && r.end > start
|
||||||
|
val same = r.item.hasSameStyle(style)
|
||||||
|
if (!(overlap && same)) builder.addStyle(r.item, r.start, r.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.addStyle(style, start, end)
|
||||||
|
|
||||||
|
value = value.copy(
|
||||||
|
annotatedString = builder.toAnnotatedString(),
|
||||||
|
selection = TextRange(end)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
HEADING / BULLET (for toolbar)
|
||||||
|
===================== */
|
||||||
|
fun setHeading(level: Int) {
|
||||||
|
snapshot()
|
||||||
|
|
||||||
|
val sel = value.selection
|
||||||
|
val text = value.text
|
||||||
|
val lineStart = text.lastIndexOf('\n', (sel.start - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }
|
||||||
|
val lineEnd = text.indexOf('\n', sel.start).let { if (it == -1) text.length else it }
|
||||||
|
|
||||||
|
val size = when (level) {
|
||||||
|
1 -> 28.sp
|
||||||
|
2 -> 22.sp
|
||||||
|
3 -> 18.sp
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
|
||||||
|
val builder = AnnotatedString.Builder(text)
|
||||||
|
value.annotatedString.spanStyles.forEach { r -> builder.addStyle(r.item, r.start, r.end) }
|
||||||
|
builder.addStyle(SpanStyle(fontSize = size, fontWeight = FontWeight.Bold), lineStart, lineEnd)
|
||||||
|
|
||||||
|
value = value.copy(annotatedString = builder.toAnnotatedString())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleBulletList() {
|
||||||
|
snapshot()
|
||||||
|
|
||||||
|
val sel = value.selection
|
||||||
|
val text = value.text
|
||||||
|
val prefix = "• "
|
||||||
|
|
||||||
|
val lineStart = text.lastIndexOf('\n', (sel.start - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }
|
||||||
|
val isBullet = text.startsWith(prefix, startIndex = lineStart)
|
||||||
|
|
||||||
|
if (isBullet) {
|
||||||
|
replaceTextPreserveSpans(
|
||||||
|
start = lineStart,
|
||||||
|
end = lineStart + prefix.length,
|
||||||
|
replacement = "",
|
||||||
|
newCursor = (sel.start - prefix.length).coerceAtLeast(lineStart)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
replaceTextPreserveSpans(
|
||||||
|
start = lineStart,
|
||||||
|
end = lineStart,
|
||||||
|
replacement = prefix,
|
||||||
|
newCursor = sel.start + prefix.length
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
AUTO-CONVERT MARKDOWN (LEVEL 3)
|
||||||
|
===================== */
|
||||||
|
private fun autoConvertMarkdown(v: TextFieldValue): TextFieldValue {
|
||||||
|
var cur = v
|
||||||
|
|
||||||
|
// order matters: bold before italic
|
||||||
|
cur = convertBold(cur)
|
||||||
|
cur = convertItalic(cur)
|
||||||
|
cur = convertHeading(cur)
|
||||||
|
cur = convertDashBullet(cur)
|
||||||
|
|
||||||
|
return cur
|
||||||
|
}
|
||||||
|
|
||||||
|
// **word** -> bold(word), remove ** **
|
||||||
|
private fun convertBold(v: TextFieldValue): TextFieldValue {
|
||||||
|
val text = v.text
|
||||||
|
val cursor = v.selection.start
|
||||||
|
if (cursor < 2) return v
|
||||||
|
if (!(text.getOrNull(cursor - 1) == '*' && text.getOrNull(cursor - 2) == '*')) return v
|
||||||
|
|
||||||
|
val startMarker = text.lastIndexOf("**", startIndex = cursor - 3)
|
||||||
|
if (startMarker == -1) return v
|
||||||
|
val contentStart = startMarker + 2
|
||||||
|
val contentEnd = cursor - 2
|
||||||
|
if (contentEnd <= contentStart) return v
|
||||||
|
if (text.substring(contentStart, contentEnd).contains('\n')) return v
|
||||||
|
|
||||||
|
// remove end marker then start marker (preserve spans)
|
||||||
|
var out = v
|
||||||
|
out = replaceTextPreserveSpansLocal(out, cursor - 2, cursor, "")
|
||||||
|
out = replaceTextPreserveSpansLocal(out, startMarker, startMarker + 2, "")
|
||||||
|
|
||||||
|
// after removing start marker, content shifts -2
|
||||||
|
val newStart = startMarker
|
||||||
|
val newEnd = contentEnd - 2
|
||||||
|
out = addStylePreserve(out, SpanStyle(fontWeight = FontWeight.Bold), newStart, newEnd)
|
||||||
|
|
||||||
|
// cursor shifts back 4 chars total
|
||||||
|
out = out.copy(selection = TextRange((cursor - 4).coerceAtLeast(newEnd)))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// *word* -> italic(word), remove * *
|
||||||
|
private fun convertItalic(v: TextFieldValue): TextFieldValue {
|
||||||
|
val text = v.text
|
||||||
|
val cursor = v.selection.start
|
||||||
|
if (cursor < 1) return v
|
||||||
|
// avoid triggering on bold closing (**)
|
||||||
|
if (text.getOrNull(cursor - 1) != '*') return v
|
||||||
|
if (text.getOrNull(cursor - 2) == '*') return v
|
||||||
|
|
||||||
|
val startMarker = text.lastIndexOf('*', startIndex = cursor - 2)
|
||||||
|
if (startMarker == -1) return v
|
||||||
|
// avoid ** as start
|
||||||
|
if (text.getOrNull(startMarker - 1) == '*') return v
|
||||||
|
|
||||||
|
val contentStart = startMarker + 1
|
||||||
|
val contentEnd = cursor - 1
|
||||||
|
if (contentEnd <= contentStart) return v
|
||||||
|
if (text.substring(contentStart, contentEnd).contains('\n')) return v
|
||||||
|
|
||||||
|
var out = v
|
||||||
|
out = replaceTextPreserveSpansLocal(out, cursor - 1, cursor, "")
|
||||||
|
out = replaceTextPreserveSpansLocal(out, startMarker, startMarker + 1, "")
|
||||||
|
|
||||||
|
val newStart = startMarker
|
||||||
|
val newEnd = contentEnd - 1
|
||||||
|
out = addStylePreserve(out, SpanStyle(fontStyle = FontStyle.Italic), newStart, newEnd)
|
||||||
|
out = out.copy(selection = TextRange((cursor - 2).coerceAtLeast(newEnd)))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// "# " / "## " / "### " at line start -> heading + remove markers
|
||||||
|
private fun convertHeading(v: TextFieldValue): TextFieldValue {
|
||||||
|
val text = v.text
|
||||||
|
val cursor = v.selection.start
|
||||||
|
|
||||||
|
val lineStart = text.lastIndexOf('\n', (cursor - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }
|
||||||
|
val lineEnd = text.indexOf('\n', cursor).let { if (it == -1) text.length else it }
|
||||||
|
|
||||||
|
val linePrefix = text.substring(lineStart, minOf(lineStart + 4, text.length))
|
||||||
|
|
||||||
|
val level = when {
|
||||||
|
linePrefix.startsWith("### ") -> 3
|
||||||
|
linePrefix.startsWith("## ") -> 2
|
||||||
|
linePrefix.startsWith("# ") -> 1
|
||||||
|
else -> return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// only trigger when user just typed the space after #'s OR when cursor is still on same line early
|
||||||
|
val removeLen = when (level) {
|
||||||
|
1 -> 2
|
||||||
|
2 -> 3
|
||||||
|
else -> 4
|
||||||
|
}
|
||||||
|
val triggerPos = lineStart + removeLen
|
||||||
|
if (cursor < triggerPos) return v
|
||||||
|
|
||||||
|
var out = v
|
||||||
|
out = replaceTextPreserveSpansLocal(out, lineStart, lineStart + removeLen, "")
|
||||||
|
// apply heading style to the line content
|
||||||
|
val newLineStart = lineStart
|
||||||
|
val newLineEnd = (lineEnd - removeLen).coerceAtLeast(newLineStart)
|
||||||
|
|
||||||
|
val size = when (level) {
|
||||||
|
1 -> 28.sp
|
||||||
|
2 -> 22.sp
|
||||||
|
else -> 18.sp
|
||||||
|
}
|
||||||
|
|
||||||
|
out = addStylePreserve(
|
||||||
|
out,
|
||||||
|
SpanStyle(fontSize = size, fontWeight = FontWeight.Bold),
|
||||||
|
newLineStart,
|
||||||
|
newLineEnd
|
||||||
|
)
|
||||||
|
|
||||||
|
// shift cursor back by removedLen
|
||||||
|
out = out.copy(selection = TextRange((cursor - removeLen).coerceAtLeast(newLineStart)))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// "- " at line start -> "• "
|
||||||
|
private fun convertDashBullet(v: TextFieldValue): TextFieldValue {
|
||||||
|
val text = v.text
|
||||||
|
val cursor = v.selection.start
|
||||||
|
|
||||||
|
val lineStart = text.lastIndexOf('\n', (cursor - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }
|
||||||
|
val prefix = "- "
|
||||||
|
|
||||||
|
if (!text.startsWith(prefix, startIndex = lineStart)) return v
|
||||||
|
// only trigger when user already typed "- "
|
||||||
|
if (cursor < lineStart + 2) return v
|
||||||
|
|
||||||
|
var out = v
|
||||||
|
out = replaceTextPreserveSpansLocal(out, lineStart, lineStart + 2, "• ")
|
||||||
|
// cursor stays same length (2)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
TOOLBAR STATE
|
||||||
|
===================== */
|
||||||
|
fun isBoldActive() = isStyleActive(fontWeight = FontWeight.Bold)
|
||||||
|
fun isItalicActive() = isStyleActive(fontStyle = FontStyle.Italic)
|
||||||
|
fun isUnderlineActive() = isStyleActive(decoration = TextDecoration.Underline)
|
||||||
|
|
||||||
|
private fun isStyleActive(
|
||||||
|
fontWeight: FontWeight? = null,
|
||||||
|
fontStyle: FontStyle? = null,
|
||||||
|
decoration: TextDecoration? = null
|
||||||
|
): Boolean {
|
||||||
|
val sel = value.selection
|
||||||
|
if (!sel.collapsed) {
|
||||||
|
return value.annotatedString.spanStyles.any {
|
||||||
|
it.start <= sel.start &&
|
||||||
|
it.end >= sel.end &&
|
||||||
|
(fontWeight == null || it.item.fontWeight == fontWeight) &&
|
||||||
|
(fontStyle == null || it.item.fontStyle == fontStyle) &&
|
||||||
|
(decoration == null || it.item.textDecoration == decoration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return activeStyles.any {
|
||||||
|
(fontWeight == null || it.fontWeight == fontWeight) &&
|
||||||
|
(fontStyle == null || it.fontStyle == fontStyle) &&
|
||||||
|
(decoration == null || it.textDecoration == decoration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
INTERNAL: text replace while preserving spans
|
||||||
|
===================== */
|
||||||
|
private fun replaceTextPreserveSpans(start: Int, end: Int, replacement: String, newCursor: Int) {
|
||||||
|
value = replaceTextPreserveSpansLocal(value, start, end, replacement)
|
||||||
|
.copy(selection = TextRange(newCursor))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replaceTextPreserveSpansLocal(
|
||||||
|
v: TextFieldValue,
|
||||||
|
start: Int,
|
||||||
|
end: Int,
|
||||||
|
replacement: String
|
||||||
|
): TextFieldValue {
|
||||||
|
val oldText = v.text
|
||||||
|
val s = start.coerceIn(0, oldText.length)
|
||||||
|
val e = end.coerceIn(0, oldText.length)
|
||||||
|
if (s > e) return v
|
||||||
|
|
||||||
|
val newText = oldText.substring(0, s) + replacement + oldText.substring(e)
|
||||||
|
val delta = replacement.length - (e - s)
|
||||||
|
|
||||||
|
val b = AnnotatedString.Builder(newText)
|
||||||
|
v.annotatedString.spanStyles.forEach { r ->
|
||||||
|
var rs = r.start
|
||||||
|
var re = r.end
|
||||||
|
|
||||||
|
// adjust spans
|
||||||
|
when {
|
||||||
|
re <= s -> Unit
|
||||||
|
rs >= e -> { rs += delta; re += delta }
|
||||||
|
rs < s && re > e -> re += delta
|
||||||
|
rs < s && re in (s + 1)..e -> re = s
|
||||||
|
rs in s until e && re > e -> { rs = s + replacement.length; re += delta }
|
||||||
|
else -> return@forEach
|
||||||
|
}
|
||||||
|
|
||||||
|
rs = rs.coerceIn(0, newText.length)
|
||||||
|
re = re.coerceIn(0, newText.length)
|
||||||
|
if (rs < re) b.addStyle(r.item, rs, re)
|
||||||
|
}
|
||||||
|
|
||||||
|
return v.copy(annotatedString = b.toAnnotatedString())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addStylePreserve(v: TextFieldValue, style: SpanStyle, start: Int, end: Int): TextFieldValue {
|
||||||
|
val s = start.coerceIn(0, v.text.length)
|
||||||
|
val e = end.coerceIn(0, v.text.length)
|
||||||
|
if (s >= e) return v
|
||||||
|
|
||||||
|
val b = AnnotatedString.Builder(v.text)
|
||||||
|
v.annotatedString.spanStyles.forEach { r -> b.addStyle(r.item, r.start, r.end) }
|
||||||
|
b.addStyle(style, s, e)
|
||||||
|
return v.copy(annotatedString = b.toAnnotatedString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
HELPERS
|
||||||
|
===================== */
|
||||||
|
private fun SpanStyle.hasSameStyle(other: SpanStyle): Boolean =
|
||||||
|
fontWeight == other.fontWeight &&
|
||||||
|
fontStyle == other.fontStyle &&
|
||||||
|
textDecoration == other.textDecoration
|
||||||
|
|
||||||
|
private fun TextRange.normalized(): TextRange =
|
||||||
|
if (start <= end) this else TextRange(end, start)
|
||||||
@ -1,13 +1,16 @@
|
|||||||
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
|
||||||
@ -20,31 +23,37 @@ 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 }
|
||||||
|
|
||||||
if (starredNotes.isEmpty()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
EmptyState(
|
if (starredNotes.isEmpty()) {
|
||||||
icon = Icons.Default.Star,
|
EmptyState(
|
||||||
message = "Belum ada catatan berbintang",
|
icon = Icons.Default.Star,
|
||||||
subtitle = "Catatan yang ditandai berbintang akan muncul di sini"
|
message = "Belum ada catatan berbintang",
|
||||||
)
|
subtitle = "Catatan yang ditandai berbintang akan muncul di sini"
|
||||||
} else {
|
)
|
||||||
LazyColumn(
|
} else {
|
||||||
contentPadding = PaddingValues(16.dp),
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
contentPadding = PaddingValues(
|
||||||
) {
|
start = 16.dp,
|
||||||
items(starredNotes) { note ->
|
end = 16.dp,
|
||||||
val category = categories.find { it.id == note.categoryId }
|
top = 16.dp,
|
||||||
StarredNoteCard(
|
bottom = 100.dp // Extra space untuk bottom bar
|
||||||
note = note,
|
),
|
||||||
categoryName = category?.name ?: "Unknown",
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
onClick = { onNoteClick(note) },
|
) {
|
||||||
onUnpin = { onUnpin(note) }
|
items(starredNotes) { note ->
|
||||||
)
|
val category = categories.find { it.id == note.categoryId }
|
||||||
|
StarredNoteCard(
|
||||||
|
note = note,
|
||||||
|
categoryName = category?.name ?: "Unknown",
|
||||||
|
onClick = { onNoteClick(note) },
|
||||||
|
onUnpin = { onUnpin(note) }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,66 @@
|
|||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SpanDto(
|
||||||
|
val start: Int,
|
||||||
|
val end: Int,
|
||||||
|
val bold: Boolean = false,
|
||||||
|
val italic: Boolean = false,
|
||||||
|
val underline: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RichTextDto(
|
||||||
|
val text: String,
|
||||||
|
val spans: List<SpanDto>
|
||||||
|
)
|
||||||
|
|
||||||
|
object AnnotatedStringSerializer {
|
||||||
|
|
||||||
|
fun toJson(value: AnnotatedString): String {
|
||||||
|
val spans = value.spanStyles.map {
|
||||||
|
SpanDto(
|
||||||
|
start = it.start,
|
||||||
|
end = it.end,
|
||||||
|
bold = it.item.fontWeight != null,
|
||||||
|
italic = it.item.fontStyle != null,
|
||||||
|
underline = it.item.textDecoration != null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Json.encodeToString(
|
||||||
|
RichTextDto(
|
||||||
|
text = value.text,
|
||||||
|
spans = spans
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fromJson(json: String): AnnotatedString {
|
||||||
|
return try {
|
||||||
|
val dto = Json.decodeFromString<RichTextDto>(json)
|
||||||
|
val builder = AnnotatedString.Builder(dto.text)
|
||||||
|
|
||||||
|
dto.spans.forEach {
|
||||||
|
builder.addStyle(
|
||||||
|
SpanStyle(
|
||||||
|
fontWeight = if (it.bold) androidx.compose.ui.text.font.FontWeight.Bold else null,
|
||||||
|
fontStyle = if (it.italic) androidx.compose.ui.text.font.FontStyle.Italic else null,
|
||||||
|
textDecoration = if (it.underline) androidx.compose.ui.text.style.TextDecoration.Underline else null
|
||||||
|
),
|
||||||
|
it.start,
|
||||||
|
it.end
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.toAnnotatedString()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
AnnotatedString(json) // fallback plain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,14 @@
|
|||||||
// 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 = "AI Notes"
|
const val APP_NAME = "NotesAI"
|
||||||
const val APP_VERSION = "1.0.0"
|
const val APP_VERSION = "1.1.0"
|
||||||
|
|
||||||
// DataStore
|
// DataStore
|
||||||
const val DATASTORE_NAME = "notes_prefs"
|
const val DATASTORE_NAME = "notes_prefs"
|
||||||
@ -17,37 +19,175 @@ 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
|
||||||
|
|
||||||
// Gradients
|
// DARK THEME COLORS
|
||||||
val GRADIENT_PRESETS = listOf(
|
object DarkColors {
|
||||||
Pair(0xFF6366F1L, 0xFFA855F7L),
|
val Background = Color(0xFF0A0A0A)
|
||||||
Pair(0xFFEC4899L, 0xFFF59E0BL),
|
val Surface = Color(0xFF141414)
|
||||||
Pair(0xFF8B5CF6L, 0xFFEC4899L),
|
val SurfaceVariant = Color(0xFF1E1E1E)
|
||||||
Pair(0xFF06B6D4L, 0xFF3B82F6L),
|
val SurfaceElevated = Color(0xFF252525)
|
||||||
Pair(0xFF10B981L, 0xFF059669L),
|
val Primary = Color(0xFF3B82F6)
|
||||||
Pair(0xFFF59E0BL, 0xFFEF4444L),
|
val PrimaryVariant = Color(0xFF60A5FA)
|
||||||
Pair(0xFF6366F1L, 0xFF8B5CF6L),
|
val PrimaryContainer = Color(0xFF1E3A8A)
|
||||||
Pair(0xFFEF4444L, 0xFFDC2626L)
|
val Secondary = Color(0xFF8B5CF6)
|
||||||
)
|
val SecondaryVariant = Color(0xFFA78BFA)
|
||||||
|
val OnBackground = Color(0xFFFFFFFF)
|
||||||
// Colors
|
val OnSurface = Color(0xFFE5E5E5)
|
||||||
object AppColors {
|
val OnSurfaceVariant = Color(0xFF9CA3AF)
|
||||||
val Primary = Color(0xFF6366F1)
|
val OnSurfaceTertiary = Color(0xFF6B7280)
|
||||||
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 TextSecondary = Color(0xFF94A3B8)
|
val Info = Color(0xFF3B82F6)
|
||||||
val TextTertiary = Color(0xFF64748B)
|
val Border = Color(0xFF2A2A2A)
|
||||||
val Divider = Color(0xFF334155)
|
val Divider = Color(0xFF1F1F1F)
|
||||||
|
val Overlay = Color(0xFF000000).copy(alpha = 0.5f)
|
||||||
|
val Shadow = Color(0xFF000000).copy(alpha = 0.3f)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Animation
|
// LIGHT THEME COLORS
|
||||||
const val ANIMATION_DURATION = 300
|
object LightColors {
|
||||||
|
val Background = Color(0xFFF8F9FA)
|
||||||
|
val Surface = Color(0xFFFFFFFF)
|
||||||
|
val SurfaceVariant = Color(0xFFF1F3F5)
|
||||||
|
val SurfaceElevated = Color(0xFFFFFFFF)
|
||||||
|
val Primary = Color(0xFF3B82F6)
|
||||||
|
val PrimaryVariant = Color(0xFF2563EB)
|
||||||
|
val PrimaryContainer = Color(0xFFDCEEFF)
|
||||||
|
val Secondary = Color(0xFF8B5CF6)
|
||||||
|
val SecondaryVariant = Color(0xFF7C3AED)
|
||||||
|
val OnBackground = Color(0xFF1F2937)
|
||||||
|
val OnSurface = Color(0xFF374151)
|
||||||
|
val OnSurfaceVariant = Color(0xFF6B7280)
|
||||||
|
val OnSurfaceTertiary = Color(0xFF9CA3AF)
|
||||||
|
val Success = Color(0xFF10B981)
|
||||||
|
val Error = Color(0xFFEF4444)
|
||||||
|
val Warning = Color(0xFFA16207)
|
||||||
|
val Info = Color(0xFF3B82F6)
|
||||||
|
val Border = Color(0xFFE5E7EB)
|
||||||
|
val Divider = Color(0xFFF3F4F6)
|
||||||
|
val Overlay = Color(0xFF000000).copy(alpha = 0.3f)
|
||||||
|
val Shadow = Color(0xFF000000).copy(alpha = 0.1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category Colors - Same for both themes
|
||||||
|
val CategoryColors = listOf(
|
||||||
|
Pair(0xFF3B82F6L, 0xFF60A5FAL), // Blue
|
||||||
|
Pair(0xFF8B5CF6L, 0xFFA78BFAL), // Purple
|
||||||
|
Pair(0xFF10B981L, 0xFF34D399L), // Green
|
||||||
|
Pair(0xFFF59E0BL, 0xFFFBBF24L), // Amber
|
||||||
|
Pair(0xFFEF4444L, 0xFFF87171L), // Red
|
||||||
|
Pair(0xFF06B6D4L, 0xFF22D3EEL), // Cyan
|
||||||
|
Pair(0xFFEC4899L, 0xFFF472B6L), // Pink
|
||||||
|
Pair(0xFF6366F1L, 0xFF818CF8L) // Indigo
|
||||||
|
)
|
||||||
|
|
||||||
|
// Animation Durations
|
||||||
|
const val ANIMATION_DURATION_SHORT = 150
|
||||||
|
const val ANIMATION_DURATION_MEDIUM = 300
|
||||||
|
const val ANIMATION_DURATION_LONG = 500
|
||||||
const val FADE_IN_DURATION = 200
|
const val FADE_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
|
||||||
}
|
}
|
||||||
58
app/src/main/java/com/example/notesai/util/MarkdownParser.kt
Normal file
58
app/src/main/java/com/example/notesai/util/MarkdownParser.kt
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
package com.example.notesai.util
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
||||||
|
object MarkdownParser {
|
||||||
|
|
||||||
|
fun parse(markdown: String): AnnotatedString {
|
||||||
|
val builder = AnnotatedString.Builder()
|
||||||
|
var i = 0
|
||||||
|
|
||||||
|
while (i < markdown.length) {
|
||||||
|
when {
|
||||||
|
markdown.startsWith("**", i) -> {
|
||||||
|
val end = markdown.indexOf("**", i + 2)
|
||||||
|
if (end != -1) {
|
||||||
|
val content = markdown.substring(i + 2, end)
|
||||||
|
val start = builder.length
|
||||||
|
builder.append(content)
|
||||||
|
builder.addStyle(
|
||||||
|
SpanStyle(fontWeight = FontWeight.Bold),
|
||||||
|
start,
|
||||||
|
start + content.length
|
||||||
|
)
|
||||||
|
i = end + 2
|
||||||
|
} else {
|
||||||
|
builder.append(markdown[i++])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markdown.startsWith("*", i) -> {
|
||||||
|
val end = markdown.indexOf("*", i + 1)
|
||||||
|
if (end != -1) {
|
||||||
|
val content = markdown.substring(i + 1, end)
|
||||||
|
val start = builder.length
|
||||||
|
builder.append(content)
|
||||||
|
builder.addStyle(
|
||||||
|
SpanStyle(fontStyle = FontStyle.Italic),
|
||||||
|
start,
|
||||||
|
start + content.length
|
||||||
|
)
|
||||||
|
i = end + 1
|
||||||
|
} else {
|
||||||
|
builder.append(markdown[i++])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
builder.append(markdown[i++])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.toAnnotatedString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
package com.example.notesai.util
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
||||||
|
object MarkdownSerializer {
|
||||||
|
|
||||||
|
fun toMarkdown(text: AnnotatedString): String {
|
||||||
|
val raw = text.text
|
||||||
|
if (text.spanStyles.isEmpty()) return raw
|
||||||
|
|
||||||
|
val markers = Array(raw.length + 1) { mutableListOf<String>() }
|
||||||
|
|
||||||
|
text.spanStyles.forEach { span ->
|
||||||
|
if (span.item.fontWeight == FontWeight.Bold) {
|
||||||
|
markers[span.start].add("**")
|
||||||
|
markers[span.end].add("**")
|
||||||
|
}
|
||||||
|
if (span.item.fontStyle == FontStyle.Italic) {
|
||||||
|
markers[span.start].add("*")
|
||||||
|
markers[span.end].add("*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val sb = StringBuilder()
|
||||||
|
for (i in raw.indices) {
|
||||||
|
markers[i].forEach { sb.append(it) }
|
||||||
|
sb.append(raw[i])
|
||||||
|
}
|
||||||
|
markers[raw.length].forEach { sb.append(it) }
|
||||||
|
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
289
app/src/main/java/com/example/notesai/util/MarkdownText.kt
Normal file
289
app/src/main/java/com/example/notesai/util/MarkdownText.kt
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
package com.example.notesai.util
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.*
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.example.notesai.util.AppColors
|
||||||
|
import com.example.notesai.util.Constants
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MarkdownText(
|
||||||
|
markdown: String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
parseMarkdown(markdown).forEach { block ->
|
||||||
|
when (block) {
|
||||||
|
is MarkdownBlock.Paragraph -> {
|
||||||
|
Text(
|
||||||
|
text = buildAnnotatedString {
|
||||||
|
appendInlineMarkdown(block.content)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = AppColors.OnSurface,
|
||||||
|
lineHeight = 24.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is MarkdownBlock.Header -> {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = block.content,
|
||||||
|
style = when (block.level) {
|
||||||
|
1 -> MaterialTheme.typography.headlineLarge
|
||||||
|
2 -> MaterialTheme.typography.headlineMedium
|
||||||
|
3 -> MaterialTheme.typography.headlineSmall
|
||||||
|
else -> MaterialTheme.typography.titleLarge
|
||||||
|
},
|
||||||
|
color = AppColors.OnBackground,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
is MarkdownBlock.CodeBlock -> {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = AppColors.SurfaceVariant,
|
||||||
|
shape = RoundedCornerShape(Constants.Radius.Medium.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = block.content,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = AppColors.OnSurface,
|
||||||
|
fontSize = 13.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is MarkdownBlock.ListItem -> {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (block.isOrdered) "${block.number}." else "•",
|
||||||
|
color = AppColors.Primary,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.width(20.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = buildAnnotatedString {
|
||||||
|
appendInlineMarkdown(block.content)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = AppColors.OnSurface,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is MarkdownBlock.Quote -> {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = AppColors.Primary.copy(alpha = 0.1f),
|
||||||
|
shape = RoundedCornerShape(Constants.Radius.Medium.dp)
|
||||||
|
) {
|
||||||
|
Row {
|
||||||
|
Spacer(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(4.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(AppColors.Primary)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = buildAnnotatedString {
|
||||||
|
appendInlineMarkdown(block.content)
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = AppColors.OnSurface,
|
||||||
|
fontStyle = FontStyle.Italic
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Markdown Block Types
|
||||||
|
sealed class MarkdownBlock {
|
||||||
|
data class Paragraph(val content: String) : MarkdownBlock()
|
||||||
|
data class Header(val level: Int, val content: String) : MarkdownBlock()
|
||||||
|
data class CodeBlock(val content: String, val language: String? = null) : MarkdownBlock()
|
||||||
|
data class ListItem(val content: String, val isOrdered: Boolean, val number: Int = 0) : MarkdownBlock()
|
||||||
|
data class Quote(val content: String) : MarkdownBlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse markdown into blocks
|
||||||
|
fun parseMarkdown(text: String): List<MarkdownBlock> {
|
||||||
|
val blocks = mutableListOf<MarkdownBlock>()
|
||||||
|
val lines = text.lines()
|
||||||
|
var i = 0
|
||||||
|
|
||||||
|
while (i < lines.size) {
|
||||||
|
val line = lines[i]
|
||||||
|
|
||||||
|
when {
|
||||||
|
// Code block
|
||||||
|
line.trimStart().startsWith("```") -> {
|
||||||
|
val language = line.trimStart().removePrefix("```").trim()
|
||||||
|
val codeLines = mutableListOf<String>()
|
||||||
|
i++
|
||||||
|
while (i < lines.size && !lines[i].trimStart().startsWith("```")) {
|
||||||
|
codeLines.add(lines[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
blocks.add(MarkdownBlock.CodeBlock(codeLines.joinToString("\n"), language.ifEmpty { null }))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
// Header
|
||||||
|
line.trimStart().startsWith("#") -> {
|
||||||
|
val level = line.takeWhile { it == '#' }.length
|
||||||
|
val content = line.removePrefix("#".repeat(level)).trim()
|
||||||
|
blocks.add(MarkdownBlock.Header(level, content))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
// Quote
|
||||||
|
line.trimStart().startsWith(">") -> {
|
||||||
|
val content = line.trimStart().removePrefix(">").trim()
|
||||||
|
blocks.add(MarkdownBlock.Quote(content))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
// Unordered list
|
||||||
|
line.trimStart().matches(Regex("^[-*+]\\s+.*")) -> {
|
||||||
|
val content = line.trimStart().replaceFirst(Regex("^[-*+]\\s+"), "")
|
||||||
|
blocks.add(MarkdownBlock.ListItem(content, false))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
// Ordered list
|
||||||
|
line.trimStart().matches(Regex("^\\d+\\.\\s+.*")) -> {
|
||||||
|
val number = line.trimStart().takeWhile { it.isDigit() }.toIntOrNull() ?: 1
|
||||||
|
val content = line.trimStart().replaceFirst(Regex("^\\d+\\.\\s+"), "")
|
||||||
|
blocks.add(MarkdownBlock.ListItem(content, true, number))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
// Empty line - skip
|
||||||
|
line.isBlank() -> {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
// Paragraph
|
||||||
|
else -> {
|
||||||
|
val paragraphLines = mutableListOf<String>()
|
||||||
|
while (i < lines.size && lines[i].isNotBlank() &&
|
||||||
|
!lines[i].trimStart().startsWith("#") &&
|
||||||
|
!lines[i].trimStart().startsWith(">") &&
|
||||||
|
!lines[i].trimStart().startsWith("```") &&
|
||||||
|
!lines[i].trimStart().matches(Regex("^[-*+]\\s+.*")) &&
|
||||||
|
!lines[i].trimStart().matches(Regex("^\\d+\\.\\s+.*"))) {
|
||||||
|
paragraphLines.add(lines[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if (paragraphLines.isNotEmpty()) {
|
||||||
|
blocks.add(MarkdownBlock.Paragraph(paragraphLines.joinToString(" ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse inline markdown (bold, italic, code, links)
|
||||||
|
fun AnnotatedString.Builder.appendInlineMarkdown(text: String) {
|
||||||
|
var currentIndex = 0
|
||||||
|
val inlinePatterns = listOf(
|
||||||
|
// Bold with **
|
||||||
|
Regex("""\*\*(.+?)\*\*""") to { content: String ->
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||||
|
append(content)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Bold with __
|
||||||
|
Regex("""__(.+?)__""") to { content: String ->
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||||
|
append(content)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Italic with *
|
||||||
|
Regex("""\*(.+?)\*""") to { content: String ->
|
||||||
|
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||||
|
append(content)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Italic with _
|
||||||
|
Regex("""_(.+?)_""") to { content: String ->
|
||||||
|
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||||
|
append(content)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Inline code
|
||||||
|
Regex("""`(.+?)`""") to { content: String ->
|
||||||
|
withStyle(
|
||||||
|
SpanStyle(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
background = Color(0xFF1E1E1E),
|
||||||
|
color = Color(0xFFE5E5E5)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
append(" $content ")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Links [text](url)
|
||||||
|
Regex("""\[(.+?)]\((.+?)\)""") to { content: String ->
|
||||||
|
withStyle(
|
||||||
|
SpanStyle(
|
||||||
|
color = Color(0xFF3B82F6),
|
||||||
|
textDecoration = TextDecoration.Underline
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
append(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
var remainingText = text
|
||||||
|
val matches = mutableListOf<Triple<Int, Int, (String) -> Unit>>()
|
||||||
|
|
||||||
|
// Find all matches
|
||||||
|
inlinePatterns.forEach { (regex, styleApplier) ->
|
||||||
|
regex.findAll(remainingText).forEach { match ->
|
||||||
|
val content = match.groupValues[1]
|
||||||
|
matches.add(Triple(match.range.first, match.range.last + 1) { styleApplier(content) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort matches by start position
|
||||||
|
val sortedMatches = matches.sortedBy { it.first }
|
||||||
|
|
||||||
|
// Apply styles
|
||||||
|
var lastIndex = 0
|
||||||
|
sortedMatches.forEach { (start, end, applier) ->
|
||||||
|
if (start >= lastIndex) {
|
||||||
|
// Append text before match
|
||||||
|
append(remainingText.substring(lastIndex, start))
|
||||||
|
// Apply style
|
||||||
|
applier("")
|
||||||
|
lastIndex = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append remaining text
|
||||||
|
if (lastIndex < remainingText.length) {
|
||||||
|
append(remainingText.substring(lastIndex))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,15 @@ 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" }
|
||||||
@ -19,6 +28,15 @@ 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" }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user