Compare commits
No commits in common. "105ba3c1c369ed959ef9a0eef97a5f80ef8b10f1" and "e8da4c5ce9c6ab305dabbec9e56767bcc1137542" have entirely different histories.
105ba3c1c3
...
e8da4c5ce9
75
.idea/androidTestResultsUserPreferences.xml
generated
75
.idea/androidTestResultsUserPreferences.xml
generated
@ -1,75 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="AndroidTestResultsUserPreferences">
|
|
||||||
<option name="androidTestResultsTableState">
|
|
||||||
<map>
|
|
||||||
<entry key="-242615617">
|
|
||||||
<value>
|
|
||||||
<AndroidTestResultsTableState>
|
|
||||||
<option name="preferredColumnWidths">
|
|
||||||
<map>
|
|
||||||
<entry key="Duration" value="90" />
|
|
||||||
<entry key="Medium_Phone_API_36.1" value="120" />
|
|
||||||
<entry key="Tests" value="360" />
|
|
||||||
<entry key="samsung SM-A127F" value="120" />
|
|
||||||
</map>
|
|
||||||
</option>
|
|
||||||
</AndroidTestResultsTableState>
|
|
||||||
</value>
|
|
||||||
</entry>
|
|
||||||
<entry key="291230364">
|
|
||||||
<value>
|
|
||||||
<AndroidTestResultsTableState>
|
|
||||||
<option name="preferredColumnWidths">
|
|
||||||
<map>
|
|
||||||
<entry key="Duration" value="90" />
|
|
||||||
<entry key="Tests" value="360" />
|
|
||||||
<entry key="samsung SM-A127F" value="120" />
|
|
||||||
</map>
|
|
||||||
</option>
|
|
||||||
</AndroidTestResultsTableState>
|
|
||||||
</value>
|
|
||||||
</entry>
|
|
||||||
<entry key="362346923">
|
|
||||||
<value>
|
|
||||||
<AndroidTestResultsTableState>
|
|
||||||
<option name="preferredColumnWidths">
|
|
||||||
<map>
|
|
||||||
<entry key="Duration" value="90" />
|
|
||||||
<entry key="Tests" value="360" />
|
|
||||||
<entry key="samsung SM-A127F" value="120" />
|
|
||||||
</map>
|
|
||||||
</option>
|
|
||||||
</AndroidTestResultsTableState>
|
|
||||||
</value>
|
|
||||||
</entry>
|
|
||||||
<entry key="459819240">
|
|
||||||
<value>
|
|
||||||
<AndroidTestResultsTableState>
|
|
||||||
<option name="preferredColumnWidths">
|
|
||||||
<map>
|
|
||||||
<entry key="Duration" value="90" />
|
|
||||||
<entry key="Tests" value="360" />
|
|
||||||
<entry key="samsung SM-A127F" value="120" />
|
|
||||||
</map>
|
|
||||||
</option>
|
|
||||||
</AndroidTestResultsTableState>
|
|
||||||
</value>
|
|
||||||
</entry>
|
|
||||||
<entry key="664695232">
|
|
||||||
<value>
|
|
||||||
<AndroidTestResultsTableState>
|
|
||||||
<option name="preferredColumnWidths">
|
|
||||||
<map>
|
|
||||||
<entry key="Duration" value="90" />
|
|
||||||
<entry key="Tests" value="360" />
|
|
||||||
<entry key="samsung SM-A127F" value="120" />
|
|
||||||
</map>
|
|
||||||
</option>
|
|
||||||
</AndroidTestResultsTableState>
|
|
||||||
</value>
|
|
||||||
</entry>
|
|
||||||
</map>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
8
.idea/appInsightsSettings.xml
generated
8
.idea/appInsightsSettings.xml
generated
@ -6,6 +6,14 @@
|
|||||||
<entry key="Firebase Crashlytics">
|
<entry key="Firebase Crashlytics">
|
||||||
<value>
|
<value>
|
||||||
<InsightsFilterSettings>
|
<InsightsFilterSettings>
|
||||||
|
<option name="connection">
|
||||||
|
<ConnectionSetting>
|
||||||
|
<option name="appId" value="PLACEHOLDER" />
|
||||||
|
<option name="mobileSdkAppId" value="" />
|
||||||
|
<option name="projectId" value="" />
|
||||||
|
<option name="projectNumber" value="" />
|
||||||
|
</ConnectionSetting>
|
||||||
|
</option>
|
||||||
<option name="signal" value="SIGNAL_UNSPECIFIED" />
|
<option name="signal" value="SIGNAL_UNSPECIFIED" />
|
||||||
<option name="timeIntervalDays" value="THIRTY_DAYS" />
|
<option name="timeIntervalDays" value="THIRTY_DAYS" />
|
||||||
<option name="visibilityType" value="ALL" />
|
<option name="visibilityType" value="ALL" />
|
||||||
|
|||||||
19
.idea/deploymentTargetSelector.xml
generated
19
.idea/deploymentTargetSelector.xml
generated
@ -4,30 +4,15 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2025-12-18T06:53:17.556062600Z">
|
<DropdownSelection timestamp="2025-12-13T07:41:36.634314200Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\Fazri Abdurrahman\.android\avd\Medium_Phone.avd" />
|
<DeviceId pluginId="PhysicalDevice" identifier="serial=RR8T103A6JZ" />
|
||||||
</handle>
|
</handle>
|
||||||
</Target>
|
</Target>
|
||||||
</DropdownSelection>
|
</DropdownSelection>
|
||||||
<DialogSelection />
|
<DialogSelection />
|
||||||
</SelectionState>
|
</SelectionState>
|
||||||
<SelectionState runConfigName="DataStoreManagerTest">
|
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
|
||||||
</SelectionState>
|
|
||||||
<SelectionState runConfigName="TrashFunctionalityTest">
|
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
|
||||||
</SelectionState>
|
|
||||||
<SelectionState runConfigName="SearchFunctionalityTest">
|
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
|
||||||
</SelectionState>
|
|
||||||
<SelectionState runConfigName="AIChatFunctionalityTest">
|
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
|
||||||
</SelectionState>
|
|
||||||
<SelectionState runConfigName="FileUploadFunctionalityTest">
|
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
|
||||||
</SelectionState>
|
|
||||||
</selectionStates>
|
</selectionStates>
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@ -1,76 +0,0 @@
|
|||||||
kotlin version: 2.0.21
|
|
||||||
error message: org.jetbrains.kotlin.util.FileAnalysisException: While analysing C:/Users/Fazri Abdurrahman/AndroidStudioProjects/NotesAI/app/src/main/java/com/example/notesai/MainActivity.kt:40:5: java.lang.IllegalArgumentException: source must not be null
|
|
||||||
at org.jetbrains.kotlin.util.AnalysisExceptionsKt.wrapIntoFileAnalysisExceptionIfNeeded(AnalysisExceptions.kt:57)
|
|
||||||
at org.jetbrains.kotlin.fir.FirCliExceptionHandler.handleExceptionOnFileAnalysis(Utils.kt:249)
|
|
||||||
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:46)
|
|
||||||
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.resolveAndCheckFir(firUtils.kt:77)
|
|
||||||
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.buildResolveAndCheckFirViaLightTree(firUtils.kt:88)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModuleToAnalyzedFir(jvmCompilerPipeline.kt:319)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModulesUsingFrontendIrAndLightTree(jvmCompilerPipeline.kt:118)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:148)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:464)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:73)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:506)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:301)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:129)
|
|
||||||
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675)
|
|
||||||
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92)
|
|
||||||
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660)
|
|
||||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
|
|
||||||
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
|
||||||
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
|
|
||||||
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
|
|
||||||
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
|
|
||||||
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
|
|
||||||
at java.base/java.lang.Thread.run(Unknown Source)
|
|
||||||
Caused by: java.lang.IllegalArgumentException: source must not be null
|
|
||||||
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.requireNotNull(KtDiagnosticReportHelpers.kt:68)
|
|
||||||
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn(KtDiagnosticReportHelpers.kt:39)
|
|
||||||
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn$default(KtDiagnosticReportHelpers.kt:31)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkSourceElement(FirIncompatibleClassExpressionChecker.kt:50)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkType$checkers(FirIncompatibleClassExpressionChecker.kt:42)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.checkers.type.FirIncompatibleClassTypeChecker.check(FirIncompatibleClassTypeChecker.kt:17)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.check(TypeCheckersDiagnosticComponent.kt:81)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:53)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:19)
|
|
||||||
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.CheckerRunningDiagnosticCollectorVisitor.checkElement(CheckerRunningDiagnosticCollectorVisitor.kt:24)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:248)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:30)
|
|
||||||
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.impl.FirSimpleFunctionImpl.acceptChildren(FirSimpleFunctionImpl.kt:63)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:118)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:30)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.FirSimpleFunction.accept(FirSimpleFunction.kt:51)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.impl.FirRegularClassImpl.acceptChildren(FirRegularClassImpl.kt:63)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitClassAndChildren(AbstractDiagnosticCollectorVisitor.kt:87)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:92)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:30)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.FirRegularClass.accept(FirRegularClass.kt:48)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.impl.FirFileImpl.acceptChildren(FirFileImpl.kt:57)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:1151)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:30)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.FirFile.accept(FirFile.kt:42)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollector.collectDiagnostics(AbstractDiagnosticCollector.kt:36)
|
|
||||||
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:34)
|
|
||||||
... 33 more
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
kotlin version: 2.0.21
|
|
||||||
error message: org.jetbrains.kotlin.util.FileAnalysisException: While analysing C:/Users/Fazri Abdurrahman/AndroidStudioProjects/NotesAI/app/src/main/java/com/example/notesai/MainActivity.kt:40:5: java.lang.IllegalArgumentException: source must not be null
|
|
||||||
at org.jetbrains.kotlin.util.AnalysisExceptionsKt.wrapIntoFileAnalysisExceptionIfNeeded(AnalysisExceptions.kt:57)
|
|
||||||
at org.jetbrains.kotlin.fir.FirCliExceptionHandler.handleExceptionOnFileAnalysis(Utils.kt:249)
|
|
||||||
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:46)
|
|
||||||
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.resolveAndCheckFir(firUtils.kt:77)
|
|
||||||
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.buildResolveAndCheckFirViaLightTree(firUtils.kt:88)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModuleToAnalyzedFir(jvmCompilerPipeline.kt:319)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModulesUsingFrontendIrAndLightTree(jvmCompilerPipeline.kt:118)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:148)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:464)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:73)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:506)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:301)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:129)
|
|
||||||
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675)
|
|
||||||
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92)
|
|
||||||
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660)
|
|
||||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
|
|
||||||
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
|
||||||
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
|
|
||||||
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
|
|
||||||
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
|
|
||||||
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
|
|
||||||
at java.base/java.lang.Thread.run(Unknown Source)
|
|
||||||
Caused by: java.lang.IllegalArgumentException: source must not be null
|
|
||||||
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.requireNotNull(KtDiagnosticReportHelpers.kt:68)
|
|
||||||
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn(KtDiagnosticReportHelpers.kt:39)
|
|
||||||
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn$default(KtDiagnosticReportHelpers.kt:31)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkSourceElement(FirIncompatibleClassExpressionChecker.kt:50)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkType$checkers(FirIncompatibleClassExpressionChecker.kt:42)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.checkers.type.FirIncompatibleClassTypeChecker.check(FirIncompatibleClassTypeChecker.kt:17)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.check(TypeCheckersDiagnosticComponent.kt:81)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:53)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:19)
|
|
||||||
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.CheckerRunningDiagnosticCollectorVisitor.checkElement(CheckerRunningDiagnosticCollectorVisitor.kt:24)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:248)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:30)
|
|
||||||
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.impl.FirSimpleFunctionImpl.acceptChildren(FirSimpleFunctionImpl.kt:63)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:118)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:30)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.FirSimpleFunction.accept(FirSimpleFunction.kt:51)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.impl.FirRegularClassImpl.acceptChildren(FirRegularClassImpl.kt:63)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitClassAndChildren(AbstractDiagnosticCollectorVisitor.kt:87)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:92)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:30)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.FirRegularClass.accept(FirRegularClass.kt:48)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.impl.FirFileImpl.acceptChildren(FirFileImpl.kt:57)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:1151)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:30)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.FirFile.accept(FirFile.kt:42)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollector.collectDiagnostics(AbstractDiagnosticCollector.kt:36)
|
|
||||||
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:34)
|
|
||||||
... 33 more
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
kotlin version: 2.0.21
|
|
||||||
error message: org.jetbrains.kotlin.util.FileAnalysisException: While analysing C:/Users/Fazri Abdurrahman/AndroidStudioProjects/NotesAI/app/src/main/java/com/example/notesai/MainActivity.kt:40:5: java.lang.IllegalArgumentException: source must not be null
|
|
||||||
at org.jetbrains.kotlin.util.AnalysisExceptionsKt.wrapIntoFileAnalysisExceptionIfNeeded(AnalysisExceptions.kt:57)
|
|
||||||
at org.jetbrains.kotlin.fir.FirCliExceptionHandler.handleExceptionOnFileAnalysis(Utils.kt:249)
|
|
||||||
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:46)
|
|
||||||
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.resolveAndCheckFir(firUtils.kt:77)
|
|
||||||
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.buildResolveAndCheckFirViaLightTree(firUtils.kt:88)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModuleToAnalyzedFir(jvmCompilerPipeline.kt:319)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModulesUsingFrontendIrAndLightTree(jvmCompilerPipeline.kt:118)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:148)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:464)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:73)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:506)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:301)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:129)
|
|
||||||
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675)
|
|
||||||
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92)
|
|
||||||
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660)
|
|
||||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
|
|
||||||
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
|
||||||
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
|
|
||||||
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
|
|
||||||
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
|
|
||||||
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
|
|
||||||
at java.base/java.lang.Thread.run(Unknown Source)
|
|
||||||
Caused by: java.lang.IllegalArgumentException: source must not be null
|
|
||||||
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.requireNotNull(KtDiagnosticReportHelpers.kt:68)
|
|
||||||
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn(KtDiagnosticReportHelpers.kt:39)
|
|
||||||
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn$default(KtDiagnosticReportHelpers.kt:31)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkSourceElement(FirIncompatibleClassExpressionChecker.kt:50)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkType$checkers(FirIncompatibleClassExpressionChecker.kt:42)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.checkers.type.FirIncompatibleClassTypeChecker.check(FirIncompatibleClassTypeChecker.kt:17)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.check(TypeCheckersDiagnosticComponent.kt:81)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:53)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:19)
|
|
||||||
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.CheckerRunningDiagnosticCollectorVisitor.checkElement(CheckerRunningDiagnosticCollectorVisitor.kt:24)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:248)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:30)
|
|
||||||
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.impl.FirSimpleFunctionImpl.acceptChildren(FirSimpleFunctionImpl.kt:63)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:118)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:30)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.FirSimpleFunction.accept(FirSimpleFunction.kt:51)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.impl.FirRegularClassImpl.acceptChildren(FirRegularClassImpl.kt:63)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitClassAndChildren(AbstractDiagnosticCollectorVisitor.kt:87)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:92)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:30)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.FirRegularClass.accept(FirRegularClass.kt:48)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.impl.FirFileImpl.acceptChildren(FirFileImpl.kt:57)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:1151)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:30)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.FirFile.accept(FirFile.kt:42)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollector.collectDiagnostics(AbstractDiagnosticCollector.kt:36)
|
|
||||||
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:34)
|
|
||||||
... 33 more
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
kotlin version: 2.0.21
|
|
||||||
error message: org.jetbrains.kotlin.util.FileAnalysisException: While analysing C:/Users/Fazri Abdurrahman/AndroidStudioProjects/NotesAI/app/src/main/java/com/example/notesai/MainActivity.kt:40:5: java.lang.IllegalArgumentException: source must not be null
|
|
||||||
at org.jetbrains.kotlin.util.AnalysisExceptionsKt.wrapIntoFileAnalysisExceptionIfNeeded(AnalysisExceptions.kt:57)
|
|
||||||
at org.jetbrains.kotlin.fir.FirCliExceptionHandler.handleExceptionOnFileAnalysis(Utils.kt:249)
|
|
||||||
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:46)
|
|
||||||
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.resolveAndCheckFir(firUtils.kt:77)
|
|
||||||
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.buildResolveAndCheckFirViaLightTree(firUtils.kt:88)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModuleToAnalyzedFir(jvmCompilerPipeline.kt:319)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModulesUsingFrontendIrAndLightTree(jvmCompilerPipeline.kt:118)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:148)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:464)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:73)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:506)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:301)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:129)
|
|
||||||
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675)
|
|
||||||
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92)
|
|
||||||
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660)
|
|
||||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
|
|
||||||
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
|
||||||
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
|
|
||||||
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
|
|
||||||
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
|
|
||||||
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
|
|
||||||
at java.base/java.lang.Thread.run(Unknown Source)
|
|
||||||
Caused by: java.lang.IllegalArgumentException: source must not be null
|
|
||||||
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.requireNotNull(KtDiagnosticReportHelpers.kt:68)
|
|
||||||
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn(KtDiagnosticReportHelpers.kt:39)
|
|
||||||
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn$default(KtDiagnosticReportHelpers.kt:31)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkSourceElement(FirIncompatibleClassExpressionChecker.kt:50)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkType$checkers(FirIncompatibleClassExpressionChecker.kt:42)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.checkers.type.FirIncompatibleClassTypeChecker.check(FirIncompatibleClassTypeChecker.kt:17)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.check(TypeCheckersDiagnosticComponent.kt:81)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:53)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:19)
|
|
||||||
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.CheckerRunningDiagnosticCollectorVisitor.checkElement(CheckerRunningDiagnosticCollectorVisitor.kt:24)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:248)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:30)
|
|
||||||
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.impl.FirSimpleFunctionImpl.acceptChildren(FirSimpleFunctionImpl.kt:63)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:118)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:30)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.FirSimpleFunction.accept(FirSimpleFunction.kt:51)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.impl.FirRegularClassImpl.acceptChildren(FirRegularClassImpl.kt:63)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitClassAndChildren(AbstractDiagnosticCollectorVisitor.kt:87)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:92)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:30)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.FirRegularClass.accept(FirRegularClass.kt:48)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.impl.FirFileImpl.acceptChildren(FirFileImpl.kt:57)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:1151)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:30)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.FirFile.accept(FirFile.kt:42)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollector.collectDiagnostics(AbstractDiagnosticCollector.kt:36)
|
|
||||||
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:34)
|
|
||||||
... 33 more
|
|
||||||
|
|
||||||
|
|
||||||
361
CHANGELOG.md
361
CHANGELOG.md
@ -1,361 +0,0 @@
|
|||||||
# **AI Notes – Changelog**
|
|
||||||
|
|
||||||
## **Tim Pengembang**
|
|
||||||
|
|
||||||
* Dendi Yogia Pratama
|
|
||||||
* Raihan Ariq Muzakki
|
|
||||||
* Fazri Abdurrahman
|
|
||||||
|
|
||||||
# **Version 1.0.0 – Initial Release**
|
|
||||||
|
|
||||||
## **Sprint 1: Struktur Dasar Aplikasi**
|
|
||||||
|
|
||||||
### **Struktur & Navigation**
|
|
||||||
* **Setup navigation system** - Implementasi routing antar halaman (Beranda, Arsip, Sampah)
|
|
||||||
* **Menu Drawer** - Navigation drawer dengan list menu (Beranda, Berbintang, Arsip, Sampah)
|
|
||||||
* **Bottom Navigation** - Home & AI Helper tabs dengan icon navigation
|
|
||||||
* **Top App Bar** - Menu hamburger dan search icon dengan Material3 styling
|
|
||||||
* **Screen Architecture** - Pembuatan screen Arsip, Sampah, Berbintang, AI Helper
|
|
||||||
|
|
||||||
### **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
|
|
||||||
|
|
||||||
### **Category Management**
|
|
||||||
* **Category Model** - Data class dengan gradient colors dan timestamp
|
|
||||||
* **Category Dialog** - Form tambah/edit kategori dengan nama + gradient picker
|
|
||||||
* **Category Card** - Design dengan icon folder, nama, jumlah catatan, gradient background
|
|
||||||
* **Staggered Grid Layout** - 2 kolom responsive dengan LazyVerticalStaggeredGrid
|
|
||||||
* **Category Actions** - Menu dropdown (⋮) untuk edit dan delete kategori
|
|
||||||
* **Empty State** - Pesan "Buat kategori pertama Anda" dengan icon
|
|
||||||
|
|
||||||
### **Note Management**
|
|
||||||
* **Note Model** - Data class dengan title, content, timestamp, isPinned, isArchived
|
|
||||||
* **Note Dialog** - Form dengan judul, deskripsi, simpan, batal, hapus
|
|
||||||
* **Note Card** - Preview dengan judul, deskripsi, timestamp, pin icon
|
|
||||||
* **Full-screen Editor** - Editable note view dengan auto-save dan actions
|
|
||||||
* **Pin Feature** - Toggle pin/unpin untuk catatan penting dengan sorting priority
|
|
||||||
* **Archive & Delete** - Actions untuk arsip dan soft delete notes
|
|
||||||
* **Search Functionality** - Real-time search berdasarkan judul dan isi (case-insensitive)
|
|
||||||
* **Smart Sorting** - Berdasarkan pin status dan timestamp (descending)
|
|
||||||
|
|
||||||
### **AI Assistant**
|
|
||||||
* **Gemini AI Integration** - Setup Google Generative AI SDK dengan gemini-2.5-flash
|
|
||||||
* **AI Helper Screen** - Layout chat interface dengan header dan statistics
|
|
||||||
* **Category Context Selector** - Dropdown untuk filter konteks AI berdasarkan kategori
|
|
||||||
* **Statistics Display** - Total catatan, pinned notes, jumlah kategori
|
|
||||||
* **Chat Interface** - User & AI bubble dengan different styling dan timestamp
|
|
||||||
* **Prompt Engineering** - Context builder dengan data catatan user (max 10 terbaru)
|
|
||||||
* **Suggestion Chips** - Quick question templates untuk user guidance
|
|
||||||
* **Copy to Clipboard** - Copy jawaban AI dengan confirmation feedback
|
|
||||||
* **Loading & Error States** - Circular progress indicator dan error messages
|
|
||||||
* **API Configuration** - Temperature 0.8, topK 40, topP 0.95, maxOutputTokens 4096
|
|
||||||
* **Auto-scroll Chat** - Scroll ke bottom otomatis dengan LaunchedEffect
|
|
||||||
|
|
||||||
### **Data Persistence**
|
|
||||||
* **DataStore Implementation** - Preferences DataStore untuk local storage
|
|
||||||
* **DataStoreManager Class** - Centralized dengan categoriesFlow & notesFlow
|
|
||||||
* **Auto-save dengan Debounce** - 500ms delay untuk optimize I/O operations
|
|
||||||
* **Flow-based Loading** - Reactive data loading dengan Flow collection
|
|
||||||
* **Error Handling** - Try-catch untuk semua I/O operations
|
|
||||||
* **Serializable Models** - JSON serialization dengan extension functions
|
|
||||||
|
|
||||||
### **UI/UX Enhancements**
|
|
||||||
* **Visual Feedback** - Click ripples, copy confirmation, loading states
|
|
||||||
* **Empty States** - Icon + descriptive messages untuk setiap screen
|
|
||||||
* **Confirmation Dialogs** - AlertDialog untuk arsip, hapus, delete actions
|
|
||||||
* **Search Empty State** - "Tidak ada hasil" message saat search kosong
|
|
||||||
* **Custom TextField** - Styled text input dengan consistent design
|
|
||||||
* **Date Formatter Utility** - Format timestamp ke readable Indonesian format
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## **Sprint 2: Project Restructuring & Advanced Features**
|
|
||||||
|
|
||||||
### **Project Architecture**
|
|
||||||
* **Clean Architecture Migration** - Dari 3 file monolith ke modular structure
|
|
||||||
* **Data Layer Separation** - Models ke `data/model/` (Category, Note, ChatMessage)
|
|
||||||
* **Local Storage Layer** - DataStoreManager ke `data/local/` dengan PreferencesKeys
|
|
||||||
* **Component Extraction** - Screen components ke folder terpisah (main, starred, archive, trash)
|
|
||||||
* **Utilities Creation** - Constants.kt, DateFormatter.kt, Extensions.kt untuk reusability
|
|
||||||
* **Import Optimization** - Update semua import sesuai package structure baru
|
|
||||||
|
|
||||||
### **Search & Filter**
|
|
||||||
* **Beranda Search** - Real-time search kategori berdasarkan nama
|
|
||||||
* **Category Notes Search** - Search catatan di dalam kategori (judul & isi)
|
|
||||||
* **Search Filtering** - Live filtering saat user mengetik
|
|
||||||
* **Search Empty State** - Descriptive message dengan alternative suggestions
|
|
||||||
|
|
||||||
### **Category Features**
|
|
||||||
* **Edit Category** - Dialog untuk ubah nama dan gradient dengan pre-filled form
|
|
||||||
* **Delete Category** - Menu dropdown dengan confirmation dialog
|
|
||||||
* **Category Actions Menu** - Icon ⋮ untuk access edit & delete options
|
|
||||||
* **Gradient Preview** - Visual preview saat edit kategori
|
|
||||||
|
|
||||||
### **Trash System**
|
|
||||||
* **Soft Delete Implementation** - isDeleted flag untuk Category dan Note
|
|
||||||
* **Trash Screen** - Tampilkan kategori & notes yang terhapus
|
|
||||||
* **TrashCategoryCard Component** - Card khusus dengan restore & delete permanent actions
|
|
||||||
* **Restore Feature** - Pulihkan kategori beserta semua notes di dalamnya
|
|
||||||
* **Permanent Delete** - Hapus kategori dan notes secara irreversible dengan confirmation
|
|
||||||
* **Counter Display** - Jumlah items terhapus di trash
|
|
||||||
* **Global Filter** - Filter `!isDeleted` di semua screen untuk hide deleted items
|
|
||||||
|
|
||||||
### **Bug Fixes & Optimization**
|
|
||||||
* **Runtime Error Debugging** - Fix NotImplementedError & FATAL EXCEPTION issues
|
|
||||||
* **Google Play Services Handling** - Error handling untuk GMS dependencies
|
|
||||||
* **Component Migration** - Update deprecated Divider ke HorizontalDivider
|
|
||||||
* **Gradle Optimization** - Cleanup unnecessary dependencies
|
|
||||||
* **State Management** - Proper state hoisting dan recomposition optimization
|
|
||||||
|
|
||||||
### **Documentation**
|
|
||||||
* **Migration Guide** - Step-by-step panduan untuk project restructuring
|
|
||||||
* **Debugging Guide** - Troubleshooting common issues dan error handling
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# **Version 1.1.0 – AI Helper Screen Enhancement & UI Refinement**
|
|
||||||
|
|
||||||
## **Sprint 3: AI Helper Screen Features & Modern UI Redesign**
|
|
||||||
|
|
||||||
### **AI Assistant Enhancements**
|
|
||||||
* **History Chat AI dengan Drawer Menu** - Riwayat percakapan AI tersimpan permanen, dikelompokkan per kategori
|
|
||||||
* **Chat History Management** - Load previous chat, delete history, start new chat dengan auto-save
|
|
||||||
* **Markdown Parser untuk AI Response** - Support bold, italic, code blocks, headers, lists, quotes, links
|
|
||||||
* **Improved Error Handling** - User-friendly error messages untuk quota, network, API issues
|
|
||||||
* **Gemini Model Update** - Switch ke gemini-1.5-flash untuk stabilitas optimal
|
|
||||||
|
|
||||||
### **Theme System**
|
|
||||||
* **Dark/Light Theme Toggle** - Dual theme dengan persistent storage di drawer menu
|
|
||||||
* **Reactive Color System** - Dynamic color switching untuk semua components
|
|
||||||
* **Complete Color Palette** - DarkColors dan LightColors objects untuk consistency
|
|
||||||
|
|
||||||
### **UI/UX Modernization**
|
|
||||||
* **Floating Design System** - TopBar dan BottomBar dengan floating style, rounded corners, shadow
|
|
||||||
* **Consistent Component Style** - Unified design language dengan CircleShape buttons
|
|
||||||
* **Optimized Layouts** - Better spacing dan vertical action stack untuk maximize content space
|
|
||||||
|
|
||||||
### **Data & Navigation**
|
|
||||||
* **Note Edit & Delete from Card** - Menu dropdown pada NoteCard untuk quick actions
|
|
||||||
* **Race Condition Fix** - Guard flags dan lifecycle-aware auto-save untuk data persistence
|
|
||||||
* **Simplified Navigation** - Unified drawer menu, remove redundant back buttons
|
|
||||||
* **Extended DataStore** - Support chat history, theme preference, improved error handling
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## **Sprint 4: Rich Text Editor Core Features & AI Chat History UI/UX Improvements**
|
|
||||||
|
|
||||||
### **Rich Text Editing**
|
|
||||||
* **Hybrid Rich Text Editor (WYSIWYG)** – Edit teks dengan format langsung tanpa syntax markdown terlihat
|
|
||||||
* **Bold, Italic, Underline** – Formatting bersifat toggle dan tetap aktif sampai dimatikan
|
|
||||||
* **Heading & Bullet List** – Support heading (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
|
|
||||||
---
|
|
||||||
|
|
||||||
## **Sprint 5: AI Assistant Enhancements & Smart Organization Features**
|
|
||||||
|
|
||||||
### **Copy Plain Text Feature**
|
|
||||||
* **Dual Copy Options** – Dropdown menu dengan 2 pilihan: "Copy dengan Format" dan "Copy Teks Asli"
|
|
||||||
* **Smart Markdown Stripper** – Utility untuk remove bold, italic, code, headers, lists, links dari text
|
|
||||||
* **Visual Feedback** – Animated checkmark indicator dengan auto-hide setelah 2 detik
|
|
||||||
* **Format Preservation** – Copy dengan format maintain semua markdown syntax
|
|
||||||
* **Clean Output** – Plain text copy menghasilkan text bersih tanpa formatting apapun
|
|
||||||
|
|
||||||
### **Document Upload & AI Summary**
|
|
||||||
* **Multi-Format Support** – Upload PDF, TXT, dan DOCX files dengan file picker
|
|
||||||
* **Smart PDF Parser** – PDFBox Android integration untuk extract text dari PDF documents
|
|
||||||
* **Lightweight DOCX Parser** – Custom XML-based parser tanpa Apache POI dependency
|
|
||||||
* **Auto-Summary Generation** – AI auto-generate ringkasan maksimal 300 kata saat file di-upload
|
|
||||||
* **File Validation** – Maximum 10MB dengan clear error messaging untuk setiap kasus
|
|
||||||
* **Loading Integration** – Loading indicator "Membuat ringkasan..." terintegrasi di chat area
|
|
||||||
* **Chat Format** – Upload result format: "📄 Upload file: filename" dengan summary response
|
|
||||||
|
|
||||||
### **Category Pin System**
|
|
||||||
* **Pin/Unpin Toggle** – Quick access untuk kategori favorit dengan dropdown menu
|
|
||||||
* **Visual Pin Indicator** – 📌 icon dengan scale dan fade animations
|
|
||||||
* **Smart Sorting Logic** – Priority sorting: pinned categories (DESC) → timestamp (DESC)
|
|
||||||
* **Persistent Storage** – Pin status tersimpan di DataStore dengan backward compatibility
|
|
||||||
* **Multiple Pins Support** – User dapat pin multiple categories dengan proper grouping
|
|
||||||
* **Context Menu Integration** – Pin option di dropdown: "Pin Kategori" / "Lepas Pin"
|
|
||||||
|
|
||||||
### **Technical Implementation**
|
|
||||||
* **MarkdownStripper.kt** – Utility class dengan regex-based markdown removal
|
|
||||||
* **FileParser.kt** – Centralized file parsing untuk PDF, TXT, DOCX
|
|
||||||
* **Category Model Update** – Added `isPinned: Boolean` field dengan serialization support
|
|
||||||
* **FileParseResult** – Sealed class untuk type-safe file parsing results
|
|
||||||
* **PDFBoxResourceLoader** – Proper initialization dalam Application.onCreate()
|
|
||||||
* **State Management** – Combined loading states untuk chat dan file upload
|
|
||||||
|
|
||||||
### **User Experience**
|
|
||||||
* **Smooth Transitions** – Loading states dengan color differentiation (Primary vs Secondary)
|
|
||||||
* **Error Handling** – Comprehensive error messages untuk berbagai failure scenarios
|
|
||||||
* **Auto-Scroll** – Scroll to bottom saat upload file untuk show loading indicator
|
|
||||||
* **Disabled States** – Upload button hidden saat processing untuk prevent duplicate actions
|
|
||||||
* **Persistence** – Pin status dan file summary survive app restart
|
|
||||||
|
|
||||||
# **Sprint 6: Functional Testing & Documentation**
|
|
||||||
|
|
||||||
## **Testing Implementation**
|
|
||||||
|
|
||||||
### **Test Methodology Design**
|
|
||||||
* **Black-Box Functional Testing** – Pengujian dari perspektif end-user dengan verifikasi Expected vs Actual Result
|
|
||||||
* **Automated Unit Testing** – 59 automated tests untuk validasi business logic internal
|
|
||||||
* **Test Case Design** – 6 comprehensive test cases (TC-01 hingga TC-06)
|
|
||||||
* **Evidence Collection** – Screenshot documentation untuk setiap test case
|
|
||||||
* **Test Report Generation** – Structured test summary dengan metrics dan status
|
|
||||||
|
|
||||||
### **Test Infrastructure Setup**
|
|
||||||
* **JUnit 4 Framework** – Core testing framework untuk Android unit tests
|
|
||||||
* **AndroidX Test Library** – Android-specific testing utilities dan test runners
|
|
||||||
* **Kotlin Coroutines Test** – Testing library untuk asynchronous code dengan runBlocking
|
|
||||||
* **Test Isolation** – Unique DataStore instances per test untuk prevent data contamination
|
|
||||||
* **Test Execution** – Configure test runner dan gradle tasks untuk automated testing
|
|
||||||
|
|
||||||
### **Unit Test Development**
|
|
||||||
* **DataStoreManagerTest** – 8 tests untuk CRUD operations, autosave, dan pin functionality
|
|
||||||
* **TrashFunctionalityTest** – 11 tests untuk soft delete, restore, dan permanent delete
|
|
||||||
* **SearchFunctionalityTest** – 14 tests untuk realtime search dengan berbagai scenarios
|
|
||||||
* **AIChatFunctionalityTest** – 14 tests untuk context building dan chat history management
|
|
||||||
* **FileUploadFunctionalityTest** – 12 tests untuk file parsing dan summary generation
|
|
||||||
|
|
||||||
### **Test Coverage Achievement**
|
|
||||||
* **Total Test Cases** – 6 functional test cases dengan screenshot evidence
|
|
||||||
* **Total Unit Tests** – 59 automated tests covering critical business logic
|
|
||||||
* **Success Rate** – 100% pass rate untuk semua tests
|
|
||||||
* **Execution Time** – Average ~2 minutes untuk full test suite
|
|
||||||
* **Code Coverage** – 100% coverage untuk tested components (DataStore, Models, Utilities)
|
|
||||||
|
|
||||||
### **Bug Fixes & Test Iterations**
|
|
||||||
* **Test Isolation Issues** – Fixed data contamination dengan unique DataStore per test
|
|
||||||
* **Async Testing** – Resolved suspend function testing dengan proper runBlocking usage
|
|
||||||
* **Chat History Tests** – Fixed unique ID collision dengan improved test data management
|
|
||||||
* **Error Handling** – Added comprehensive error handling untuk file I/O operations
|
|
||||||
* **Test Stability** – Achieved consistent test results dengan proper setup/teardown
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## **Documentation Development**
|
|
||||||
|
|
||||||
### **Technical Documentation**
|
|
||||||
* **Testing Methodology** – 16-section comprehensive document explaining black-box testing approach
|
|
||||||
* **Architecture Documentation** – Package structure, data flow, dan component relationships
|
|
||||||
* **API Documentation** – Gemini API integration, endpoints, dan configuration details
|
|
||||||
* **Code Comments** – Inline documentation untuk complex logic dan business rules
|
|
||||||
* **Setup Guide** – Step-by-step installation dan configuration instructions
|
|
||||||
|
|
||||||
### **User Documentation**
|
|
||||||
* **User Guide** – Complete panduan penggunaan dengan 7 user flows dan screenshots
|
|
||||||
* **Feature Documentation** – Detailed explanation untuk setiap fitur utama aplikasi
|
|
||||||
* **Quick Start Guide** – 6-step quick reference untuk new users
|
|
||||||
* **Troubleshooting Guide** – Common issues dan solutions untuk end-users
|
|
||||||
* **FAQ Section** – Frequently asked questions dengan clear answers
|
|
||||||
|
|
||||||
### **Project Documentation**
|
|
||||||
* **README.md** – Comprehensive project overview dengan badges, links, dan quick navigation
|
|
||||||
* **CHANGELOG.md** – Detailed version history dengan sprint breakdown dan feature tracking
|
|
||||||
* **TEST_SUMMARY_REPORT.md** – Complete test report dengan metrics, results, dan evidence
|
|
||||||
* **DEVELOPMENT_PLAN.md** – Sprint history, roadmap, dan future development planning
|
|
||||||
* **Academic Report** – 35-page formal documentation untuk course submission
|
|
||||||
|
|
||||||
### **Visual Documentation**
|
|
||||||
* **Wireframe Design** – Complete app wireframe dengan all screens dan navigation flows
|
|
||||||
* **Screenshot Collection** – 35+ screenshots documenting every feature dan user flow
|
|
||||||
* **Mockup Integration** – High-fidelity mockups embedded dalam user guide
|
|
||||||
* **Architecture Diagrams** – Visual representation of data layer, presentation layer, dan flow
|
|
||||||
* **Test Evidence** – Screenshot proof untuk setiap test case execution
|
|
||||||
|
|
||||||
### **Reference Documentation**
|
|
||||||
* **Dependencies List** – Complete list of libraries dengan version numbers
|
|
||||||
* **Tech Stack Documentation** – Detailed explanation of technology choices
|
|
||||||
* **API References** – Links to official documentation untuk Gemini, Android, Kotlin
|
|
||||||
* **Academic Citations** – Proper citation format untuk research sources (33 references)
|
|
||||||
* **Version Control** – Git workflow documentation dan branching strategy
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Fitur Utama (Core Features)
|
|
||||||
1. **Note Management System** ⭐ (PRIMARY)
|
|
||||||
|
|
||||||
* Create, Read, Update, Delete catatan
|
|
||||||
* Organize notes dalam categories
|
|
||||||
* Pin notes untuk quick access
|
|
||||||
* Archive & Trash system dengan soft delete
|
|
||||||
* Full-screen rich text editor dengan WYSIWYG
|
|
||||||
* Real-time search & filter notes
|
|
||||||
|
|
||||||
2. **AI Assistant** 🤖 (PRIMARY)
|
|
||||||
|
|
||||||
* Chat dengan AI menggunakan Gemini 2.5 Flash
|
|
||||||
* Context-aware responses berdasarkan notes user
|
|
||||||
* Chat history management dengan persistent storage
|
|
||||||
* Document upload & auto-summary (PDF, TXT, DOCX)
|
|
||||||
* Markdown support untuk AI responses
|
|
||||||
* Copy plain text atau formatted text
|
|
||||||
|
|
||||||
3. **Category Organization** 📁 (CORE)
|
|
||||||
|
|
||||||
* Create & manage categories dengan 8 gradient color presets
|
|
||||||
* Pin favorite categories untuk quick access
|
|
||||||
* Smart sorting: pinned categories → timestamp descending
|
|
||||||
* Category-based note filtering dengan statistics
|
|
||||||
* Staggered grid layout responsive
|
|
||||||
|
|
||||||
4. **Rich Text Editor** ✏️ (CORE)
|
|
||||||
|
|
||||||
* WYSIWYG (What You See Is What You Get) editing experience tanpa raw markdown
|
|
||||||
* Bold, italic, underline, headings (H1-H3), bullet lists
|
|
||||||
* Floating draggable toolbar dengan active state indicators
|
|
||||||
* Undo/Redo functionality
|
|
||||||
* Markdown compatibility untuk import/export
|
|
||||||
* Auto-save lifecycle-aware
|
|
||||||
|
|
||||||
5. **Data Persistence** 💾 (ESSENTIAL)
|
|
||||||
|
|
||||||
* Local storage dengan DataStore Preferences
|
|
||||||
* Auto-save dengan debounce (500ms delay)
|
|
||||||
* Theme preference storage (dark/light mode)
|
|
||||||
* Chat history persistence dengan serialization
|
|
||||||
* Flow-based reactive data loading
|
|
||||||
* Error handling untuk semua I/O operations
|
|
||||||
BIN
NotesAI.apk
BIN
NotesAI.apk
Binary file not shown.
578
README.md
578
README.md
@ -1,578 +0,0 @@
|
|||||||
<div align="center">
|
|
||||||
|
|
||||||
<img title="Logo NotesAI" src="docs/Logo%20Aplikasi.png" width="150" height="150">
|
|
||||||
|
|
||||||
# NotesAI - AI-Powered Note Taking App 1.1.0
|
|
||||||
|
|
||||||
|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
**Aplikasi pencatatan cerdas berbasis AI untuk Android dengan fitur organisasi terstruktur dan AI Assistant**
|
|
||||||
|
|
||||||
[📖 Dokumentasi Lengkap](./docs/Laporan%20Dokumentasi%20Aplikasi%20-%20Kelompok%201.pdf) | [🧪 Test Report](./TEST_SUMMARY_REPORT.md) | [📋 Changelog](./CHANGELOG.md)
|
|
||||||
|
|
||||||
Unduh [NotesAI.apk](./NotesAI.apk)
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 👥 Tim Pengembang
|
|
||||||
|
|
||||||
**Kelompok 1 - Kelas F5A5**
|
|
||||||
|
|
||||||
| Nama | NPM |
|
|
||||||
|------|-----|
|
|
||||||
| Dendi Yogia Pratama | 202310715051 |
|
|
||||||
| Raihan Ariq Muzakki | 202310715297 |
|
|
||||||
| Fazri Abdurahman | 202310715082 |
|
|
||||||
|
|
||||||
**Dosen Pengampu:** Arif Rifai Dwiyanto, ST., MTI
|
|
||||||
**Mata Kuliah:** Pemrograman Perangkat Bergerak
|
|
||||||
**Institusi:** Universitas Bhayangkara Jakarta Raya
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📌 Informasi Proyek
|
|
||||||
|
|
||||||
- **Nama Aplikasi:** NotesAI
|
|
||||||
- **Repository:** [NotesAI](https://git.lab.ubharajaya.ac.id/202310715297-RAIHAN-ARIQ-MUZAKKI/NotesAI.git)
|
|
||||||
- **Platform:** Android (Minimum API 24 / Android 7.0)
|
|
||||||
- **Bahasa:** Kotlin 100%
|
|
||||||
- **UI Framework:** Jetpack Compose + Material 3
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Tujuan dan Sasaran Aplikasi
|
|
||||||
|
|
||||||
### Masalah yang Dipecahkan
|
|
||||||
1. **Catatan tidak terorganisir** - Catatan bercampur tanpa struktur yang jelas
|
|
||||||
2. **Sulit mencari informasi** - Kehilangan waktu mencari catatan penting
|
|
||||||
3. **Kurang interaktif** - Tidak ada bantuan untuk merangkum atau menganalisis catatan
|
|
||||||
4. **Risiko kehilangan data** - Catatan terhapus permanen tanpa backup
|
|
||||||
|
|
||||||
### Target Pengguna
|
|
||||||
- 🎓 **Mahasiswa** (18-25 tahun) - Catatan kuliah, tugas, ringkasan dokumen
|
|
||||||
- 💼 **Profesional** (22-40 tahun) - Meeting notes, to-do lists, catatan kerja
|
|
||||||
- 👤 **Pengguna Umum** (18-40 tahun) - Catatan pribadi, jurnal, daftar belanja
|
|
||||||
|
|
||||||
### Keunggulan NotesAI
|
|
||||||
✅ **Organisasi Terstruktur** - Sistem kategori dengan visual gradien warna
|
|
||||||
✅ **AI Assistant** - Powered by Gemini 2.5 Flash untuk respons kontekstual
|
|
||||||
✅ **Rich Text Editor** - WYSIWYG editor dengan drag-and-drop toolbar
|
|
||||||
✅ **Smart Search** - Pencarian realtime dengan filter kategori
|
|
||||||
✅ **Data Safety** - Soft delete dengan trash & restore functionality
|
|
||||||
✅ **Document Parser** - Upload PDF/TXT/DOCX dengan auto-summary
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ Fitur Utama
|
|
||||||
|
|
||||||
### 1️⃣ Note Management System
|
|
||||||
- ✏️ **CRUD Operations** - Create, Read, Update, Delete catatan
|
|
||||||
- 📁 **Category Organization** - Pengelompokan visual dengan gradien warna
|
|
||||||
- 📌 **Pin Priority** - Pin notes/kategori untuk akses cepat
|
|
||||||
- 🗄️ **Archive & Trash** - Soft delete dengan kemampuan restore
|
|
||||||
- 🎨 **Rich Text Editor** - Bold, italic, underline, heading, bullet list
|
|
||||||
- 🔍 **Smart Search** - Realtime search dengan case-insensitive
|
|
||||||
|
|
||||||
### 2️⃣ AI Assistant
|
|
||||||
- 🤖 **Contextual AI Chat** - AI memahami konteks dari catatan Anda
|
|
||||||
- 📄 **Document Upload** - Support PDF, TXT, DOCX
|
|
||||||
- 📝 **Auto Summary** - Ringkasan otomatis dari dokumen
|
|
||||||
- 💬 **Chat History** - Penyimpanan percakapan persistent
|
|
||||||
- 🎯 **Category Filter** - Filter konteks berdasarkan kategori
|
|
||||||
- ✍️ **Markdown Support** - Formatted AI responses
|
|
||||||
|
|
||||||
### 3️⃣ Data Management
|
|
||||||
- 💾 **Local Storage** - DataStore Preferences untuk data persistence
|
|
||||||
- 🔄 **Autosave** - Lifecycle-aware autosave dengan debounce 500ms
|
|
||||||
- 🌙 **Theme Preference** - Dark/Light mode support
|
|
||||||
- 📊 **Reactive Data** - Flow-based reactive data loading
|
|
||||||
- ⚡ **Fast Performance** - Optimized untuk pengalaman smooth
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Riset dan Analisis
|
|
||||||
|
|
||||||
### Analisis Pasar
|
|
||||||
Berdasarkan riset pasar 2025:
|
|
||||||
- **70% responden** percaya AI meningkatkan produktivitas kerja *(Eurobarometer, Feb 2025)*
|
|
||||||
- **Pasar note-taking apps** menunjukkan pertumbuhan dengan tren integrasi AI
|
|
||||||
- **Gemini 2.5 Flash** dipilih sebagai model "price-performance" terbaik untuk low-latency tasks
|
|
||||||
|
|
||||||
### Diferensiasi NotesAI
|
|
||||||
🎯 **AI Contextual** - AI dapat membaca semua catatan sebagai konteks
|
|
||||||
🎯 **Document to Summary** - Upload file langsung dapat ringkasan
|
|
||||||
🎯 **Local-First** - Tidak butuh login, data tersimpan lokal
|
|
||||||
🎯 **Free & Open** - Tidak ada paywall untuk fitur utama
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 Desain UI/UX
|
|
||||||
|
|
||||||
### Design System
|
|
||||||
- **Material Design 3** - Modern, clean, consistent
|
|
||||||
- **Color Scheme** - Dynamic gradients untuk kategori
|
|
||||||
- **Typography** - Clear hierarchy untuk readability
|
|
||||||
- **Spacing** - Consistent 4dp baseline grid
|
|
||||||
|
|
||||||
### User Flow
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────┐
|
|
||||||
│ 🏠 Main Screen (Home) │
|
|
||||||
│ ┌─────────────┐ ┌─────────────┐ │
|
|
||||||
│ │ Category │ │ Category │ │
|
|
||||||
│ │ Card │ │ Card │ │
|
|
||||||
│ └─────────────┘ └─────────────┘ │
|
|
||||||
└───────────┬─────────────────────────────────┘
|
|
||||||
│ Select Category
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────┐
|
|
||||||
│ 📄 Notes List Screen │
|
|
||||||
│ ┌─────────────┐ ┌─────────────┐ │
|
|
||||||
│ │ Note Card │ │ Note Card │ │
|
|
||||||
│ │ (Pinned) │ │ (Regular) │ │
|
|
||||||
│ └─────────────┘ └─────────────┘ │
|
|
||||||
└───────────┬─────────────────────────────────┘
|
|
||||||
│ Open Note
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────┐
|
|
||||||
│ ✏️ Full-Screen Editor │
|
|
||||||
│ • Rich Text Toolbar (Draggable) │
|
|
||||||
│ • Bold, Italic, Underline │
|
|
||||||
│ • Heading, Bullet List │
|
|
||||||
│ • Auto-save on background │
|
|
||||||
└─────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Wireframe & Mockup
|
|
||||||
📐 **Wireframe lengkap** tersedia di [Dokumentasi](docs/Laporan%20Dokumentasi%20Aplikasi%20-%20Kelompok%201.pdf) Halaman 15
|
|
||||||
📸 **Screenshot aplikasi** tersedia di Panduan Pengguna (Halaman 21-28)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ Teknologi dan Alat
|
|
||||||
|
|
||||||
### Tech Stack
|
|
||||||
|
|
||||||
#### Core Technologies
|
|
||||||
```
|
|
||||||
// Build Configuration
|
|
||||||
Kotlin 1.9.0
|
|
||||||
Android Gradle Plugin 8.2.0
|
|
||||||
Compile SDK 34
|
|
||||||
Min SDK 24 (Android 7.0)
|
|
||||||
Target SDK 34
|
|
||||||
```
|
|
||||||
|
|
||||||
#### UI & Architecture
|
|
||||||
- **Jetpack Compose** - Modern declarative UI
|
|
||||||
- **Material 3** - Latest Material Design components
|
|
||||||
- **Navigation Compose** - Type-safe navigation
|
|
||||||
- **Lifecycle** - Lifecycle-aware components
|
|
||||||
|
|
||||||
#### Data & Storage
|
|
||||||
- **DataStore Preferences** - Key-value storage
|
|
||||||
- **Kotlinx Serialization** - JSON serialization
|
|
||||||
- **Kotlin Coroutines** - Asynchronous programming
|
|
||||||
- **Flow** - Reactive data streams
|
|
||||||
|
|
||||||
#### AI & Integration
|
|
||||||
- **Gemini API** - Google Generative AI (Gemini 2.5 Flash)
|
|
||||||
- **PDFBox-Android** - PDF text extraction
|
|
||||||
- **Activity Result API** - File picker for document upload
|
|
||||||
|
|
||||||
#### Development Tools
|
|
||||||
- **Android Studio**
|
|
||||||
- **Git** - Version control (git.lab.ubharajaya.ac.id)
|
|
||||||
- **Gradle** - Build automation
|
|
||||||
- **Claude AI** - AI-assisted development
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
```gradle
|
|
||||||
dependencies {
|
|
||||||
// Compose
|
|
||||||
implementation("androidx.compose.ui:ui:1.6.0")
|
|
||||||
implementation("androidx.compose.material3:material3:1.2.0")
|
|
||||||
|
|
||||||
// DataStore
|
|
||||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
|
||||||
|
|
||||||
// Serialization
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
|
|
||||||
|
|
||||||
// AI
|
|
||||||
implementation("com.google.ai.client.generativeai:generativeai:0.1.2")
|
|
||||||
|
|
||||||
// PDF Parser
|
|
||||||
implementation("com.tom-roush:pdfbox-android:2.0.27.0")
|
|
||||||
|
|
||||||
// Testing
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏗️ Arsitektur Aplikasi
|
|
||||||
|
|
||||||
### Architecture Pattern
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────┐
|
|
||||||
│ PRESENTATION LAYER │
|
|
||||||
│ ┌──────────────────────────────────────────┐ │
|
|
||||||
│ │ Composable Screens │ │
|
|
||||||
│ │ • MainScreen │ │
|
|
||||||
│ │ • AIHelperScreen │ │
|
|
||||||
│ │ • ArchiveScreen, TrashScreen │ │
|
|
||||||
│ └──────────────────────────────────────────┘ │
|
|
||||||
└────────────────────┬─────────────────────────────┘
|
|
||||||
│ Uses
|
|
||||||
▼
|
|
||||||
┌──────────────────────────────────────────────────┐
|
|
||||||
│ DATA LAYER │
|
|
||||||
│ ┌──────────────────────────────────────────┐ │
|
|
||||||
│ │ DataStoreManager │ │
|
|
||||||
│ │ • saveNotes() / notesFlow │ │
|
|
||||||
│ │ • saveCategories() / categoriesFlow │ │
|
|
||||||
│ │ • saveChatHistory() / chatHistoryFlow │ │
|
|
||||||
│ └──────────────────────────────────────────┘ │
|
|
||||||
└────────────────────┬─────────────────────────────┘
|
|
||||||
│ Stores
|
|
||||||
▼
|
|
||||||
┌──────────────────────────────────────────────────┐
|
|
||||||
│ MODEL LAYER │
|
|
||||||
│ • Note (id, title, content, isPinned...) │
|
|
||||||
│ • Category (id, name, gradients, isPinned...) │
|
|
||||||
│ • ChatHistory (messages, timestamp...) │
|
|
||||||
└──────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Package Structure
|
|
||||||
```
|
|
||||||
com.example.notesai/
|
|
||||||
├── 📁 config/
|
|
||||||
│ └── APIKey.kt
|
|
||||||
├── 📁 data/
|
|
||||||
│ ├── 📁 local/
|
|
||||||
│ │ └── DataStoreManager.kt
|
|
||||||
│ └── 📁 model/
|
|
||||||
│ ├── Note.kt
|
|
||||||
│ ├── Category.kt
|
|
||||||
│ ├── ChatHistory.kt
|
|
||||||
│ └── ChatMessage.kt
|
|
||||||
├── 📁 presentation/
|
|
||||||
│ ├── 📁 components/
|
|
||||||
│ │ ├── DrawerMenu.kt
|
|
||||||
│ │ ├── ModernTopBar.kt
|
|
||||||
│ │ └── ModernBottomBar.kt
|
|
||||||
│ └── 📁 screens/
|
|
||||||
│ ├── 📁 main/
|
|
||||||
│ ├── 📁 ai/
|
|
||||||
│ ├── 📁 archive/
|
|
||||||
│ ├── 📁 trash/
|
|
||||||
│ └── 📁 note/
|
|
||||||
└── 📁 util/
|
|
||||||
├── FileParser.kt
|
|
||||||
├── Constants.kt
|
|
||||||
└── AppColors.kt
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Resource yang Digunakan
|
|
||||||
|
|
||||||
### Visual Assets
|
|
||||||
- 🎨 **Icons** - Material Icons & Lucide Icons
|
|
||||||
- 🌈 **Colors** - Material 3 Dynamic Color
|
|
||||||
- 🖼️ **Illustrations** - Custom empty states
|
|
||||||
|
|
||||||
### Content & Data
|
|
||||||
- 📖 **Sample Data** - Test categories & notes untuk demo
|
|
||||||
- 📄 **Documents** - Sample PDF/TXT untuk testing parser
|
|
||||||
|
|
||||||
### External Services
|
|
||||||
- 🤖 **Gemini API** - Google Generative AI
|
|
||||||
- 📚 **Documentation** - Android Developers, Kotlin Docs
|
|
||||||
|
|
||||||
### Learning Resources
|
|
||||||
Referensi utama yang digunakan:
|
|
||||||
- [Android App Architecture Guide](https://developer.android.com/topic/architecture)
|
|
||||||
- [Jetpack Compose Documentation](https://developer.android.com/jetpack/compose)
|
|
||||||
- [Gemini API Documentation](https://ai.google.dev/docs)
|
|
||||||
- [Kotlin Serialization](https://kotlinlang.org/docs/serialization.html)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Pengujian Aplikasi
|
|
||||||
|
|
||||||
### Metodologi Testing
|
|
||||||
|
|
||||||
**Primary Method:** Black-Box Functional Testing
|
|
||||||
**Supporting Method:** Automated Unit Testing
|
|
||||||
|
|
||||||
Pengujian dilakukan menggunakan metode **Black-Box Functional Testing** sebagai pendekatan utama, dimana setiap fitur divalidasi dari perspektif end-user dengan memverifikasi kesesuaian **Expected vs Actual Result**. Didukung oleh **59 automated unit tests** untuk validasi business logic internal.
|
|
||||||
|
|
||||||
### Test Coverage Summary
|
|
||||||
|
|
||||||
| Test Case | Fitur | Tests | Status |
|
|
||||||
|-----------|-------|-------|--------|
|
|
||||||
| **TC-01** | Create Note & Category + Autosave | 8 tests | ✅ PASSED |
|
|
||||||
| **TC-02** | Pin Note (Priority) | included | ✅ PASSED |
|
|
||||||
| **TC-03** | Soft Delete & Restore | 11 tests | ✅ PASSED |
|
|
||||||
| **TC-04** | Search Realtime | 14 tests | ✅ PASSED |
|
|
||||||
| **TC-05** | AI Chat with Context | 14 tests | ✅ PASSED |
|
|
||||||
| **TC-06** | Upload PDF → Summary | 12 tests | ✅ PASSED |
|
|
||||||
| **TOTAL** | **6 Test Cases** | **59 tests** | **✅ 100%** |
|
|
||||||
|
|
||||||
### Test Results
|
|
||||||
|
|
||||||
```
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
📊 TEST SUMMARY REPORT
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
Total Test Cases: 6
|
|
||||||
Passed Test Cases: 6
|
|
||||||
Failed Test Cases: 0
|
|
||||||
Success Rate: 100%
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
Total Unit Tests: 59
|
|
||||||
Passed Tests: 59
|
|
||||||
Failed Tests: 0
|
|
||||||
Execution Time: ~2 minutes
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
Status: ✅ ALL TESTS PASSED
|
|
||||||
Quality: ✅ PRODUCTION READY
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
```
|
|
||||||
|
|
||||||
📄 **Laporan lengkap:** [TEST_SUMMARY_REPORT.md](./TEST_SUMMARY_REPORT.md)
|
|
||||||
|
|
||||||
### Testing Framework
|
|
||||||
- **JUnit 4** - Unit testing framework
|
|
||||||
- **AndroidX Test** - Android testing utilities
|
|
||||||
- **Kotlin Coroutines Test** - Async testing
|
|
||||||
- **Truth** - Assertion library
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Instalasi dan Penggunaan
|
|
||||||
|
|
||||||
### Prasyarat
|
|
||||||
- ✅ Android 7.0 (API 24) atau lebih tinggi
|
|
||||||
- ✅ Koneksi internet (untuk fitur AI)
|
|
||||||
- ✅ Minimal 50MB storage kosong
|
|
||||||
|
|
||||||
### Cara Menjalankan dari Source Code
|
|
||||||
|
|
||||||
#### 1. Clone Repository
|
|
||||||
```bash
|
|
||||||
git clone https://git.lab.ubharajaya.ac.id/kelompok-3/notesai.git
|
|
||||||
cd notesai
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Setup API Key
|
|
||||||
Buat file `local.properties` di root project:
|
|
||||||
```properties
|
|
||||||
GEMINI_API_KEY=your_gemini_api_key_here
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Open di Android Studio
|
|
||||||
1. Buka Android Studio
|
|
||||||
2. File → Open → Pilih folder project
|
|
||||||
3. Tunggu Gradle sync selesai
|
|
||||||
|
|
||||||
#### 4. Run Aplikasi
|
|
||||||
1. Pilih device/emulator
|
|
||||||
2. Klik Run (▶️) atau `Shift + F10`
|
|
||||||
3. Aplikasi akan terinstall dan berjalan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📱 Panduan Penggunaan
|
|
||||||
|
|
||||||
### Quick Start Guide
|
|
||||||
|
|
||||||
#### 1️⃣ Membuat Kategori Pertama
|
|
||||||
```
|
|
||||||
Main Screen → Tap (➕) → Pilih "Kategori Baru"
|
|
||||||
→ Isi nama kategori → Pilih warna → Save
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2️⃣ Membuat Note
|
|
||||||
```
|
|
||||||
Main Screen → Tap kategori → Tap (➕) floating button
|
|
||||||
→ Isi judul & konten → Auto-saved ✓
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3️⃣ Menggunakan Rich Text Editor
|
|
||||||
```
|
|
||||||
Buka note → Tap area teks → Muncul draggable toolbar
|
|
||||||
→ Pilih format (Bold/Italic/Heading/Bullet) → Type away!
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4️⃣ Pin untuk Prioritas
|
|
||||||
```
|
|
||||||
Long-press note → Tap ⭐ Pin icon
|
|
||||||
→ Note akan muncul di urutan teratas
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 5️⃣ Chat dengan AI
|
|
||||||
```
|
|
||||||
Drawer Menu → AI Assistant → Ketik pertanyaan
|
|
||||||
→ AI akan menjawab berdasarkan catatan Anda
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 6️⃣ Upload & Summarize Document
|
|
||||||
```
|
|
||||||
AI Assistant → Tap 📎 Upload → Pilih file (PDF/TXT/DOCX)
|
|
||||||
→ AI otomatis generate summary → Tersimpan di chat history
|
|
||||||
```
|
|
||||||
|
|
||||||
### User Flow Lengkap
|
|
||||||
📖 Panduan detail dengan screenshot tersedia di [Dokumentasi](./docs/Laporan%20Dokumentasi%20Aplikasi%20-%20Kelompok%201.pdf) Bab III (Halaman 21-28)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ Batasan dan Catatan
|
|
||||||
|
|
||||||
### Keterbatasan Saat Ini
|
|
||||||
1. **AI Dependency**
|
|
||||||
- ⚠️ Fitur AI memerlukan koneksi internet
|
|
||||||
- ⚠️ Menggunakan free tier Gemini API (rate limited)
|
|
||||||
- ℹ️ Catatan lokal tetap berfungsi offline
|
|
||||||
|
|
||||||
2. **Document Format**
|
|
||||||
- ✅ Support: PDF, TXT, DOCX
|
|
||||||
- ❌ Tidak support: XLS, PPT, Images
|
|
||||||
|
|
||||||
3. **Data Storage**
|
|
||||||
- 💾 Data tersimpan lokal (DataStore)
|
|
||||||
- ⚠️ Tidak ada cloud backup (planned v2.0)
|
|
||||||
- ℹ️ Risiko data loss jika uninstall app
|
|
||||||
|
|
||||||
4. **Performance**
|
|
||||||
- ⚡ Optimal untuk <1000 notes
|
|
||||||
- ⚠️ Search dapat melambat pada dataset besar
|
|
||||||
- 🔜 Indexing optimization planned
|
|
||||||
|
|
||||||
### Privasi & Keamanan
|
|
||||||
- ✅ Catatan disimpan **100% lokal**
|
|
||||||
- ⚠️ Saat menggunakan AI, konten relevan dikirim ke Gemini API
|
|
||||||
- 🔐 API key tidak hardcoded (menggunakan BuildConfig)
|
|
||||||
- ℹ️ Tidak ada tracking atau analytics
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔮 Roadmap & Future Development
|
|
||||||
|
|
||||||
### Version 1.2.0 (Q2 2025) 🎯
|
|
||||||
- [ ] **Cloud Sync** - Firebase/Supabase integration
|
|
||||||
- [ ] **Backup & Restore** - Export/import data
|
|
||||||
- [ ] **Widget** - Home screen quick note
|
|
||||||
- [ ] **Reminder** - Notification untuk note penting
|
|
||||||
- [ ] **Templates** - Pre-made note templates
|
|
||||||
|
|
||||||
### Version 1.3.0 (Q3 2025) 🚀
|
|
||||||
- [ ] **Collaboration** - Share notes dengan user lain
|
|
||||||
- [ ] **Voice Input** - Speech-to-text untuk note
|
|
||||||
- [ ] **Image Support** - Attach images ke notes
|
|
||||||
- [ ] **Tag System** - Alternative categorization
|
|
||||||
- [ ] **Advanced Search** - Filter by date, tag, etc.
|
|
||||||
|
|
||||||
### Version 1.4.0 (Q4 2025) 💎
|
|
||||||
- [ ] **Multi-device Sync** - Real-time sync
|
|
||||||
- [ ] **Encryption** - End-to-end encryption
|
|
||||||
- [ ] **Premium AI** - Upgrade ke Gemini Pro
|
|
||||||
- [ ] **Offline AI** - On-device AI model
|
|
||||||
- [ ] **iOS Version** - Cross-platform support
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📄 Dokumentasi Tambahan
|
|
||||||
|
|
||||||
### File Dokumentasi
|
|
||||||
- 📖 [Laporan Lengkap](./docs/Laporan%20Dokumentasi%20Aplikasi%20-%20Kelompok%201.pdf) - Dokumentasi lengkap 34 halaman
|
|
||||||
- 🧪 [Test Summary Report](./TEST_SUMMARY_REPORT.md) - Laporan pengujian detail
|
|
||||||
- 📋 [Changelog](./CHANGELOG.md) - Riwayat perubahan versi
|
|
||||||
|
|
||||||
### API Documentation
|
|
||||||
- 🤖 [Gemini API Docs](https://ai.google.dev/docs)
|
|
||||||
- 📚 [Android Developers](https://developer.android.com)
|
|
||||||
- 📝 [Kotlin Documentation](https://kotlinlang.org/docs)
|
|
||||||
|
|
||||||
### Referensi Akademik
|
|
||||||
Semua referensi dan kutipan tercantum di [Daftar Pustaka](./docs/Laporan%20Dokumentasi%20Aplikasi%20-%20Kelompok%201.pdf) halaman 33
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🤝 Kontribusi & Support
|
|
||||||
|
|
||||||
### Cara Berkontribusi
|
|
||||||
1. Fork repository ini
|
|
||||||
2. Buat branch fitur (`git checkout -b feature/AmazingFeature`)
|
|
||||||
3. Commit changes (`git commit -m 'Add some AmazingFeature'`)
|
|
||||||
4. Push ke branch (`git push origin feature/AmazingFeature`)
|
|
||||||
5. Buat Pull Request
|
|
||||||
|
|
||||||
### Coding Standards
|
|
||||||
- ✅ Follow Kotlin coding conventions
|
|
||||||
- ✅ Write descriptive commit messages
|
|
||||||
- ✅ Add unit tests untuk fitur baru
|
|
||||||
- ✅ Update documentation
|
|
||||||
|
|
||||||
### Laporkan Bug
|
|
||||||
Jika menemukan bug, silakan buat issue dengan:
|
|
||||||
- 📝 Deskripsi bug
|
|
||||||
- 📱 Device & Android version
|
|
||||||
- 🔄 Steps to reproduce
|
|
||||||
- 📸 Screenshot (jika ada)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🙏 Acknowledgments
|
|
||||||
|
|
||||||
### Special Thanks
|
|
||||||
- 🎓 **Bapak Arif Rifai Dwiyanto, ST., MTI** - Dosen pembimbing dan pengampu mata kuliah
|
|
||||||
- 🏫 **Universitas Bhayangkara Jakarta Raya** - Dukungan fasilitas
|
|
||||||
- 🤖 **Claude AI (Anthropic)** - AI assistant untuk pengembangan dan debugging
|
|
||||||
- 🎨 **Material Design Team** - Design system yang digunakan
|
|
||||||
- 📚 **Android Developer Community** - Dokumentasi dan best practices
|
|
||||||
|
|
||||||
### Tools & Services
|
|
||||||
- Android Studio - IDE terbaik untuk Android development
|
|
||||||
- Jetpack Compose - Modern UI toolkit
|
|
||||||
- Gemini API - AI capabilities
|
|
||||||
- PDFBox - PDF parsing library
|
|
||||||
- Git Lab UBHARA - Version control hosting
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📞 Kontak
|
|
||||||
|
|
||||||
**Tim Pengembang Kelompok 3**
|
|
||||||
|
|
||||||
📧 Email:
|
|
||||||
1. [202310715297@mhs.ubharajaya.ac.id](mailto:202310715297@mhs.ubharajaya.ac.id)
|
|
||||||
2. [202310715051@mhs.ubharajaya.ac.id](mailto:202310715051@mhs.ubharajaya.ac.id)
|
|
||||||
3. [202310715082@mhs.ubharajaya.ac.id](mailto:202310715082@mhs.ubharajaya.ac.id)
|
|
||||||
🌐 Repository: [NotesAI](https://git.lab.ubharajaya.ac.id/202310715297-RAIHAN-ARIQ-MUZAKKI/NotesAI.git)
|
|
||||||
🏫 Institusi: Universitas Bhayangkara Jakarta Raya
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
**Made with ❤️ by Kelompok 3**
|
|
||||||
|
|
||||||
**Fakultas Ilmu Komputer - Program Studi Informatika**
|
|
||||||
**Universitas Bhayangkara Jakarta Raya**
|
|
||||||
|
|
||||||
**2025**
|
|
||||||
|
|
||||||
</div>
|
|
||||||
157
Readme.md
Normal file
157
Readme.md
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
# **AI Notes – Changelog**
|
||||||
|
|
||||||
|
## **Tim Pengembang**
|
||||||
|
|
||||||
|
* Dendi Yogia Pratama
|
||||||
|
* Raihan Ariq Muzakki
|
||||||
|
* Fazri Abdurrahman
|
||||||
|
|
||||||
|
# **Version 1.0.0 – Initial Release**
|
||||||
|
## **Sprint 1: Struktur Dasar Aplikasi**
|
||||||
|
|
||||||
|
* **Implementasi struktur navigasi dasar aplikasi** - Setup navigation system
|
||||||
|
* **Pembuatan menu drawer untuk navigasi screen** - Drawer dengan list menu (Beranda, Berbintang, Arsip, Sampah)
|
||||||
|
* **Pembuatan screen Arsip dan Sampah** - Layout dan UI untuk Archive & Trash
|
||||||
|
* **Implementasi routing antar halaman** - Navigation flow (Beranda, Arsip, Sampah)
|
||||||
|
* **Penambahan Bottom Navigation** - Home & AI Helper tabs
|
||||||
|
* **Penambahan Top App Bar** - Menu hamburger dan search icon
|
||||||
|
* **Setup Material3 dengan Dark Theme** - Color scheme dark mode
|
||||||
|
* **Implementasi color scheme & gradient header** - Primary/Secondary colors dengan gradient
|
||||||
|
* **Pembuatan data class** - Category, Note, ChatMessage models
|
||||||
|
* **Implementasi sistem kategori pada halaman beranda** - Category management system
|
||||||
|
* **Pembuatan dialog tambah kategori** - Form dengan nama + gradient picker
|
||||||
|
* **Penambahan validasi input form kategori** - Prevent empty category name
|
||||||
|
* **Tampilan kategori Staggered Grid** - 2 kolom responsive layout
|
||||||
|
* **Category Card design** - Ikon folder, nama, jumlah catatan, gradient background
|
||||||
|
* **Empty state kategori** - Pesan "Buat kategori pertama Anda"
|
||||||
|
* **Implementasi LazyVerticalStaggeredGrid** - Compose grid layout
|
||||||
|
* **Gradient preset 8 warna** - Pre-defined color combinations
|
||||||
|
* **Manajemen state kategori** - Remember state untuk categories list
|
||||||
|
* **Implementasi pembuatan dan pengeditan catatan** - Note CRUD operations
|
||||||
|
* **Dialog catatan** - Form dengan judul, isi, simpan, batal, hapus
|
||||||
|
* **Note Card design** - Judul, preview, timestamp, pin icon
|
||||||
|
* **Fitur pin untuk catatan penting** - Toggle pin/unpin functionality
|
||||||
|
* **Full-screen editable note view** - Editor dengan auto-save
|
||||||
|
* **Fitur arsip, hapus, dan pin** - Actions di full-screen mode
|
||||||
|
* **Fitur search catatan** - Filter berdasarkan judul + isi
|
||||||
|
* **Sorting catatan** - Berdasarkan pin & timestamp (descending)
|
||||||
|
* **Implementasi custom TextField** - Styled text input fields
|
||||||
|
* **Date formatter utility** - Format timestamp ke readable format
|
||||||
|
* **Edit in-place full-screen note** - Direct editing tanpa dialog
|
||||||
|
* **Pembuatan screen AI Helper** - Layout untuk chat dengan AI
|
||||||
|
* **Header AI dengan ikon bintang** - Badge "Powered by Gemini AI"
|
||||||
|
* **Category selector** - Dropdown untuk filter konteks AI
|
||||||
|
* **Statistik ringkas** - Total catatan, pinned, jumlah kategori
|
||||||
|
* **Welcome state AI** - Icon + greeting message
|
||||||
|
* **Suggestion chips** - Quick question templates
|
||||||
|
* **Input area multiline** - TextField dengan tombol kirim gradient
|
||||||
|
* **Auto-scroll chat** - Scroll ke bottom dengan LaunchedEffect
|
||||||
|
* **State management chat messages** - List of ChatMessage
|
||||||
|
* **Integrasi Gemini 2.5 Flash API** - Setup API connection
|
||||||
|
* **Prompt engineering** - Context dari data catatan user
|
||||||
|
* **Chat bubble user & AI** - Different styling untuk user/AI
|
||||||
|
* **Copy-to-clipboard** - Copy jawaban AI ke clipboard
|
||||||
|
* **Loading indicator** - Circular progress saat AI processing
|
||||||
|
* **Error message informatif** - Display error dengan jelas
|
||||||
|
* **Timestamp pada setiap pesan** - Format HH:mm
|
||||||
|
* **Filter catatan berdasarkan kategori** - Context untuk AI berdasarkan selected category
|
||||||
|
* **Pembatasan 10 catatan terbaru** - Optimasi token usage
|
||||||
|
* **Implementasi Google AI SDK** - Configuration (temperature, topK, topP, maxOutputTokens)
|
||||||
|
* **Context builder** - String builder untuk kategori & catatan
|
||||||
|
* **API calls dengan coroutine** - Async operations menggunakan launch
|
||||||
|
* **Refinement warna & gradient** - Polish color palette
|
||||||
|
* **Smooth animations** - Drawer slide, FAB scale, transitions
|
||||||
|
* **Peningkatan shadow dan elevation** - Card depth visual
|
||||||
|
* **Konsistensi spacing dan padding** - 8dp, 12dp, 16dp, 20dp standards
|
||||||
|
* **Peningkatan desain Card** - Rounded corners (12dp, 16dp, 20dp)
|
||||||
|
* **Optimasi readability teks** - Font sizes dan line heights
|
||||||
|
* **Visual feedback** - Click ripples, copy confirmation, loading states
|
||||||
|
* **Empty state improvements** - Icon + pesan yang lebih jelas
|
||||||
|
* **Perbaikan error messages** - Dengan ikon dan warna merah
|
||||||
|
* **State hoisting** - Optimasi recomposition
|
||||||
|
* **Perbaikan smooth scroll** - Keyboard handling di chat
|
||||||
|
* **Implementasi DataStore** - Preferences DataStore untuk persistence
|
||||||
|
* **Auto-save dengan debounce** - 500ms delay sebelum save
|
||||||
|
* **Persistence data penuh** - Data tetap ada setelah app ditutup
|
||||||
|
* **Error handling DataStore** - Try-catch untuk I/O operations
|
||||||
|
* **Flow-based data loading** - Collect dari Flow dengan LaunchedEffect
|
||||||
|
* **Implementasi DataStoreManager** - Class dengan categoriesFlow & notesFlow
|
||||||
|
* **Try-catch semua operasi I/O** - Comprehensive error handling
|
||||||
|
* **Optimasi lifecycle data** - Proper state management
|
||||||
|
* **Halaman Catatan Berbintang** - StarredNotesScreen dengan filter isPinned
|
||||||
|
* **Ikon bintang untuk pesan** - Star icon pada note cards
|
||||||
|
* **Konfirmasi Arsip** - AlertDialog "Arsipkan Catatan?"
|
||||||
|
* **Konfirmasi Hapus** - AlertDialog "Hapus Catatan?"
|
||||||
|
|
||||||
|
## **Sprint 2: Project Restructuring, Fitur Search, Delete Kategori**
|
||||||
|
|
||||||
|
* **Fitur search beranda** - Cari kategori berdasarkan nama
|
||||||
|
* **Search filtering real-time** - Kategori otomatis filter saat mengetik
|
||||||
|
* **Delete kategori dengan UI** - Tombol X di top-right corner setiap kategori
|
||||||
|
* **Confirmation dialog untuk delete** - Prevent accidental deletion dengan warning message
|
||||||
|
* **Search di kategori** - Cari catatan berdasarkan judul & isi (case-insensitive)
|
||||||
|
* **Search empty state** - Tampilkan pesan "Tidak ada hasil" saat search kosong
|
||||||
|
* **Gradle optimization** - Cleanup dependencies yang tidak diperlukan
|
||||||
|
* **Hilangkan Fitur Tahan Untuk Hapus**
|
||||||
|
* **Project restructuring** - Migrasi dari 3 file monolith ke Clean Architecture
|
||||||
|
* **Data layer separation** - Pisahkan Category, Note, ChatMessage ke `data/model/`
|
||||||
|
* **DataStore refactoring** - Pindahkan DataStoreManager ke `data/local/` dengan PreferencesKeys
|
||||||
|
* **Component extraction** - Pisahkan MainScreen, CategoryCard, NoteCard ke folder terpisah
|
||||||
|
* **Utilities creation** - Buat Constants.kt, DateFormatter.kt, Extensions.kt
|
||||||
|
* **SerializableModels dengan extension functions** - Konversi model lebih clean
|
||||||
|
* **Import optimization** - Update semua import ke package structure baru
|
||||||
|
* **Menu dropdown kategori** - Icon titik tiga (⋮) untuk edit & delete
|
||||||
|
* **Edit kategori feature** - Dialog untuk ubah nama dan gradient kategori
|
||||||
|
* **Pre-filled edit form** - Auto-select gradient yang sedang dipakai
|
||||||
|
* **Soft delete implementation** - Pindahkan ke trash (bukan hapus permanen)
|
||||||
|
* **Trash system dengan kategori** - Tampilkan kategori & note yang dihapus
|
||||||
|
* **TrashCategoryCard component** - Card khusus untuk kategori di trash
|
||||||
|
* **Restore kategori feature** - Pulihkan kategori beserta semua note
|
||||||
|
* **Delete permanen kategori** - Hapus kategori dan note secara permanent
|
||||||
|
* **Counter display di trash** - Jumlah kategori dan note terhapus
|
||||||
|
* **Category model extension** - Tambah field `isDeleted` untuk soft delete
|
||||||
|
* **Global category filter** - Filter `!isDeleted` di semua screen
|
||||||
|
* **Gradient preview di trash** - Kategori tetap tampilkan gradient (opacity)
|
||||||
|
* **Dialog konfirmasi delete permanent** - Warning untuk tindakan irreversible
|
||||||
|
* **Runtime error debugging** - Fix NotImplementedError & FATAL EXCEPTION
|
||||||
|
* **Google Play Services error handling** - Handle GMS error untuk Gemini AI
|
||||||
|
* **HorizontalDivider migration** - Ganti deprecated Divider component
|
||||||
|
* **Migration guide documentation** - Panduan lengkap step-by-step migrasi
|
||||||
|
* **Debugging documentation** - Guide untuk troubleshoot common issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **Fitur Utama Aplikasi**
|
||||||
|
|
||||||
|
* Sistem kategori dengan gradient
|
||||||
|
* Buat/edit/hapus kategori dengan confirmation dialog
|
||||||
|
* Buat/edit/hapus catatan
|
||||||
|
* Pin catatan penting
|
||||||
|
* Full-screen editor
|
||||||
|
* Search kategori di beranda
|
||||||
|
* Search catatan dalam kategori
|
||||||
|
* Arsip & Sampah dengan restore/delete permanen
|
||||||
|
* AI Chat powered by Gemini
|
||||||
|
* AI membaca & menganalisis catatan pengguna
|
||||||
|
* Suggestion chips & copy response
|
||||||
|
* Filter AI berdasarkan kategori
|
||||||
|
* Dark theme modern + gradient
|
||||||
|
* Animasi smooth
|
||||||
|
* Empty states & error handling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **Planned Features (v1.1.0)**
|
||||||
|
|
||||||
|
* 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
|
||||||
|
---
|
||||||
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
## Functional Testing (Unit Testing)
|
|
||||||
# Test Summary Report - NotesAI App
|
|
||||||
|
|
||||||
**Project:** NotesAI - Note Taking Application with AI Assistant
|
|
||||||
**Test Date:** December 2024
|
|
||||||
**Tester:** QA Team
|
|
||||||
**Total Test Cases:** 6
|
|
||||||
**Total Unit Tests:** 59
|
|
||||||
**Status:** ✅ ALL PASSED
|
|
||||||
|
|
||||||
Pengujian fungsional dilakukan secara manual dengan metode black-box berbasis test case,
|
|
||||||
didukung oleh Automated Unit Tests, memverifikasi kesesuaian expected vs actual result pada fitur inti
|
|
||||||
(notes, kategori, pin, trash/restore, search, AI context, dan upload-summary).
|
|
||||||
Unit testing adalah pengujian otomatis terhadap unit terkecil dari kode
|
|
||||||
(functions, methods) untuk memvalidasi logic internal aplikasi.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Detailed Test Results
|
|
||||||
|
|
||||||
### TC-01 & TC-02: Note & Category Management (8 tests)
|
|
||||||
**File:** `DataStoreManagerTest.kt`
|
|
||||||
**Status:** ✅ 8/8 PASSED
|
|
||||||
|
|
||||||
| Test Method | Description | Status |
|
|
||||||
|-------------|-------------|--------|
|
|
||||||
| `testCreateCategory_shouldSaveSuccessfully` | Membuat category baru | ✅ PASSED |
|
|
||||||
| `testCreateNote_shouldSaveSuccessfully` | Membuat note baru | ✅ PASSED |
|
|
||||||
| `testCreateMultipleCategories_shouldSaveInCorrectOrder` | Multiple categories | ✅ PASSED |
|
|
||||||
| `testAutoSave_shouldUpdateExistingNote` | Autosave dengan debounce 500ms | ✅ PASSED |
|
|
||||||
| `testPinNote_shouldAppearFirst` | Pinned note muncul pertama | ✅ PASSED |
|
|
||||||
| `testMultiplePinnedNotes_shouldSortByTimestamp` | Multiple pinned notes sorting | ✅ PASSED |
|
|
||||||
| `testUnpinNote_shouldMoveToNormalPosition` | Unpin note ke posisi normal | ✅ PASSED |
|
|
||||||
| `testPinCategory_shouldPersist` | Pin category persist | ✅ PASSED |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### TC-03: Trash & Restore (11 tests)
|
|
||||||
**File:** `TrashFunctionalityTest.kt`
|
|
||||||
**Status:** ✅ 11/11 PASSED
|
|
||||||
|
|
||||||
| Test Method | Description | Status |
|
|
||||||
|-------------|-------------|--------|
|
|
||||||
| `testSoftDeleteNote_shouldMarkAsDeleted` | Soft delete note | ✅ PASSED |
|
|
||||||
| `testRestoreNoteFromTrash_shouldUnmarkDeleted` | Restore note dari trash | ✅ PASSED |
|
|
||||||
| `testSoftDeleteCategory_shouldMarkAsDeleted` | Soft delete category | ✅ PASSED |
|
|
||||||
| `testRestoreCategoryFromTrash_shouldUnmarkDeleted` | Restore category dari trash | ✅ PASSED |
|
|
||||||
| `testFilterDeletedNotes_shouldOnlyShowDeleted` | Filter deleted notes | ✅ PASSED |
|
|
||||||
| `testFilterDeletedCategories_shouldOnlyShowDeleted` | Filter deleted categories | ✅ PASSED |
|
|
||||||
| `testPermanentDeleteNote_shouldRemoveCompletely` | Permanent delete note | ✅ PASSED |
|
|
||||||
| `testPermanentDeleteCategory_shouldRemoveCompletely` | Permanent delete category | ✅ PASSED |
|
|
||||||
| `testSearchInTrash_shouldFindDeletedItems` | Search di trash | ✅ PASSED |
|
|
||||||
| `testRestoreMultipleNotes_shouldRestoreAll` | Restore multiple notes | ✅ PASSED |
|
|
||||||
| `testDeletedNotePreservesAllData_shouldKeepContent` | Data preserved saat deleted | ✅ PASSED |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### TC-04: Search Functionality (14 tests)
|
|
||||||
**File:** `SearchFunctionalityTest.kt`
|
|
||||||
**Status:** ✅ 14/14 PASSED
|
|
||||||
|
|
||||||
| Test Method | Description | Status |
|
|
||||||
|-------------|-------------|--------|
|
|
||||||
| `testSearchNoteByTitle_shouldFindMatches` | Search by title | ✅ PASSED |
|
|
||||||
| `testSearchNoteByContent_shouldFindMatches` | Search by content | ✅ PASSED |
|
|
||||||
| `testSearchCaseInsensitive_shouldFindMatches` | Case-insensitive search | ✅ PASSED |
|
|
||||||
| `testSearchPartialMatch_shouldFindResults` | Partial keyword match | ✅ PASSED |
|
|
||||||
| `testSearchEmptyQuery_shouldReturnAllNotes` | Empty query return all | ✅ PASSED |
|
|
||||||
| `testSearchNoMatches_shouldReturnEmpty` | No matches return empty | ✅ PASSED |
|
|
||||||
| `testSearchExcludesDeletedNotes_shouldNotFindDeleted` | Exclude deleted notes | ✅ PASSED |
|
|
||||||
| `testSearchExcludesArchivedNotes_shouldNotFindArchived` | Exclude archived notes | ✅ PASSED |
|
|
||||||
| `testSearchCategory_shouldFindByName` | Search category by name | ✅ PASSED |
|
|
||||||
| `testSearchCategoryPartialMatch_shouldFind` | Category partial match | ✅ PASSED |
|
|
||||||
| `testSearchMultipleKeywords_shouldFindAll` | Multiple keyword matches | ✅ PASSED |
|
|
||||||
| `testSearchRealtime_shouldUpdateImmediately` | Realtime update | ✅ PASSED |
|
|
||||||
| `testSearchWithSpecialCharacters_shouldHandle` | Handle special characters | ✅ PASSED |
|
|
||||||
| `testSearchFilteredByCategory_shouldOnlySearchInCategory` | Search dalam category | ✅ PASSED |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### TC-05: AI Chat with Context (14 tests)
|
|
||||||
**File:** `AIChatFunctionalityTest.kt`
|
|
||||||
**Status:** ✅ 14/14 PASSED
|
|
||||||
|
|
||||||
| Test Method | Description | Status |
|
|
||||||
|-------------|-------------|--------|
|
|
||||||
| `testBuildNotesContext_shouldIncludeAllNotes` | Build context dengan notes | ✅ PASSED |
|
|
||||||
| `testBuildNotesContext_shouldFilterByCategory` | Filter context by category | ✅ PASSED |
|
|
||||||
| `testBuildNotesContext_shouldExcludeArchivedNotes` | Exclude archived notes | ✅ PASSED |
|
|
||||||
| `testSaveChatHistory_shouldPersist` | Save chat history | ✅ PASSED |
|
|
||||||
| `testLoadChatHistory_shouldRestoreMessages` | Load chat history | ✅ PASSED |
|
|
||||||
| `testChatPreview_shouldTruncateLongMessages` | Truncate long preview | ✅ PASSED |
|
|
||||||
| `testMultipleChatHistories_shouldSortByTimestamp` | Sort histories by timestamp | ✅ PASSED |
|
|
||||||
| `testUpdateChatHistory_shouldUpdateExisting` | Update existing chat | ✅ PASSED |
|
|
||||||
| `testDeleteChatHistory_shouldMarkAsDeleted` | Soft delete chat history | ✅ PASSED |
|
|
||||||
| `testCustomChatTitle_shouldPersist` | Custom title persist | ✅ PASSED |
|
|
||||||
| `testUpdateChatTitle_shouldUpdate` | Update chat title | ✅ PASSED |
|
|
||||||
| `testChatWithContext_shouldBuildCorrectPrompt` | Build prompt with context | ✅ PASSED |
|
|
||||||
| `testChatMessageConversion_shouldPreserveData` | Message conversion | ✅ PASSED |
|
|
||||||
| `testEmptyNotesContext_shouldHandleGracefully` | Handle empty notes | ✅ PASSED |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### TC-06: File Upload & Summary (12 tests)
|
|
||||||
**File:** `FileUploadFunctionalityTest.kt`
|
|
||||||
**Status:** ✅ 12/12 PASSED
|
|
||||||
|
|
||||||
| Test Method | Description | Status |
|
|
||||||
|-------------|-------------|--------|
|
|
||||||
| `testFileParseResult_shouldCalculateWordCount` | Calculate word count | ✅ PASSED |
|
|
||||||
| `testFileParseResult_shouldIdentifyFileType` | Identify file type | ✅ PASSED |
|
|
||||||
| `testFileParseError_shouldContainMessage` | Error message handling | ✅ PASSED |
|
|
||||||
| `testFormatFileSize_shouldFormatCorrectly` | Format file size (B/KB/MB) | ✅ PASSED |
|
|
||||||
| `testSaveSummaryToChatHistory_shouldPersist` | Save summary to history | ✅ PASSED |
|
|
||||||
| `testMultipleFileUploads_shouldTrackAll` | Track multiple uploads | ✅ PASSED |
|
|
||||||
| `testSummaryContent_shouldBeReadable` | Summary readability | ✅ PASSED |
|
|
||||||
| `testFileUploadError_shouldHandleGracefully` | Handle upload errors | ✅ PASSED |
|
|
||||||
| `testPDFSummaryFormat_shouldBeStructured` | Structured summary format | ✅ PASSED |
|
|
||||||
| `testSearchInSummaries_shouldFindKeywords` | Search in summaries | ✅ PASSED |
|
|
||||||
| `testLongSummary_shouldTruncatePreview` | Truncate long preview | ✅ PASSED |
|
|
||||||
| `testFileMetadata_shouldBePreserved` | Preserve file metadata | ✅ PASSED |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Coverage Summary
|
|
||||||
|
|
||||||
| Component | Tests | Passed | Failed | Coverage |
|
|
||||||
|-----------|-------|--------|--------|----------|
|
|
||||||
| DataStore Management | 8 | 8 | 0 | 100% |
|
|
||||||
| Trash & Restore | 11 | 11 | 0 | 100% |
|
|
||||||
| Search Functionality | 14 | 14 | 0 | 100% |
|
|
||||||
| AI Chat Context | 14 | 14 | 0 | 100% |
|
|
||||||
| File Upload & Summary | 12 | 12 | 0 | 100% |
|
|
||||||
| **TOTAL** | **59** | **59** | **0** | **100%** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
✅ **All 59 unit tests passed successfully**
|
|
||||||
✅ **100% test coverage** untuk semua fitur utama
|
|
||||||
✅ **All 6 test cases** memenuhi kriteria acceptance
|
|
||||||
|
|
||||||
**Test Environment:**
|
|
||||||
- Framework: JUnit4 + AndroidX Test
|
|
||||||
- Coroutines: kotlinx-coroutines-test
|
|
||||||
- DataStore: androidx.datastore.preferences
|
|
||||||
- Device: Samsung SM-A127F
|
|
||||||
|
|
||||||
---
|
|
||||||
@ -7,7 +7,7 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.notesai"
|
namespace = "com.example.notesai"
|
||||||
compileSdk = 35
|
compileSdk = 34
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.example.notesai"
|
applicationId = "com.example.notesai"
|
||||||
@ -16,6 +16,8 @@ android {
|
|||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
@ -31,102 +33,45 @@ android {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "1.8"
|
jvmTarget = "1.8"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
excludes += "/META-INF/DEPENDENCIES"
|
|
||||||
excludes += "/META-INF/LICENSE"
|
|
||||||
excludes += "/META-INF/LICENSE.txt"
|
|
||||||
excludes += "/META-INF/license.txt"
|
|
||||||
excludes += "/META-INF/NOTICE"
|
|
||||||
excludes += "/META-INF/NOTICE.txt"
|
|
||||||
excludes += "/META-INF/notice.txt"
|
|
||||||
excludes += "/META-INF/ASL2.0"
|
|
||||||
excludes += "/META-INF/*.kotlin_module"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Core Android
|
|
||||||
implementation("androidx.core:core-ktx:1.12.0")
|
implementation("androidx.core:core-ktx:1.12.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||||
implementation("androidx.activity:activity-compose:1.8.2")
|
implementation("androidx.activity:activity-compose:1.8.2")
|
||||||
|
|
||||||
// Compose BOM
|
|
||||||
implementation(platform("androidx.compose:compose-bom:2024.02.00"))
|
implementation(platform("androidx.compose:compose-bom:2024.02.00"))
|
||||||
implementation("androidx.compose.ui:ui")
|
implementation("androidx.compose.ui:ui")
|
||||||
implementation("androidx.compose.ui:ui-graphics")
|
implementation("androidx.compose.ui:ui-graphics")
|
||||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
implementation("androidx.compose.material3:material3")
|
implementation("androidx.compose.material3:material3")
|
||||||
implementation("androidx.compose.material:material-icons-extended-android:1.6.7")
|
implementation("androidx.compose.material:material-icons-extended-android:1.6.7")
|
||||||
|
|
||||||
// Material Design
|
|
||||||
implementation("com.google.android.material:material:1.9.0")
|
implementation("com.google.android.material:material:1.9.0")
|
||||||
|
|
||||||
// DataStore
|
|
||||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||||
|
|
||||||
// Serialization
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
|
||||||
|
|
||||||
// Gemini AI
|
|
||||||
implementation("com.google.ai.client.generativeai:generativeai:0.9.0")
|
implementation("com.google.ai.client.generativeai:generativeai:0.9.0")
|
||||||
|
// Untuk integrasi Gemini AI (optional - uncomment jika sudah ada API key)
|
||||||
|
// implementation("com.google.ai.client.generativeai:generativeai:0.1.2")
|
||||||
|
|
||||||
// Version Catalog (libs)
|
|
||||||
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)
|
|
||||||
|
|
||||||
// File operations
|
|
||||||
implementation("androidx.documentfile:documentfile:1.0.1")
|
|
||||||
|
|
||||||
// PDF Parser
|
|
||||||
implementation("com.tom-roush:pdfbox-android:2.0.27.0")
|
|
||||||
implementation(libs.androidx.junit.ktx)
|
|
||||||
|
|
||||||
// Debug
|
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
|
||||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
|
||||||
|
|
||||||
// Testing dependencies - TAMBAHKAN INI
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||||
|
androidTestImplementation(platform("androidx.compose:compose-bom:2024.02.00"))
|
||||||
// AndroidX Test - Core library
|
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||||
androidTestImplementation("androidx.test:core:1.5.0")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
androidTestImplementation("androidx.test:core-ktx:1.5.0")
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||||
|
|
||||||
// AndroidX Test - Rules
|
|
||||||
androidTestImplementation("androidx.test:rules:1.5.0")
|
|
||||||
androidTestImplementation("androidx.test:runner:1.5.2")
|
|
||||||
|
|
||||||
// Coroutines Test
|
|
||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
|
||||||
androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
|
||||||
|
|
||||||
// DataStore Testing
|
|
||||||
androidTestImplementation("androidx.datastore:datastore-preferences:1.0.0")
|
|
||||||
|
|
||||||
// Truth (optional, untuk assertion yang lebih baik)
|
|
||||||
testImplementation("com.google.truth:truth:1.1.5")
|
|
||||||
androidTestImplementation("com.google.truth:truth:1.1.5")
|
|
||||||
}
|
}
|
||||||
@ -1,519 +0,0 @@
|
|||||||
package com.example.notesai
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.datastore.core.DataStore
|
|
||||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
|
||||||
import androidx.datastore.preferences.core.Preferences
|
|
||||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import com.example.notesai.data.local.DataStoreManager
|
|
||||||
import com.example.notesai.data.model.*
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.junit.Assert.*
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit Test untuk AI Chat Functionality
|
|
||||||
* Coverage:
|
|
||||||
* - TC-05: AI chat menjawab dengan konteks note (gunakan 1-2 contoh note)
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class AIChatFunctionalityTest {
|
|
||||||
|
|
||||||
private lateinit var context: Context
|
|
||||||
private lateinit var dataStore: DataStore<Preferences>
|
|
||||||
private lateinit var dataStoreManager: DataStoreManager
|
|
||||||
private lateinit var testScope: CoroutineScope
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() = runBlocking {
|
|
||||||
context = ApplicationProvider.getApplicationContext()
|
|
||||||
testScope = CoroutineScope(SupervisorJob())
|
|
||||||
|
|
||||||
val testDataStoreName = "test_ai_chat_prefs_${UUID.randomUUID()}"
|
|
||||||
dataStore = PreferenceDataStoreFactory.create(
|
|
||||||
scope = testScope,
|
|
||||||
produceFile = { context.preferencesDataStoreFile(testDataStoreName) }
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager = DataStoreManager(context)
|
|
||||||
|
|
||||||
// Clear all data before each test
|
|
||||||
dataStoreManager.saveNotes(emptyList())
|
|
||||||
dataStoreManager.saveCategories(emptyList())
|
|
||||||
dataStoreManager.saveChatHistory(emptyList())
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
testScope.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================== TC-05: AI CHAT WITH CONTEXT ==================
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testBuildNotesContext_shouldIncludeAllNotes() = runBlocking {
|
|
||||||
// Given - Create sample notes
|
|
||||||
val notes = listOf(
|
|
||||||
Note(
|
|
||||||
id = "note_001",
|
|
||||||
categoryId = "cat_work",
|
|
||||||
title = "Project Meeting",
|
|
||||||
content = "Discussed Q4 goals and timeline for new features",
|
|
||||||
isArchived = false,
|
|
||||||
isDeleted = false
|
|
||||||
),
|
|
||||||
Note(
|
|
||||||
id = "note_002",
|
|
||||||
categoryId = "cat_work",
|
|
||||||
title = "Technical Specs",
|
|
||||||
content = "API design for user authentication module",
|
|
||||||
isArchived = false,
|
|
||||||
isDeleted = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(notes)
|
|
||||||
|
|
||||||
// When - Build context (simulating AIHelperScreen context building)
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
val filteredNotes = savedNotes.filter { !it.isArchived && !it.isDeleted }
|
|
||||||
|
|
||||||
val notesContext = buildString {
|
|
||||||
appendLine("Data catatan pengguna:")
|
|
||||||
appendLine("Total catatan: ${filteredNotes.size}")
|
|
||||||
appendLine()
|
|
||||||
appendLine("Daftar catatan:")
|
|
||||||
filteredNotes.forEach { note ->
|
|
||||||
appendLine("- Judul: ${note.title}")
|
|
||||||
appendLine(" Isi: ${note.content.take(100)}")
|
|
||||||
appendLine()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - Context should contain both notes
|
|
||||||
assertTrue(notesContext.contains("Total catatan: 2"))
|
|
||||||
assertTrue(notesContext.contains("Project Meeting"))
|
|
||||||
assertTrue(notesContext.contains("Technical Specs"))
|
|
||||||
assertTrue(notesContext.contains("Discussed Q4 goals"))
|
|
||||||
assertTrue(notesContext.contains("API design"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testBuildNotesContext_shouldFilterByCategory() = runBlocking {
|
|
||||||
// Given - Notes in different categories
|
|
||||||
val notes = listOf(
|
|
||||||
Note(id = "note_001", categoryId = "cat_work", title = "Work Note", content = "Work content", isArchived = false),
|
|
||||||
Note(id = "note_002", categoryId = "cat_personal", title = "Personal Note", content = "Personal content", isArchived = false),
|
|
||||||
Note(id = "note_003", categoryId = "cat_work", title = "Another Work", content = "More work", isArchived = false)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(notes)
|
|
||||||
|
|
||||||
// When - Build context for specific category
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
val selectedCategoryId = "cat_work"
|
|
||||||
val filteredNotes = savedNotes.filter {
|
|
||||||
it.categoryId == selectedCategoryId && !it.isArchived && !it.isDeleted
|
|
||||||
}
|
|
||||||
|
|
||||||
val notesContext = buildString {
|
|
||||||
appendLine("Data catatan pengguna:")
|
|
||||||
appendLine("Total catatan: ${filteredNotes.size}")
|
|
||||||
appendLine("Kategori: Work")
|
|
||||||
appendLine()
|
|
||||||
filteredNotes.forEach { note ->
|
|
||||||
appendLine("- Judul: ${note.title}")
|
|
||||||
appendLine(" Isi: ${note.content}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - Should only include work category notes
|
|
||||||
assertTrue(notesContext.contains("Total catatan: 2"))
|
|
||||||
assertTrue(notesContext.contains("Work Note"))
|
|
||||||
assertTrue(notesContext.contains("Another Work"))
|
|
||||||
assertFalse(notesContext.contains("Personal Note"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testBuildNotesContext_shouldExcludeArchivedNotes() = runBlocking {
|
|
||||||
// Given - Mix of archived and active notes
|
|
||||||
val notes = listOf(
|
|
||||||
Note(id = "note_001", categoryId = "cat_001", title = "Active Note", content = "Active", isArchived = false),
|
|
||||||
Note(id = "note_002", categoryId = "cat_001", title = "Archived Note", content = "Archived", isArchived = true),
|
|
||||||
Note(id = "note_003", categoryId = "cat_001", title = "Another Active", content = "Active", isArchived = false)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(notes)
|
|
||||||
|
|
||||||
// When - Build context excluding archived
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
val filteredNotes = savedNotes.filter { !it.isArchived && !it.isDeleted }
|
|
||||||
|
|
||||||
val notesContext = buildString {
|
|
||||||
appendLine("Total catatan: ${filteredNotes.size}")
|
|
||||||
filteredNotes.forEach { note ->
|
|
||||||
appendLine("- ${note.title}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - Should exclude archived notes
|
|
||||||
assertTrue(notesContext.contains("Total catatan: 2"))
|
|
||||||
assertTrue(notesContext.contains("Active Note"))
|
|
||||||
assertTrue(notesContext.contains("Another Active"))
|
|
||||||
assertFalse(notesContext.contains("Archived Note"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testSaveChatHistory_shouldPersist() = runBlocking {
|
|
||||||
// Given - Clear existing data first
|
|
||||||
dataStoreManager.saveChatHistory(emptyList())
|
|
||||||
|
|
||||||
// Chat messages
|
|
||||||
val messages = listOf(
|
|
||||||
ChatMessage(id = "msg_001", message = "Apa isi catatan saya?", isUser = true),
|
|
||||||
ChatMessage(id = "msg_002", message = "Anda memiliki 3 catatan tentang project meeting", isUser = false)
|
|
||||||
)
|
|
||||||
|
|
||||||
val chatHistory = ChatHistory(
|
|
||||||
id = "chat_001",
|
|
||||||
categoryId = "cat_work",
|
|
||||||
categoryName = "Work",
|
|
||||||
messages = messages.map { it.toSerializable() },
|
|
||||||
lastMessagePreview = "Anda memiliki 3 catatan...",
|
|
||||||
timestamp = System.currentTimeMillis(),
|
|
||||||
isDeleted = false
|
|
||||||
)
|
|
||||||
|
|
||||||
// When - Save chat history
|
|
||||||
dataStoreManager.addChatHistory(chatHistory)
|
|
||||||
|
|
||||||
// Then - Should be saved and retrievable
|
|
||||||
val savedHistories = dataStoreManager.chatHistoryFlow.first()
|
|
||||||
assertEquals(1, savedHistories.size)
|
|
||||||
assertEquals("chat_001", savedHistories[0].id)
|
|
||||||
assertEquals("Work", savedHistories[0].categoryName)
|
|
||||||
assertEquals(2, savedHistories[0].messages.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testLoadChatHistory_shouldRestoreMessages() = runBlocking {
|
|
||||||
// Given - Saved chat history
|
|
||||||
val messages = listOf(
|
|
||||||
ChatMessage(id = "msg_001", message = "Berapa banyak catatan saya?", isUser = true),
|
|
||||||
ChatMessage(id = "msg_002", message = "Anda memiliki 5 catatan", isUser = false),
|
|
||||||
ChatMessage(id = "msg_003", message = "Apa topik utamanya?", isUser = true),
|
|
||||||
ChatMessage(id = "msg_004", message = "Topik utama adalah project planning", isUser = false)
|
|
||||||
)
|
|
||||||
|
|
||||||
val chatHistory = ChatHistory(
|
|
||||||
id = "chat_001",
|
|
||||||
categoryId = null,
|
|
||||||
categoryName = "Semua Kategori",
|
|
||||||
messages = messages.map { it.toSerializable() },
|
|
||||||
lastMessagePreview = "Topik utama adalah project...",
|
|
||||||
timestamp = System.currentTimeMillis()
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.addChatHistory(chatHistory)
|
|
||||||
|
|
||||||
// When - Load chat history
|
|
||||||
val savedHistories = dataStoreManager.chatHistoryFlow.first()
|
|
||||||
val loadedHistory = savedHistories.first()
|
|
||||||
val restoredMessages = loadedHistory.messages.map { it.toChatMessage() }
|
|
||||||
|
|
||||||
// Then - Messages should be restored correctly
|
|
||||||
assertEquals(4, restoredMessages.size)
|
|
||||||
assertEquals("Berapa banyak catatan saya?", restoredMessages[0].message)
|
|
||||||
assertTrue(restoredMessages[0].isUser)
|
|
||||||
assertEquals("Anda memiliki 5 catatan", restoredMessages[1].message)
|
|
||||||
assertFalse(restoredMessages[1].isUser)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testChatPreview_shouldTruncateLongMessages() = runBlocking {
|
|
||||||
// Given - Long message
|
|
||||||
val longMessage = "Ini adalah pesan yang sangat panjang yang harus dipotong menjadi preview yang lebih pendek untuk ditampilkan di list"
|
|
||||||
|
|
||||||
// When - Create preview (simulating toSafeChatPreview function)
|
|
||||||
val maxLength = 30
|
|
||||||
val preview = if (longMessage.length > maxLength) {
|
|
||||||
longMessage.take(maxLength).trim() + "..."
|
|
||||||
} else {
|
|
||||||
longMessage.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - Should be truncated
|
|
||||||
assertTrue(preview.length <= maxLength + 3) // +3 for "..."
|
|
||||||
assertTrue(preview.endsWith("..."))
|
|
||||||
assertTrue(preview.startsWith("Ini adalah pesan"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testMultipleChatHistories_shouldSortByTimestamp() = runBlocking {
|
|
||||||
// Given - Clear existing data first
|
|
||||||
dataStoreManager.saveChatHistory(emptyList())
|
|
||||||
|
|
||||||
// Multiple chat histories with different timestamps
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
|
|
||||||
val histories = listOf(
|
|
||||||
ChatHistory(
|
|
||||||
id = "chat_001",
|
|
||||||
categoryId = null,
|
|
||||||
categoryName = "Semua",
|
|
||||||
messages = listOf(ChatMessage(message = "Old chat", isUser = true).toSerializable()),
|
|
||||||
lastMessagePreview = "Old chat",
|
|
||||||
timestamp = now - 3000,
|
|
||||||
isDeleted = false
|
|
||||||
),
|
|
||||||
ChatHistory(
|
|
||||||
id = "chat_002",
|
|
||||||
categoryId = null,
|
|
||||||
categoryName = "Semua",
|
|
||||||
messages = listOf(ChatMessage(message = "Recent chat", isUser = true).toSerializable()),
|
|
||||||
lastMessagePreview = "Recent chat",
|
|
||||||
timestamp = now - 1000,
|
|
||||||
isDeleted = false
|
|
||||||
),
|
|
||||||
ChatHistory(
|
|
||||||
id = "chat_003",
|
|
||||||
categoryId = null,
|
|
||||||
categoryName = "Semua",
|
|
||||||
messages = listOf(ChatMessage(message = "Newest chat", isUser = true).toSerializable()),
|
|
||||||
lastMessagePreview = "Newest chat",
|
|
||||||
timestamp = now,
|
|
||||||
isDeleted = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// When - Save all histories
|
|
||||||
histories.forEach { dataStoreManager.addChatHistory(it) }
|
|
||||||
|
|
||||||
// Then - Should be sorted by timestamp (newest first)
|
|
||||||
val savedHistories = dataStoreManager.chatHistoryFlow.first()
|
|
||||||
assertEquals(3, savedHistories.size)
|
|
||||||
assertEquals("Newest chat", savedHistories[0].lastMessagePreview)
|
|
||||||
assertEquals("Recent chat", savedHistories[1].lastMessagePreview)
|
|
||||||
assertEquals("Old chat", savedHistories[2].lastMessagePreview)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testUpdateChatHistory_shouldUpdateExisting() = runBlocking {
|
|
||||||
// Given - Clear existing data first
|
|
||||||
dataStoreManager.saveChatHistory(emptyList())
|
|
||||||
|
|
||||||
// Existing chat history
|
|
||||||
val initialHistory = ChatHistory(
|
|
||||||
id = "chat_update_001",
|
|
||||||
categoryId = "cat_work",
|
|
||||||
categoryName = "Work",
|
|
||||||
messages = listOf(
|
|
||||||
ChatMessage(message = "Hello", isUser = true).toSerializable()
|
|
||||||
),
|
|
||||||
lastMessagePreview = "Hello",
|
|
||||||
timestamp = System.currentTimeMillis(),
|
|
||||||
isDeleted = false
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.addChatHistory(initialHistory)
|
|
||||||
|
|
||||||
// When - Update with new messages
|
|
||||||
val updatedHistory = initialHistory.copy(
|
|
||||||
messages = initialHistory.messages + ChatMessage(message = "How can I help?", isUser = false).toSerializable(),
|
|
||||||
lastMessagePreview = "How can I help?"
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.addChatHistory(updatedHistory)
|
|
||||||
|
|
||||||
// Then - Should update existing history
|
|
||||||
val savedHistories = dataStoreManager.chatHistoryFlow.first()
|
|
||||||
assertEquals(1, savedHistories.size) // Should still be 1, not 2
|
|
||||||
assertEquals(2, savedHistories[0].messages.size)
|
|
||||||
assertEquals("How can I help?", savedHistories[0].lastMessagePreview)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testDeleteChatHistory_shouldMarkAsDeleted() = runBlocking {
|
|
||||||
// Given - Clear existing data first
|
|
||||||
dataStoreManager.saveChatHistory(emptyList())
|
|
||||||
|
|
||||||
// Chat history
|
|
||||||
val chatHistory = ChatHistory(
|
|
||||||
id = "chat_delete_001",
|
|
||||||
categoryId = null,
|
|
||||||
categoryName = "Semua",
|
|
||||||
messages = listOf(ChatMessage(message = "Test", isUser = true).toSerializable()),
|
|
||||||
lastMessagePreview = "Test",
|
|
||||||
timestamp = System.currentTimeMillis(),
|
|
||||||
isDeleted = false
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.addChatHistory(chatHistory)
|
|
||||||
|
|
||||||
// Verify it's saved
|
|
||||||
val beforeDelete = dataStoreManager.chatHistoryFlow.first()
|
|
||||||
assertEquals(1, beforeDelete.size)
|
|
||||||
|
|
||||||
// When - Delete chat history
|
|
||||||
dataStoreManager.deleteChatHistory("chat_delete_001")
|
|
||||||
|
|
||||||
// Then - Should be filtered out (soft deleted)
|
|
||||||
val savedHistories = dataStoreManager.chatHistoryFlow.first()
|
|
||||||
assertEquals(0, savedHistories.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testCustomChatTitle_shouldPersist() = runBlocking {
|
|
||||||
// Given - Clear existing data first
|
|
||||||
dataStoreManager.saveChatHistory(emptyList())
|
|
||||||
|
|
||||||
// Chat history with custom title
|
|
||||||
val chatHistory = ChatHistory(
|
|
||||||
id = "chat_custom_001",
|
|
||||||
categoryId = null,
|
|
||||||
categoryName = "Semua",
|
|
||||||
messages = listOf(ChatMessage(message = "Question", isUser = true).toSerializable()),
|
|
||||||
lastMessagePreview = "Question",
|
|
||||||
customTitle = "My Custom Chat Title",
|
|
||||||
timestamp = System.currentTimeMillis(),
|
|
||||||
isDeleted = false
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.addChatHistory(chatHistory)
|
|
||||||
|
|
||||||
// Then - Custom title should be saved
|
|
||||||
val savedHistories = dataStoreManager.chatHistoryFlow.first()
|
|
||||||
assertEquals(1, savedHistories.size)
|
|
||||||
assertEquals("My Custom Chat Title", savedHistories[0].customTitle)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testUpdateChatTitle_shouldUpdate() = runBlocking {
|
|
||||||
// Given - Clear existing data first
|
|
||||||
dataStoreManager.saveChatHistory(emptyList())
|
|
||||||
|
|
||||||
// Chat history
|
|
||||||
val chatHistory = ChatHistory(
|
|
||||||
id = "chat_title_001",
|
|
||||||
categoryId = null,
|
|
||||||
categoryName = "Semua",
|
|
||||||
messages = listOf(ChatMessage(message = "Test", isUser = true).toSerializable()),
|
|
||||||
lastMessagePreview = "Test",
|
|
||||||
customTitle = null,
|
|
||||||
timestamp = System.currentTimeMillis(),
|
|
||||||
isDeleted = false
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.addChatHistory(chatHistory)
|
|
||||||
|
|
||||||
// When - Update title
|
|
||||||
dataStoreManager.updateChatHistoryTitle("chat_title_001", "Updated Title")
|
|
||||||
|
|
||||||
// Then - Title should be updated
|
|
||||||
val savedHistories = dataStoreManager.chatHistoryFlow.first()
|
|
||||||
assertEquals("Updated Title", savedHistories[0].customTitle)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testChatWithContext_shouldBuildCorrectPrompt() = runBlocking {
|
|
||||||
// Given - Sample notes for context
|
|
||||||
val notes = listOf(
|
|
||||||
Note(
|
|
||||||
id = "note_001",
|
|
||||||
categoryId = "cat_work",
|
|
||||||
title = "Sprint Planning",
|
|
||||||
content = "Plan features for next sprint: authentication, dashboard, notifications",
|
|
||||||
isArchived = false,
|
|
||||||
isDeleted = false
|
|
||||||
),
|
|
||||||
Note(
|
|
||||||
id = "note_002",
|
|
||||||
categoryId = "cat_work",
|
|
||||||
title = "API Design",
|
|
||||||
content = "REST API endpoints for user management and data sync",
|
|
||||||
isArchived = false,
|
|
||||||
isDeleted = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(notes)
|
|
||||||
|
|
||||||
// When - Build full prompt with context
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
val filteredNotes = savedNotes.filter { !it.isArchived && !it.isDeleted }
|
|
||||||
|
|
||||||
val notesContext = buildString {
|
|
||||||
appendLine("Data catatan pengguna:")
|
|
||||||
appendLine("Total catatan: ${filteredNotes.size}")
|
|
||||||
appendLine()
|
|
||||||
appendLine("Daftar catatan:")
|
|
||||||
filteredNotes.take(10).forEach { note ->
|
|
||||||
appendLine("- Judul: ${note.title}")
|
|
||||||
appendLine(" Isi: ${note.content.take(100)}")
|
|
||||||
appendLine()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val userPrompt = "Apa yang perlu saya lakukan di sprint berikutnya?"
|
|
||||||
val fullPrompt = "$notesContext\n\nPertanyaan: $userPrompt\n\nBerikan jawaban dalam bahasa Indonesia yang natural dan membantu."
|
|
||||||
|
|
||||||
// Then - Prompt should contain context and question
|
|
||||||
assertTrue(fullPrompt.contains("Total catatan: 2"))
|
|
||||||
assertTrue(fullPrompt.contains("Sprint Planning"))
|
|
||||||
assertTrue(fullPrompt.contains("authentication, dashboard"))
|
|
||||||
assertTrue(fullPrompt.contains("Pertanyaan: Apa yang perlu saya lakukan"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testChatMessageConversion_shouldPreserveData() = runBlocking {
|
|
||||||
// Given - Original ChatMessage
|
|
||||||
val originalMessage = ChatMessage(
|
|
||||||
id = "msg_001",
|
|
||||||
message = "Test message content",
|
|
||||||
isUser = true,
|
|
||||||
timestamp = 1234567890L
|
|
||||||
)
|
|
||||||
|
|
||||||
// When - Convert to serializable and back
|
|
||||||
val serializable = originalMessage.toSerializable()
|
|
||||||
val converted = serializable.toChatMessage()
|
|
||||||
|
|
||||||
// Then - All data should be preserved
|
|
||||||
assertEquals(originalMessage.id, converted.id)
|
|
||||||
assertEquals(originalMessage.message, converted.message)
|
|
||||||
assertEquals(originalMessage.isUser, converted.isUser)
|
|
||||||
assertEquals(originalMessage.timestamp, converted.timestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testEmptyNotesContext_shouldHandleGracefully() = runBlocking {
|
|
||||||
// Given - No notes
|
|
||||||
dataStoreManager.saveNotes(emptyList())
|
|
||||||
|
|
||||||
// When - Build context
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
val filteredNotes = savedNotes.filter { !it.isArchived && !it.isDeleted }
|
|
||||||
|
|
||||||
val notesContext = buildString {
|
|
||||||
appendLine("Data catatan pengguna:")
|
|
||||||
appendLine("Total catatan: ${filteredNotes.size}")
|
|
||||||
if (filteredNotes.isEmpty()) {
|
|
||||||
appendLine("Belum ada catatan tersimpan.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - Should handle empty case
|
|
||||||
assertTrue(notesContext.contains("Total catatan: 0"))
|
|
||||||
assertTrue(notesContext.contains("Belum ada catatan"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,370 +0,0 @@
|
|||||||
package com.example.notesai
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.datastore.core.DataStore
|
|
||||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
|
||||||
import androidx.datastore.preferences.core.Preferences
|
|
||||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import com.example.notesai.data.local.DataStoreManager
|
|
||||||
import com.example.notesai.data.model.Category
|
|
||||||
import com.example.notesai.data.model.Note
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
|
||||||
import kotlinx.coroutines.test.TestScope
|
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
|
||||||
import kotlinx.coroutines.test.resetMain
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import kotlinx.coroutines.test.setMain
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit Test untuk DataStoreManager
|
|
||||||
* Coverage:
|
|
||||||
* - TC-01: Create note & category + autosave debounce 500ms
|
|
||||||
* - TC-02: Pin note muncul di urutan teratas
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class DataStoreManagerTest {
|
|
||||||
|
|
||||||
private lateinit var context: Context
|
|
||||||
private lateinit var dataStore: DataStore<Preferences>
|
|
||||||
private lateinit var dataStoreManager: DataStoreManager
|
|
||||||
private val testDispatcher = StandardTestDispatcher()
|
|
||||||
private val testScope = TestScope(testDispatcher + Job())
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
Dispatchers.setMain(testDispatcher)
|
|
||||||
context = ApplicationProvider.getApplicationContext()
|
|
||||||
|
|
||||||
// Create test DataStore with unique name
|
|
||||||
val testDataStoreName = "test_notes_prefs_${UUID.randomUUID()}"
|
|
||||||
dataStore = PreferenceDataStoreFactory.create(
|
|
||||||
scope = testScope,
|
|
||||||
produceFile = { context.preferencesDataStoreFile(testDataStoreName) }
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager = DataStoreManager(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
testScope.cancel()
|
|
||||||
Dispatchers.resetMain()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================== TC-01: CREATE NOTE & CATEGORY ==================
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testCreateCategory_shouldSaveSuccessfully() = testScope.runTest {
|
|
||||||
// Given
|
|
||||||
val category = Category(
|
|
||||||
id = "cat_001",
|
|
||||||
name = "Work",
|
|
||||||
gradientStart = 0xFFE91E63,
|
|
||||||
gradientEnd = 0xFF9C27B0,
|
|
||||||
timestamp = System.currentTimeMillis(),
|
|
||||||
isDeleted = false,
|
|
||||||
isPinned = false
|
|
||||||
)
|
|
||||||
|
|
||||||
// When
|
|
||||||
dataStoreManager.saveCategories(listOf(category))
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
// Then
|
|
||||||
val savedCategories = dataStoreManager.categoriesFlow.first()
|
|
||||||
Assert.assertEquals(1, savedCategories.size)
|
|
||||||
Assert.assertEquals("Work", savedCategories[0].name)
|
|
||||||
Assert.assertEquals("cat_001", savedCategories[0].id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testCreateNote_shouldSaveSuccessfully() = testScope.runTest {
|
|
||||||
// Given
|
|
||||||
val note = Note(
|
|
||||||
id = "note_001",
|
|
||||||
categoryId = "cat_001",
|
|
||||||
title = "Meeting Notes",
|
|
||||||
description = "Team meeting summary",
|
|
||||||
content = "Discussed project timeline",
|
|
||||||
timestamp = System.currentTimeMillis(),
|
|
||||||
isPinned = false,
|
|
||||||
isArchived = false,
|
|
||||||
isDeleted = false
|
|
||||||
)
|
|
||||||
|
|
||||||
// When
|
|
||||||
dataStoreManager.saveNotes(listOf(note))
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
// Then
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
Assert.assertEquals(1, savedNotes.size)
|
|
||||||
Assert.assertEquals("Meeting Notes", savedNotes[0].title)
|
|
||||||
Assert.assertEquals("note_001", savedNotes[0].id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testCreateMultipleCategories_shouldSaveInCorrectOrder() = testScope.runTest {
|
|
||||||
// Given
|
|
||||||
val categories = listOf(
|
|
||||||
Category(
|
|
||||||
id = "cat_001",
|
|
||||||
name = "Work",
|
|
||||||
gradientStart = 0xFF000000,
|
|
||||||
gradientEnd = 0xFF111111
|
|
||||||
),
|
|
||||||
Category(
|
|
||||||
id = "cat_002",
|
|
||||||
name = "Personal",
|
|
||||||
gradientStart = 0xFF222222,
|
|
||||||
gradientEnd = 0xFF333333
|
|
||||||
),
|
|
||||||
Category(
|
|
||||||
id = "cat_003",
|
|
||||||
name = "Ideas",
|
|
||||||
gradientStart = 0xFF444444,
|
|
||||||
gradientEnd = 0xFF555555
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// When
|
|
||||||
dataStoreManager.saveCategories(categories)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
// Then
|
|
||||||
val savedCategories = dataStoreManager.categoriesFlow.first()
|
|
||||||
Assert.assertEquals(3, savedCategories.size)
|
|
||||||
Assert.assertEquals("Work", savedCategories[0].name)
|
|
||||||
Assert.assertEquals("Personal", savedCategories[1].name)
|
|
||||||
Assert.assertEquals("Ideas", savedCategories[2].name)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testAutoSave_shouldUpdateExistingNote() = testScope.runTest {
|
|
||||||
// Given - Initial note
|
|
||||||
val initialNote = Note(
|
|
||||||
id = "note_001",
|
|
||||||
categoryId = "cat_001",
|
|
||||||
title = "Draft",
|
|
||||||
content = "Initial content",
|
|
||||||
timestamp = System.currentTimeMillis()
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(listOf(initialNote))
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
// When - Simulate autosave with updated content (debounce simulation)
|
|
||||||
val updatedNote = initialNote.copy(
|
|
||||||
title = "Updated Draft",
|
|
||||||
content = "Updated content after typing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Simulate 500ms debounce
|
|
||||||
delay(500)
|
|
||||||
dataStoreManager.saveNotes(listOf(updatedNote))
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
// Then
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
Assert.assertEquals(1, savedNotes.size)
|
|
||||||
Assert.assertEquals("Updated Draft", savedNotes[0].title)
|
|
||||||
Assert.assertEquals("Updated content after typing", savedNotes[0].content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================== TC-02: PIN NOTE - URUTAN TERATAS ==================
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testPinNote_shouldAppearFirst() = testScope.runTest {
|
|
||||||
// Given - Create 3 notes with different timestamps
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val notes = listOf(
|
|
||||||
Note(
|
|
||||||
id = "note_001",
|
|
||||||
categoryId = "cat_001",
|
|
||||||
title = "Oldest",
|
|
||||||
timestamp = now - 2000,
|
|
||||||
isPinned = false
|
|
||||||
),
|
|
||||||
Note(
|
|
||||||
id = "note_002",
|
|
||||||
categoryId = "cat_001",
|
|
||||||
title = "Middle",
|
|
||||||
timestamp = now - 1000,
|
|
||||||
isPinned = false
|
|
||||||
),
|
|
||||||
Note(
|
|
||||||
id = "note_003",
|
|
||||||
categoryId = "cat_001",
|
|
||||||
title = "Newest",
|
|
||||||
timestamp = now,
|
|
||||||
isPinned = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(notes)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
// When - Pin the oldest note
|
|
||||||
val pinnedNotes = notes.map {
|
|
||||||
if (it.id == "note_001") it.copy(isPinned = true) else it
|
|
||||||
}
|
|
||||||
dataStoreManager.saveNotes(pinnedNotes)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
// Then - Sort by pinned then timestamp (like in MainScreen.kt)
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
val sortedNotes = savedNotes
|
|
||||||
.sortedWith(compareByDescending<Note> { it.isPinned }.thenByDescending { it.timestamp })
|
|
||||||
|
|
||||||
Assert.assertEquals("Oldest", sortedNotes[0].title)
|
|
||||||
Assert.assertTrue(sortedNotes[0].isPinned)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testMultiplePinnedNotes_shouldSortByTimestamp() = testScope.runTest {
|
|
||||||
// Given - Create notes with 2 pinned
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val notes = listOf(
|
|
||||||
Note(
|
|
||||||
id = "note_001",
|
|
||||||
categoryId = "cat_001",
|
|
||||||
title = "Pinned Old",
|
|
||||||
timestamp = now - 3000,
|
|
||||||
isPinned = true
|
|
||||||
),
|
|
||||||
Note(
|
|
||||||
id = "note_002",
|
|
||||||
categoryId = "cat_001",
|
|
||||||
title = "Normal",
|
|
||||||
timestamp = now - 2000,
|
|
||||||
isPinned = false
|
|
||||||
),
|
|
||||||
Note(
|
|
||||||
id = "note_003",
|
|
||||||
categoryId = "cat_001",
|
|
||||||
title = "Pinned New",
|
|
||||||
timestamp = now - 1000,
|
|
||||||
isPinned = true
|
|
||||||
),
|
|
||||||
Note(
|
|
||||||
id = "note_004",
|
|
||||||
categoryId = "cat_001",
|
|
||||||
title = "Newest Normal",
|
|
||||||
timestamp = now,
|
|
||||||
isPinned = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(notes)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
// When - Get sorted notes
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
val sortedNotes = savedNotes
|
|
||||||
.sortedWith(compareByDescending<Note> { it.isPinned }.thenByDescending { it.timestamp })
|
|
||||||
|
|
||||||
// Then - Pinned notes should be first, sorted by timestamp
|
|
||||||
Assert.assertEquals(4, sortedNotes.size)
|
|
||||||
Assert.assertEquals("Pinned New", sortedNotes[0].title) // Pinned, newest
|
|
||||||
Assert.assertEquals("Pinned Old", sortedNotes[1].title) // Pinned, older
|
|
||||||
Assert.assertEquals("Newest Normal", sortedNotes[2].title) // Not pinned, newest
|
|
||||||
Assert.assertEquals("Normal", sortedNotes[3].title) // Not pinned, older
|
|
||||||
|
|
||||||
Assert.assertTrue(sortedNotes[0].isPinned)
|
|
||||||
Assert.assertTrue(sortedNotes[1].isPinned)
|
|
||||||
Assert.assertFalse(sortedNotes[2].isPinned)
|
|
||||||
Assert.assertFalse(sortedNotes[3].isPinned)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testUnpinNote_shouldMoveToNormalPosition() = testScope.runTest {
|
|
||||||
// Given - Note that is pinned
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val notes = listOf(
|
|
||||||
Note(
|
|
||||||
id = "note_001",
|
|
||||||
categoryId = "cat_001",
|
|
||||||
title = "Old",
|
|
||||||
timestamp = now - 2000,
|
|
||||||
isPinned = false
|
|
||||||
),
|
|
||||||
Note(
|
|
||||||
id = "note_002",
|
|
||||||
categoryId = "cat_001",
|
|
||||||
title = "Pinned",
|
|
||||||
timestamp = now - 1000,
|
|
||||||
isPinned = true
|
|
||||||
),
|
|
||||||
Note(
|
|
||||||
id = "note_003",
|
|
||||||
categoryId = "cat_001",
|
|
||||||
title = "New",
|
|
||||||
timestamp = now,
|
|
||||||
isPinned = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(notes)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
// When - Unpin the note
|
|
||||||
val unpinnedNotes = notes.map {
|
|
||||||
if (it.id == "note_002") it.copy(isPinned = false) else it
|
|
||||||
}
|
|
||||||
dataStoreManager.saveNotes(unpinnedNotes)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
// Then - Should be sorted by timestamp only
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
val sortedNotes = savedNotes
|
|
||||||
.sortedWith(compareByDescending<Note> { it.isPinned }.thenByDescending { it.timestamp })
|
|
||||||
|
|
||||||
Assert.assertEquals("New", sortedNotes[0].title)
|
|
||||||
Assert.assertEquals("Pinned", sortedNotes[1].title) // Now in middle based on timestamp
|
|
||||||
Assert.assertEquals("Old", sortedNotes[2].title)
|
|
||||||
|
|
||||||
Assert.assertFalse(sortedNotes[0].isPinned)
|
|
||||||
Assert.assertFalse(sortedNotes[1].isPinned)
|
|
||||||
Assert.assertFalse(sortedNotes[2].isPinned)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testPinCategory_shouldPersist() = testScope.runTest {
|
|
||||||
// Given
|
|
||||||
val category = Category(
|
|
||||||
id = "cat_001",
|
|
||||||
name = "Important",
|
|
||||||
gradientStart = 0xFFE91E63,
|
|
||||||
gradientEnd = 0xFF9C27B0,
|
|
||||||
isPinned = false
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveCategories(listOf(category))
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
// When - Pin category
|
|
||||||
val pinnedCategory = category.copy(isPinned = true)
|
|
||||||
dataStoreManager.saveCategories(listOf(pinnedCategory))
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
// Then
|
|
||||||
val savedCategories = dataStoreManager.categoriesFlow.first()
|
|
||||||
Assert.assertEquals(1, savedCategories.size)
|
|
||||||
Assert.assertTrue(savedCategories[0].isPinned)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,485 +0,0 @@
|
|||||||
package com.example.notesai
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.datastore.core.DataStore
|
|
||||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
|
||||||
import androidx.datastore.preferences.core.Preferences
|
|
||||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import com.example.notesai.data.local.DataStoreManager
|
|
||||||
import com.example.notesai.data.model.ChatHistory
|
|
||||||
import com.example.notesai.data.model.ChatMessage
|
|
||||||
import com.example.notesai.data.model.toSerializable
|
|
||||||
import com.example.notesai.util.FileParseResult
|
|
||||||
import com.example.notesai.util.FileParser
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.junit.Assert.*
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit Test untuk File Upload & PDF Summary Functionality
|
|
||||||
* Coverage:
|
|
||||||
* - TC-06: Upload PDF → summary tersimpan/terbaca
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class FileUploadFunctionalityTest {
|
|
||||||
|
|
||||||
private lateinit var context: Context
|
|
||||||
private lateinit var dataStore: DataStore<Preferences>
|
|
||||||
private lateinit var dataStoreManager: DataStoreManager
|
|
||||||
private lateinit var testScope: CoroutineScope
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
runBlocking {
|
|
||||||
context = ApplicationProvider.getApplicationContext()
|
|
||||||
testScope = CoroutineScope(SupervisorJob())
|
|
||||||
|
|
||||||
val testDataStoreName = "test_file_upload_prefs_${UUID.randomUUID()}"
|
|
||||||
dataStore = PreferenceDataStoreFactory.create(
|
|
||||||
scope = testScope,
|
|
||||||
produceFile = { context.preferencesDataStoreFile(testDataStoreName) }
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager = DataStoreManager(context)
|
|
||||||
|
|
||||||
// Clear all data before each test
|
|
||||||
dataStoreManager.saveChatHistory(emptyList())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
testScope.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================== TC-06: FILE UPLOAD & SUMMARY ==================
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testFileParseResult_shouldCalculateWordCount() {
|
|
||||||
runBlocking {
|
|
||||||
// Given - Text with known word count
|
|
||||||
val content = "One two three four five words here"
|
|
||||||
|
|
||||||
val fileResult = FileParseResult.Success(
|
|
||||||
content = content,
|
|
||||||
fileName = "word_count_test.txt",
|
|
||||||
fileType = "Text",
|
|
||||||
wordCount = content.split(Regex("\\s+")).size
|
|
||||||
)
|
|
||||||
|
|
||||||
// Then - Should count words correctly
|
|
||||||
assertEquals(7, fileResult.wordCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testFileParseResult_shouldIdentifyFileType() {
|
|
||||||
runBlocking {
|
|
||||||
// Given - Different file types
|
|
||||||
val pdfResult = FileParseResult.Success(
|
|
||||||
content = "PDF content",
|
|
||||||
fileName = "test.pdf",
|
|
||||||
fileType = "PDF",
|
|
||||||
wordCount = 2
|
|
||||||
)
|
|
||||||
|
|
||||||
val txtResult = FileParseResult.Success(
|
|
||||||
content = "Text content",
|
|
||||||
fileName = "test.txt",
|
|
||||||
fileType = "Text",
|
|
||||||
wordCount = 2
|
|
||||||
)
|
|
||||||
|
|
||||||
val docxResult = FileParseResult.Success(
|
|
||||||
content = "Word content",
|
|
||||||
fileName = "test.docx",
|
|
||||||
fileType = "Word",
|
|
||||||
wordCount = 2
|
|
||||||
)
|
|
||||||
|
|
||||||
// Then - Should identify correctly
|
|
||||||
assertEquals("PDF", pdfResult.fileType)
|
|
||||||
assertEquals("Text", txtResult.fileType)
|
|
||||||
assertEquals("Word", docxResult.fileType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testFileParseError_shouldContainMessage() {
|
|
||||||
// Given - Error result
|
|
||||||
val errorResult = FileParseResult.Error("File kosong atau tidak dapat dibaca")
|
|
||||||
|
|
||||||
// Then - Should contain error message
|
|
||||||
assertEquals("File kosong atau tidak dapat dibaca", errorResult.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testFormatFileSize_shouldFormatCorrectly() {
|
|
||||||
// Given - Different file sizes
|
|
||||||
val testCases = mapOf(
|
|
||||||
500L to "B",
|
|
||||||
1024L to "KB",
|
|
||||||
1024L * 1024 to "MB",
|
|
||||||
1024L * 1024 * 5 to "MB"
|
|
||||||
)
|
|
||||||
|
|
||||||
// When & Then - Format each size
|
|
||||||
testCases.forEach { (bytes, expectedUnit) ->
|
|
||||||
val formatted = FileParser.formatFileSize(bytes)
|
|
||||||
assertTrue("Expected unit $expectedUnit in $formatted",
|
|
||||||
formatted.contains(expectedUnit))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testSaveSummaryToChatHistory_shouldPersist() {
|
|
||||||
runBlocking {
|
|
||||||
// Given - Clear existing data
|
|
||||||
dataStoreManager.saveChatHistory(emptyList())
|
|
||||||
|
|
||||||
// Simulate file upload and summary generation
|
|
||||||
val fileResult = FileParseResult.Success(
|
|
||||||
content = "This is the content of the uploaded PDF document.",
|
|
||||||
fileName = "test_document.pdf",
|
|
||||||
fileType = "PDF",
|
|
||||||
wordCount = 9
|
|
||||||
)
|
|
||||||
|
|
||||||
// Simulate user message
|
|
||||||
val userMessage = ChatMessage(
|
|
||||||
message = "📄 Upload file: ${fileResult.fileName}\n\nMohon buatkan ringkasan dari file ini.",
|
|
||||||
isUser = true,
|
|
||||||
timestamp = System.currentTimeMillis()
|
|
||||||
)
|
|
||||||
|
|
||||||
// Simulate AI summary response
|
|
||||||
val aiSummary = ChatMessage(
|
|
||||||
message = "Ringkasan dokumen:\n\n1. Poin utama pertama\n2. Poin utama kedua\n3. Kesimpulan",
|
|
||||||
isUser = false,
|
|
||||||
timestamp = System.currentTimeMillis()
|
|
||||||
)
|
|
||||||
|
|
||||||
// When - Save to chat history
|
|
||||||
val chatHistory = ChatHistory(
|
|
||||||
id = "chat_pdf_001",
|
|
||||||
categoryId = null,
|
|
||||||
categoryName = "Semua Kategori",
|
|
||||||
messages = listOf(userMessage, aiSummary).map { it.toSerializable() },
|
|
||||||
lastMessagePreview = "Ringkasan dokumen: 1. Poin...",
|
|
||||||
timestamp = System.currentTimeMillis(),
|
|
||||||
isDeleted = false
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.addChatHistory(chatHistory)
|
|
||||||
|
|
||||||
// Then - Should be saved and retrievable
|
|
||||||
val savedHistories = dataStoreManager.chatHistoryFlow.first()
|
|
||||||
assertEquals(1, savedHistories.size)
|
|
||||||
assertEquals(2, savedHistories[0].messages.size)
|
|
||||||
|
|
||||||
val messages = savedHistories[0].messages
|
|
||||||
assertTrue(messages[0].message.contains("Upload file"))
|
|
||||||
assertTrue(messages[0].message.contains("test_document.pdf"))
|
|
||||||
assertTrue(messages[1].message.contains("Ringkasan dokumen"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testMultipleFileUploads_shouldTrackAll() {
|
|
||||||
runBlocking {
|
|
||||||
// Given - Clear existing data
|
|
||||||
dataStoreManager.saveChatHistory(emptyList())
|
|
||||||
|
|
||||||
// Multiple file uploads with summaries
|
|
||||||
val file1Message = ChatMessage(
|
|
||||||
message = "📄 Upload file: document1.pdf\n\nBuatkan ringkasan.",
|
|
||||||
isUser = true
|
|
||||||
)
|
|
||||||
val summary1 = ChatMessage(
|
|
||||||
message = "Ringkasan document1: Topik A dan B",
|
|
||||||
isUser = false
|
|
||||||
)
|
|
||||||
|
|
||||||
val file2Message = ChatMessage(
|
|
||||||
message = "📄 Upload file: document2.pdf\n\nBuatkan ringkasan.",
|
|
||||||
isUser = true
|
|
||||||
)
|
|
||||||
val summary2 = ChatMessage(
|
|
||||||
message = "Ringkasan document2: Topik C dan D",
|
|
||||||
isUser = false
|
|
||||||
)
|
|
||||||
|
|
||||||
// When - Save chat with multiple uploads
|
|
||||||
val chatHistory = ChatHistory(
|
|
||||||
id = "chat_multi_pdf",
|
|
||||||
categoryId = null,
|
|
||||||
categoryName = "Semua",
|
|
||||||
messages = listOf(file1Message, summary1, file2Message, summary2).map { it.toSerializable() },
|
|
||||||
lastMessagePreview = "Ringkasan document2...",
|
|
||||||
timestamp = System.currentTimeMillis(),
|
|
||||||
isDeleted = false
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.addChatHistory(chatHistory)
|
|
||||||
|
|
||||||
// Then - Should track all uploads
|
|
||||||
val savedHistories = dataStoreManager.chatHistoryFlow.first()
|
|
||||||
assertEquals(4, savedHistories[0].messages.size)
|
|
||||||
|
|
||||||
val messages = savedHistories[0].messages
|
|
||||||
assertTrue(messages[0].message.contains("document1.pdf"))
|
|
||||||
assertTrue(messages[2].message.contains("document2.pdf"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testSummaryContent_shouldBeReadable() {
|
|
||||||
runBlocking {
|
|
||||||
// Given - Clear existing data
|
|
||||||
dataStoreManager.saveChatHistory(emptyList())
|
|
||||||
|
|
||||||
// File upload with summary
|
|
||||||
val fileContent = """
|
|
||||||
Important Meeting Notes
|
|
||||||
|
|
||||||
Topics discussed:
|
|
||||||
1. Project timeline
|
|
||||||
2. Budget allocation
|
|
||||||
3. Team assignments
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
val summary = """
|
|
||||||
Ringkasan Meeting Notes:
|
|
||||||
|
|
||||||
Topik yang dibahas:
|
|
||||||
- Timeline proyek
|
|
||||||
- Alokasi budget
|
|
||||||
- Penugasan tim
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
// When - Save chat history
|
|
||||||
val chatHistory = ChatHistory(
|
|
||||||
id = "chat_summary_readable",
|
|
||||||
categoryId = null,
|
|
||||||
categoryName = "Semua",
|
|
||||||
messages = listOf(
|
|
||||||
ChatMessage(message = "Upload: meeting_notes.txt", isUser = true).toSerializable(),
|
|
||||||
ChatMessage(message = summary, isUser = false).toSerializable()
|
|
||||||
),
|
|
||||||
lastMessagePreview = "Ringkasan Meeting Notes...",
|
|
||||||
timestamp = System.currentTimeMillis(),
|
|
||||||
isDeleted = false
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.addChatHistory(chatHistory)
|
|
||||||
|
|
||||||
// Then - Summary should be readable
|
|
||||||
val savedHistories = dataStoreManager.chatHistoryFlow.first()
|
|
||||||
val savedSummary = savedHistories[0].messages[1].message
|
|
||||||
|
|
||||||
assertTrue(savedSummary.contains("Ringkasan"))
|
|
||||||
assertTrue(savedSummary.contains("Timeline proyek"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testFileUploadError_shouldHandleGracefully() {
|
|
||||||
runBlocking {
|
|
||||||
// Given - Clear existing data
|
|
||||||
dataStoreManager.saveChatHistory(emptyList())
|
|
||||||
|
|
||||||
// Error scenario (file too large or unsupported format)
|
|
||||||
val errorMessage = "⚠️ Gagal membuat ringkasan: File terlalu besar. Maksimal 10MB"
|
|
||||||
|
|
||||||
// When - Save error in chat
|
|
||||||
val chatHistory = ChatHistory(
|
|
||||||
id = "chat_error",
|
|
||||||
categoryId = null,
|
|
||||||
categoryName = "Semua",
|
|
||||||
messages = listOf(
|
|
||||||
ChatMessage(message = "Upload file: large_file.pdf", isUser = true).toSerializable(),
|
|
||||||
ChatMessage(message = errorMessage, isUser = false).toSerializable()
|
|
||||||
),
|
|
||||||
lastMessagePreview = "⚠️ Gagal membuat ringkasan...",
|
|
||||||
timestamp = System.currentTimeMillis(),
|
|
||||||
isDeleted = false
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.addChatHistory(chatHistory)
|
|
||||||
|
|
||||||
// Then - Error should be tracked
|
|
||||||
val savedHistories = dataStoreManager.chatHistoryFlow.first()
|
|
||||||
assertTrue(savedHistories[0].messages[1].message.contains("Gagal"))
|
|
||||||
assertTrue(savedHistories[0].messages[1].message.contains("Maksimal 10MB"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testPDFSummaryFormat_shouldBeStructured() {
|
|
||||||
runBlocking {
|
|
||||||
// Given - Clear existing data
|
|
||||||
dataStoreManager.saveChatHistory(emptyList())
|
|
||||||
|
|
||||||
// Structured PDF summary
|
|
||||||
val structuredSummary = """
|
|
||||||
# Ringkasan Dokumen
|
|
||||||
|
|
||||||
## Poin Utama
|
|
||||||
1. **Introduction**: Overview of the topic
|
|
||||||
2. **Main Content**: Detailed discussion
|
|
||||||
3. **Conclusion**: Key takeaways
|
|
||||||
|
|
||||||
## Rekomendasi
|
|
||||||
Dokumen ini cocok untuk pembelajaran dasar AI.
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
// When - Save summary
|
|
||||||
val chatHistory = ChatHistory(
|
|
||||||
id = "chat_structured",
|
|
||||||
categoryId = null,
|
|
||||||
categoryName = "Semua",
|
|
||||||
messages = listOf(
|
|
||||||
ChatMessage(message = "Upload: ai_basics.pdf", isUser = true).toSerializable(),
|
|
||||||
ChatMessage(message = structuredSummary, isUser = false).toSerializable()
|
|
||||||
),
|
|
||||||
lastMessagePreview = "Ringkasan Dokumen...",
|
|
||||||
timestamp = System.currentTimeMillis(),
|
|
||||||
isDeleted = false
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.addChatHistory(chatHistory)
|
|
||||||
|
|
||||||
// Then - Structure should be preserved
|
|
||||||
val savedHistories = dataStoreManager.chatHistoryFlow.first()
|
|
||||||
val savedSummary = savedHistories[0].messages[1].message
|
|
||||||
|
|
||||||
assertTrue(savedSummary.contains("# Ringkasan Dokumen"))
|
|
||||||
assertTrue(savedSummary.contains("## Poin Utama"))
|
|
||||||
assertTrue(savedSummary.contains("## Rekomendasi"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testSearchInSummaries_shouldFindKeywords() {
|
|
||||||
runBlocking {
|
|
||||||
// Given - Clear existing data
|
|
||||||
dataStoreManager.saveChatHistory(emptyList())
|
|
||||||
|
|
||||||
// Multiple summaries
|
|
||||||
val summaries = listOf(
|
|
||||||
ChatHistory(
|
|
||||||
id = "chat_sum1",
|
|
||||||
categoryId = null,
|
|
||||||
categoryName = "Semua",
|
|
||||||
messages = listOf(
|
|
||||||
ChatMessage(message = "Upload: kotlin_guide.pdf", isUser = true).toSerializable(),
|
|
||||||
ChatMessage(message = "Ringkasan: Kotlin adalah bahasa programming modern", isUser = false).toSerializable()
|
|
||||||
),
|
|
||||||
lastMessagePreview = "Ringkasan: Kotlin adalah...",
|
|
||||||
timestamp = System.currentTimeMillis() - 2000,
|
|
||||||
isDeleted = false
|
|
||||||
),
|
|
||||||
ChatHistory(
|
|
||||||
id = "chat_sum2",
|
|
||||||
categoryId = null,
|
|
||||||
categoryName = "Semua",
|
|
||||||
messages = listOf(
|
|
||||||
ChatMessage(message = "Upload: java_basics.pdf", isUser = true).toSerializable(),
|
|
||||||
ChatMessage(message = "Ringkasan: Java adalah bahasa OOP yang populer", isUser = false).toSerializable()
|
|
||||||
),
|
|
||||||
lastMessagePreview = "Ringkasan: Java adalah...",
|
|
||||||
timestamp = System.currentTimeMillis() - 1000,
|
|
||||||
isDeleted = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
summaries.forEach { dataStoreManager.addChatHistory(it) }
|
|
||||||
|
|
||||||
// When - Search for "Kotlin"
|
|
||||||
val savedHistories = dataStoreManager.chatHistoryFlow.first()
|
|
||||||
val searchQuery = "Kotlin"
|
|
||||||
val searchResults = savedHistories.filter { history ->
|
|
||||||
history.messages.any { msg ->
|
|
||||||
msg.message.contains(searchQuery, ignoreCase = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - Should find Kotlin summary
|
|
||||||
assertEquals(1, searchResults.size)
|
|
||||||
assertTrue(searchResults[0].messages.any { it.message.contains("Kotlin") })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testLongSummary_shouldTruncatePreview() {
|
|
||||||
// Given - Very long summary
|
|
||||||
val longSummary = "A".repeat(200) + " This is a very long summary that should be truncated in the preview"
|
|
||||||
|
|
||||||
// When - Create preview (max 30 chars)
|
|
||||||
val maxLength = 30
|
|
||||||
val preview = if (longSummary.length > maxLength) {
|
|
||||||
longSummary.take(maxLength).trim() + "..."
|
|
||||||
} else {
|
|
||||||
longSummary
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - Should be truncated
|
|
||||||
assertTrue(preview.length <= maxLength + 3)
|
|
||||||
assertTrue(preview.endsWith("..."))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testFileMetadata_shouldBePreserved() {
|
|
||||||
runBlocking {
|
|
||||||
// Given - Clear existing data
|
|
||||||
dataStoreManager.saveChatHistory(emptyList())
|
|
||||||
|
|
||||||
// File with metadata
|
|
||||||
val fileResult = FileParseResult.Success(
|
|
||||||
content = "Sample content for testing metadata preservation",
|
|
||||||
fileName = "important_document.pdf",
|
|
||||||
fileType = "PDF",
|
|
||||||
wordCount = 7
|
|
||||||
)
|
|
||||||
|
|
||||||
// When - Save chat with file metadata
|
|
||||||
val chatHistory = ChatHistory(
|
|
||||||
id = "chat_metadata",
|
|
||||||
categoryId = null,
|
|
||||||
categoryName = "Semua",
|
|
||||||
messages = listOf(
|
|
||||||
ChatMessage(
|
|
||||||
message = "📄 Upload file: ${fileResult.fileName}\n" +
|
|
||||||
"Tipe: ${fileResult.fileType}\n" +
|
|
||||||
"Jumlah kata: ${fileResult.wordCount}",
|
|
||||||
isUser = true
|
|
||||||
).toSerializable(),
|
|
||||||
ChatMessage(message = "Ringkasan telah dibuat.", isUser = false).toSerializable()
|
|
||||||
),
|
|
||||||
lastMessagePreview = "Ringkasan telah dibuat.",
|
|
||||||
timestamp = System.currentTimeMillis(),
|
|
||||||
isDeleted = false
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.addChatHistory(chatHistory)
|
|
||||||
|
|
||||||
// Then - Metadata should be preserved
|
|
||||||
val savedHistories = dataStoreManager.chatHistoryFlow.first()
|
|
||||||
val userMessage = savedHistories[0].messages[0].message
|
|
||||||
|
|
||||||
assertTrue(userMessage.contains("important_document.pdf"))
|
|
||||||
assertTrue(userMessage.contains("PDF"))
|
|
||||||
assertTrue(userMessage.contains("7"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,433 +0,0 @@
|
|||||||
package com.example.notesai
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.datastore.core.DataStore
|
|
||||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
|
||||||
import androidx.datastore.preferences.core.Preferences
|
|
||||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import com.example.notesai.data.local.DataStoreManager
|
|
||||||
import com.example.notesai.data.model.Category
|
|
||||||
import com.example.notesai.data.model.Note
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.junit.Assert.*
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit Test untuk Search Functionality
|
|
||||||
* Coverage:
|
|
||||||
* - TC-04: Search realtime menemukan keyword
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class SearchFunctionalityTest {
|
|
||||||
|
|
||||||
private lateinit var context: Context
|
|
||||||
private lateinit var dataStore: DataStore<Preferences>
|
|
||||||
private lateinit var dataStoreManager: DataStoreManager
|
|
||||||
private lateinit var testScope: CoroutineScope
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
context = ApplicationProvider.getApplicationContext()
|
|
||||||
testScope = CoroutineScope(SupervisorJob())
|
|
||||||
|
|
||||||
val testDataStoreName = "test_search_prefs_${UUID.randomUUID()}"
|
|
||||||
dataStore = PreferenceDataStoreFactory.create(
|
|
||||||
scope = testScope,
|
|
||||||
produceFile = { context.preferencesDataStoreFile(testDataStoreName) }
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager = DataStoreManager(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
testScope.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================== TC-04: SEARCH REALTIME ==================
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testSearchNoteByTitle_shouldFindMatches() = runBlocking {
|
|
||||||
// Given - Create multiple notes
|
|
||||||
val notes = listOf(
|
|
||||||
Note(id = "note_001", categoryId = "cat_001", title = "Meeting Notes", content = "Discuss project", isDeleted = false),
|
|
||||||
Note(id = "note_002", categoryId = "cat_001", title = "Shopping List", content = "Buy groceries", isDeleted = false),
|
|
||||||
Note(id = "note_003", categoryId = "cat_001", title = "Project Ideas", content = "Brainstorm features", isDeleted = false)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(notes)
|
|
||||||
|
|
||||||
// When - Search for "Meeting" (simulating MainScreen search behavior)
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
val searchQuery = "Meeting"
|
|
||||||
val searchResults = savedNotes.filter { note ->
|
|
||||||
!note.isDeleted && !note.isArchived &&
|
|
||||||
(note.title.contains(searchQuery, ignoreCase = true) ||
|
|
||||||
note.content.contains(searchQuery, ignoreCase = true))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - Should find 1 note
|
|
||||||
assertEquals(1, searchResults.size)
|
|
||||||
assertEquals("Meeting Notes", searchResults[0].title)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testSearchNoteByContent_shouldFindMatches() = runBlocking {
|
|
||||||
// Given - Notes with specific content
|
|
||||||
val notes = listOf(
|
|
||||||
Note(id = "note_001", categoryId = "cat_001", title = "Random Title", content = "This contains Kotlin code", isDeleted = false),
|
|
||||||
Note(id = "note_002", categoryId = "cat_001", title = "Another Note", content = "JavaScript examples here", isDeleted = false),
|
|
||||||
Note(id = "note_003", categoryId = "cat_001", title = "Third Note", content = "Python tutorials", isDeleted = false)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(notes)
|
|
||||||
|
|
||||||
// When - Search for "Kotlin"
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
val searchQuery = "Kotlin"
|
|
||||||
val searchResults = savedNotes.filter { note ->
|
|
||||||
!note.isDeleted && !note.isArchived &&
|
|
||||||
(note.title.contains(searchQuery, ignoreCase = true) ||
|
|
||||||
note.content.contains(searchQuery, ignoreCase = true))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - Should find note with Kotlin in content
|
|
||||||
assertEquals(1, searchResults.size)
|
|
||||||
assertEquals("Random Title", searchResults[0].title)
|
|
||||||
assertTrue(searchResults[0].content.contains("Kotlin"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testSearchCaseInsensitive_shouldFindMatches() = runBlocking {
|
|
||||||
// Given - Note with mixed case title
|
|
||||||
val note = Note(
|
|
||||||
id = "note_001",
|
|
||||||
categoryId = "cat_001",
|
|
||||||
title = "Android Development",
|
|
||||||
content = "Building mobile apps",
|
|
||||||
isDeleted = false
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(listOf(note))
|
|
||||||
|
|
||||||
// When - Search with different cases
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
|
|
||||||
val searchQueries = listOf("android", "ANDROID", "Android", "AnDrOiD")
|
|
||||||
|
|
||||||
searchQueries.forEach { searchQuery ->
|
|
||||||
val searchResults = savedNotes.filter { n ->
|
|
||||||
!n.isDeleted && !n.isArchived &&
|
|
||||||
(n.title.contains(searchQuery, ignoreCase = true) ||
|
|
||||||
n.content.contains(searchQuery, ignoreCase = true))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - Should find match regardless of case
|
|
||||||
assertEquals("Failed for query: $searchQuery", 1, searchResults.size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testSearchPartialMatch_shouldFindResults() = runBlocking {
|
|
||||||
// Given - Notes with various titles
|
|
||||||
val notes = listOf(
|
|
||||||
Note(id = "note_001", categoryId = "cat_001", title = "Development Guide", isDeleted = false),
|
|
||||||
Note(id = "note_002", categoryId = "cat_001", title = "Developer Tools", isDeleted = false),
|
|
||||||
Note(id = "note_003", categoryId = "cat_001", title = "Design Patterns", isDeleted = false)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(notes)
|
|
||||||
|
|
||||||
// When - Search for partial word "Dev"
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
val searchQuery = "Dev"
|
|
||||||
val searchResults = savedNotes.filter { note ->
|
|
||||||
!note.isDeleted && !note.isArchived &&
|
|
||||||
(note.title.contains(searchQuery, ignoreCase = true) ||
|
|
||||||
note.content.contains(searchQuery, ignoreCase = true))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - Should find 2 notes containing "Dev"
|
|
||||||
assertEquals(2, searchResults.size)
|
|
||||||
assertTrue(searchResults.any { it.title == "Development Guide" })
|
|
||||||
assertTrue(searchResults.any { it.title == "Developer Tools" })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testSearchEmptyQuery_shouldReturnAllNotes() = runBlocking {
|
|
||||||
// Given - Multiple notes
|
|
||||||
val notes = listOf(
|
|
||||||
Note(id = "note_001", categoryId = "cat_001", title = "Note 1", isDeleted = false),
|
|
||||||
Note(id = "note_002", categoryId = "cat_001", title = "Note 2", isDeleted = false),
|
|
||||||
Note(id = "note_003", categoryId = "cat_001", title = "Note 3", isDeleted = false)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(notes)
|
|
||||||
|
|
||||||
// When - Search with empty query
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
val searchQuery = ""
|
|
||||||
val searchResults = if (searchQuery.isEmpty()) {
|
|
||||||
savedNotes.filter { !it.isDeleted && !it.isArchived }
|
|
||||||
} else {
|
|
||||||
savedNotes.filter { note ->
|
|
||||||
!note.isDeleted && !note.isArchived &&
|
|
||||||
(note.title.contains(searchQuery, ignoreCase = true) ||
|
|
||||||
note.content.contains(searchQuery, ignoreCase = true))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - Should return all active notes
|
|
||||||
assertEquals(3, searchResults.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testSearchNoMatches_shouldReturnEmpty() = runBlocking {
|
|
||||||
// Given - Notes that don't match search
|
|
||||||
val notes = listOf(
|
|
||||||
Note(id = "note_001", categoryId = "cat_001", title = "Note 1", content = "Content 1", isDeleted = false),
|
|
||||||
Note(id = "note_002", categoryId = "cat_001", title = "Note 2", content = "Content 2", isDeleted = false)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(notes)
|
|
||||||
|
|
||||||
// When - Search for non-existent keyword
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
val searchQuery = "NonExistentKeyword"
|
|
||||||
val searchResults = savedNotes.filter { note ->
|
|
||||||
!note.isDeleted && !note.isArchived &&
|
|
||||||
(note.title.contains(searchQuery, ignoreCase = true) ||
|
|
||||||
note.content.contains(searchQuery, ignoreCase = true))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - Should return empty
|
|
||||||
assertEquals(0, searchResults.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testSearchExcludesDeletedNotes_shouldNotFindDeleted() = runBlocking {
|
|
||||||
// Given - Mix of active and deleted notes with same keyword
|
|
||||||
val notes = listOf(
|
|
||||||
Note(id = "note_001", categoryId = "cat_001", title = "Active Kotlin Note", isDeleted = false),
|
|
||||||
Note(id = "note_002", categoryId = "cat_001", title = "Deleted Kotlin Note", isDeleted = true),
|
|
||||||
Note(id = "note_003", categoryId = "cat_001", title = "Another Kotlin Note", isDeleted = false)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(notes)
|
|
||||||
|
|
||||||
// When - Search for "Kotlin"
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
val searchQuery = "Kotlin"
|
|
||||||
val searchResults = savedNotes.filter { note ->
|
|
||||||
!note.isDeleted && !note.isArchived &&
|
|
||||||
(note.title.contains(searchQuery, ignoreCase = true) ||
|
|
||||||
note.content.contains(searchQuery, ignoreCase = true))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - Should only find 2 active notes
|
|
||||||
assertEquals(2, searchResults.size)
|
|
||||||
assertTrue(searchResults.all { !it.isDeleted })
|
|
||||||
assertFalse(searchResults.any { it.title == "Deleted Kotlin Note" })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testSearchExcludesArchivedNotes_shouldNotFindArchived() = runBlocking {
|
|
||||||
// Given - Mix of active and archived notes
|
|
||||||
val notes = listOf(
|
|
||||||
Note(id = "note_001", categoryId = "cat_001", title = "Active Project", isArchived = false),
|
|
||||||
Note(id = "note_002", categoryId = "cat_001", title = "Archived Project", isArchived = true),
|
|
||||||
Note(id = "note_003", categoryId = "cat_001", title = "Another Project", isArchived = false)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(notes)
|
|
||||||
|
|
||||||
// When - Search for "Project"
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
val searchQuery = "Project"
|
|
||||||
val searchResults = savedNotes.filter { note ->
|
|
||||||
!note.isDeleted && !note.isArchived &&
|
|
||||||
(note.title.contains(searchQuery, ignoreCase = true) ||
|
|
||||||
note.content.contains(searchQuery, ignoreCase = true))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - Should only find 2 active notes
|
|
||||||
assertEquals(2, searchResults.size)
|
|
||||||
assertTrue(searchResults.all { !it.isArchived })
|
|
||||||
assertFalse(searchResults.any { it.title == "Archived Project" })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testSearchCategory_shouldFindByName() = runBlocking {
|
|
||||||
// Given - Multiple categories
|
|
||||||
val categories = listOf(
|
|
||||||
Category(id = "cat_001", name = "Work Projects", gradientStart = 0xFF000000, gradientEnd = 0xFF111111),
|
|
||||||
Category(id = "cat_002", name = "Personal Tasks", gradientStart = 0xFF222222, gradientEnd = 0xFF333333),
|
|
||||||
Category(id = "cat_003", name = "Ideas and Notes", gradientStart = 0xFF444444, gradientEnd = 0xFF555555)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveCategories(categories)
|
|
||||||
|
|
||||||
// When - Search for "Work" (simulating MainScreen category search)
|
|
||||||
val savedCategories = dataStoreManager.categoriesFlow.first()
|
|
||||||
val searchQuery = "Work"
|
|
||||||
val searchResults = savedCategories.filter { category ->
|
|
||||||
!category.isDeleted &&
|
|
||||||
category.name.contains(searchQuery, ignoreCase = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - Should find 1 category
|
|
||||||
assertEquals(1, searchResults.size)
|
|
||||||
assertEquals("Work Projects", searchResults[0].name)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testSearchCategoryPartialMatch_shouldFind() = runBlocking {
|
|
||||||
// Given - Categories with similar names
|
|
||||||
val categories = listOf(
|
|
||||||
Category(id = "cat_001", name = "Development", gradientStart = 0xFF000000, gradientEnd = 0xFF111111),
|
|
||||||
Category(id = "cat_002", name = "Developer Tools", gradientStart = 0xFF222222, gradientEnd = 0xFF333333),
|
|
||||||
Category(id = "cat_003", name = "Design", gradientStart = 0xFF444444, gradientEnd = 0xFF555555)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveCategories(categories)
|
|
||||||
|
|
||||||
// When - Search for "Dev"
|
|
||||||
val savedCategories = dataStoreManager.categoriesFlow.first()
|
|
||||||
val searchQuery = "Dev"
|
|
||||||
val searchResults = savedCategories.filter { category ->
|
|
||||||
!category.isDeleted &&
|
|
||||||
category.name.contains(searchQuery, ignoreCase = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - Should find 2 categories
|
|
||||||
assertEquals(2, searchResults.size)
|
|
||||||
assertTrue(searchResults.any { it.name == "Development" })
|
|
||||||
assertTrue(searchResults.any { it.name == "Developer Tools" })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testSearchMultipleKeywords_shouldFindAll() = runBlocking {
|
|
||||||
// Given - Notes with various content
|
|
||||||
val notes = listOf(
|
|
||||||
Note(id = "note_001", categoryId = "cat_001", title = "Kotlin Tutorial", content = "Learn Kotlin basics", isDeleted = false),
|
|
||||||
Note(id = "note_002", categoryId = "cat_001", title = "Java Guide", content = "Java programming", isDeleted = false),
|
|
||||||
Note(id = "note_003", categoryId = "cat_001", title = "Android Tips", content = "Kotlin for Android", isDeleted = false)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(notes)
|
|
||||||
|
|
||||||
// When - Search for "Kotlin"
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
val searchQuery = "Kotlin"
|
|
||||||
val searchResults = savedNotes.filter { note ->
|
|
||||||
!note.isDeleted && !note.isArchived &&
|
|
||||||
(note.title.contains(searchQuery, ignoreCase = true) ||
|
|
||||||
note.content.contains(searchQuery, ignoreCase = true))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - Should find 2 notes (title and content matches)
|
|
||||||
assertEquals(2, searchResults.size)
|
|
||||||
assertTrue(searchResults.any { it.title == "Kotlin Tutorial" })
|
|
||||||
assertTrue(searchResults.any { it.title == "Android Tips" })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testSearchRealtime_shouldUpdateImmediately() = runBlocking {
|
|
||||||
// Given - Initial set of notes
|
|
||||||
val notes = listOf(
|
|
||||||
Note(id = "note_001", categoryId = "cat_001", title = "First Note", isDeleted = false),
|
|
||||||
Note(id = "note_002", categoryId = "cat_001", title = "Second Note", isDeleted = false)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(notes)
|
|
||||||
|
|
||||||
// When - Search for "First"
|
|
||||||
val savedNotes1 = dataStoreManager.notesFlow.first()
|
|
||||||
val searchQuery1 = "First"
|
|
||||||
val searchResults1 = savedNotes1.filter { note ->
|
|
||||||
!note.isDeleted && !note.isArchived &&
|
|
||||||
(note.title.contains(searchQuery1, ignoreCase = true) ||
|
|
||||||
note.content.contains(searchQuery1, ignoreCase = true))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - Should find 1 note
|
|
||||||
assertEquals(1, searchResults1.size)
|
|
||||||
|
|
||||||
// When - Change search query to "Second" (simulating real-time update)
|
|
||||||
val searchQuery2 = "Second"
|
|
||||||
val searchResults2 = savedNotes1.filter { note ->
|
|
||||||
!note.isDeleted && !note.isArchived &&
|
|
||||||
(note.title.contains(searchQuery2, ignoreCase = true) ||
|
|
||||||
note.content.contains(searchQuery2, ignoreCase = true))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - Should immediately find different note
|
|
||||||
assertEquals(1, searchResults2.size)
|
|
||||||
assertEquals("Second Note", searchResults2[0].title)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testSearchWithSpecialCharacters_shouldHandle() = runBlocking {
|
|
||||||
// Given - Note with special characters
|
|
||||||
val note = Note(
|
|
||||||
id = "note_001",
|
|
||||||
categoryId = "cat_001",
|
|
||||||
title = "C++ Programming",
|
|
||||||
content = "Learn C++ basics & advanced topics",
|
|
||||||
isDeleted = false
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(listOf(note))
|
|
||||||
|
|
||||||
// When - Search for "C++"
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
val searchQuery = "C++"
|
|
||||||
val searchResults = savedNotes.filter { n ->
|
|
||||||
!n.isDeleted && !n.isArchived &&
|
|
||||||
(n.title.contains(searchQuery, ignoreCase = true) ||
|
|
||||||
n.content.contains(searchQuery, ignoreCase = true))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - Should find the note
|
|
||||||
assertEquals(1, searchResults.size)
|
|
||||||
assertEquals("C++ Programming", searchResults[0].title)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testSearchFilteredByCategory_shouldOnlySearchInCategory() = runBlocking {
|
|
||||||
// Given - Notes in different categories
|
|
||||||
val notes = listOf(
|
|
||||||
Note(id = "note_001", categoryId = "cat_work", title = "Work Meeting", isDeleted = false),
|
|
||||||
Note(id = "note_002", categoryId = "cat_personal", title = "Personal Meeting", isDeleted = false),
|
|
||||||
Note(id = "note_003", categoryId = "cat_work", title = "Work Report", isDeleted = false)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(notes)
|
|
||||||
|
|
||||||
// When - Search for "Meeting" in "cat_work" category only
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
val searchQuery = "Meeting"
|
|
||||||
val selectedCategoryId = "cat_work"
|
|
||||||
val searchResults = savedNotes.filter { note ->
|
|
||||||
note.categoryId == selectedCategoryId &&
|
|
||||||
!note.isDeleted && !note.isArchived &&
|
|
||||||
(note.title.contains(searchQuery, ignoreCase = true) ||
|
|
||||||
note.content.contains(searchQuery, ignoreCase = true))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - Should only find 1 note in cat_work
|
|
||||||
assertEquals(1, searchResults.size)
|
|
||||||
assertEquals("Work Meeting", searchResults[0].title)
|
|
||||||
assertEquals("cat_work", searchResults[0].categoryId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,323 +0,0 @@
|
|||||||
package com.example.notesai
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.datastore.core.DataStore
|
|
||||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
|
||||||
import androidx.datastore.preferences.core.Preferences
|
|
||||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import com.example.notesai.data.local.DataStoreManager
|
|
||||||
import com.example.notesai.data.model.Category
|
|
||||||
import com.example.notesai.data.model.Note
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.junit.Assert.*
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit Test untuk Trash Functionality
|
|
||||||
* Coverage:
|
|
||||||
* - TC-03: Soft delete → restore dari trash
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class TrashFunctionalityTest {
|
|
||||||
|
|
||||||
private lateinit var context: Context
|
|
||||||
private lateinit var dataStore: DataStore<Preferences>
|
|
||||||
private lateinit var dataStoreManager: DataStoreManager
|
|
||||||
private lateinit var testScope: CoroutineScope
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
context = ApplicationProvider.getApplicationContext()
|
|
||||||
testScope = CoroutineScope(SupervisorJob())
|
|
||||||
|
|
||||||
val testDataStoreName = "test_trash_prefs_${UUID.randomUUID()}"
|
|
||||||
dataStore = PreferenceDataStoreFactory.create(
|
|
||||||
scope = testScope,
|
|
||||||
produceFile = { context.preferencesDataStoreFile(testDataStoreName) }
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager = DataStoreManager(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
testScope.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================== TC-03: SOFT DELETE & RESTORE ==================
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testSoftDeleteNote_shouldMarkAsDeleted() = runBlocking {
|
|
||||||
// Given - Create a note
|
|
||||||
val note = Note(
|
|
||||||
id = "note_001",
|
|
||||||
categoryId = "cat_001",
|
|
||||||
title = "Important Note",
|
|
||||||
content = "This is important content",
|
|
||||||
timestamp = System.currentTimeMillis(),
|
|
||||||
isDeleted = false
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(listOf(note))
|
|
||||||
|
|
||||||
// When - Soft delete the note
|
|
||||||
val deletedNote = note.copy(isDeleted = true)
|
|
||||||
dataStoreManager.saveNotes(listOf(deletedNote))
|
|
||||||
|
|
||||||
// Then - Note should be marked as deleted
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
assertEquals(1, savedNotes.size)
|
|
||||||
assertTrue(savedNotes[0].isDeleted)
|
|
||||||
assertEquals("Important Note", savedNotes[0].title)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testRestoreNoteFromTrash_shouldUnmarkDeleted() = runBlocking {
|
|
||||||
// Given - A deleted note
|
|
||||||
val deletedNote = Note(
|
|
||||||
id = "note_001",
|
|
||||||
categoryId = "cat_001",
|
|
||||||
title = "Deleted Note",
|
|
||||||
content = "This was deleted",
|
|
||||||
timestamp = System.currentTimeMillis(),
|
|
||||||
isDeleted = true
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(listOf(deletedNote))
|
|
||||||
|
|
||||||
// When - Restore the note
|
|
||||||
val restoredNote = deletedNote.copy(isDeleted = false)
|
|
||||||
dataStoreManager.saveNotes(listOf(restoredNote))
|
|
||||||
|
|
||||||
// Then - Note should be restored
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
assertEquals(1, savedNotes.size)
|
|
||||||
assertFalse(savedNotes[0].isDeleted)
|
|
||||||
assertEquals("Deleted Note", savedNotes[0].title)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testSoftDeleteCategory_shouldMarkAsDeleted() = runBlocking {
|
|
||||||
// Given - Create a category
|
|
||||||
val category = Category(
|
|
||||||
id = "cat_001",
|
|
||||||
name = "Work",
|
|
||||||
gradientStart = 0xFFE91E63,
|
|
||||||
gradientEnd = 0xFF9C27B0,
|
|
||||||
timestamp = System.currentTimeMillis(),
|
|
||||||
isDeleted = false
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveCategories(listOf(category))
|
|
||||||
|
|
||||||
// When - Soft delete the category
|
|
||||||
val deletedCategory = category.copy(isDeleted = true)
|
|
||||||
dataStoreManager.saveCategories(listOf(deletedCategory))
|
|
||||||
|
|
||||||
// Then - Category should be marked as deleted
|
|
||||||
val savedCategories = dataStoreManager.categoriesFlow.first()
|
|
||||||
assertEquals(1, savedCategories.size)
|
|
||||||
assertTrue(savedCategories[0].isDeleted)
|
|
||||||
assertEquals("Work", savedCategories[0].name)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testRestoreCategoryFromTrash_shouldUnmarkDeleted() = runBlocking {
|
|
||||||
// Given - A deleted category
|
|
||||||
val deletedCategory = Category(
|
|
||||||
id = "cat_001",
|
|
||||||
name = "Personal",
|
|
||||||
gradientStart = 0xFF2196F3,
|
|
||||||
gradientEnd = 0xFF03A9F4,
|
|
||||||
timestamp = System.currentTimeMillis(),
|
|
||||||
isDeleted = true
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveCategories(listOf(deletedCategory))
|
|
||||||
|
|
||||||
// When - Restore the category
|
|
||||||
val restoredCategory = deletedCategory.copy(isDeleted = false)
|
|
||||||
dataStoreManager.saveCategories(listOf(restoredCategory))
|
|
||||||
|
|
||||||
// Then - Category should be restored
|
|
||||||
val savedCategories = dataStoreManager.categoriesFlow.first()
|
|
||||||
assertEquals(1, savedCategories.size)
|
|
||||||
assertFalse(savedCategories[0].isDeleted)
|
|
||||||
assertEquals("Personal", savedCategories[0].name)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testFilterDeletedNotes_shouldOnlyShowDeleted() = runBlocking {
|
|
||||||
// Given - Mix of deleted and active notes
|
|
||||||
val notes = listOf(
|
|
||||||
Note(id = "note_001", categoryId = "cat_001", title = "Active 1", isDeleted = false),
|
|
||||||
Note(id = "note_002", categoryId = "cat_001", title = "Deleted 1", isDeleted = true),
|
|
||||||
Note(id = "note_003", categoryId = "cat_001", title = "Active 2", isDeleted = false),
|
|
||||||
Note(id = "note_004", categoryId = "cat_001", title = "Deleted 2", isDeleted = true),
|
|
||||||
Note(id = "note_005", categoryId = "cat_001", title = "Active 3", isDeleted = false)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(notes)
|
|
||||||
|
|
||||||
// When - Filter only deleted notes (simulating TrashScreen behavior)
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
val deletedNotes = savedNotes.filter { it.isDeleted }
|
|
||||||
|
|
||||||
// Then - Should only have 2 deleted notes
|
|
||||||
assertEquals(2, deletedNotes.size)
|
|
||||||
assertTrue(deletedNotes.all { it.isDeleted })
|
|
||||||
assertEquals(setOf("Deleted 1", "Deleted 2"), deletedNotes.map { it.title }.toSet())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testFilterDeletedCategories_shouldOnlyShowDeleted() = runBlocking {
|
|
||||||
// Given - Mix of deleted and active categories
|
|
||||||
val categories = listOf(
|
|
||||||
Category(id = "cat_001", name = "Active Work", gradientStart = 0xFF000000, gradientEnd = 0xFF111111, isDeleted = false),
|
|
||||||
Category(id = "cat_002", name = "Deleted Personal", gradientStart = 0xFF222222, gradientEnd = 0xFF333333, isDeleted = true),
|
|
||||||
Category(id = "cat_003", name = "Active Ideas", gradientStart = 0xFF444444, gradientEnd = 0xFF555555, isDeleted = false),
|
|
||||||
Category(id = "cat_004", name = "Deleted Projects", gradientStart = 0xFF666666, gradientEnd = 0xFF777777, isDeleted = true)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveCategories(categories)
|
|
||||||
|
|
||||||
// When - Filter only deleted categories
|
|
||||||
val savedCategories = dataStoreManager.categoriesFlow.first()
|
|
||||||
val deletedCategories = savedCategories.filter { it.isDeleted }
|
|
||||||
|
|
||||||
// Then - Should only have 2 deleted categories
|
|
||||||
assertEquals(2, deletedCategories.size)
|
|
||||||
assertTrue(deletedCategories.all { it.isDeleted })
|
|
||||||
assertEquals(setOf("Deleted Personal", "Deleted Projects"), deletedCategories.map { it.name }.toSet())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testPermanentDeleteNote_shouldRemoveCompletely() = runBlocking {
|
|
||||||
// Given - Two deleted notes
|
|
||||||
val notes = listOf(
|
|
||||||
Note(id = "note_001", categoryId = "cat_001", title = "Keep This", isDeleted = true),
|
|
||||||
Note(id = "note_002", categoryId = "cat_001", title = "Delete This", isDeleted = true)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(notes)
|
|
||||||
|
|
||||||
// When - Permanently delete one note (remove from list)
|
|
||||||
val remainingNotes = notes.filter { it.id != "note_002" }
|
|
||||||
dataStoreManager.saveNotes(remainingNotes)
|
|
||||||
|
|
||||||
// Then - Should only have 1 note left
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
assertEquals(1, savedNotes.size)
|
|
||||||
assertEquals("Keep This", savedNotes[0].title)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testPermanentDeleteCategory_shouldRemoveCompletely() = runBlocking {
|
|
||||||
// Given - Two deleted categories
|
|
||||||
val categories = listOf(
|
|
||||||
Category(id = "cat_001", name = "Keep This", gradientStart = 0xFF000000, gradientEnd = 0xFF111111, isDeleted = true),
|
|
||||||
Category(id = "cat_002", name = "Delete This", gradientStart = 0xFF222222, gradientEnd = 0xFF333333, isDeleted = true)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveCategories(categories)
|
|
||||||
|
|
||||||
// When - Permanently delete one category
|
|
||||||
val remainingCategories = categories.filter { it.id != "cat_002" }
|
|
||||||
dataStoreManager.saveCategories(remainingCategories)
|
|
||||||
|
|
||||||
// Then - Should only have 1 category left
|
|
||||||
val savedCategories = dataStoreManager.categoriesFlow.first()
|
|
||||||
assertEquals(1, savedCategories.size)
|
|
||||||
assertEquals("Keep This", savedCategories[0].name)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testSearchInTrash_shouldFindDeletedItems() = runBlocking {
|
|
||||||
// Given - Deleted notes with different content
|
|
||||||
val notes = listOf(
|
|
||||||
Note(id = "note_001", categoryId = "cat_001", title = "Meeting Minutes", content = "Discuss Q4 goals", isDeleted = true),
|
|
||||||
Note(id = "note_002", categoryId = "cat_001", title = "Shopping List", content = "Buy groceries", isDeleted = true),
|
|
||||||
Note(id = "note_003", categoryId = "cat_001", title = "Project Ideas", content = "Brainstorm features", isDeleted = true)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(notes)
|
|
||||||
|
|
||||||
// When - Search for "Meeting" (simulating TrashScreen search)
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
val deletedNotes = savedNotes.filter { it.isDeleted }
|
|
||||||
val searchQuery = "Meeting"
|
|
||||||
val searchResults = deletedNotes.filter {
|
|
||||||
it.title.contains(searchQuery, ignoreCase = true) ||
|
|
||||||
it.content.contains(searchQuery, ignoreCase = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - Should find 1 note
|
|
||||||
assertEquals(1, searchResults.size)
|
|
||||||
assertEquals("Meeting Minutes", searchResults[0].title)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testRestoreMultipleNotes_shouldRestoreAll() = runBlocking {
|
|
||||||
// Given - Multiple deleted notes
|
|
||||||
val notes = listOf(
|
|
||||||
Note(id = "note_001", categoryId = "cat_001", title = "Note 1", isDeleted = true),
|
|
||||||
Note(id = "note_002", categoryId = "cat_001", title = "Note 2", isDeleted = true),
|
|
||||||
Note(id = "note_003", categoryId = "cat_001", title = "Note 3", isDeleted = true)
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(notes)
|
|
||||||
|
|
||||||
// When - Restore all notes
|
|
||||||
val restoredNotes = notes.map { it.copy(isDeleted = false) }
|
|
||||||
dataStoreManager.saveNotes(restoredNotes)
|
|
||||||
|
|
||||||
// Then - All notes should be restored
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
assertEquals(3, savedNotes.size)
|
|
||||||
assertTrue(savedNotes.all { !it.isDeleted })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testDeletedNotePreservesAllData_shouldKeepContent() = runBlocking {
|
|
||||||
// Given - A note with all fields populated
|
|
||||||
val note = Note(
|
|
||||||
id = "note_001",
|
|
||||||
categoryId = "cat_001",
|
|
||||||
title = "Complete Note",
|
|
||||||
description = "This is a description",
|
|
||||||
content = "Full content here with details",
|
|
||||||
timestamp = System.currentTimeMillis(),
|
|
||||||
isPinned = true,
|
|
||||||
isArchived = false,
|
|
||||||
isDeleted = false
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.saveNotes(listOf(note))
|
|
||||||
|
|
||||||
// When - Soft delete
|
|
||||||
val deletedNote = note.copy(isDeleted = true)
|
|
||||||
dataStoreManager.saveNotes(listOf(deletedNote))
|
|
||||||
|
|
||||||
// Then - All data should be preserved
|
|
||||||
val savedNotes = dataStoreManager.notesFlow.first()
|
|
||||||
assertEquals(1, savedNotes.size)
|
|
||||||
with(savedNotes[0]) {
|
|
||||||
assertEquals("Complete Note", title)
|
|
||||||
assertEquals("This is a description", description)
|
|
||||||
assertEquals("Full content here with details", content)
|
|
||||||
assertTrue(isPinned)
|
|
||||||
assertFalse(isArchived)
|
|
||||||
assertTrue(isDeleted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,29 +3,19 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<!-- Read files -->
|
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
|
||||||
android:maxSdkVersion="32" />
|
|
||||||
|
|
||||||
<!-- For Android 13+ -->
|
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".NotesAIApplication"
|
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/iconapp"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/iconapp"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.NotesAI"
|
android:theme="@style/Theme.NotesAI"
|
||||||
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,22 +1,24 @@
|
|||||||
package com.example.notesai
|
package com.example.notesai
|
||||||
|
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.animation.core.Spring
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.animation.core.spring
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import java.util.UUID
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import com.example.notesai.data.local.DataStoreManager
|
import com.example.notesai.data.local.DataStoreManager
|
||||||
import com.example.notesai.presentation.components.DrawerMenu
|
import com.example.notesai.presentation.components.DrawerMenu
|
||||||
@ -32,103 +34,32 @@ import com.example.notesai.presentation.screens.starred.StarredNotesScreen
|
|||||||
import com.example.notesai.presentation.screens.trash.TrashScreen
|
import com.example.notesai.presentation.screens.trash.TrashScreen
|
||||||
import com.example.notesai.data.model.Note
|
import com.example.notesai.data.model.Note
|
||||||
import com.example.notesai.data.model.Category
|
import com.example.notesai.data.model.Category
|
||||||
import com.example.notesai.util.AppColors
|
import com.example.notesai.util.updateWhere
|
||||||
import com.example.notesai.util.Constants
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContent {
|
setContent {
|
||||||
NotesAppTheme()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun NotesAppTheme(content: @Composable () -> Unit = { NotesApp() }) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val dataStoreManager = remember { DataStoreManager(context) }
|
|
||||||
var isDarkTheme by remember { mutableStateOf(true) }
|
|
||||||
|
|
||||||
// Load theme preference
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
dataStoreManager.themeFlow.collect { theme ->
|
|
||||||
isDarkTheme = theme == "dark"
|
|
||||||
AppColors.setTheme(isDarkTheme)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create dynamic color scheme based on theme
|
|
||||||
val colorScheme = if (isDarkTheme) {
|
|
||||||
darkColorScheme(
|
|
||||||
primary = AppColors.Primary,
|
|
||||||
onPrimary = Color.White,
|
|
||||||
primaryContainer = AppColors.Primary.copy(alpha = 0.3f),
|
|
||||||
onPrimaryContainer = Color.White,
|
|
||||||
secondary = AppColors.Secondary,
|
|
||||||
onSecondary = Color.White,
|
|
||||||
secondaryContainer = AppColors.Secondary.copy(alpha = 0.3f),
|
|
||||||
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
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
lightColorScheme(
|
|
||||||
primary = AppColors.Primary,
|
|
||||||
onPrimary = Color.White,
|
|
||||||
primaryContainer = AppColors.Primary.copy(alpha = 0.1f),
|
|
||||||
onPrimaryContainer = AppColors.Primary,
|
|
||||||
secondary = AppColors.Secondary,
|
|
||||||
onSecondary = Color.White,
|
|
||||||
secondaryContainer = AppColors.Secondary.copy(alpha = 0.1f),
|
|
||||||
onSecondaryContainer = AppColors.Secondary,
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = darkColorScheme(
|
||||||
typography = Typography(
|
primary = Color(0xFF6366F1),
|
||||||
displayLarge = MaterialTheme.typography.displayLarge.copy(
|
secondary = Color(0xFFA855F7),
|
||||||
fontWeight = FontWeight.Bold
|
background = Color(0xFF0F172A),
|
||||||
),
|
surface = Color(0xFF1E293B),
|
||||||
headlineLarge = MaterialTheme.typography.headlineLarge.copy(
|
onPrimary = Color.White,
|
||||||
fontWeight = FontWeight.Bold
|
onSecondary = Color.White,
|
||||||
),
|
onBackground = Color(0xFFE2E8F0),
|
||||||
titleLarge = MaterialTheme.typography.titleLarge.copy(
|
onSurface = Color(0xFFE2E8F0)
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
),
|
|
||||||
bodyLarge = MaterialTheme.typography.bodyLarge.copy(
|
|
||||||
lineHeight = 24.sp
|
|
||||||
),
|
|
||||||
bodyMedium = MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
lineHeight = 20.sp
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background
|
color = MaterialTheme.colorScheme.background
|
||||||
) {
|
) {
|
||||||
content()
|
NotesApp()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -139,7 +70,6 @@ fun NotesApp() {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val dataStoreManager = remember { DataStoreManager(context) }
|
val dataStoreManager = remember { DataStoreManager(context) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
|
|
||||||
|
|
||||||
var categories by remember { mutableStateOf(listOf<Category>()) }
|
var categories by remember { mutableStateOf(listOf<Category>()) }
|
||||||
var notes by remember { mutableStateOf(listOf<Note>()) }
|
var notes by remember { mutableStateOf(listOf<Note>()) }
|
||||||
@ -153,130 +83,85 @@ 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) }
|
|
||||||
|
|
||||||
// STATE UNTUK AI
|
|
||||||
var showAIDrawer by remember { mutableStateOf(false) }
|
|
||||||
var aiSelectedCategory by remember { mutableStateOf<Category?>(null) }
|
|
||||||
var currentChatId by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
var isDataLoaded by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// Load chat histories dari DataStore
|
|
||||||
val chatHistories by dataStoreManager.chatHistoryFlow.collectAsState(initial = emptyList())
|
|
||||||
|
|
||||||
fun sortCategories(categories: List<Category>): List<Category> {
|
|
||||||
return categories
|
|
||||||
.filter { !it.isDeleted }
|
|
||||||
.sortedWith(
|
|
||||||
compareByDescending<Category> { it.isPinned }
|
|
||||||
.thenByDescending { it.timestamp }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
dataStoreManager.themeFlow.collect { theme ->
|
|
||||||
isDarkTheme = theme == "dark"
|
|
||||||
AppColors.setTheme(isDarkTheme)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Load data dari DataStore
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
try {
|
||||||
dataStoreManager.categoriesFlow.collect { loadedCategories ->
|
dataStoreManager.categoriesFlow.collect { loadedCategories ->
|
||||||
if (!isDataLoaded) {
|
|
||||||
android.util.Log.d("NotesApp", "Loading ${loadedCategories.size} categories")
|
|
||||||
categories = loadedCategories
|
categories = loadedCategories
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(categories) {
|
// Simpan categories dengan debounce
|
||||||
if (isDataLoaded && categories.isNotEmpty()) {
|
LaunchedEffect(categories.size) {
|
||||||
android.util.Log.d("NotesApp", "Saving ${categories.size} categories")
|
|
||||||
scope.launch {
|
|
||||||
dataStoreManager.saveCategories(categories)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(notes) {
|
|
||||||
if (isDataLoaded && notes.isNotEmpty()) {
|
|
||||||
android.util.Log.d("NotesApp", "Saving ${notes.size} notes")
|
|
||||||
scope.launch {
|
|
||||||
dataStoreManager.saveNotes(notes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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()) {
|
if (categories.isNotEmpty()) {
|
||||||
|
delay(500)
|
||||||
|
try {
|
||||||
dataStoreManager.saveCategories(categories)
|
dataStoreManager.saveCategories(categories)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simpan notes dengan debounce
|
||||||
|
LaunchedEffect(notes.size) {
|
||||||
if (notes.isNotEmpty()) {
|
if (notes.isNotEmpty()) {
|
||||||
|
delay(500)
|
||||||
|
try {
|
||||||
dataStoreManager.saveNotes(notes)
|
dataStoreManager.saveNotes(notes)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
lifecycleOwner.lifecycle.addObserver(observer)
|
|
||||||
onDispose {
|
|
||||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
// LAYER 1: Main Content (Scaffold)
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = AppColors.Background,
|
|
||||||
topBar = {
|
topBar = {
|
||||||
if (!showFullScreenNote && currentScreen != "ai") {
|
if (!showFullScreenNote) {
|
||||||
ModernTopBar(
|
ModernTopBar(
|
||||||
title = when(currentScreen) {
|
title = when(currentScreen) {
|
||||||
"main" -> if (selectedCategory != null) selectedCategory!!.name else "NotesAI"
|
"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 -> "NotesAI"
|
else -> "AI Notes"
|
||||||
},
|
},
|
||||||
showBackButton = (selectedCategory != null && currentScreen == "main"),
|
showBackButton = (selectedCategory != null && currentScreen == "main") || currentScreen == "ai" || currentScreen == "starred",
|
||||||
onBackClick = {
|
onBackClick = {
|
||||||
|
if (currentScreen == "ai" || currentScreen == "starred") {
|
||||||
|
currentScreen = "main"
|
||||||
|
} else {
|
||||||
selectedCategory = null
|
selectedCategory = null
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onMenuClick = { drawerState = !drawerState },
|
onMenuClick = { drawerState = !drawerState },
|
||||||
onSearchClick = {
|
onSearchClick = { showSearch = !showSearch },
|
||||||
showSearch = !showSearch
|
|
||||||
if (!showSearch) searchQuery = "" // Reset search saat close
|
|
||||||
},
|
|
||||||
searchQuery = searchQuery,
|
searchQuery = searchQuery,
|
||||||
onSearchQueryChange = { searchQuery = it },
|
onSearchQueryChange = { searchQuery = it },
|
||||||
showSearch = showSearch // AKTIFKAN UNTUK SEMUA SCREEN
|
showSearch = showSearch && currentScreen == "main"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = currentScreen == "main" && !showFullScreenNote,
|
visible = currentScreen == "main" && !showFullScreenNote,
|
||||||
enter = scaleIn(
|
enter = scaleIn() + fadeIn(),
|
||||||
animationSpec = spring(
|
|
||||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
|
||||||
stiffness = Spring.StiffnessLow
|
|
||||||
)
|
|
||||||
) + fadeIn(),
|
|
||||||
exit = scaleOut() + fadeOut()
|
exit = scaleOut() + fadeOut()
|
||||||
) {
|
) {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
@ -288,18 +173,20 @@ fun NotesApp() {
|
|||||||
showCategoryDialog = true
|
showCategoryDialog = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
containerColor = AppColors.Primary,
|
containerColor = Color.Transparent,
|
||||||
contentColor = Color.White,
|
modifier = Modifier
|
||||||
elevation = FloatingActionButtonDefaults.elevation(
|
.shadow(8.dp, CircleShape)
|
||||||
defaultElevation = 8.dp,
|
.background(
|
||||||
pressedElevation = 12.dp
|
brush = Brush.linearGradient(
|
||||||
|
colors = listOf(Color(0xFF6366F1), Color(0xFFA855F7))
|
||||||
),
|
),
|
||||||
modifier = Modifier.size(64.dp)
|
shape = CircleShape
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Add,
|
Icons.Default.Add,
|
||||||
contentDescription = if (selectedCategory != null) "Tambah Catatan" else "Tambah Kategori",
|
contentDescription = if (selectedCategory != null) "Tambah Catatan" else "Tambah Kategori",
|
||||||
modifier = Modifier.size(28.dp)
|
tint = Color.White
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -368,7 +255,7 @@ fun NotesApp() {
|
|||||||
) {
|
) {
|
||||||
when (currentScreen) {
|
when (currentScreen) {
|
||||||
"main" -> MainScreen(
|
"main" -> MainScreen(
|
||||||
categories = sortCategories(categories),
|
categories = categories.filter { !it.isDeleted }, // TAMBAHKAN FILTER INI
|
||||||
notes = notes,
|
notes = notes,
|
||||||
selectedCategory = selectedCategory,
|
selectedCategory = selectedCategory,
|
||||||
searchQuery = searchQuery,
|
searchQuery = searchQuery,
|
||||||
@ -384,10 +271,12 @@ fun NotesApp() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCategoryDelete = { category ->
|
onCategoryDelete = { category ->
|
||||||
|
// UBAH: Jangan filter, tapi set isDeleted = true
|
||||||
categories = categories.map {
|
categories = categories.map {
|
||||||
if (it.id == category.id) it.copy(isDeleted = true)
|
if (it.id == category.id) it.copy(isDeleted = true)
|
||||||
else it
|
else it
|
||||||
}
|
}
|
||||||
|
// Note di dalam kategori juga di-delete
|
||||||
notes = notes.map {
|
notes = notes.map {
|
||||||
if (it.categoryId == category.id) it.copy(isDeleted = true)
|
if (it.categoryId == category.id) it.copy(isDeleted = true)
|
||||||
else it
|
else it
|
||||||
@ -407,29 +296,12 @@ fun NotesApp() {
|
|||||||
it
|
it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
onCategoryPin = { category ->
|
|
||||||
categories = categories.map {
|
|
||||||
if (it.id == category.id) it.copy(isPinned = !it.isPinned)
|
|
||||||
else it
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onNoteEdit = { note ->
|
|
||||||
editingNote = note
|
|
||||||
showNoteDialog = true
|
|
||||||
},
|
|
||||||
onNoteDelete = { note ->
|
|
||||||
notes = notes.map {
|
|
||||||
if (it.id == note.id) it.copy(isDeleted = true)
|
|
||||||
else it
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
"trash" -> TrashScreen(
|
"trash" -> TrashScreen(
|
||||||
notes = notes.filter { it.isDeleted },
|
notes = notes.filter { it.isDeleted },
|
||||||
categories = categories,
|
categories = categories, // Pass semua categories (sudah ada yang isDeleted)
|
||||||
searchQuery = searchQuery, // TAMBAHKAN INI
|
|
||||||
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)
|
||||||
@ -440,29 +312,34 @@ fun NotesApp() {
|
|||||||
notes = notes.filter { it.id != note.id }
|
notes = notes.filter { it.id != note.id }
|
||||||
},
|
},
|
||||||
onRestoreCategory = { category ->
|
onRestoreCategory = { category ->
|
||||||
|
// Restore kategori
|
||||||
categories = categories.map {
|
categories = categories.map {
|
||||||
if (it.id == category.id) it.copy(isDeleted = false)
|
if (it.id == category.id) it.copy(isDeleted = false)
|
||||||
else it
|
else it
|
||||||
}
|
}
|
||||||
|
// Restore semua note di dalam kategori
|
||||||
notes = notes.map {
|
notes = notes.map {
|
||||||
if (it.categoryId == category.id) it.copy(isDeleted = false, isArchived = false)
|
if (it.categoryId == category.id) it.copy(isDeleted = false, isArchived = false)
|
||||||
else it
|
else it
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDeleteCategoryPermanent = { category ->
|
onDeleteCategoryPermanent = { category ->
|
||||||
|
// Hapus kategori permanen
|
||||||
categories = categories.filter { it.id != category.id }
|
categories = categories.filter { it.id != category.id }
|
||||||
|
// Hapus semua note di dalam kategori permanen
|
||||||
notes = notes.filter { it.categoryId != category.id }
|
notes = notes.filter { it.categoryId != category.id }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
"starred" -> StarredNotesScreen(
|
"starred" -> StarredNotesScreen(
|
||||||
notes = notes,
|
notes = notes,
|
||||||
categories = categories.filter { !it.isDeleted },
|
categories = categories.filter { !it.isDeleted }, // FILTER
|
||||||
searchQuery = searchQuery, // TAMBAHKAN INI
|
|
||||||
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)
|
||||||
@ -473,8 +350,7 @@ fun NotesApp() {
|
|||||||
|
|
||||||
"archive" -> ArchiveScreen(
|
"archive" -> ArchiveScreen(
|
||||||
notes = notes.filter { it.isArchived && !it.isDeleted },
|
notes = notes.filter { it.isArchived && !it.isDeleted },
|
||||||
categories = categories.filter { !it.isDeleted },
|
categories = categories.filter { !it.isDeleted }, // FILTER
|
||||||
searchQuery = searchQuery,
|
|
||||||
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)
|
||||||
@ -490,14 +366,14 @@ fun NotesApp() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
"ai" -> AIHelperScreen(
|
"ai" -> AIHelperScreen(
|
||||||
categories = categories.filter { !it.isDeleted },
|
categories = categories.filter { !it.isDeleted }, // FILTER
|
||||||
notes = notes.filter { !it.isDeleted },
|
notes = notes.filter { !it.isDeleted }
|
||||||
onShowDrawer = { showAIDrawer = true }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dialogs
|
||||||
if (showCategoryDialog) {
|
if (showCategoryDialog) {
|
||||||
CategoryDialog(
|
CategoryDialog(
|
||||||
onDismiss = { showCategoryDialog = false },
|
onDismiss = { showCategoryDialog = false },
|
||||||
@ -515,18 +391,17 @@ fun NotesApp() {
|
|||||||
if (showNoteDialog && selectedCategory != null) {
|
if (showNoteDialog && selectedCategory != null) {
|
||||||
NoteDialog(
|
NoteDialog(
|
||||||
note = editingNote,
|
note = editingNote,
|
||||||
categoryId = selectedCategory!!.id,
|
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
showNoteDialog = false
|
showNoteDialog = false
|
||||||
editingNote = null
|
editingNote = null
|
||||||
},
|
},
|
||||||
onSave = { title, description ->
|
onSave = { title, content ->
|
||||||
if (editingNote != null) {
|
if (editingNote != null) {
|
||||||
notes = notes.map {
|
notes = notes.map {
|
||||||
if (it.id == editingNote!!.id)
|
if (it.id == editingNote!!.id)
|
||||||
it.copy(
|
it.copy(
|
||||||
title = title,
|
title = title,
|
||||||
description = description,
|
content = content,
|
||||||
timestamp = System.currentTimeMillis()
|
timestamp = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
else it
|
else it
|
||||||
@ -535,8 +410,7 @@ fun NotesApp() {
|
|||||||
notes = notes + Note(
|
notes = notes + Note(
|
||||||
categoryId = selectedCategory!!.id,
|
categoryId = selectedCategory!!.id,
|
||||||
title = title,
|
title = title,
|
||||||
description = description,
|
content = content
|
||||||
content = ""
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
showNoteDialog = false
|
showNoteDialog = false
|
||||||
@ -557,73 +431,29 @@ fun NotesApp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LAYER 2: Main Drawer (z-index 150)
|
// Drawer with Animation - DI LUAR SCAFFOLD agar di atas semua
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = drawerState,
|
visible = drawerState,
|
||||||
enter = fadeIn() + slideInHorizontally(initialOffsetX = { -it }),
|
enter = fadeIn() + slideInHorizontally(
|
||||||
exit = fadeOut() + slideOutHorizontally(targetOffsetX = { -it }),
|
initialOffsetX = { -it }
|
||||||
modifier = Modifier.zIndex(150f)
|
),
|
||||||
|
exit = fadeOut() + slideOutHorizontally(
|
||||||
|
targetOffsetX = { -it }
|
||||||
|
),
|
||||||
|
modifier = Modifier.zIndex(100f) // Z-index tinggi
|
||||||
) {
|
) {
|
||||||
DrawerMenu(
|
DrawerMenu(
|
||||||
currentScreen = currentScreen,
|
currentScreen = currentScreen,
|
||||||
isDarkTheme = isDarkTheme,
|
|
||||||
onDismiss = { drawerState = false },
|
onDismiss = { drawerState = false },
|
||||||
onItemClick = { screen ->
|
onItemClick = { screen ->
|
||||||
currentScreen = screen
|
currentScreen = screen
|
||||||
selectedCategory = null
|
selectedCategory = null
|
||||||
drawerState = false
|
drawerState = false
|
||||||
showSearch = false // TUTUP SEARCH
|
showSearch = false
|
||||||
searchQuery = "" // RESET SEARCH QUERY
|
searchQuery = ""
|
||||||
},
|
|
||||||
onThemeToggle = {
|
|
||||||
isDarkTheme = !isDarkTheme
|
|
||||||
AppColors.setTheme(isDarkTheme)
|
|
||||||
scope.launch {
|
|
||||||
dataStoreManager.saveTheme(if (isDarkTheme) "dark" else "light")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// LAYER 3: AI History Drawer (z-index 200 - PALING ATAS)
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = showAIDrawer,
|
|
||||||
enter = fadeIn() + slideInHorizontally(initialOffsetX = { -it }),
|
|
||||||
exit = fadeOut() + slideOutHorizontally(targetOffsetX = { -it }),
|
|
||||||
modifier = Modifier.zIndex(200f)
|
|
||||||
) {
|
|
||||||
com.example.notesai.presentation.screens.ai.components.ChatHistoryDrawer(
|
|
||||||
chatHistories = chatHistories, // GUNAKAN chatHistories dari collectAsState
|
|
||||||
categories = categories.filter { !it.isDeleted },
|
|
||||||
notes = notes.filter { !it.isDeleted },
|
|
||||||
selectedCategory = aiSelectedCategory,
|
|
||||||
onDismiss = { showAIDrawer = false },
|
|
||||||
onHistoryClick = { history ->
|
|
||||||
// Load chat history
|
|
||||||
aiSelectedCategory = categories.find { it.id == history.categoryId }
|
|
||||||
currentChatId = history.id
|
|
||||||
showAIDrawer = false
|
|
||||||
// Anda perlu cara untuk pass data ini ke AIHelperScreen
|
|
||||||
},
|
|
||||||
onDeleteHistory = { historyId ->
|
|
||||||
scope.launch {
|
|
||||||
dataStoreManager.deleteChatHistory(historyId)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onCategorySelected = { category ->
|
|
||||||
aiSelectedCategory = category
|
|
||||||
},
|
|
||||||
onNewChat = {
|
|
||||||
aiSelectedCategory = null
|
|
||||||
currentChatId = null
|
|
||||||
showAIDrawer = false
|
|
||||||
},
|
|
||||||
onEditHistoryTitle = { historyId, newTitle ->
|
|
||||||
scope.launch {
|
|
||||||
dataStoreManager.updateChatHistoryTitle(historyId, newTitle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
package com.example.notesai
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import com.example.notesai.util.FileParser
|
|
||||||
|
|
||||||
class NotesAIApplication : Application() {
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
|
|
||||||
// Initialize PDFBox
|
|
||||||
FileParser.initPDFBox(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -11,7 +11,6 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
|||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import com.example.notesai.data.model.Note
|
import com.example.notesai.data.model.Note
|
||||||
import com.example.notesai.data.model.Category
|
import com.example.notesai.data.model.Category
|
||||||
import com.example.notesai.data.model.ChatHistory
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@ -29,9 +28,7 @@ data class SerializableCategory(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val gradientStart: Long,
|
val gradientStart: Long,
|
||||||
val gradientEnd: Long,
|
val gradientEnd: Long,
|
||||||
val timestamp: Long,
|
val timestamp: Long
|
||||||
val isDeleted: Boolean = false,
|
|
||||||
val isPinned: Boolean = false // NEW
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@ -39,20 +36,17 @@ data class SerializableNote(
|
|||||||
val id: String,
|
val id: String,
|
||||||
val categoryId: String,
|
val categoryId: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val description: String = "",
|
val content: String,
|
||||||
val content: String = "",
|
|
||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
val isArchived: Boolean = false,
|
val isArchived: Boolean,
|
||||||
val isDeleted: Boolean = false,
|
val isDeleted: Boolean,
|
||||||
val isPinned: Boolean = false
|
val isPinned: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
class DataStoreManager(private val context: Context) {
|
class DataStoreManager(private val context: Context) {
|
||||||
companion object {
|
companion object {
|
||||||
val CATEGORIES_KEY = stringPreferencesKey("categories")
|
val CATEGORIES_KEY = stringPreferencesKey("categories")
|
||||||
val NOTES_KEY = stringPreferencesKey("notes")
|
val NOTES_KEY = stringPreferencesKey("notes")
|
||||||
val CHAT_HISTORY_KEY = stringPreferencesKey("chat_history")
|
|
||||||
val THEME_KEY = stringPreferencesKey("theme") // "dark" or "light"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val json = Json {
|
private val json = Json {
|
||||||
@ -72,18 +66,9 @@ class DataStoreManager(private val context: Context) {
|
|||||||
val jsonString = preferences[CATEGORIES_KEY] ?: "[]"
|
val jsonString = preferences[CATEGORIES_KEY] ?: "[]"
|
||||||
try {
|
try {
|
||||||
json.decodeFromString<List<SerializableCategory>>(jsonString).map {
|
json.decodeFromString<List<SerializableCategory>>(jsonString).map {
|
||||||
Category(
|
Category(it.id, it.name, it.gradientStart, it.gradientEnd, it.timestamp)
|
||||||
id = it.id,
|
|
||||||
name = it.name,
|
|
||||||
gradientStart = it.gradientStart,
|
|
||||||
gradientEnd = it.gradientEnd,
|
|
||||||
timestamp = it.timestamp,
|
|
||||||
isDeleted = it.isDeleted,
|
|
||||||
isPinned = it.isPinned // NEW
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -101,40 +86,17 @@ class DataStoreManager(private val context: Context) {
|
|||||||
try {
|
try {
|
||||||
json.decodeFromString<List<SerializableNote>>(jsonString).map {
|
json.decodeFromString<List<SerializableNote>>(jsonString).map {
|
||||||
Note(
|
Note(
|
||||||
id = it.id,
|
it.id,
|
||||||
categoryId = it.categoryId,
|
it.categoryId,
|
||||||
title = it.title,
|
it.title,
|
||||||
description = it.description,
|
it.content,
|
||||||
content = it.content,
|
it.timestamp,
|
||||||
timestamp = it.timestamp,
|
it.isArchived,
|
||||||
isPinned = it.isPinned,
|
it.isDeleted,
|
||||||
isArchived = it.isArchived,
|
it.isPinned
|
||||||
isDeleted = it.isDeleted
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chat History Flow
|
|
||||||
val chatHistoryFlow: Flow<List<ChatHistory>> = context.dataStore.data
|
|
||||||
.catch { exception ->
|
|
||||||
if (exception is IOException) {
|
|
||||||
emit(emptyPreferences())
|
|
||||||
} else {
|
|
||||||
throw exception
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.map { preferences ->
|
|
||||||
val jsonString = preferences[CHAT_HISTORY_KEY] ?: "[]"
|
|
||||||
try {
|
|
||||||
json.decodeFromString<List<ChatHistory>>(jsonString)
|
|
||||||
.filter { !it.isDeleted }
|
|
||||||
.sortedByDescending { it.timestamp }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -143,15 +105,7 @@ class DataStoreManager(private val context: Context) {
|
|||||||
try {
|
try {
|
||||||
context.dataStore.edit { preferences ->
|
context.dataStore.edit { preferences ->
|
||||||
val serializable = categories.map {
|
val serializable = categories.map {
|
||||||
SerializableCategory(
|
SerializableCategory(it.id, it.name, it.gradientStart, it.gradientEnd, it.timestamp)
|
||||||
id = it.id,
|
|
||||||
name = it.name,
|
|
||||||
gradientStart = it.gradientStart,
|
|
||||||
gradientEnd = it.gradientEnd,
|
|
||||||
timestamp = it.timestamp,
|
|
||||||
isDeleted = it.isDeleted,
|
|
||||||
isPinned = it.isPinned // NEW
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
preferences[CATEGORIES_KEY] = json.encodeToString(serializable)
|
preferences[CATEGORIES_KEY] = json.encodeToString(serializable)
|
||||||
}
|
}
|
||||||
@ -164,17 +118,7 @@ class DataStoreManager(private val context: Context) {
|
|||||||
try {
|
try {
|
||||||
context.dataStore.edit { preferences ->
|
context.dataStore.edit { preferences ->
|
||||||
val serializable = notes.map {
|
val serializable = notes.map {
|
||||||
SerializableNote(
|
SerializableNote(it.id, it.categoryId, it.title, it.content, it.timestamp, it.isArchived, it.isDeleted, it.isPinned)
|
||||||
id = it.id,
|
|
||||||
categoryId = it.categoryId,
|
|
||||||
title = it.title,
|
|
||||||
description = it.description,
|
|
||||||
content = it.content,
|
|
||||||
timestamp = it.timestamp,
|
|
||||||
isPinned = it.isPinned,
|
|
||||||
isArchived = it.isArchived,
|
|
||||||
isDeleted = it.isDeleted
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
preferences[NOTES_KEY] = json.encodeToString(serializable)
|
preferences[NOTES_KEY] = json.encodeToString(serializable)
|
||||||
}
|
}
|
||||||
@ -182,97 +126,4 @@ class DataStoreManager(private val context: Context) {
|
|||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save Chat History
|
|
||||||
suspend fun saveChatHistory(chatHistoryList: List<ChatHistory>) {
|
|
||||||
try {
|
|
||||||
context.dataStore.edit { preferences ->
|
|
||||||
preferences[CHAT_HISTORY_KEY] = json.encodeToString(chatHistoryList)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add new chat history
|
|
||||||
suspend fun addChatHistory(chatHistory: ChatHistory) {
|
|
||||||
try {
|
|
||||||
context.dataStore.edit { preferences ->
|
|
||||||
val jsonString = preferences[CHAT_HISTORY_KEY] ?: "[]"
|
|
||||||
val currentList = json.decodeFromString<List<ChatHistory>>(jsonString).toMutableList()
|
|
||||||
|
|
||||||
// Check if already exists, update instead
|
|
||||||
val existingIndex = currentList.indexOfFirst { it.id == chatHistory.id }
|
|
||||||
if (existingIndex != -1) {
|
|
||||||
currentList[existingIndex] = chatHistory
|
|
||||||
} else {
|
|
||||||
currentList.add(0, chatHistory) // Add to beginning
|
|
||||||
}
|
|
||||||
|
|
||||||
preferences[CHAT_HISTORY_KEY] = json.encodeToString(currentList)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete chat history (soft delete)
|
|
||||||
suspend fun deleteChatHistory(historyId: String) {
|
|
||||||
try {
|
|
||||||
context.dataStore.edit { preferences ->
|
|
||||||
val jsonString = preferences[CHAT_HISTORY_KEY] ?: "[]"
|
|
||||||
val currentList = json.decodeFromString<List<ChatHistory>>(jsonString).toMutableList()
|
|
||||||
val updatedList = currentList.map {
|
|
||||||
if (it.id == historyId) it.copy(isDeleted = true) else it
|
|
||||||
}
|
|
||||||
preferences[CHAT_HISTORY_KEY] = json.encodeToString(updatedList)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NEW: Update chat history title
|
|
||||||
suspend fun updateChatHistoryTitle(historyId: String, newTitle: String) {
|
|
||||||
try {
|
|
||||||
context.dataStore.edit { preferences ->
|
|
||||||
val jsonString = preferences[CHAT_HISTORY_KEY] ?: "[]"
|
|
||||||
val currentList = json.decodeFromString<List<ChatHistory>>(jsonString).toMutableList()
|
|
||||||
val updatedList = currentList.map {
|
|
||||||
if (it.id == historyId) {
|
|
||||||
it.copy(customTitle = newTitle)
|
|
||||||
} else {
|
|
||||||
it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
preferences[CHAT_HISTORY_KEY] = json.encodeToString(updatedList)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Theme Preference Flow
|
|
||||||
val themeFlow: Flow<String> = context.dataStore.data
|
|
||||||
.catch { exception ->
|
|
||||||
if (exception is IOException) {
|
|
||||||
emit(emptyPreferences())
|
|
||||||
} else {
|
|
||||||
throw exception
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.map { preferences ->
|
|
||||||
preferences[THEME_KEY] ?: "dark" // Default dark theme
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save Theme Preference
|
|
||||||
suspend fun saveTheme(theme: String) {
|
|
||||||
try {
|
|
||||||
context.dataStore.edit { preferences ->
|
|
||||||
preferences[THEME_KEY] = theme
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -1,15 +1,13 @@
|
|||||||
|
// File: data/model/Category.kt
|
||||||
package com.example.notesai.data.model
|
package com.example.notesai.data.model
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Category(
|
data class Category(
|
||||||
val id: String = UUID.randomUUID().toString(),
|
val id: String = UUID.randomUUID().toString(),
|
||||||
val name: String,
|
val name: String,
|
||||||
val gradientStart: Long,
|
val gradientStart: Long,
|
||||||
val gradientEnd: Long,
|
val gradientEnd: Long,
|
||||||
val timestamp: Long = System.currentTimeMillis(),
|
val timestamp: Long = System.currentTimeMillis(),
|
||||||
val isDeleted: Boolean = false,
|
val isDeleted: Boolean = false // TAMBAHKAN INI
|
||||||
val isPinned: Boolean = false // NEW: Tambahkan ini
|
|
||||||
)
|
)
|
||||||
@ -1,40 +0,0 @@
|
|||||||
package com.example.notesai.data.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ChatHistory(
|
|
||||||
val id: String = UUID.randomUUID().toString(),
|
|
||||||
val categoryId: String?, // null berarti "Semua Kategori"
|
|
||||||
val categoryName: String, // Untuk display
|
|
||||||
val messages: List<SerializableChatMessage>,
|
|
||||||
val lastMessagePreview: String, // Preview pesan terakhir
|
|
||||||
val customTitle: String? = null, // Custom title yang di-edit user (support markdown)
|
|
||||||
val timestamp: Long = System.currentTimeMillis(),
|
|
||||||
val isDeleted: Boolean = false
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class SerializableChatMessage(
|
|
||||||
val id: String,
|
|
||||||
val message: String,
|
|
||||||
val isUser: Boolean,
|
|
||||||
val timestamp: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
// Extension function untuk convert ChatMessage ke SerializableChatMessage
|
|
||||||
fun ChatMessage.toSerializable() = SerializableChatMessage(
|
|
||||||
id = id,
|
|
||||||
message = message,
|
|
||||||
isUser = isUser,
|
|
||||||
timestamp = timestamp
|
|
||||||
)
|
|
||||||
|
|
||||||
// Extension function untuk convert SerializableChatMessage ke ChatMessage
|
|
||||||
fun SerializableChatMessage.toChatMessage() = ChatMessage(
|
|
||||||
id = id,
|
|
||||||
message = message,
|
|
||||||
isUser = isUser,
|
|
||||||
timestamp = timestamp
|
|
||||||
)
|
|
||||||
@ -1,9 +1,8 @@
|
|||||||
|
// File: data/model/ChatMessage.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 ChatMessage(
|
data class ChatMessage(
|
||||||
val id: String = UUID.randomUUID().toString(),
|
val id: String = UUID.randomUUID().toString(),
|
||||||
val message: String,
|
val message: String,
|
||||||
|
|||||||
@ -1,20 +1,15 @@
|
|||||||
|
// File: data/model/Note.kt
|
||||||
package com.example.notesai.data.model
|
package com.example.notesai.data.model
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
//noinspection UnsafeOptInUsageError
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@SuppressLint("UnsafeOptInUsageError")
|
|
||||||
@Serializable
|
|
||||||
data class Note(
|
data class Note(
|
||||||
val id: String = UUID.randomUUID().toString(),
|
val id: String = UUID.randomUUID().toString(),
|
||||||
val categoryId: String,
|
val categoryId: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val description: String = "", // Field baru untuk preview di NoteCard
|
val content: String,
|
||||||
val content: String = "", // Konten lengkap untuk EditableFullScreenNoteView
|
|
||||||
val timestamp: Long = System.currentTimeMillis(),
|
val timestamp: Long = System.currentTimeMillis(),
|
||||||
val isPinned: Boolean = false,
|
|
||||||
val isArchived: Boolean = false,
|
val isArchived: Boolean = false,
|
||||||
val isDeleted: Boolean = false
|
val isDeleted: Boolean = false,
|
||||||
|
val isPinned: Boolean = false
|
||||||
)
|
)
|
||||||
@ -3,6 +3,7 @@ package com.example.notesai.data.model
|
|||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@SuppressLint("UnsafeOptInUsageError")
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SerializableCategory(
|
data class SerializableCategory(
|
||||||
val id: String,
|
val id: String,
|
||||||
@ -10,8 +11,7 @@ data class SerializableCategory(
|
|||||||
val gradientStart: Long,
|
val gradientStart: Long,
|
||||||
val gradientEnd: Long,
|
val gradientEnd: Long,
|
||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
val isDeleted: Boolean = false,
|
val isDeleted: Boolean = false // TAMBAHKAN INI
|
||||||
val isPinned: Boolean = false
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@SuppressLint("UnsafeOptInUsageError")
|
@SuppressLint("UnsafeOptInUsageError")
|
||||||
@ -20,8 +20,7 @@ data class SerializableNote(
|
|||||||
val id: String,
|
val id: String,
|
||||||
val categoryId: String,
|
val categoryId: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val description: String = "",
|
val content: String,
|
||||||
val content: String = "",
|
|
||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
val isArchived: Boolean,
|
val isArchived: Boolean,
|
||||||
val isDeleted: Boolean,
|
val isDeleted: Boolean,
|
||||||
@ -35,8 +34,7 @@ fun Category.toSerializable() = SerializableCategory(
|
|||||||
gradientStart = gradientStart,
|
gradientStart = gradientStart,
|
||||||
gradientEnd = gradientEnd,
|
gradientEnd = gradientEnd,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
isDeleted = isDeleted,
|
isDeleted = isDeleted // TAMBAHKAN INI
|
||||||
isPinned = isPinned
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fun SerializableCategory.toCategory() = Category(
|
fun SerializableCategory.toCategory() = Category(
|
||||||
@ -45,15 +43,13 @@ fun SerializableCategory.toCategory() = Category(
|
|||||||
gradientStart = gradientStart,
|
gradientStart = gradientStart,
|
||||||
gradientEnd = gradientEnd,
|
gradientEnd = gradientEnd,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
isDeleted = isDeleted,
|
isDeleted = isDeleted // TAMBAHKAN INI
|
||||||
isPinned = isPinned
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fun Note.toSerializable() = SerializableNote(
|
fun Note.toSerializable() = SerializableNote(
|
||||||
id = id,
|
id = id,
|
||||||
categoryId = categoryId,
|
categoryId = categoryId,
|
||||||
title = title,
|
title = title,
|
||||||
description = description,
|
|
||||||
content = content,
|
content = content,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
isArchived = isArchived,
|
isArchived = isArchived,
|
||||||
@ -65,7 +61,6 @@ fun SerializableNote.toNote() = Note(
|
|||||||
id = id,
|
id = id,
|
||||||
categoryId = categoryId,
|
categoryId = categoryId,
|
||||||
title = title,
|
title = title,
|
||||||
description = description,
|
|
||||||
content = content,
|
content = content,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
isArchived = isArchived,
|
isArchived = isArchived,
|
||||||
|
|||||||
@ -1,44 +1,54 @@
|
|||||||
package com.example.notesai.presentation.components
|
package com.example.notesai.presentation.components
|
||||||
|
|
||||||
import androidx.compose.animation.*
|
|
||||||
import androidx.compose.animation.core.*
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.Archive
|
||||||
import androidx.compose.material.icons.outlined.*
|
import androidx.compose.material.icons.filled.Create
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.material.icons.filled.Home
|
||||||
|
import androidx.compose.material.icons.filled.Star
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Divider
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.scale
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.example.notesai.util.Constants
|
|
||||||
import com.example.notesai.util.AppColors
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DrawerMenu(
|
fun DrawerMenu(
|
||||||
currentScreen: String,
|
currentScreen: String,
|
||||||
isDarkTheme: Boolean,
|
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onItemClick: (String) -> Unit,
|
onItemClick: (String) -> Unit
|
||||||
onThemeToggle: () -> Unit
|
|
||||||
) {
|
) {
|
||||||
// Backdrop with blur effect
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.statusBarsPadding() // Padding untuk status bar
|
||||||
.background(Color.Black.copy(alpha = 0.5f))
|
.background(Color.Black.copy(alpha = 0.5f))
|
||||||
.clickable(
|
.clickable(
|
||||||
onClick = onDismiss,
|
onClick = onDismiss,
|
||||||
@ -46,307 +56,122 @@ fun DrawerMenu(
|
|||||||
interactionSource = remember { MutableInteractionSource() }
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
// Drawer Content
|
Card(
|
||||||
Surface(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.width(280.dp)
|
.width(250.dp)
|
||||||
.align(Alignment.CenterStart)
|
.align(Alignment.CenterStart)
|
||||||
.clickable(
|
.clickable(
|
||||||
onClick = {},
|
onClick = {},
|
||||||
indication = null,
|
indication = null,
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
),
|
),
|
||||||
color = AppColors.Surface,
|
shape = RoundedCornerShape(topEnd = 0.dp, bottomEnd = 0.dp),
|
||||||
shadowElevation = Constants.Elevation.ExtraLarge.dp
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = Color(0xFF1E293B)
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 16.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
modifier = Modifier.fillMaxSize()
|
// Header Drawer dengan tombol close
|
||||||
) {
|
|
||||||
// Header - Minimalist
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(
|
.background(
|
||||||
brush = Brush.verticalGradient(
|
brush = Brush.verticalGradient(
|
||||||
colors = listOf(
|
colors = listOf(Color(0xFF6366F1), Color(0xFFA855F7))
|
||||||
AppColors.Primary.copy(alpha = 0.15f),
|
|
||||||
Color.Transparent
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
.padding(24.dp)
|
||||||
.padding(Constants.Spacing.ExtraLarge.dp)
|
|
||||||
) {
|
) {
|
||||||
Column {
|
|
||||||
// App Icon with subtle background
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(56.dp)
|
|
||||||
.background(
|
|
||||||
color = AppColors.Primary.copy(alpha = 0.2f),
|
|
||||||
shape = RoundedCornerShape(Constants.Radius.Medium.dp)
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Create,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = AppColors.Primary,
|
|
||||||
modifier = Modifier.size(32.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(Constants.Spacing.Medium.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
"AI Notes",
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
color = AppColors.OnBackground,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 24.sp
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(Constants.Spacing.ExtraSmall.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
"Smart Note Taking",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = AppColors.OnSurfaceVariant,
|
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(Constants.Spacing.Large.dp))
|
|
||||||
|
|
||||||
// Menu Items
|
|
||||||
DrawerMenuItem(
|
|
||||||
icon = if (currentScreen == "main") Icons.Filled.Home else Icons.Outlined.Home,
|
|
||||||
text = "Beranda",
|
|
||||||
isSelected = currentScreen == "main",
|
|
||||||
onClick = { onItemClick("main") }
|
|
||||||
)
|
|
||||||
|
|
||||||
DrawerMenuItem(
|
|
||||||
icon = if (currentScreen == "starred") Icons.Filled.Star else Icons.Outlined.StarBorder,
|
|
||||||
text = "Berbintang",
|
|
||||||
isSelected = currentScreen == "starred",
|
|
||||||
onClick = { onItemClick("starred") }
|
|
||||||
)
|
|
||||||
|
|
||||||
DrawerMenuItem(
|
|
||||||
icon = if (currentScreen == "archive") Icons.Filled.Archive else Icons.Outlined.Archive,
|
|
||||||
text = "Arsip",
|
|
||||||
isSelected = currentScreen == "archive",
|
|
||||||
onClick = { onItemClick("archive") }
|
|
||||||
)
|
|
||||||
|
|
||||||
DrawerMenuItem(
|
|
||||||
icon = if (currentScreen == "trash") Icons.Filled.Delete else Icons.Outlined.Delete,
|
|
||||||
text = "Sampah",
|
|
||||||
isSelected = currentScreen == "trash",
|
|
||||||
onClick = { onItemClick("trash") }
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(Constants.Spacing.Medium.dp))
|
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
color = AppColors.Divider,
|
|
||||||
modifier = Modifier.padding(horizontal = Constants.Spacing.Medium.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(Constants.Spacing.Medium.dp))
|
|
||||||
|
|
||||||
// Theme Toggle
|
|
||||||
ThemeToggleItem(
|
|
||||||
isDarkTheme = isDarkTheme,
|
|
||||||
onToggle = onThemeToggle
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
|
|
||||||
// Footer - Version info
|
|
||||||
HorizontalDivider(
|
|
||||||
color = AppColors.Divider,
|
|
||||||
modifier = Modifier.padding(horizontal = Constants.Spacing.Medium.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(Constants.Spacing.Medium.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Column {
|
||||||
"Version 1.1.0",
|
|
||||||
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(
|
Icon(
|
||||||
Icons.Default.AutoAwesome,
|
Icons.Default.Create,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = AppColors.Primary,
|
tint = Color.White,
|
||||||
modifier = Modifier.size(12.dp)
|
modifier = Modifier.size(36.dp)
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
"Gemini AI",
|
"AI Notes",
|
||||||
color = AppColors.Primary,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontSize = 11.sp,
|
color = Color.White,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
}
|
Text(
|
||||||
}
|
"Smart & Modern",
|
||||||
}
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
}
|
color = Color.White.copy(0.8f)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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
|
// // Tombol Close
|
||||||
Column {
|
// IconButton(
|
||||||
Text(
|
// onClick = onDismiss,
|
||||||
"Tema Aplikasi",
|
// modifier = Modifier
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
// .size(40.dp)
|
||||||
color = AppColors.OnSurface,
|
// .background(
|
||||||
fontWeight = FontWeight.SemiBold,
|
// Color.White.copy(alpha = 0.2f),
|
||||||
fontSize = 15.sp
|
// shape = CircleShape
|
||||||
|
// )
|
||||||
|
// ) {
|
||||||
|
// Icon(
|
||||||
|
// Icons.Default.Close,
|
||||||
|
// contentDescription = "Tutup Menu",
|
||||||
|
// tint = Color.White,
|
||||||
|
// modifier = Modifier.size(24.dp)
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Menu Items
|
||||||
|
MenuItem(
|
||||||
|
icon = Icons.Default.Home,
|
||||||
|
text = "Beranda",
|
||||||
|
isSelected = currentScreen == "main"
|
||||||
|
) { onItemClick("main") }
|
||||||
|
|
||||||
|
MenuItem(
|
||||||
|
icon = Icons.Default.Star,
|
||||||
|
text = "Berbintang",
|
||||||
|
isSelected = currentScreen == "starred"
|
||||||
|
) { onItemClick("starred") }
|
||||||
|
|
||||||
|
MenuItem(
|
||||||
|
icon = Icons.Default.Archive,
|
||||||
|
text = "Arsip",
|
||||||
|
isSelected = currentScreen == "archive"
|
||||||
|
) { onItemClick("archive") }
|
||||||
|
|
||||||
|
MenuItem(
|
||||||
|
icon = Icons.Default.Delete,
|
||||||
|
text = "Sampah",
|
||||||
|
isSelected = currentScreen == "trash"
|
||||||
|
) { onItemClick("trash") }
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
Divider(
|
||||||
|
color = Color.White.copy(alpha = 0.1f),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
if (isDarkTheme) "Mode Gelap" else "Mode Terang",
|
text = "Version 1.0.0",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = AppColors.OnSurfaceTertiary,
|
color = Color.White.copy(alpha = 0.5f),
|
||||||
fontSize = 12.sp
|
modifier = Modifier.padding(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle Switch
|
|
||||||
Switch(
|
|
||||||
checked = isDarkTheme,
|
|
||||||
onCheckedChange = { onToggle() },
|
|
||||||
colors = SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = Color.White,
|
|
||||||
checkedTrackColor = AppColors.Primary,
|
|
||||||
uncheckedThumbColor = Color.White,
|
|
||||||
uncheckedTrackColor = AppColors.Border
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DrawerMenuItem(
|
|
||||||
icon: ImageVector,
|
|
||||||
text: String,
|
|
||||||
isSelected: Boolean,
|
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
// Scale animation
|
|
||||||
val scale by animateFloatAsState(
|
|
||||||
targetValue = if (isSelected) 1.02f else 1f,
|
|
||||||
animationSpec = spring(
|
|
||||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
|
||||||
stiffness = Spring.StiffnessMedium
|
|
||||||
),
|
|
||||||
label = "scale"
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.scale(scale)
|
|
||||||
.clip(RoundedCornerShape(Constants.Radius.Medium.dp))
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.background(
|
|
||||||
color = if (isSelected)
|
|
||||||
AppColors.Primary.copy(alpha = 0.1f)
|
|
||||||
else
|
|
||||||
Color.Transparent
|
|
||||||
)
|
|
||||||
.padding(horizontal = Constants.Spacing.Medium.dp, vertical = Constants.Spacing.Medium.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
// Icon with background
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(40.dp)
|
|
||||||
.background(
|
|
||||||
color = if (isSelected)
|
|
||||||
AppColors.Primary.copy(alpha = 0.2f)
|
|
||||||
else
|
|
||||||
AppColors.SurfaceVariant,
|
|
||||||
shape = CircleShape
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = if (isSelected) AppColors.Primary else AppColors.OnSurfaceVariant,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(Constants.Spacing.Medium.dp))
|
|
||||||
|
|
||||||
// Text
|
|
||||||
Text(
|
|
||||||
text,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = if (isSelected) AppColors.Primary else AppColors.OnSurface,
|
|
||||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
|
|
||||||
fontSize = 15.sp
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,33 +1,31 @@
|
|||||||
package com.example.notesai.presentation.components
|
package com.example.notesai.presentation.components
|
||||||
|
|
||||||
import androidx.compose.animation.*
|
|
||||||
import androidx.compose.animation.core.*
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.AutoAwesome
|
|
||||||
import androidx.compose.material.icons.filled.Home
|
import androidx.compose.material.icons.filled.Home
|
||||||
import androidx.compose.material.icons.filled.Star
|
import androidx.compose.material.icons.filled.Star
|
||||||
import androidx.compose.material.icons.outlined.AutoAwesome
|
import androidx.compose.material3.BottomAppBar
|
||||||
import androidx.compose.material.icons.outlined.Home
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material.icons.outlined.StarBorder
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.scale
|
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.example.notesai.util.AppColors
|
|
||||||
import com.example.notesai.util.Constants
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ModernBottomBar(
|
fun ModernBottomBar(
|
||||||
@ -35,125 +33,70 @@ fun ModernBottomBar(
|
|||||||
onHomeClick: () -> Unit,
|
onHomeClick: () -> Unit,
|
||||||
onAIClick: () -> Unit
|
onAIClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
// Floating Bottom Bar with Glassmorphism
|
BottomAppBar(
|
||||||
Box(
|
containerColor = Color.Transparent,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.shadow(8.dp, RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp))
|
||||||
.padding(horizontal = 16.dp)
|
.background(
|
||||||
) {
|
brush = Brush.verticalGradient(
|
||||||
Surface(
|
colors = listOf(
|
||||||
modifier = Modifier
|
Color(0xFF1E293B).copy(0.95f),
|
||||||
.fillMaxWidth()
|
Color(0xFF334155).copy(0.95f)
|
||||||
.shadow(
|
)
|
||||||
elevation = Constants.Elevation.Large.dp,
|
|
||||||
shape = RoundedCornerShape(Constants.Radius.ExtraLarge.dp)
|
|
||||||
),
|
),
|
||||||
color = AppColors.SurfaceElevated,
|
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)
|
||||||
shape = RoundedCornerShape(Constants.Radius.ExtraLarge.dp)
|
)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 8.dp, horizontal = 24.dp),
|
.padding(horizontal = 16.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// Home Button
|
Column(
|
||||||
BottomBarItem(
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
selected = currentScreen == "main",
|
modifier = Modifier
|
||||||
onClick = onHomeClick,
|
.weight(1f)
|
||||||
icon = if (currentScreen == "main") Icons.Filled.Home else Icons.Outlined.Home,
|
.clickable(onClick = onHomeClick)
|
||||||
label = "Beranda"
|
.padding(vertical = 8.dp)
|
||||||
)
|
|
||||||
|
|
||||||
// AI Button
|
|
||||||
BottomBarItem(
|
|
||||||
selected = currentScreen == "ai",
|
|
||||||
onClick = onAIClick,
|
|
||||||
icon = if (currentScreen == "ai") Icons.Filled.AutoAwesome else Icons.Outlined.AutoAwesome,
|
|
||||||
label = "AI Helper"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun BottomBarItem(
|
|
||||||
selected: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
|
||||||
label: String
|
|
||||||
) {
|
) {
|
||||||
// Scale animation
|
Icon(
|
||||||
val scale by animateFloatAsState(
|
Icons.Default.Home,
|
||||||
targetValue = if (selected) 1.1f else 1f,
|
contentDescription = "Home",
|
||||||
animationSpec = spring(
|
tint = if (currentScreen == "main") Color(0xFF6366F1) else Color(0xFF94A3B8),
|
||||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
modifier = Modifier.size(24.dp)
|
||||||
stiffness = Spring.StiffnessLow
|
|
||||||
),
|
|
||||||
label = "scale"
|
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
// Color animation
|
Text(
|
||||||
val iconColor by animateColorAsState(
|
"Home",
|
||||||
targetValue = if (selected) AppColors.Primary else AppColors.OnSurfaceVariant,
|
color = if (currentScreen == "main") Color(0xFF6366F1) else Color(0xFF94A3B8),
|
||||||
animationSpec = tween(Constants.ANIMATION_DURATION_MEDIUM),
|
style = MaterialTheme.typography.bodySmall,
|
||||||
label = "color"
|
fontWeight = if (currentScreen == "main") FontWeight.Bold else FontWeight.Normal
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.weight(1f)
|
||||||
.clickable(
|
.clickable(onClick = onAIClick)
|
||||||
onClick = onClick,
|
.padding(vertical = 8.dp)
|
||||||
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(
|
||||||
icon,
|
Icons.Default.Star,
|
||||||
contentDescription = label,
|
contentDescription = "AI Helper",
|
||||||
tint = iconColor,
|
tint = if (currentScreen == "ai") Color(0xFFFBBF24) else Color(0xFF94A3B8),
|
||||||
modifier = Modifier.size(24.dp)
|
modifier = Modifier.size(24.dp)
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
// Label with fade animation
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = selected,
|
|
||||||
enter = fadeIn() + expandVertically(),
|
|
||||||
exit = fadeOut() + shrinkVertically()
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
label,
|
"AI Helper",
|
||||||
color = AppColors.Primary,
|
color = if (currentScreen == "ai") Color(0xFFFBBF24) else Color(0xFF94A3B8),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = if (currentScreen == "ai") FontWeight.Bold else FontWeight.Normal
|
||||||
fontSize = 12.sp
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
@ -1,28 +1,42 @@
|
|||||||
package com.example.notesai.presentation.components
|
package com.example.notesai.presentation.components
|
||||||
|
|
||||||
import androidx.compose.animation.*
|
|
||||||
import androidx.compose.animation.core.*
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Home
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material.icons.filled.Star
|
||||||
|
import androidx.compose.material3.BottomAppBar
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.example.notesai.util.AppColors
|
|
||||||
import com.example.notesai.util.Constants
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@ -36,144 +50,58 @@ fun ModernTopBar(
|
|||||||
onSearchQueryChange: (String) -> Unit,
|
onSearchQueryChange: (String) -> Unit,
|
||||||
showSearch: Boolean
|
showSearch: Boolean
|
||||||
) {
|
) {
|
||||||
// Floating Top Bar with same style as Bottom Bar
|
TopAppBar(
|
||||||
Box(
|
title = {
|
||||||
modifier = Modifier
|
if (showSearch) {
|
||||||
.fillMaxWidth()
|
TextField(
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
) {
|
|
||||||
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,
|
value = searchQuery,
|
||||||
onValueChange = onSearchQueryChange,
|
onValueChange = onSearchQueryChange,
|
||||||
placeholder = {
|
placeholder = { Text("Cari catatan...", color = Color.White.copy(0.6f)) },
|
||||||
Text(
|
colors = TextFieldDefaults.colors(
|
||||||
"Cari...",
|
focusedContainerColor = Color.Transparent,
|
||||||
color = AppColors.OnSurfaceVariant,
|
unfocusedContainerColor = Color.Transparent,
|
||||||
fontSize = 15.sp
|
focusedTextColor = Color.White,
|
||||||
)
|
unfocusedTextColor = Color.White,
|
||||||
},
|
cursorColor = Color.White,
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
focusedIndicatorColor = Color.Transparent,
|
||||||
focusedContainerColor = AppColors.SurfaceVariant,
|
unfocusedIndicatorColor = Color.Transparent
|
||||||
unfocusedContainerColor = AppColors.SurfaceVariant,
|
|
||||||
focusedTextColor = AppColors.OnBackground,
|
|
||||||
unfocusedTextColor = AppColors.OnSurface,
|
|
||||||
cursorColor = AppColors.Primary,
|
|
||||||
focusedBorderColor = AppColors.Primary,
|
|
||||||
unfocusedBorderColor = Color.Transparent
|
|
||||||
),
|
),
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth()
|
||||||
.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 {
|
} 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(
|
Text(
|
||||||
title,
|
title,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontSize = 18.sp,
|
fontSize = 22.sp
|
||||||
color = AppColors.OnBackground,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
// Search Button
|
},
|
||||||
IconButton(
|
navigationIcon = {
|
||||||
onClick = onSearchClick,
|
IconButton(onClick = if (showBackButton) onBackClick else onMenuClick) {
|
||||||
modifier = Modifier
|
|
||||||
.size(40.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(AppColors.SurfaceVariant)
|
|
||||||
) {
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Search,
|
if (showBackButton) Icons.AutoMirrored.Filled.ArrowBack else Icons.Default.Menu,
|
||||||
contentDescription = "Search",
|
contentDescription = null,
|
||||||
tint = AppColors.OnSurfaceVariant,
|
tint = Color.White
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onSearchClick) {
|
||||||
|
Icon(
|
||||||
|
if (showSearch) Icons.Default.Close else Icons.Default.Search,
|
||||||
|
contentDescription = "Search",
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
}
|
containerColor = Color.Transparent
|
||||||
}
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
brush = Brush.horizontalGradient(
|
||||||
|
colors = listOf(Color(0xFF6366F1), Color(0xFFA855F7))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@ -1,15 +1,33 @@
|
|||||||
package com.example.notesai.presentation.dialogs
|
package com.example.notesai.presentation.dialogs
|
||||||
|
|
||||||
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.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@ -17,9 +35,6 @@ 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 CategoryDialog(
|
fun CategoryDialog(
|
||||||
@ -29,81 +44,68 @@ fun CategoryDialog(
|
|||||||
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(
|
||||||
|
Pair(0xFF6366F1L, 0xFFA855F7L),
|
||||||
|
Pair(0xFFEC4899L, 0xFFF59E0BL),
|
||||||
|
Pair(0xFF8B5CF6L, 0xFFEC4899L),
|
||||||
|
Pair(0xFF06B6D4L, 0xFF3B82F6L),
|
||||||
|
Pair(0xFF10B981L, 0xFF059669L),
|
||||||
|
Pair(0xFFF59E0BL, 0xFFEF4444L),
|
||||||
|
Pair(0xFF6366F1L, 0xFF8B5CF6L),
|
||||||
|
Pair(0xFFEF4444L, 0xFFDC2626L)
|
||||||
|
)
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
containerColor = AppColors.Surface,
|
containerColor = Color(0xFF1E293B),
|
||||||
shape = RoundedCornerShape(Constants.Radius.Large.dp),
|
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
"Kategori Baru",
|
"Buat Kategori Baru",
|
||||||
color = AppColors.OnBackground,
|
color = Color.White,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold
|
||||||
fontSize = 20.sp
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
Column(
|
Column {
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
// Name Input
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
label = {
|
label = { Text("Nama Kategori", color = Color(0xFF94A3B8)) },
|
||||||
Text(
|
|
||||||
"Nama Kategori",
|
|
||||||
color = AppColors.OnSurfaceVariant
|
|
||||||
)
|
|
||||||
},
|
|
||||||
placeholder = {
|
|
||||||
Text(
|
|
||||||
"Contoh: Pekerjaan, Pribadi...",
|
|
||||||
color = AppColors.OnSurfaceTertiary,
|
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
colors = TextFieldDefaults.colors(
|
||||||
focusedTextColor = AppColors.OnBackground,
|
focusedTextColor = Color.White,
|
||||||
unfocusedTextColor = AppColors.OnSurface,
|
unfocusedTextColor = Color.White,
|
||||||
focusedContainerColor = AppColors.SurfaceVariant,
|
focusedContainerColor = Color(0xFF334155),
|
||||||
unfocusedContainerColor = AppColors.SurfaceVariant,
|
unfocusedContainerColor = Color(0xFF334155),
|
||||||
cursorColor = AppColors.Primary,
|
cursorColor = Color(0xFFA855F7),
|
||||||
focusedBorderColor = AppColors.Primary,
|
focusedIndicatorColor = Color(0xFFA855F7),
|
||||||
unfocusedBorderColor = Color.Transparent
|
unfocusedIndicatorColor = Color(0xFF64748B)
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(Constants.Radius.Medium.dp),
|
shape = RoundedCornerShape(12.dp)
|
||||||
singleLine = true
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
// Color picker title
|
|
||||||
Text(
|
Text(
|
||||||
"Pilih Warna:",
|
"Pilih Gradient:",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = AppColors.OnSurface,
|
color = Color.White,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
// Color Grid - 2 rows of 4 colors
|
gradients.chunked(4).forEach { row ->
|
||||||
Constants.CategoryColors.chunked(4).forEach { row ->
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
row.forEachIndexed { _, gradient ->
|
row.forEachIndexed { index, gradient ->
|
||||||
val globalIndex = Constants.CategoryColors.indexOf(gradient)
|
val globalIndex = gradients.indexOf(gradient)
|
||||||
val isSelected = selectedGradient == globalIndex
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.aspectRatio(1f)
|
.aspectRatio(1f)
|
||||||
.clip(RoundedCornerShape(Constants.Radius.Medium.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.background(
|
.background(
|
||||||
brush = Brush.linearGradient(
|
brush = Brush.linearGradient(
|
||||||
colors = listOf(
|
colors = listOf(
|
||||||
@ -115,76 +117,46 @@ fun CategoryDialog(
|
|||||||
.clickable { selectedGradient = globalIndex },
|
.clickable { selectedGradient = globalIndex },
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
// Checkmark with animation
|
if (selectedGradient == globalIndex) {
|
||||||
androidx.compose.animation.AnimatedVisibility(
|
|
||||||
visible = isSelected,
|
|
||||||
enter = scaleIn(
|
|
||||||
animationSpec = spring(
|
|
||||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
|
||||||
stiffness = Spring.StiffnessMedium
|
|
||||||
)
|
|
||||||
) + fadeIn(),
|
|
||||||
exit = scaleOut(
|
|
||||||
animationSpec = spring(
|
|
||||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
|
||||||
stiffness = Spring.StiffnessMedium
|
|
||||||
)
|
|
||||||
) + fadeOut()
|
|
||||||
) {
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Check,
|
Icons.Default.Check,
|
||||||
contentDescription = "Selected",
|
contentDescription = null,
|
||||||
tint = Color.White,
|
tint = Color.White,
|
||||||
modifier = Modifier.size(28.dp)
|
modifier = Modifier.size(24.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
// Cancel button
|
|
||||||
TextButton(
|
|
||||||
onClick = onDismiss,
|
|
||||||
shape = RoundedCornerShape(Constants.Radius.Medium.dp),
|
|
||||||
modifier = Modifier.height(48.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Batal",
|
|
||||||
color = AppColors.OnSurfaceVariant,
|
|
||||||
fontSize = 15.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save button
|
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (name.isNotBlank()) {
|
if (name.isNotBlank()) {
|
||||||
val gradient = Constants.CategoryColors[selectedGradient]
|
val gradient = gradients[selectedGradient]
|
||||||
onSave(name, gradient.first, gradient.second)
|
onSave(name, gradient.first, gradient.second)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = name.isNotBlank(),
|
enabled = name.isNotBlank(),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = AppColors.Primary,
|
containerColor = Color.Transparent
|
||||||
disabledContainerColor = AppColors.Primary.copy(alpha = 0.5f)
|
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(Constants.Radius.Medium.dp),
|
modifier = Modifier.background(
|
||||||
modifier = Modifier.height(48.dp)
|
brush = Brush.linearGradient(
|
||||||
|
colors = listOf(Color(0xFF6366F1), Color(0xFFA855F7))
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text("Simpan", color = Color.White, fontWeight = FontWeight.Bold)
|
||||||
"Buat",
|
|
||||||
color = Color.White,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 15.sp
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Batal", color = Color(0xFF94A3B8))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,213 +1,123 @@
|
|||||||
package com.example.notesai.presentation.dialogs
|
package com.example.notesai.presentation.dialogs
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.example.notesai.data.model.Note
|
import com.example.notesai.data.model.Note
|
||||||
import com.example.notesai.util.AppColors
|
|
||||||
import com.example.notesai.util.Constants
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NoteDialog(
|
fun NoteDialog(
|
||||||
categoryId: String, // Parameter untuk kategori ID
|
note: Note?,
|
||||||
note: Note? = null, // Null jika buat baru, isi jika edit
|
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSave: (String, String) -> Unit,
|
onSave: (String, String) -> Unit,
|
||||||
onDelete: (() -> Unit)? = null
|
onDelete: (() -> Unit)?
|
||||||
) {
|
) {
|
||||||
var title by remember { mutableStateOf(note?.title ?: "") }
|
var title by remember { mutableStateOf(note?.title ?: "") }
|
||||||
var description by remember { mutableStateOf(note?.description ?: "") }
|
var content by remember { mutableStateOf(note?.content ?: "") }
|
||||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// Delete confirmation dialog
|
|
||||||
if (showDeleteConfirm) {
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showDeleteConfirm = false },
|
onDismissRequest = onDismiss,
|
||||||
containerColor = AppColors.Surface,
|
containerColor = Color(0xFF1E293B),
|
||||||
shape = RoundedCornerShape(Constants.Radius.Large.dp),
|
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
"Hapus Catatan?",
|
if (note == null) "Catatan Baru" else "Edit Catatan",
|
||||||
color = AppColors.OnBackground,
|
color = Color.White,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Column {
|
||||||
"Catatan ini akan dipindahkan ke sampah.",
|
|
||||||
color = AppColors.OnSurfaceVariant
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
onDelete?.invoke()
|
|
||||||
showDeleteConfirm = false
|
|
||||||
},
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = AppColors.Error
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(Constants.Radius.Medium.dp)
|
|
||||||
) {
|
|
||||||
Text("Hapus", color = Color.White, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = { showDeleteConfirm = false },
|
|
||||||
shape = RoundedCornerShape(Constants.Radius.Medium.dp)
|
|
||||||
) {
|
|
||||||
Text("Batal", color = AppColors.OnSurfaceVariant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
containerColor = AppColors.Surface,
|
|
||||||
shape = RoundedCornerShape(Constants.Radius.Large.dp),
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
if (note == null) "Catatan Baru" else "Edit Catatan",
|
|
||||||
color = AppColors.OnBackground,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 20.sp
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
// Title Input
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = title,
|
value = title,
|
||||||
onValueChange = { title = it },
|
onValueChange = { title = it },
|
||||||
label = {
|
label = { Text("Judul", color = Color(0xFF94A3B8)) },
|
||||||
Text(
|
|
||||||
"Judul",
|
|
||||||
color = AppColors.OnSurfaceVariant
|
|
||||||
)
|
|
||||||
},
|
|
||||||
placeholder = {
|
|
||||||
Text(
|
|
||||||
"Masukkan judul catatan",
|
|
||||||
color = AppColors.OnSurfaceTertiary,
|
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
colors = TextFieldDefaults.colors(
|
||||||
focusedTextColor = AppColors.OnBackground,
|
focusedTextColor = Color.White,
|
||||||
unfocusedTextColor = AppColors.OnSurface,
|
unfocusedTextColor = Color.White,
|
||||||
focusedContainerColor = AppColors.SurfaceVariant,
|
focusedContainerColor = Color(0xFF334155),
|
||||||
unfocusedContainerColor = AppColors.SurfaceVariant,
|
unfocusedContainerColor = Color(0xFF334155),
|
||||||
cursorColor = AppColors.Primary,
|
cursorColor = Color(0xFFA855F7),
|
||||||
focusedBorderColor = AppColors.Primary,
|
focusedIndicatorColor = Color(0xFFA855F7),
|
||||||
unfocusedBorderColor = Color.Transparent
|
unfocusedIndicatorColor = Color(0xFF64748B)
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(Constants.Radius.Medium.dp),
|
shape = RoundedCornerShape(12.dp)
|
||||||
singleLine = true
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Description Input
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = description,
|
value = content,
|
||||||
onValueChange = { description = it },
|
onValueChange = { content = it },
|
||||||
label = {
|
label = { Text("Isi Catatan", color = Color(0xFF94A3B8)) },
|
||||||
Text(
|
|
||||||
"Deskripsi",
|
|
||||||
color = AppColors.OnSurfaceVariant
|
|
||||||
)
|
|
||||||
},
|
|
||||||
placeholder = {
|
|
||||||
Text(
|
|
||||||
"Tambahkan deskripsi singkat...",
|
|
||||||
color = AppColors.OnSurfaceTertiary,
|
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = 120.dp, max = 200.dp),
|
.height(200.dp),
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
maxLines = 10,
|
||||||
focusedTextColor = AppColors.OnBackground,
|
colors = TextFieldDefaults.colors(
|
||||||
unfocusedTextColor = AppColors.OnSurface,
|
focusedTextColor = Color.White,
|
||||||
focusedContainerColor = AppColors.SurfaceVariant,
|
unfocusedTextColor = Color.White,
|
||||||
unfocusedContainerColor = AppColors.SurfaceVariant,
|
focusedContainerColor = Color(0xFF334155),
|
||||||
cursorColor = AppColors.Primary,
|
unfocusedContainerColor = Color(0xFF334155),
|
||||||
focusedBorderColor = AppColors.Primary,
|
cursorColor = Color(0xFFA855F7),
|
||||||
unfocusedBorderColor = Color.Transparent
|
focusedIndicatorColor = Color(0xFFA855F7),
|
||||||
|
unfocusedIndicatorColor = Color(0xFF64748B)
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(Constants.Radius.Medium.dp),
|
shape = RoundedCornerShape(12.dp)
|
||||||
maxLines = 8
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
Row(
|
Row {
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
// Delete button (if editing)
|
|
||||||
if (onDelete != null) {
|
if (onDelete != null) {
|
||||||
IconButton(
|
TextButton(onClick = onDelete) {
|
||||||
onClick = { showDeleteConfirm = true },
|
Text("Hapus", color = Color(0xFFEF4444), fontWeight = FontWeight.Bold)
|
||||||
modifier = Modifier.size(48.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Delete,
|
|
||||||
contentDescription = "Hapus",
|
|
||||||
tint = AppColors.Error
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
|
|
||||||
// Cancel button
|
|
||||||
TextButton(
|
|
||||||
onClick = onDismiss,
|
|
||||||
shape = RoundedCornerShape(Constants.Radius.Medium.dp),
|
|
||||||
modifier = Modifier.height(48.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Batal",
|
|
||||||
color = AppColors.OnSurfaceVariant,
|
|
||||||
fontSize = 15.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save button
|
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = { if (title.isNotBlank()) onSave(title, content) },
|
||||||
if (title.isNotBlank()) {
|
|
||||||
onSave(title, description)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = title.isNotBlank(),
|
enabled = title.isNotBlank(),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = AppColors.Primary,
|
containerColor = Color.Transparent
|
||||||
disabledContainerColor = AppColors.Primary.copy(alpha = 0.5f)
|
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(Constants.Radius.Medium.dp),
|
modifier = Modifier.background(
|
||||||
modifier = Modifier.height(48.dp)
|
brush = Brush.linearGradient(
|
||||||
|
colors = listOf(Color(0xFF6366F1), Color(0xFFA855F7))
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text("Simpan", color = Color.White, fontWeight = FontWeight.Bold)
|
||||||
"Simpan",
|
|
||||||
color = Color.White,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 15.sp
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Batal", color = Color(0xFF94A3B8))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -1,84 +1,91 @@
|
|||||||
package com.example.notesai.presentation.screens.ai
|
package com.example.notesai.presentation.screens.ai
|
||||||
|
|
||||||
import androidx.compose.animation.*
|
|
||||||
import androidx.compose.animation.core.*
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material.icons.filled.Folder
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.material.icons.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.Star
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Divider
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import com.example.notesai.data.model.Note
|
||||||
import androidx.compose.ui.zIndex
|
import com.example.notesai.data.model.ChatMessage
|
||||||
|
import com.example.notesai.data.model.Category
|
||||||
import com.example.notesai.config.APIKey
|
import com.example.notesai.config.APIKey
|
||||||
import com.example.notesai.data.local.DataStoreManager
|
import com.example.notesai.presentation.screens.ai.components.ChatBubble
|
||||||
import com.example.notesai.data.model.*
|
import com.example.notesai.presentation.screens.ai.components.CompactStatItem
|
||||||
import com.example.notesai.util.Constants
|
import com.example.notesai.presentation.screens.ai.components.SuggestionChip
|
||||||
|
import com.example.notesai.util.Constants.AppColors.Divider
|
||||||
import com.google.ai.client.generativeai.GenerativeModel
|
import com.google.ai.client.generativeai.GenerativeModel
|
||||||
import com.google.ai.client.generativeai.type.generationConfig
|
import com.google.ai.client.generativeai.type.generationConfig
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.*
|
import kotlin.collections.plus
|
||||||
import com.example.notesai.presentation.screens.ai.components.*
|
|
||||||
import com.example.notesai.util.AppColors
|
|
||||||
import com.example.notesai.util.FileParseResult
|
|
||||||
|
|
||||||
private const val MAX_CHAT_TITLE_LENGTH = 30
|
|
||||||
|
|
||||||
private fun String.toSafeChatPreview(maxLength: Int = MAX_CHAT_TITLE_LENGTH): String {
|
|
||||||
return if (this.length > maxLength) {
|
|
||||||
this.take(maxLength).trim() + "..."
|
|
||||||
} else {
|
|
||||||
this.trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AIHelperScreen(
|
fun AIHelperScreen(
|
||||||
categories: List<Category>,
|
categories: List<Category>,
|
||||||
notes: List<Note>,
|
notes: List<Note>
|
||||||
onShowDrawer: () -> Unit = {}
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
val dataStoreManager = remember { DataStoreManager(context) }
|
|
||||||
|
|
||||||
var prompt by remember { mutableStateOf("") }
|
var prompt by remember { mutableStateOf("") }
|
||||||
var isLoading by remember { mutableStateOf(false) }
|
var isLoading by remember { mutableStateOf(false) }
|
||||||
var selectedCategory by remember { mutableStateOf<Category?>(null) }
|
var selectedCategory by remember { mutableStateOf<Category?>(null) }
|
||||||
|
var showCategoryDropdown by remember { mutableStateOf(false) }
|
||||||
var errorMessage by remember { mutableStateOf("") }
|
var errorMessage by remember { mutableStateOf("") }
|
||||||
var chatMessages by remember { mutableStateOf(listOf<ChatMessage>()) }
|
var chatMessages by remember { mutableStateOf(listOf<ChatMessage>()) }
|
||||||
var showCopiedMessage by remember { mutableStateOf(false) }
|
var showCopiedMessage by remember { mutableStateOf(false) }
|
||||||
var copiedMessageId by remember { mutableStateOf("") }
|
var copiedMessageId by remember { mutableStateOf("") }
|
||||||
var showHistoryDrawer by remember { mutableStateOf(false) }
|
|
||||||
var currentChatId by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
// File Upload States
|
|
||||||
var uploadedFile by remember { mutableStateOf<FileParseResult.Success?>(null) }
|
|
||||||
var isGeneratingSummary by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val clipboardManager = LocalClipboardManager.current
|
val clipboardManager = LocalClipboardManager.current
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
// Load chat histories
|
|
||||||
val chatHistories by dataStoreManager.chatHistoryFlow.collectAsState(initial = emptyList())
|
|
||||||
|
|
||||||
// Inisialisasi Gemini Model
|
// Inisialisasi Gemini Model
|
||||||
val generativeModel = remember {
|
val generativeModel = remember {
|
||||||
GenerativeModel(
|
GenerativeModel(
|
||||||
@ -102,302 +109,224 @@ fun AIHelperScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to save chat history
|
|
||||||
fun saveChatHistory() {
|
|
||||||
if (chatMessages.isNotEmpty()) {
|
|
||||||
scope.launch {
|
|
||||||
val lastMessage = chatMessages.lastOrNull()?.message ?: ""
|
|
||||||
val preview = lastMessage.toSafeChatPreview()
|
|
||||||
|
|
||||||
val chatHistory = ChatHistory(
|
|
||||||
id = currentChatId ?: UUID.randomUUID().toString(),
|
|
||||||
categoryId = selectedCategory?.id,
|
|
||||||
categoryName = selectedCategory?.name ?: "Semua Kategori",
|
|
||||||
messages = chatMessages.map { it.toSerializable() },
|
|
||||||
lastMessagePreview = preview,
|
|
||||||
customTitle = null,
|
|
||||||
timestamp = System.currentTimeMillis()
|
|
||||||
)
|
|
||||||
|
|
||||||
dataStoreManager.addChatHistory(chatHistory)
|
|
||||||
currentChatId = chatHistory.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to load chat history
|
|
||||||
fun loadChatHistory(history: ChatHistory) {
|
|
||||||
chatMessages = history.messages.map { it.toChatMessage() }
|
|
||||||
currentChatId = history.id
|
|
||||||
selectedCategory = categories.find { it.id == history.categoryId }
|
|
||||||
showHistoryDrawer = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to start new chat
|
|
||||||
fun startNewChat() {
|
|
||||||
chatMessages = emptyList()
|
|
||||||
currentChatId = null
|
|
||||||
errorMessage = ""
|
|
||||||
showHistoryDrawer = false
|
|
||||||
uploadedFile = null
|
|
||||||
isGeneratingSummary = false
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
) {
|
|
||||||
// MAIN CONTENT - Layer 1 (paling bawah)
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.Companion
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(AppColors.Background)
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.Companion.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color.Companion.Transparent),
|
||||||
|
shape = RoundedCornerShape(0.dp)
|
||||||
) {
|
) {
|
||||||
// UPDATED: Floating Top Bar
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.Companion
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
color = AppColors.SurfaceElevated,
|
|
||||||
shape = RoundedCornerShape(Constants.Radius.ExtraLarge.dp),
|
|
||||||
shadowElevation = Constants.Elevation.Large.dp
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
// History Drawer Button
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
onShowDrawer() // Panggil callback ke parent
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.size(40.dp)
|
|
||||||
.background(
|
.background(
|
||||||
AppColors.Primary.copy(alpha = 0.1f),
|
brush = Brush.Companion.linearGradient(
|
||||||
CircleShape
|
colors = listOf(Color(0xFF6366F1), Color(0xFFA855F7))
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
.padding(20.dp)
|
||||||
) {
|
) {
|
||||||
|
Column {
|
||||||
|
Row(verticalAlignment = Alignment.Companion.CenterVertically) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Menu,
|
Icons.Default.Star,
|
||||||
contentDescription = "Menu",
|
contentDescription = null,
|
||||||
tint = AppColors.Primary,
|
tint = Color(0xFFFBBF24),
|
||||||
modifier = Modifier.size(20.dp)
|
modifier = Modifier.Companion.size(28.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.Companion.width(12.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"AI Helper",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = Color.Companion.White,
|
||||||
|
fontWeight = FontWeight.Companion.Bold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Powered by Gemini AI",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Color.Companion.White.copy(0.8f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Category Badge
|
// Category Selector & Stats - Compact Version
|
||||||
Surface(
|
Column(
|
||||||
color = AppColors.Primary.copy(alpha = 0.1f),
|
modifier = Modifier.Companion
|
||||||
shape = RoundedCornerShape(Constants.Radius.Medium.dp)
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
// Category Selector
|
||||||
|
Box {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.Companion
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { showCategoryDropdown = !showCategoryDropdown },
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E293B)),
|
||||||
|
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(
|
modifier = Modifier.Companion
|
||||||
horizontal = Constants.Spacing.Medium.dp,
|
.fillMaxWidth()
|
||||||
vertical = Constants.Spacing.Small.dp
|
.padding(12.dp),
|
||||||
),
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
verticalAlignment = Alignment.Companion.CenterVertically
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.Companion.CenterVertically) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Folder,
|
Icons.Default.Folder,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = AppColors.Primary,
|
tint = Color(0xFF6366F1),
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.Companion.size(20.dp)
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.Companion.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
selectedCategory?.name ?: "Semua Kategori",
|
selectedCategory?.name ?: "Semua Kategori",
|
||||||
color = AppColors.Primary,
|
color = Color.Companion.White,
|
||||||
fontSize = 13.sp,
|
style = MaterialTheme.typography.bodyMedium
|
||||||
fontWeight = FontWeight.SemiBold
|
)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
Icons.Default.ArrowDropDown,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFF94A3B8)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// New Chat Button
|
DropdownMenu(
|
||||||
if (chatMessages.isNotEmpty()) {
|
expanded = showCategoryDropdown,
|
||||||
Button(
|
onDismissRequest = { showCategoryDropdown = false },
|
||||||
onClick = { startNewChat() },
|
modifier = Modifier.Companion
|
||||||
colors = ButtonDefaults.buttonColors(
|
.fillMaxWidth()
|
||||||
containerColor = AppColors.Primary
|
.background(Color(0xFF1E293B))
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(Constants.Radius.Medium.dp),
|
|
||||||
contentPadding = PaddingValues(
|
|
||||||
horizontal = Constants.Spacing.Medium.dp,
|
|
||||||
vertical = Constants.Spacing.Small.dp
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
DropdownMenuItem(
|
||||||
Icons.Default.Add,
|
text = { Text("Semua Kategori", color = Color.Companion.White) },
|
||||||
contentDescription = null,
|
onClick = {
|
||||||
modifier = Modifier.size(18.dp)
|
selectedCategory = null
|
||||||
|
showCategoryDropdown = false
|
||||||
|
}
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
categories.forEach { category ->
|
||||||
Text(
|
DropdownMenuItem(
|
||||||
"Baru",
|
text = { Text(category.name, color = Color.Companion.White) },
|
||||||
fontSize = 14.sp,
|
onClick = {
|
||||||
fontWeight = FontWeight.SemiBold
|
selectedCategory = category
|
||||||
|
showCategoryDropdown = false
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stats - Compact
|
||||||
|
Spacer(modifier = Modifier.Companion.height(12.dp))
|
||||||
|
val filteredNotes = if (selectedCategory != null) {
|
||||||
|
notes.filter { it.categoryId == selectedCategory!!.id && !it.isArchived }
|
||||||
|
} else {
|
||||||
|
notes.filter { !it.isArchived }
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.Companion.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
CompactStatItem(
|
||||||
|
label = "Total",
|
||||||
|
value = filteredNotes.size.toString(),
|
||||||
|
color = Color(0xFF6366F1)
|
||||||
|
)
|
||||||
|
CompactStatItem(
|
||||||
|
label = "Dipasang",
|
||||||
|
value = filteredNotes.count { it.isPinned }.toString(),
|
||||||
|
color = Color(0xFFFBBF24)
|
||||||
|
)
|
||||||
|
CompactStatItem(
|
||||||
|
label = "Kategori",
|
||||||
|
value = categories.size.toString(),
|
||||||
|
color = Color(0xFFA855F7)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Divider(color = Color(0xFF334155), thickness = 1.dp)
|
||||||
|
|
||||||
// Chat Area
|
// Chat Area
|
||||||
Box(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.Companion
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
if (chatMessages.isEmpty()) {
|
if (chatMessages.isEmpty()) {
|
||||||
// Welcome State - Optimized Layout
|
// Welcome State
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.Companion
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
.padding(32.dp),
|
||||||
.verticalScroll(rememberScrollState()),
|
horizontalAlignment = Alignment.Companion.CenterHorizontally,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
verticalArrangement = Arrangement.Center
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.weight(0.5f))
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(64.dp)
|
|
||||||
.background(
|
|
||||||
color = AppColors.Primary.copy(alpha = 0.1f),
|
|
||||||
shape = CircleShape
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.AutoAwesome,
|
Icons.Default.Star,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.Companion.size(64.dp),
|
||||||
tint = AppColors.Primary
|
tint = Color(0xFF6366F1).copy(0.5f)
|
||||||
)
|
)
|
||||||
}
|
Spacer(modifier = Modifier.Companion.height(16.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"AI Assistant",
|
"Mulai Percakapan",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = AppColors.OnBackground,
|
color = Color.Companion.White,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Companion.Bold
|
||||||
fontSize = 24.sp
|
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.Companion.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
"Tanyakan apa saja tentang catatan Anda",
|
"Tanyakan apa saja tentang catatan Anda",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = AppColors.OnSurfaceVariant,
|
color = Color(0xFF94A3B8),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Companion.Center
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.Companion.height(24.dp))
|
||||||
|
|
||||||
// Suggestion Chips - Lebih besar
|
// Suggestion Chips
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.Companion.Start,
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
modifier = Modifier.Companion.fillMaxWidth(0.8f)
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
"Contoh pertanyaan:",
|
"Contoh pertanyaan:",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = AppColors.OnSurfaceTertiary,
|
color = Color(0xFF64748B),
|
||||||
fontSize = 13.sp
|
modifier = Modifier.Companion.padding(bottom = 8.dp)
|
||||||
)
|
)
|
||||||
SuggestionChip("Analisis catatan saya") { prompt = it }
|
SuggestionChip("Analisis catatan saya", onSelect = { prompt = it })
|
||||||
SuggestionChip("Buat ringkasan") { prompt = it }
|
SuggestionChip("Buat ringkasan", onSelect = { prompt = it })
|
||||||
SuggestionChip("Berikan saran organisasi") { prompt = it }
|
SuggestionChip("Berikan saran organisasi", onSelect = { prompt = it })
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
// File Upload Button - PENTING: Jangan dihapus!
|
|
||||||
FileUploadButton(
|
|
||||||
onFileSelected = { fileResult ->
|
|
||||||
uploadedFile = fileResult
|
|
||||||
|
|
||||||
// Auto-generate summary
|
|
||||||
scope.launch {
|
|
||||||
isGeneratingSummary = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
val summaryPrompt = """
|
|
||||||
Buatkan ringkasan dari dokumen berikut dalam bahasa Indonesia:
|
|
||||||
|
|
||||||
Judul File: ${fileResult.fileName}
|
|
||||||
Tipe: ${fileResult.fileType}
|
|
||||||
Jumlah Kata: ${fileResult.wordCount}
|
|
||||||
|
|
||||||
Konten:
|
|
||||||
${fileResult.content}
|
|
||||||
|
|
||||||
Buat ringkasan yang:
|
|
||||||
1. Mencakup poin-poin utama
|
|
||||||
2. Terstruktur dengan baik (gunakan markdown)
|
|
||||||
3. Mudah dipahami
|
|
||||||
4. Maksimal 300 kata
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
// Add user message
|
|
||||||
chatMessages = chatMessages + ChatMessage(
|
|
||||||
message = "📄 Upload file: ${fileResult.fileName}\n\nMohon buatkan ringkasan dari file ini.",
|
|
||||||
isUser = true
|
|
||||||
)
|
|
||||||
|
|
||||||
// Scroll to bottom to show loading
|
|
||||||
delay(100)
|
|
||||||
scrollState.animateScrollTo(scrollState.maxValue)
|
|
||||||
|
|
||||||
// Generate summary dengan Gemini
|
|
||||||
val response =
|
|
||||||
generativeModel.generateContent(summaryPrompt)
|
|
||||||
val summary = response.text ?: "Gagal membuat ringkasan"
|
|
||||||
|
|
||||||
// Add AI response
|
|
||||||
chatMessages = chatMessages + ChatMessage(
|
|
||||||
message = summary,
|
|
||||||
isUser = false
|
|
||||||
)
|
|
||||||
|
|
||||||
saveChatHistory()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
errorMessage = "⚠️ Gagal membuat ringkasan: ${e.message}"
|
|
||||||
} finally {
|
|
||||||
isGeneratingSummary = false
|
|
||||||
uploadedFile = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError = { error ->
|
|
||||||
errorMessage = error
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(0.5f))
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Chat Messages - UPDATED: Bottom padding dari 100dp ke 120dp
|
// Chat Messages
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.Companion
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState)
|
||||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 120.dp)
|
.padding(16.dp)
|
||||||
) {
|
) {
|
||||||
chatMessages.forEach { message ->
|
chatMessages.forEach { message ->
|
||||||
ChatBubble(
|
ChatBubble(
|
||||||
message = message,
|
message = message,
|
||||||
onCopy = { textToCopy ->
|
onCopy = {
|
||||||
clipboardManager.setText(AnnotatedString(textToCopy))
|
clipboardManager.setText(AnnotatedString(message.message))
|
||||||
copiedMessageId = message.id
|
copiedMessageId = message.id
|
||||||
showCopiedMessage = true
|
showCopiedMessage = true
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@ -407,34 +336,36 @@ fun AIHelperScreen(
|
|||||||
},
|
},
|
||||||
showCopied = showCopiedMessage && copiedMessageId == message.id
|
showCopied = showCopiedMessage && copiedMessageId == message.id
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.Companion.height(12.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading Indicator
|
// Loading Indicator
|
||||||
if (isLoading || isGeneratingSummary) {
|
if (isLoading) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.Companion
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 8.dp),
|
.padding(vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.Start
|
horizontalArrangement = Arrangement.Start
|
||||||
) {
|
) {
|
||||||
Surface(
|
Card(
|
||||||
color = AppColors.SurfaceVariant,
|
colors = CardDefaults.cardColors(
|
||||||
shape = RoundedCornerShape(16.dp)
|
containerColor = Color(0xFF1E293B)
|
||||||
|
),
|
||||||
|
shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.Companion.padding(16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.Companion.CenterVertically
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.Companion.size(20.dp),
|
||||||
color = if (isGeneratingSummary) AppColors.Secondary else AppColors.Primary,
|
color = Color(0xFF6366F1),
|
||||||
strokeWidth = 2.dp
|
strokeWidth = 2.dp
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.Companion.width(12.dp))
|
||||||
Text(
|
Text(
|
||||||
if (isGeneratingSummary) "Membuat ringkasan..." else "AI sedang berpikir...",
|
"AI sedang berpikir...",
|
||||||
color = AppColors.OnSurfaceVariant,
|
color = Color(0xFF94A3B8),
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -444,111 +375,52 @@ fun AIHelperScreen(
|
|||||||
|
|
||||||
// Error Message
|
// Error Message
|
||||||
if (errorMessage.isNotEmpty()) {
|
if (errorMessage.isNotEmpty()) {
|
||||||
Surface(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.Companion.fillMaxWidth(),
|
||||||
color = AppColors.Error.copy(alpha = 0.1f),
|
colors = CardDefaults.cardColors(
|
||||||
shape = RoundedCornerShape(12.dp)
|
containerColor = Color(0xFFEF4444).copy(0.2f)
|
||||||
|
),
|
||||||
|
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(12.dp),
|
modifier = Modifier.Companion.padding(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.Companion.CenterVertically
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Warning,
|
Icons.Default.Warning,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = AppColors.Error,
|
tint = Color(0xFFEF4444),
|
||||||
modifier = Modifier.size(20.dp)
|
modifier = Modifier.Companion.size(20.dp)
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.Companion.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
errorMessage,
|
errorMessage,
|
||||||
color = AppColors.Error,
|
color = Color(0xFFEF4444),
|
||||||
style = MaterialTheme.typography.bodySmall
|
style = MaterialTheme.typography.bodySmall
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.Companion.height(80.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Input Area - Layer 2 (di atas chat, tapi di bawah drawer)
|
// Input Area
|
||||||
Surface(
|
Card(
|
||||||
color = AppColors.Surface,
|
modifier = Modifier.Companion.fillMaxWidth(),
|
||||||
shadowElevation = 8.dp,
|
colors = CardDefaults.cardColors(
|
||||||
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
|
containerColor = Color(0xFF1E293B)
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.Companion
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalAlignment = Alignment.Companion.Bottom
|
||||||
) {
|
|
||||||
// Upload File Button (di atas input text)
|
|
||||||
if (chatMessages.isNotEmpty() && !isGeneratingSummary && !isLoading) {
|
|
||||||
FileUploadButton(
|
|
||||||
onFileSelected = { fileResult ->
|
|
||||||
uploadedFile = fileResult
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
isGeneratingSummary = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
val summaryPrompt = """
|
|
||||||
Buatkan ringkasan dari dokumen berikut dalam bahasa Indonesia:
|
|
||||||
|
|
||||||
Judul File: ${fileResult.fileName}
|
|
||||||
Tipe: ${fileResult.fileType}
|
|
||||||
Jumlah Kata: ${fileResult.wordCount}
|
|
||||||
|
|
||||||
Konten:
|
|
||||||
${fileResult.content}
|
|
||||||
|
|
||||||
Buat ringkasan yang:
|
|
||||||
1. Mencakup poin-poin utama
|
|
||||||
2. Terstruktur dengan baik (gunakan markdown)
|
|
||||||
3. Mudah dipahami
|
|
||||||
4. Maksimal 300 kata
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
chatMessages = chatMessages + ChatMessage(
|
|
||||||
message = "📄 Upload file: ${fileResult.fileName}\n\nMohon buatkan ringkasan dari file ini.",
|
|
||||||
isUser = true
|
|
||||||
)
|
|
||||||
|
|
||||||
delay(100)
|
|
||||||
scrollState.animateScrollTo(scrollState.maxValue)
|
|
||||||
|
|
||||||
val response =
|
|
||||||
generativeModel.generateContent(summaryPrompt)
|
|
||||||
val summary = response.text ?: "Gagal membuat ringkasan"
|
|
||||||
|
|
||||||
chatMessages = chatMessages + ChatMessage(
|
|
||||||
message = summary,
|
|
||||||
isUser = false
|
|
||||||
)
|
|
||||||
|
|
||||||
saveChatHistory()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
errorMessage = "⚠️ Gagal membuat ringkasan: ${e.message}"
|
|
||||||
} finally {
|
|
||||||
isGeneratingSummary = false
|
|
||||||
uploadedFile = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError = { error ->
|
|
||||||
errorMessage = error
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text Input & Send Button
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.Bottom,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = prompt,
|
value = prompt,
|
||||||
@ -556,30 +428,33 @@ fun AIHelperScreen(
|
|||||||
placeholder = {
|
placeholder = {
|
||||||
Text(
|
Text(
|
||||||
"Ketik pesan...",
|
"Ketik pesan...",
|
||||||
color = AppColors.OnSurfaceTertiary
|
color = Color(0xFF64748B)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier.Companion
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.heightIn(min = 48.dp, max = 120.dp),
|
.heightIn(min = 48.dp, max = 120.dp),
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
colors = TextFieldDefaults.colors(
|
||||||
focusedTextColor = AppColors.OnBackground,
|
focusedTextColor = Color.Companion.White,
|
||||||
unfocusedTextColor = AppColors.OnSurface,
|
unfocusedTextColor = Color.Companion.White,
|
||||||
focusedContainerColor = AppColors.SurfaceVariant,
|
focusedContainerColor = Color(0xFF334155),
|
||||||
unfocusedContainerColor = AppColors.SurfaceVariant,
|
unfocusedContainerColor = Color(0xFF334155),
|
||||||
cursorColor = AppColors.Primary,
|
cursorColor = Color(0xFFA855F7),
|
||||||
focusedBorderColor = AppColors.Primary,
|
focusedIndicatorColor = Color(0xFF6366F1),
|
||||||
unfocusedBorderColor = Color.Transparent
|
unfocusedIndicatorColor = Color(0xFF475569)
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(24.dp),
|
shape = androidx.compose.foundation.shape.RoundedCornerShape(24.dp),
|
||||||
maxLines = 4
|
maxLines = 4
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.Companion.width(12.dp))
|
||||||
|
|
||||||
// Send Button
|
// Send Button
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (prompt.isNotBlank() && !isLoading) {
|
if (prompt.isNotBlank() && !isLoading) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
// Add user message
|
||||||
chatMessages = chatMessages + ChatMessage(
|
chatMessages = chatMessages + ChatMessage(
|
||||||
message = prompt,
|
message = prompt,
|
||||||
isUser = true
|
isUser = true
|
||||||
@ -612,78 +487,42 @@ fun AIHelperScreen(
|
|||||||
|
|
||||||
val fullPrompt =
|
val fullPrompt =
|
||||||
"$notesContext\n\nPertanyaan: $userPrompt\n\nBerikan jawaban dalam bahasa Indonesia yang natural dan membantu."
|
"$notesContext\n\nPertanyaan: $userPrompt\n\nBerikan jawaban dalam bahasa Indonesia yang natural dan membantu."
|
||||||
val result = generativeModel.generateContent(fullPrompt)
|
|
||||||
val response =
|
|
||||||
result.text ?: "Tidak ada respons dari AI"
|
|
||||||
|
|
||||||
|
val result = generativeModel.generateContent(fullPrompt)
|
||||||
|
val response = result.text ?: "Tidak ada respons dari AI"
|
||||||
|
|
||||||
|
// Add AI response
|
||||||
chatMessages = chatMessages + ChatMessage(
|
chatMessages = chatMessages + ChatMessage(
|
||||||
message = response,
|
message = response,
|
||||||
isUser = false
|
isUser = false
|
||||||
)
|
)
|
||||||
|
|
||||||
saveChatHistory()
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
errorMessage = when {
|
errorMessage = "Error: ${e.message}"
|
||||||
e.message?.contains(
|
|
||||||
"quota",
|
|
||||||
ignoreCase = true
|
|
||||||
) == true ->
|
|
||||||
"⚠️ Kuota API habis. Silakan coba lagi nanti atau hubungi developer."
|
|
||||||
|
|
||||||
e.message?.contains(
|
|
||||||
"404",
|
|
||||||
ignoreCase = true
|
|
||||||
) == true ||
|
|
||||||
e.message?.contains(
|
|
||||||
"not found",
|
|
||||||
ignoreCase = true
|
|
||||||
) == true ->
|
|
||||||
"⚠️ Model AI tidak ditemukan. Silakan hubungi developer."
|
|
||||||
|
|
||||||
e.message?.contains(
|
|
||||||
"401",
|
|
||||||
ignoreCase = true
|
|
||||||
) == true ||
|
|
||||||
e.message?.contains(
|
|
||||||
"API key",
|
|
||||||
ignoreCase = true
|
|
||||||
) == true ->
|
|
||||||
"⚠️ API key tidak valid. Silakan hubungi developer."
|
|
||||||
|
|
||||||
e.message?.contains(
|
|
||||||
"timeout",
|
|
||||||
ignoreCase = true
|
|
||||||
) == true ->
|
|
||||||
"⚠️ Koneksi timeout. Periksa koneksi internet Anda."
|
|
||||||
|
|
||||||
e.message?.contains(
|
|
||||||
"network",
|
|
||||||
ignoreCase = true
|
|
||||||
) == true ->
|
|
||||||
"⚠️ Tidak ada koneksi internet. Silakan periksa koneksi Anda."
|
|
||||||
|
|
||||||
else ->
|
|
||||||
"⚠️ Terjadi kesalahan: ${e.message?.take(100) ?: "Unknown error"}"
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
containerColor = AppColors.Primary,
|
containerColor = Color.Companion.Transparent,
|
||||||
modifier = Modifier.size(48.dp)
|
modifier = Modifier.Companion
|
||||||
|
.size(48.dp)
|
||||||
|
.background(
|
||||||
|
brush = Brush.Companion.linearGradient(
|
||||||
|
colors = listOf(Color(0xFF6366F1), Color(0xFFA855F7))
|
||||||
|
),
|
||||||
|
shape = CircleShape
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Send,
|
Icons.Default.Send,
|
||||||
contentDescription = "Send",
|
contentDescription = "Send",
|
||||||
tint = Color.White,
|
tint = Color.Companion.White,
|
||||||
modifier = Modifier.size(20.dp)
|
modifier = Modifier.Companion.size(24.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,76 +1,76 @@
|
|||||||
package com.example.notesai.presentation.screens.ai.components
|
package com.example.notesai.presentation.screens.ai.components
|
||||||
|
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.AutoAwesome
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.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.MarkdownStripper
|
|
||||||
import com.example.notesai.util.AppColors
|
|
||||||
import com.example.notesai.util.Constants
|
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatBubble(
|
fun ChatBubble(
|
||||||
message: ChatMessage,
|
message: ChatMessage,
|
||||||
onCopy: (String) -> Unit, // CHANGED: Sekarang terima text parameter
|
onCopy: () -> Unit,
|
||||||
showCopied: Boolean
|
showCopied: Boolean
|
||||||
) {
|
) {
|
||||||
val dateFormat = remember { SimpleDateFormat("HH:mm", Locale("id", "ID")) }
|
val dateFormat = remember { SimpleDateFormat("HH:mm", Locale("id", "ID")) }
|
||||||
var showCopyMenu by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalAlignment = if (message.isUser) Alignment.End else Alignment.Start
|
|
||||||
) {
|
|
||||||
if (message.isUser) {
|
|
||||||
// User Message (tidak berubah)
|
|
||||||
Surface(
|
|
||||||
color = AppColors.Primary,
|
|
||||||
shape = RoundedCornerShape(
|
|
||||||
topStart = Constants.Radius.Large.dp,
|
|
||||||
topEnd = Constants.Radius.Large.dp,
|
|
||||||
bottomStart = Constants.Radius.Large.dp,
|
|
||||||
bottomEnd = 4.dp
|
|
||||||
),
|
|
||||||
shadowElevation = 2.dp,
|
|
||||||
modifier = Modifier.widthIn(max = 320.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(12.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
horizontalArrangement = if (message.isUser) Arrangement.End else Arrangement.Start
|
||||||
) {
|
) {
|
||||||
|
if (!message.isUser) {
|
||||||
|
// Ganti ikon bintang dengan ikon robot/sparkles
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Person,
|
Icons.Default.AutoAwesome, // Atau bisa diganti dengan ikon lain seperti AutoAwesome
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = Color.White.copy(alpha = 0.9f),
|
tint = Color(0xFF6366F1), // Warna ungu/biru untuk AI
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier
|
||||||
)
|
.size(32.dp)
|
||||||
Text(
|
.padding(end = 8.dp)
|
||||||
"Anda",
|
|
||||||
color = Color.White.copy(alpha = 0.9f),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(6.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(
|
||||||
|
topStart = 16.dp,
|
||||||
|
topEnd = 16.dp,
|
||||||
|
bottomStart = if (message.isUser) 16.dp else 4.dp,
|
||||||
|
bottomEnd = if (message.isUser) 4.dp else 16.dp
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
Text(
|
Text(
|
||||||
message.message,
|
message.message,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
@ -78,173 +78,43 @@ fun ChatBubble(
|
|||||||
lineHeight = 20.sp
|
lineHeight = 20.sp
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
dateFormat.format(Date(message.timestamp)),
|
|
||||||
color = Color.White.copy(alpha = 0.7f),
|
|
||||||
fontSize = 11.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// AI Message with IMPROVED Copy Options
|
|
||||||
Surface(
|
|
||||||
color = AppColors.SurfaceVariant,
|
|
||||||
shape = RoundedCornerShape(
|
|
||||||
topStart = Constants.Radius.Large.dp,
|
|
||||||
topEnd = Constants.Radius.Large.dp,
|
|
||||||
bottomStart = 4.dp,
|
|
||||||
bottomEnd = Constants.Radius.Large.dp
|
|
||||||
),
|
|
||||||
shadowElevation = 2.dp,
|
|
||||||
modifier = Modifier.widthIn(max = 320.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(12.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.SmartToy,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = AppColors.Primary,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"AI Assistant",
|
|
||||||
color = AppColors.OnSurfaceVariant,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IMPROVED: Copy Button with Dropdown Menu
|
|
||||||
Box {
|
|
||||||
IconButton(
|
|
||||||
onClick = { showCopyMenu = !showCopyMenu },
|
|
||||||
modifier = Modifier.size(28.dp)
|
|
||||||
) {
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = showCopied,
|
|
||||||
transitionSpec = {
|
|
||||||
fadeIn() + scaleIn() togetherWith fadeOut() + scaleOut()
|
|
||||||
},
|
|
||||||
label = "copy_icon"
|
|
||||||
) { copied ->
|
|
||||||
Icon(
|
|
||||||
if (copied) Icons.Default.Check else Icons.Default.ContentCopy,
|
|
||||||
contentDescription = if (copied) "Copied" else "Copy",
|
|
||||||
tint = if (copied) AppColors.Success else AppColors.OnSurfaceVariant,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dropdown Menu untuk pilihan copy
|
|
||||||
DropdownMenu(
|
|
||||||
expanded = showCopyMenu,
|
|
||||||
onDismissRequest = { showCopyMenu = false },
|
|
||||||
modifier = Modifier.background(AppColors.SurfaceElevated)
|
|
||||||
) {
|
|
||||||
// Option 1: Copy dengan Format (Markdown)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Code,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = AppColors.Primary,
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
"Copy dengan Format",
|
|
||||||
fontSize = 13.sp,
|
|
||||||
color = AppColors.OnSurface,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Termasuk markdown",
|
|
||||||
fontSize = 10.sp,
|
|
||||||
color = AppColors.OnSurfaceTertiary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
onCopy(message.message) // Copy original dengan markdown
|
|
||||||
showCopyMenu = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider(color = AppColors.Divider)
|
|
||||||
|
|
||||||
// Option 2: Copy Teks Asli (Plain Text)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.TextFields,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = AppColors.Secondary,
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
"Copy Teks Asli",
|
|
||||||
fontSize = 13.sp,
|
|
||||||
color = AppColors.OnSurface,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Tanpa format",
|
|
||||||
fontSize = 10.sp,
|
|
||||||
color = AppColors.OnSurfaceTertiary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
val plainText = MarkdownStripper.stripMarkdown(message.message)
|
|
||||||
onCopy(plainText) // Copy plain text tanpa markdown
|
|
||||||
showCopyMenu = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
// Use MarkdownText for AI responses
|
|
||||||
MarkdownText(
|
|
||||||
markdown = message.message,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
dateFormat.format(Date(message.timestamp)),
|
dateFormat.format(Date(message.timestamp)),
|
||||||
color = AppColors.OnSurfaceTertiary,
|
color = Color.White.copy(0.6f),
|
||||||
fontSize = 11.sp
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(top = 4.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!message.isUser) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onCopy,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.ContentCopy,
|
||||||
|
contentDescription = "Copy",
|
||||||
|
tint = Color.White.copy(0.7f),
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showCopied && !message.isUser) {
|
||||||
|
Text(
|
||||||
|
"✓ Disalin",
|
||||||
|
color = Color(0xFF10B981),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(top = 4.dp, start = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,42 @@
|
|||||||
|
package com.example.notesai.presentation.screens.ai.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CompactStatItem(label: String, value: String, color: Color) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
color = Color(0xFF1E293B),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = color,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Color(0xFF94A3B8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,206 +0,0 @@
|
|||||||
package com.example.notesai.presentation.screens.ai.components
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.animation.*
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.example.notesai.util.AppColors
|
|
||||||
import com.example.notesai.util.Constants
|
|
||||||
import com.example.notesai.util.FileParser
|
|
||||||
import com.example.notesai.util.FileParseResult
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun FileUploadButton(
|
|
||||||
onFileSelected: (FileParseResult.Success) -> Unit,
|
|
||||||
onError: (String) -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var isProcessing by remember { mutableStateOf(false) }
|
|
||||||
var selectedFileName by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
// File picker launcher
|
|
||||||
val filePicker = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.GetContent()
|
|
||||||
) { uri: Uri? ->
|
|
||||||
uri?.let {
|
|
||||||
scope.launch {
|
|
||||||
isProcessing = true
|
|
||||||
try {
|
|
||||||
// Get file info
|
|
||||||
val fileSize = FileParser.getFileSize(context, uri)
|
|
||||||
|
|
||||||
// Check file size (max 10MB)
|
|
||||||
if (fileSize > 10 * 1024 * 1024) {
|
|
||||||
onError("File terlalu besar. Maksimal 10MB")
|
|
||||||
isProcessing = false
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse file
|
|
||||||
val result = withContext(Dispatchers.IO) {
|
|
||||||
FileParser.parseFile(context, uri)
|
|
||||||
}
|
|
||||||
|
|
||||||
when (result) {
|
|
||||||
is FileParseResult.Success -> {
|
|
||||||
selectedFileName = result.fileName
|
|
||||||
onFileSelected(result)
|
|
||||||
}
|
|
||||||
is FileParseResult.Error -> {
|
|
||||||
onError(result.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
onError("Gagal memproses file: ${e.message}")
|
|
||||||
} finally {
|
|
||||||
isProcessing = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
// Launch file picker untuk PDF, TXT, DOCX
|
|
||||||
filePicker.launch("*/*")
|
|
||||||
},
|
|
||||||
enabled = !isProcessing,
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = AppColors.Secondary.copy(alpha = 0.15f),
|
|
||||||
contentColor = AppColors.Secondary,
|
|
||||||
disabledContainerColor = AppColors.SurfaceVariant,
|
|
||||||
disabledContentColor = AppColors.OnSurfaceVariant
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(Constants.Radius.Medium.dp),
|
|
||||||
modifier = modifier,
|
|
||||||
contentPadding = PaddingValues(
|
|
||||||
horizontal = Constants.Spacing.Medium.dp,
|
|
||||||
vertical = Constants.Spacing.Small.dp
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
if (isProcessing) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
color = AppColors.Secondary,
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text("Memproses...")
|
|
||||||
} else {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.AttachFile,
|
|
||||||
contentDescription = "Upload File",
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
"Upload File",
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun FilePreviewCard(
|
|
||||||
fileResult: FileParseResult.Success,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
modifier = modifier,
|
|
||||||
color = AppColors.SurfaceVariant,
|
|
||||||
shape = RoundedCornerShape(Constants.Radius.Large.dp),
|
|
||||||
shadowElevation = 4.dp
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp)
|
|
||||||
) {
|
|
||||||
// Header
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
when (fileResult.fileType) {
|
|
||||||
"PDF" -> Icons.Default.PictureAsPdf
|
|
||||||
"Word" -> Icons.Default.Description
|
|
||||||
else -> Icons.Default.TextSnippet
|
|
||||||
},
|
|
||||||
contentDescription = null,
|
|
||||||
tint = AppColors.Secondary,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
fileResult.fileName,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = AppColors.OnSurface
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"${fileResult.fileType} • ${fileResult.wordCount} kata",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = AppColors.OnSurfaceTertiary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
IconButton(
|
|
||||||
onClick = onDismiss,
|
|
||||||
modifier = Modifier.size(32.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Close,
|
|
||||||
contentDescription = "Close",
|
|
||||||
tint = AppColors.OnSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
// Content Preview
|
|
||||||
Surface(
|
|
||||||
color = AppColors.Surface,
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
fileResult.content.take(200) + if (fileResult.content.length > 200) "..." else "",
|
|
||||||
modifier = Modifier.padding(12.dp),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = AppColors.OnSurfaceVariant,
|
|
||||||
lineHeight = 18.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
package com.example.notesai.presentation.screens.ai.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StatItem(label: String, value: String, color: Color) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = color,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Color(0xFF94A3B8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,6 @@
|
|||||||
package com.example.notesai.presentation.screens.ai.components
|
package com.example.notesai.presentation.screens.ai.components
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@ -9,49 +8,44 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Lightbulb
|
|
||||||
import androidx.compose.material.icons.filled.Star
|
import androidx.compose.material.icons.filled.Star
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.example.notesai.util.AppColors
|
|
||||||
import com.example.notesai.util.Constants
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SuggestionChip(
|
fun SuggestionChip(text: String, onSelect: (String) -> Unit) {
|
||||||
text: String,
|
Card(
|
||||||
onSelect: (String) -> Unit
|
modifier = Modifier
|
||||||
) {
|
.padding(vertical = 4.dp)
|
||||||
Surface(
|
.clickable { onSelect(text) },
|
||||||
onClick = { onSelect(text) },
|
colors = CardDefaults.cardColors(
|
||||||
color = AppColors.SurfaceVariant,
|
containerColor = Color(0xFF1E293B)
|
||||||
|
),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(12.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Lightbulb,
|
Icons.Default.Star,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = AppColors.Primary,
|
tint = Color(0xFF6366F1),
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text,
|
text,
|
||||||
color = AppColors.OnSurface,
|
color = Color.White,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,12 @@
|
|||||||
package com.example.notesai.presentation.screens.archive
|
package com.example.notesai.presentation.screens.archive
|
||||||
|
|
||||||
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.Archive
|
import androidx.compose.material.icons.filled.Archive
|
||||||
import androidx.compose.material.icons.filled.Search
|
|
||||||
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.data.model.Note
|
import com.example.notesai.data.model.Note
|
||||||
import com.example.notesai.data.model.Category
|
import com.example.notesai.data.model.Category
|
||||||
@ -21,49 +17,21 @@ import com.example.notesai.presentation.screens.archive.components.ArchiveNoteCa
|
|||||||
fun ArchiveScreen(
|
fun ArchiveScreen(
|
||||||
notes: List<Note>,
|
notes: List<Note>,
|
||||||
categories: List<Category>,
|
categories: List<Category>,
|
||||||
searchQuery: String = "", // Tambahkan parameter ini
|
|
||||||
onRestore: (Note) -> Unit,
|
onRestore: (Note) -> Unit,
|
||||||
onDelete: (Note) -> Unit
|
onDelete: (Note) -> Unit
|
||||||
) {
|
) {
|
||||||
// Filter berdasarkan search query dari ModernTopBar
|
if (notes.isEmpty()) {
|
||||||
val filteredNotes = if (searchQuery.isBlank()) {
|
|
||||||
notes
|
|
||||||
} else {
|
|
||||||
notes.filter { note ->
|
|
||||||
note.title.contains(searchQuery, ignoreCase = true) ||
|
|
||||||
note.content.contains(searchQuery, ignoreCase = true) ||
|
|
||||||
note.description.contains(searchQuery, ignoreCase = true) ||
|
|
||||||
categories.find { it.id == note.categoryId }?.name?.contains(searchQuery, ignoreCase = true) == true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
// Content
|
|
||||||
if (filteredNotes.isEmpty()) {
|
|
||||||
if (searchQuery.isNotEmpty()) {
|
|
||||||
EmptyState(
|
|
||||||
icon = Icons.Default.Search,
|
|
||||||
message = "Tidak ada hasil",
|
|
||||||
subtitle = "Coba kata kunci lain"
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
EmptyState(
|
EmptyState(
|
||||||
icon = Icons.Default.Archive,
|
icon = Icons.Default.Archive,
|
||||||
message = "Arsip kosong",
|
message = "Arsip kosong",
|
||||||
subtitle = "Catatan yang diarsipkan akan muncul di sini"
|
subtitle = "Catatan yang diarsipkan akan muncul di sini"
|
||||||
)
|
)
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(16.dp),
|
||||||
start = 16.dp,
|
|
||||||
end = 16.dp,
|
|
||||||
top = 8.dp,
|
|
||||||
bottom = 100.dp
|
|
||||||
),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
items(filteredNotes) { note ->
|
items(notes) { note ->
|
||||||
val category = categories.find { it.id == note.categoryId }
|
val category = categories.find { it.id == note.categoryId }
|
||||||
ArchiveNoteCard(
|
ArchiveNoteCard(
|
||||||
note = note,
|
note = note,
|
||||||
@ -75,4 +43,3 @@ fun ArchiveScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
@ -25,24 +25,6 @@ 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 com.example.notesai.data.model.Note
|
import com.example.notesai.data.model.Note
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.jsonObject
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
|
|
||||||
// Helper function untuk extract plain text dari AnnotatedString JSON
|
|
||||||
private fun extractPlainText(jsonContent: String): String {
|
|
||||||
return try {
|
|
||||||
if (jsonContent.trim().startsWith("{")) {
|
|
||||||
val jsonElement = Json.parseToJsonElement(jsonContent)
|
|
||||||
val jsonObject = jsonElement.jsonObject
|
|
||||||
jsonObject["text"]?.jsonPrimitive?.content ?: ""
|
|
||||||
} else {
|
|
||||||
jsonContent
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
jsonContent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ArchiveNoteCard(
|
fun ArchiveNoteCard(
|
||||||
@ -51,12 +33,6 @@ fun ArchiveNoteCard(
|
|||||||
onRestore: () -> Unit,
|
onRestore: () -> Unit,
|
||||||
onDelete: () -> Unit
|
onDelete: () -> Unit
|
||||||
) {
|
) {
|
||||||
val displayContent = if (note.description.isNotEmpty()) {
|
|
||||||
note.description
|
|
||||||
} else {
|
|
||||||
extractPlainText(note.content)
|
|
||||||
}
|
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
@ -86,10 +62,10 @@ fun ArchiveNoteCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (displayContent.isNotEmpty()) {
|
if (note.content.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
displayContent,
|
note.content,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
color = Color(0xFF94A3B8),
|
color = Color(0xFF94A3B8),
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
// File: presentation/screens/main/MainScreen.kt
|
||||||
package com.example.notesai.presentation.screens.main
|
package com.example.notesai.presentation.screens.main
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
@ -27,10 +28,7 @@ fun MainScreen(
|
|||||||
onNoteClick: (Note) -> Unit,
|
onNoteClick: (Note) -> Unit,
|
||||||
onPinToggle: (Note) -> Unit,
|
onPinToggle: (Note) -> Unit,
|
||||||
onCategoryDelete: (Category) -> Unit,
|
onCategoryDelete: (Category) -> Unit,
|
||||||
onCategoryEdit: (Category, String, Long, Long) -> Unit,
|
onCategoryEdit: (Category, String, Long, Long) -> Unit // Parameter baru
|
||||||
onCategoryPin: (Category) -> Unit, // NEW: Pin category callback
|
|
||||||
onNoteEdit: (Note) -> Unit = {},
|
|
||||||
onNoteDelete: (Note) -> Unit = {}
|
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
if (selectedCategory == null) {
|
if (selectedCategory == null) {
|
||||||
@ -43,25 +41,11 @@ fun MainScreen(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Filter kategori berdasarkan searchQuery
|
// Filter kategori berdasarkan searchQuery
|
||||||
val filteredCategories = if (searchQuery.isBlank()) {
|
val filteredCategories = if (searchQuery.isEmpty()) {
|
||||||
categories
|
categories
|
||||||
} else {
|
} else {
|
||||||
categories.filter { category ->
|
categories.filter {
|
||||||
// 1. Cek nama kategori cocok
|
it.name.contains(searchQuery, ignoreCase = true)
|
||||||
val categoryNameMatches = category.name.contains(searchQuery, ignoreCase = true)
|
|
||||||
|
|
||||||
// 2. Cek ada catatan yang cocok di kategori ini
|
|
||||||
val hasMatchingNotes = notes.any { note ->
|
|
||||||
note.categoryId == category.id &&
|
|
||||||
!note.isDeleted &&
|
|
||||||
!note.isArchived &&
|
|
||||||
(note.title.contains(searchQuery, ignoreCase = true) ||
|
|
||||||
note.description.contains(searchQuery, ignoreCase = true) ||
|
|
||||||
note.content.contains(searchQuery, ignoreCase = true))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kategori muncul jika salah satu kondisi terpenuhi
|
|
||||||
categoryNameMatches || hasMatchingNotes
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,12 +58,7 @@ fun MainScreen(
|
|||||||
} else {
|
} else {
|
||||||
LazyVerticalStaggeredGrid(
|
LazyVerticalStaggeredGrid(
|
||||||
columns = StaggeredGridCells.Fixed(2),
|
columns = StaggeredGridCells.Fixed(2),
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(16.dp),
|
||||||
start = 16.dp,
|
|
||||||
end = 16.dp,
|
|
||||||
top = 16.dp,
|
|
||||||
bottom = 100.dp
|
|
||||||
),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalItemSpacing = 12.dp,
|
verticalItemSpacing = 12.dp,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
@ -96,8 +75,7 @@ fun MainScreen(
|
|||||||
onDelete = { onCategoryDelete(category) },
|
onDelete = { onCategoryDelete(category) },
|
||||||
onEdit = { name, gradientStart, gradientEnd ->
|
onEdit = { name, gradientStart, gradientEnd ->
|
||||||
onCategoryEdit(category, name, gradientStart, gradientEnd)
|
onCategoryEdit(category, name, gradientStart, gradientEnd)
|
||||||
},
|
}
|
||||||
onPin = { onCategoryPin(category) } // NEW: Pass pin callback
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -124,12 +102,7 @@ fun MainScreen(
|
|||||||
} else {
|
} else {
|
||||||
LazyVerticalStaggeredGrid(
|
LazyVerticalStaggeredGrid(
|
||||||
columns = StaggeredGridCells.Fixed(2),
|
columns = StaggeredGridCells.Fixed(2),
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(16.dp),
|
||||||
start = 16.dp,
|
|
||||||
end = 16.dp,
|
|
||||||
top = 16.dp,
|
|
||||||
bottom = 100.dp
|
|
||||||
),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalItemSpacing = 12.dp,
|
verticalItemSpacing = 12.dp,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
@ -138,9 +111,7 @@ fun MainScreen(
|
|||||||
NoteCard(
|
NoteCard(
|
||||||
note = note,
|
note = note,
|
||||||
onClick = { onNoteClick(note) },
|
onClick = { onNoteClick(note) },
|
||||||
onPinClick = { onPinToggle(note) },
|
onPinClick = { onPinToggle(note) }
|
||||||
onEdit = { onNoteEdit(note) },
|
|
||||||
onDelete = { onNoteDelete(note) }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,29 +1,21 @@
|
|||||||
// File: presentation/screens/main/components/CategoryCard.kt
|
|
||||||
package com.example.notesai.presentation.screens.main.components
|
package com.example.notesai.presentation.screens.main.components
|
||||||
|
|
||||||
import androidx.compose.animation.*
|
|
||||||
import androidx.compose.animation.core.*
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material.icons.outlined.FolderOpen
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.scale
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.example.notesai.data.model.Category
|
import com.example.notesai.data.model.Category
|
||||||
import com.example.notesai.util.AppColors
|
|
||||||
import com.example.notesai.util.Constants
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CategoryCard(
|
fun CategoryCard(
|
||||||
@ -31,39 +23,21 @@ fun CategoryCard(
|
|||||||
noteCount: Int,
|
noteCount: Int,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onDelete: () -> Unit = {},
|
onDelete: () -> Unit = {},
|
||||||
onEdit: (String, Long, Long) -> Unit = { _, _, _ -> },
|
onEdit: (String, Long, Long) -> Unit = { _, _, _ -> }
|
||||||
onPin: () -> Unit = {}
|
|
||||||
) {
|
) {
|
||||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||||
var showEditDialog by remember { mutableStateOf(false) }
|
var showEditDialog by remember { mutableStateOf(false) }
|
||||||
var showMenu by remember { mutableStateOf(false) }
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
var isPressed by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// Smooth scale animation
|
|
||||||
val scale by animateFloatAsState(
|
|
||||||
targetValue = if (isPressed) 0.95f else 1f,
|
|
||||||
animationSpec = spring(
|
|
||||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
|
||||||
stiffness = Spring.StiffnessLow
|
|
||||||
),
|
|
||||||
label = "scale"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Delete Confirmation Dialog
|
// Delete Confirmation Dialog
|
||||||
if (showDeleteConfirm) {
|
if (showDeleteConfirm) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showDeleteConfirm = false },
|
onDismissRequest = { showDeleteConfirm = false },
|
||||||
title = {
|
title = { Text("Pindahkan ke Sampah?", color = Color.White) },
|
||||||
Text(
|
|
||||||
"Pindahkan ke Sampah?",
|
|
||||||
color = AppColors.OnBackground,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
"Kategori '${category.name}' dan $noteCount catatan di dalamnya akan dipindahkan ke sampah.",
|
"Kategori '${category.name}' dan semua catatan di dalamnya akan dipindahkan ke sampah.",
|
||||||
color = AppColors.OnSurfaceVariant
|
color = Color.White
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
@ -73,19 +47,23 @@ fun CategoryCard(
|
|||||||
showDeleteConfirm = false
|
showDeleteConfirm = false
|
||||||
},
|
},
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = AppColors.Error
|
containerColor = Color(0xFFEF4444)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Text("Hapus", color = Color.White)
|
Text("Hapus", color = Color.White)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { showDeleteConfirm = false }) {
|
Button(
|
||||||
Text("Batal", color = AppColors.OnSurfaceVariant)
|
onClick = { showDeleteConfirm = false },
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFF64748B)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("Batal", color = Color.White)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
containerColor = AppColors.Surface,
|
containerColor = Color(0xFF1E293B)
|
||||||
shape = RoundedCornerShape(Constants.Radius.Large.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,19 +79,13 @@ fun CategoryCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main Card - Minimalist Design
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.scale(scale)
|
|
||||||
.clickable(onClick = onClick),
|
.clickable(onClick = onClick),
|
||||||
shape = RoundedCornerShape(Constants.Radius.Large.dp),
|
shape = RoundedCornerShape(20.dp),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
|
||||||
containerColor = AppColors.Surface
|
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||||
),
|
|
||||||
elevation = CardDefaults.cardElevation(
|
|
||||||
defaultElevation = Constants.Elevation.Small.dp
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -121,129 +93,67 @@ fun CategoryCard(
|
|||||||
.background(
|
.background(
|
||||||
brush = Brush.linearGradient(
|
brush = Brush.linearGradient(
|
||||||
colors = listOf(
|
colors = listOf(
|
||||||
Color(category.gradientStart).copy(alpha = 0.1f),
|
Color(category.gradientStart),
|
||||||
Color(category.gradientEnd).copy(alpha = 0.05f)
|
Color(category.gradientEnd)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.padding(20.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(Constants.Spacing.Large.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.Top
|
|
||||||
) {
|
|
||||||
// Icon dengan gradient accent
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(48.dp)
|
|
||||||
.clip(RoundedCornerShape(Constants.Radius.Medium.dp))
|
|
||||||
.background(
|
|
||||||
brush = Brush.linearGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color(category.gradientStart).copy(alpha = 0.2f),
|
|
||||||
Color(category.gradientEnd).copy(alpha = 0.1f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.FolderOpen,
|
Icons.Default.Folder,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = Color(category.gradientStart),
|
tint = Color.White.copy(0.9f),
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pin Indicator & Menu Button
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
// Pin indicator (only show if pinned)
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = category.isPinned,
|
|
||||||
enter = scaleIn() + fadeIn(),
|
|
||||||
exit = scaleOut() + fadeOut()
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.PushPin,
|
|
||||||
contentDescription = "Pinned",
|
|
||||||
tint = AppColors.Warning,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Menu Button
|
|
||||||
Box {
|
|
||||||
IconButton(
|
|
||||||
onClick = { showMenu = true },
|
|
||||||
modifier = Modifier.size(32.dp)
|
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(
|
||||||
|
onClick = { showMenu = true }
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.MoreVert,
|
Icons.Default.MoreVert,
|
||||||
contentDescription = "Menu",
|
contentDescription = "Menu",
|
||||||
tint = AppColors.OnSurfaceVariant,
|
tint = Color.White.copy(0.9f)
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = showMenu,
|
expanded = showMenu,
|
||||||
onDismissRequest = { showMenu = false },
|
onDismissRequest = { showMenu = false },
|
||||||
modifier = Modifier.background(AppColors.SurfaceElevated)
|
modifier = Modifier.background(Color(0xFF1E293B))
|
||||||
) {
|
) {
|
||||||
// Pin/Unpin Menu Item
|
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = {
|
text = {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(Constants.Spacing.Small.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.PushPin,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = if (category.isPinned) AppColors.Warning else AppColors.OnSurfaceVariant,
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
if (category.isPinned) "Lepas Pin" else "Pin Kategori",
|
|
||||||
color = AppColors.OnSurface,
|
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
onPin()
|
|
||||||
showMenu = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider(color = AppColors.Divider)
|
|
||||||
|
|
||||||
// Edit Menu Item
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(Constants.Spacing.Small.dp)
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Edit,
|
Icons.Default.Edit,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = AppColors.Primary,
|
tint = Color(0xFF6366F1),
|
||||||
modifier = Modifier.size(18.dp)
|
modifier = Modifier.size(20.dp)
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Edit Kategori",
|
|
||||||
color = AppColors.OnSurface,
|
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
)
|
||||||
|
Text("Edit Kategori", color = Color.White)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
@ -252,26 +162,19 @@ fun CategoryCard(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
HorizontalDivider(color = AppColors.Divider)
|
|
||||||
|
|
||||||
// Delete Menu Item
|
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = {
|
text = {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(Constants.Spacing.Small.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Delete,
|
Icons.Default.Delete,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = AppColors.Error,
|
tint = Color(0xFFEF4444),
|
||||||
modifier = Modifier.size(18.dp)
|
modifier = Modifier.size(20.dp)
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Pindah ke Sampah",
|
|
||||||
color = AppColors.OnSurface,
|
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
)
|
||||||
|
Text("Pindah ke Sampah", color = Color.White)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
@ -283,41 +186,6 @@ fun CategoryCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(Constants.Spacing.Medium.dp))
|
|
||||||
|
|
||||||
// Category Name
|
|
||||||
Text(
|
|
||||||
category.name,
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = AppColors.OnBackground,
|
|
||||||
fontSize = 18.sp
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(Constants.Spacing.ExtraSmall.dp))
|
|
||||||
|
|
||||||
// Note Count dengan subtle styling
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Description,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = AppColors.OnSurfaceTertiary,
|
|
||||||
modifier = Modifier.size(14.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"$noteCount catatan",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = AppColors.OnSurfaceTertiary,
|
|
||||||
fontSize = 13.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -326,23 +194,31 @@ fun EditCategoryDialog(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSave: (String, Long, Long) -> Unit
|
onSave: (String, Long, Long) -> Unit
|
||||||
) {
|
) {
|
||||||
|
val gradients = listOf(
|
||||||
|
Pair(0xFF6366F1L, 0xFFA855F7L),
|
||||||
|
Pair(0xFFEC4899L, 0xFFF59E0BL),
|
||||||
|
Pair(0xFF8B5CF6L, 0xFFEC4899L),
|
||||||
|
Pair(0xFF06B6D4L, 0xFF3B82F6L),
|
||||||
|
Pair(0xFF10B981L, 0xFF059669L),
|
||||||
|
Pair(0xFFF59E0BL, 0xFFEF4444L),
|
||||||
|
Pair(0xFF6366F1L, 0xFF8B5CF6L),
|
||||||
|
Pair(0xFFEF4444L, 0xFFDC2626L)
|
||||||
|
)
|
||||||
var name by remember { mutableStateOf(category.name) }
|
var name by remember { mutableStateOf(category.name) }
|
||||||
var selectedGradient by remember {
|
var selectedGradient by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
Constants.CategoryColors.indexOfFirst {
|
gradients.indexOfFirst {
|
||||||
it.first == category.gradientStart && it.second == category.gradientEnd
|
it.first == category.gradientStart && it.second == category.gradientEnd
|
||||||
}.takeIf { it >= 0 } ?: 0
|
}.takeIf { it >= 0 } ?: 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
containerColor = AppColors.Surface,
|
containerColor = Color(0xFF1E293B),
|
||||||
shape = RoundedCornerShape(Constants.Radius.Large.dp),
|
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
"Edit Kategori",
|
"Edit Kategori",
|
||||||
color = AppColors.OnBackground,
|
color = Color.White,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@ -351,52 +227,42 @@ fun EditCategoryDialog(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
label = {
|
label = { Text("Nama Kategori", color = Color(0xFF94A3B8)) },
|
||||||
Text(
|
|
||||||
"Nama Kategori",
|
|
||||||
color = AppColors.OnSurfaceVariant
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
colors = TextFieldDefaults.colors(
|
||||||
focusedTextColor = AppColors.OnBackground,
|
focusedTextColor = Color.White,
|
||||||
unfocusedTextColor = AppColors.OnSurface,
|
unfocusedTextColor = Color.White,
|
||||||
focusedContainerColor = AppColors.SurfaceVariant,
|
focusedContainerColor = Color(0xFF334155),
|
||||||
unfocusedContainerColor = AppColors.SurfaceVariant,
|
unfocusedContainerColor = Color(0xFF334155),
|
||||||
cursorColor = AppColors.Primary,
|
cursorColor = Color(0xFFA855F7),
|
||||||
focusedBorderColor = AppColors.Primary,
|
focusedIndicatorColor = Color(0xFFA855F7),
|
||||||
unfocusedBorderColor = AppColors.Border
|
unfocusedIndicatorColor = Color(0xFF64748B)
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(Constants.Radius.Medium.dp)
|
shape = RoundedCornerShape(12.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Pilih Warna:",
|
"Pilih Gradient:",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = AppColors.OnSurface,
|
color = Color.White,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
// 8 colors in 2 rows
|
gradients.chunked(4).forEach { row ->
|
||||||
Constants.CategoryColors.chunked(4).forEach { row ->
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
row.forEachIndexed { _, gradient ->
|
row.forEachIndexed { _, gradient ->
|
||||||
val globalIndex = Constants.CategoryColors.indexOf(gradient)
|
val globalIndex = gradients.indexOf(gradient)
|
||||||
val isSelected = selectedGradient == globalIndex
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.aspectRatio(1f)
|
.aspectRatio(1f)
|
||||||
.clip(RoundedCornerShape(Constants.Radius.Medium.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.background(
|
.background(
|
||||||
brush = Brush.linearGradient(
|
brush = Brush.linearGradient(
|
||||||
colors = listOf(
|
colors = listOf(
|
||||||
@ -408,22 +274,18 @@ fun EditCategoryDialog(
|
|||||||
.clickable { selectedGradient = globalIndex },
|
.clickable { selectedGradient = globalIndex },
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
this@Row.AnimatedVisibility(
|
if (selectedGradient == globalIndex) {
|
||||||
visible = isSelected,
|
|
||||||
enter = scaleIn() + fadeIn(),
|
|
||||||
exit = scaleOut() + fadeOut()
|
|
||||||
) {
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Check,
|
Icons.Default.Check,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = Color.White,
|
tint = Color.White,
|
||||||
modifier = Modifier.size(28.dp)
|
modifier = Modifier.size(24.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -431,13 +293,19 @@ fun EditCategoryDialog(
|
|||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (name.isNotBlank()) {
|
if (name.isNotBlank()) {
|
||||||
val gradient = Constants.CategoryColors[selectedGradient]
|
val gradient = gradients[selectedGradient]
|
||||||
onSave(name, gradient.first, gradient.second)
|
onSave(name, gradient.first, gradient.second)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = name.isNotBlank(),
|
enabled = name.isNotBlank(),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = AppColors.Primary
|
containerColor = Color.Transparent
|
||||||
|
),
|
||||||
|
modifier = Modifier.background(
|
||||||
|
brush = Brush.linearGradient(
|
||||||
|
colors = listOf(Color(0xFF6366F1), Color(0xFFA855F7))
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Text("Simpan", color = Color.White, fontWeight = FontWeight.Bold)
|
Text("Simpan", color = Color.White, fontWeight = FontWeight.Bold)
|
||||||
@ -445,7 +313,7 @@ fun EditCategoryDialog(
|
|||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = onDismiss) {
|
TextButton(onClick = onDismiss) {
|
||||||
Text("Batal", color = AppColors.OnSurfaceVariant)
|
Text("Batal", color = Color(0xFF94A3B8))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,365 +1,128 @@
|
|||||||
// File: presentation/screens/main/components/NoteCard.kt
|
|
||||||
package com.example.notesai.presentation.screens.main.components
|
package com.example.notesai.presentation.screens.main.components
|
||||||
|
|
||||||
import androidx.compose.animation.*
|
|
||||||
import androidx.compose.animation.core.*
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.Star
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material.icons.outlined.StarBorder
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.rotate
|
|
||||||
import androidx.compose.ui.draw.scale
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.example.notesai.data.model.Note
|
import com.example.notesai.data.model.Note
|
||||||
import com.example.notesai.util.AppColors
|
|
||||||
import com.example.notesai.util.Constants
|
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun NoteCard(
|
fun NoteCard(
|
||||||
note: Note,
|
note: Note,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onPinClick: () -> Unit,
|
onPinClick: () -> Unit
|
||||||
onEdit: () -> Unit = {},
|
|
||||||
onDelete: () -> Unit = {}
|
|
||||||
) {
|
) {
|
||||||
val dateFormat = SimpleDateFormat("dd MMM, HH:mm", Locale("id", "ID"))
|
val dateFormat = SimpleDateFormat("dd MMM, HH:mm", Locale("id", "ID"))
|
||||||
var showMenu by remember { mutableStateOf(false) }
|
|
||||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// Scale animation on press
|
|
||||||
var isPressed by remember { mutableStateOf(false) }
|
|
||||||
val scale by animateFloatAsState(
|
|
||||||
targetValue = if (isPressed) 0.95f else 1f,
|
|
||||||
animationSpec = spring(
|
|
||||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
|
||||||
stiffness = Spring.StiffnessLow
|
|
||||||
),
|
|
||||||
label = "scale"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Pin icon rotation animation
|
|
||||||
val pinRotation by animateFloatAsState(
|
|
||||||
targetValue = if (note.isPinned) 0f else 45f,
|
|
||||||
animationSpec = spring(
|
|
||||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
|
||||||
stiffness = Spring.StiffnessMedium
|
|
||||||
),
|
|
||||||
label = "rotation"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Elevation animation for pinned state
|
|
||||||
val elevation by animateDpAsState(
|
|
||||||
targetValue = if (note.isPinned) Constants.Elevation.Medium.dp else Constants.Elevation.Small.dp,
|
|
||||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
|
|
||||||
label = "elevation"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Delete Confirmation Dialog
|
|
||||||
if (showDeleteConfirm) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showDeleteConfirm = false },
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.DeleteForever,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = AppColors.Error,
|
|
||||||
modifier = Modifier.size(32.dp)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
"Pindahkan ke Sampah?",
|
|
||||||
color = AppColors.OnBackground,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
"Catatan '${note.title}' akan dipindahkan ke sampah.",
|
|
||||||
color = AppColors.OnSurfaceVariant
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
onDelete()
|
|
||||||
showDeleteConfirm = false
|
|
||||||
},
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = AppColors.Error
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text("Hapus", color = Color.White)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showDeleteConfirm = false }) {
|
|
||||||
Text("Batal", color = AppColors.OnSurfaceVariant)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
containerColor = AppColors.Surface,
|
|
||||||
shape = RoundedCornerShape(Constants.Radius.Large.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.scale(scale)
|
.combinedClickable(
|
||||||
.combinedClickable(onClick = onClick),
|
onClick = onClick,
|
||||||
shape = RoundedCornerShape(Constants.Radius.Large.dp),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = if (note.isPinned)
|
|
||||||
AppColors.SurfaceVariant.copy(alpha = 0.95f)
|
|
||||||
else
|
|
||||||
AppColors.SurfaceVariant
|
|
||||||
),
|
),
|
||||||
elevation = CardDefaults.cardElevation(
|
shape = RoundedCornerShape(16.dp),
|
||||||
defaultElevation = elevation
|
colors = CardDefaults.cardColors(
|
||||||
)
|
containerColor = Color(0xFF1E293B)
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(Constants.Spacing.Large.dp)
|
.padding(16.dp)
|
||||||
) {
|
) {
|
||||||
// Header: Title + Menu
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.Top
|
verticalAlignment = Alignment.Top
|
||||||
) {
|
) {
|
||||||
// Title with pin badge
|
// Judul
|
||||||
Row(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
note.title,
|
note.title,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = AppColors.OnBackground,
|
color = Color.White,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis
|
||||||
fontSize = 18.sp,
|
|
||||||
modifier = Modifier.weight(1f, fill = false)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Pin Badge next to title
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = note.isPinned,
|
|
||||||
enter = scaleIn(spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + fadeIn(),
|
|
||||||
exit = scaleOut(spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + fadeOut()
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
color = AppColors.Warning.copy(alpha = 0.2f),
|
|
||||||
shape = RoundedCornerShape(Constants.Radius.Small.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.PushPin,
|
|
||||||
contentDescription = "Disematkan",
|
|
||||||
tint = AppColors.Warning,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(12.dp)
|
|
||||||
.rotate(45f)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Pin",
|
|
||||||
fontSize = 10.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = AppColors.Warning
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Menu Button
|
|
||||||
Box {
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { showMenu = true },
|
onClick = onPinClick,
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier.size(24.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.MoreVert,
|
if (note.isPinned) Icons.Filled.Star else Icons.Outlined.StarBorder,
|
||||||
contentDescription = "Menu",
|
contentDescription = "Pin",
|
||||||
tint = AppColors.OnSurfaceVariant,
|
tint = if (note.isPinned) Color(0xFFFBBF24) else Color.Gray,
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
DropdownMenu(
|
|
||||||
expanded = showMenu,
|
|
||||||
onDismissRequest = { showMenu = false },
|
|
||||||
modifier = Modifier.background(AppColors.SurfaceElevated)
|
|
||||||
) {
|
|
||||||
// Pin/Unpin option
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(Constants.Spacing.Small.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.PushPin,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = if (note.isPinned) AppColors.Warning else AppColors.Primary,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(18.dp)
|
|
||||||
.rotate(pinRotation)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
if (note.isPinned) "Lepas Sematan" else "Sematkan",
|
|
||||||
color = AppColors.OnSurface,
|
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
showMenu = false
|
|
||||||
onPinClick()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider(color = AppColors.Divider)
|
|
||||||
|
|
||||||
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)
|
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
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Description Preview
|
// Deskripsi
|
||||||
if (note.description.isNotEmpty()) {
|
if (note.content.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
note.description,
|
text = "Deskripsi",
|
||||||
|
style = MaterialTheme.typography.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 = 3,
|
maxLines = 4,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = AppColors.OnSurfaceVariant,
|
color = Color(0xFFCBD5E1),
|
||||||
lineHeight = 20.sp,
|
lineHeight = 20.sp
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
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(16.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
// Divider
|
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
color = AppColors.Divider,
|
color = Color(0xFF334155),
|
||||||
thickness = 1.dp
|
thickness = 1.dp
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
// Footer: Timestamp
|
// Timestamp
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
dateFormat.format(Date(note.timestamp)),
|
dateFormat.format(Date(note.timestamp)),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = AppColors.OnSurfaceTertiary,
|
color = Color(0xFF64748B)
|
||||||
fontSize = 12.sp
|
|
||||||
)
|
|
||||||
|
|
||||||
// Pin indicator icon in footer
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = note.isPinned,
|
|
||||||
enter = fadeIn() + expandHorizontally(),
|
|
||||||
exit = fadeOut() + shrinkHorizontally()
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.PushPin,
|
|
||||||
contentDescription = "Disematkan",
|
|
||||||
tint = AppColors.Warning.copy(alpha = 0.6f),
|
|
||||||
modifier = Modifier
|
|
||||||
.size(14.dp)
|
|
||||||
.rotate(45f)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,44 +1,50 @@
|
|||||||
package com.example.notesai.presentation.screens.note
|
package com.example.notesai.presentation.screens.note
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
|
||||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
|
||||||
import androidx.compose.foundation.layout.imeNestedScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.Archive
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Star
|
||||||
import androidx.compose.material.icons.outlined.StarBorder
|
import androidx.compose.material.icons.outlined.StarBorder
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.material3.Divider
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
|
||||||
import androidx.compose.ui.platform.*
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.*
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import com.example.notesai.data.model.Note
|
import com.example.notesai.data.model.Note
|
||||||
import com.example.notesai.presentation.screens.note.components.DraggableMiniMarkdownToolbar
|
|
||||||
import com.example.notesai.presentation.screens.note.editor.RichEditorState
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.Date
|
||||||
import kotlin.math.roundToInt
|
import java.util.Locale
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EditableFullScreenNoteView(
|
fun EditableFullScreenNoteView(
|
||||||
note: Note,
|
note: Note,
|
||||||
@ -49,275 +55,196 @@ fun EditableFullScreenNoteView(
|
|||||||
onPinToggle: () -> Unit
|
onPinToggle: () -> Unit
|
||||||
) {
|
) {
|
||||||
var title by remember { mutableStateOf(note.title) }
|
var title by remember { mutableStateOf(note.title) }
|
||||||
var isContentFocused by remember { mutableStateOf(false) }
|
var content by remember { mutableStateOf(note.content) }
|
||||||
|
var showArchiveDialog by remember { mutableStateOf(false) }
|
||||||
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
val dateFormat = remember { SimpleDateFormat("dd MMMM yyyy, HH:mm", Locale("id", "ID")) }
|
||||||
|
|
||||||
val editorState = remember(note.id) {
|
// Dialog Konfirmasi Arsip
|
||||||
RichEditorState(
|
if (showArchiveDialog) {
|
||||||
AnnotatedStringSerializer.fromJson(note.content)
|
AlertDialog(
|
||||||
|
onDismissRequest = { showArchiveDialog = false },
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "Arsipkan Catatan?",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
|
text = {
|
||||||
val focusRequester = remember { FocusRequester() }
|
Text(
|
||||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
text = "Catatan ini akan dipindahkan ke arsip. Anda masih bisa mengaksesnya nanti.",
|
||||||
val scrollState = rememberScrollState()
|
style = MaterialTheme.typography.bodyMedium
|
||||||
val scope = rememberCoroutineScope()
|
)
|
||||||
val keyboard = LocalSoftwareKeyboardController.current
|
},
|
||||||
|
confirmButton = {
|
||||||
fun ensureFocus() {
|
TextButton(
|
||||||
focusRequester.requestFocus()
|
onClick = {
|
||||||
keyboard?.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveNote() {
|
|
||||||
if (title.isNotBlank()) {
|
if (title.isNotBlank()) {
|
||||||
onSave(
|
onSave(title, content)
|
||||||
title,
|
|
||||||
AnnotatedStringSerializer.toJson(editorState.value.annotatedString)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
onArchive()
|
||||||
|
showArchiveDialog = false
|
||||||
}
|
}
|
||||||
|
) {
|
||||||
// AUTO SAVE SAAT APP BACKGROUND / KELUAR
|
Text("Arsipkan", color = MaterialTheme.colorScheme.primary)
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
|
||||||
|
|
||||||
DisposableEffect(lifecycleOwner) {
|
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
|
||||||
if (event == Lifecycle.Event.ON_STOP) {
|
|
||||||
saveNote()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lifecycleOwner.lifecycle.addObserver(observer)
|
|
||||||
|
|
||||||
onDispose {
|
|
||||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val dateFormat = remember {
|
|
||||||
SimpleDateFormat("dd MMMM yyyy, HH:mm", Locale("id", "ID"))
|
|
||||||
}
|
|
||||||
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val config = LocalConfiguration.current
|
|
||||||
|
|
||||||
val screenWidthPx = with(density) { config.screenWidthDp.dp.toPx() }
|
|
||||||
val screenHeightPx = with(density) { config.screenHeightDp.dp.toPx() }
|
|
||||||
val marginPx = with(density) { 16.dp.toPx() }
|
|
||||||
|
|
||||||
val imeBottomPx = with(density) {
|
|
||||||
WindowInsets.ime.getBottom(this).toFloat()
|
|
||||||
}
|
|
||||||
|
|
||||||
var toolbarSizePx by remember {
|
|
||||||
mutableStateOf(androidx.compose.ui.geometry.Size.Zero)
|
|
||||||
}
|
|
||||||
|
|
||||||
var toolbarOffset by remember {
|
|
||||||
mutableStateOf(Offset(marginPx, screenHeightPx * 0.6f))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun moveToolbar(dx: Float, dy: Float) {
|
|
||||||
toolbarOffset = toolbarOffset.copy(
|
|
||||||
x = toolbarOffset.x + dx,
|
|
||||||
y = toolbarOffset.y + dy
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = {
|
|
||||||
saveNote()
|
|
||||||
onBack()
|
|
||||||
}) {
|
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, null)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showArchiveDialog = false }) {
|
||||||
|
Text("Batal", color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialog Konfirmasi Hapus
|
||||||
|
if (showDeleteDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDeleteDialog = false },
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "Hapus Catatan?",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = "Catatan ini akan dihapus permanen dan tidak bisa dikembalikan.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onDelete()
|
||||||
|
showDeleteDialog = false
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Hapus", color = Color(0xFFEF4444))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDeleteDialog = false }) {
|
||||||
|
Text("Batal", color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
title = { },
|
title = { },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = {
|
||||||
|
if (title.isNotBlank()) {
|
||||||
|
onSave(title, content)
|
||||||
|
}
|
||||||
|
onBack()
|
||||||
|
}) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Kembali", tint = MaterialTheme.colorScheme.onBackground)
|
||||||
|
}
|
||||||
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
saveNote()
|
if (title.isNotBlank()) {
|
||||||
|
onSave(title, content)
|
||||||
|
}
|
||||||
onPinToggle()
|
onPinToggle()
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
if (note.isPinned) Icons.Filled.Star
|
if (note.isPinned) Icons.Filled.Star else Icons.Outlined.StarBorder,
|
||||||
else Icons.Outlined.StarBorder,
|
contentDescription = "Pin Catatan",
|
||||||
contentDescription = null,
|
tint = if (note.isPinned) Color(0xFFFBBF24) else MaterialTheme.colorScheme.onSurface
|
||||||
tint = if (note.isPinned) Color(0xFFFFB300) else MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = onArchive) {
|
IconButton(onClick = { showArchiveDialog = true }) {
|
||||||
Icon(
|
Icon(Icons.Default.Archive, contentDescription = "Arsipkan", tint = MaterialTheme.colorScheme.onSurface)
|
||||||
Icons.Default.Archive,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color(0xFF4CAF50)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
IconButton(onClick = onDelete) {
|
IconButton(onClick = { showDeleteDialog = true }) {
|
||||||
Icon(
|
Icon(Icons.Default.Delete, contentDescription = "Hapus", tint = MaterialTheme.colorScheme.onSurface)
|
||||||
Icons.Default.Delete,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color(0xFFF44336)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
contentWindowInsets = WindowInsets(0)
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
) { paddingValues ->
|
containerColor = Color.Transparent
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(padding)
|
||||||
.imeNestedScroll()
|
.verticalScroll(rememberScrollState())
|
||||||
.verticalScroll(scrollState)
|
|
||||||
.padding(horizontal = 20.dp)
|
.padding(horizontal = 20.dp)
|
||||||
.padding(
|
|
||||||
bottom = WindowInsets.ime
|
|
||||||
.asPaddingValues()
|
|
||||||
.calculateBottomPadding()
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
|
TextField(
|
||||||
// FIXED: BasicTextField untuk judul agar sejajar dengan konten
|
|
||||||
BasicTextField(
|
|
||||||
value = title,
|
value = title,
|
||||||
onValueChange = { title = it },
|
onValueChange = { title = it },
|
||||||
textStyle = MaterialTheme.typography.headlineLarge.copy(
|
textStyle = MaterialTheme.typography.headlineLarge.copy(
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onBackground
|
color = MaterialTheme.colorScheme.onBackground
|
||||||
),
|
),
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
placeholder = {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 8.dp),
|
|
||||||
decorationBox = { innerTextField ->
|
|
||||||
Box {
|
|
||||||
if (title.isEmpty()) {
|
|
||||||
Text(
|
Text(
|
||||||
"Judul",
|
"Judul",
|
||||||
style = MaterialTheme.typography.headlineLarge.copy(
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
color = Color(0xFF64748B)
|
||||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.4f)
|
|
||||||
)
|
)
|
||||||
)
|
},
|
||||||
}
|
colors = TextFieldDefaults.colors(
|
||||||
innerTextField()
|
focusedContainerColor = Color.Transparent,
|
||||||
}
|
unfocusedContainerColor = Color.Transparent,
|
||||||
}
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
cursorColor = Color(0xFFA855F7)
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Terakhir diubah: ${dateFormat.format(Date(note.timestamp))}",
|
text = "Terakhir diubah: ${dateFormat.format(Date(note.timestamp))}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f)
|
color = Color(0xFF64748B)
|
||||||
)
|
)
|
||||||
|
|
||||||
HorizontalDivider(Modifier.padding(vertical = 20.dp))
|
Divider(
|
||||||
|
modifier = Modifier.padding(vertical = 20.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
|
||||||
// Konten editor
|
TextField(
|
||||||
BasicTextField(
|
value = content,
|
||||||
value = editorState.value,
|
onValueChange = { content = it },
|
||||||
onValueChange = {
|
|
||||||
editorState.onValueChange(it)
|
|
||||||
scope.launch {
|
|
||||||
bringIntoViewRequester.bringIntoView()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
cursorBrush = SolidColor(Color(0xFFA855F7)),
|
|
||||||
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
lineHeight = 28.sp
|
lineHeight = 28.sp
|
||||||
),
|
),
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
"Mulai menulis...",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = Color(0xFF64748B)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
cursorColor = Color(0xFFA855F7)
|
||||||
|
),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.defaultMinSize(minHeight = 400.dp)
|
.heightIn(min = 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))
|
Spacer(modifier = Modifier.height(100.dp))
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GANTI SELURUH bagian DraggableMiniMarkdownToolbar di EditableFullScreenNoteView.kt
|
|
||||||
// Letakkan kode ini di dalam Box setelah Scaffold, sebelum closing bracket
|
|
||||||
|
|
||||||
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(),
|
|
||||||
canUndo = editorState.canUndo(),
|
|
||||||
canRedo = editorState.canRedo(),
|
|
||||||
|
|
||||||
onDrag = ::moveToolbar,
|
|
||||||
onBold = {
|
|
||||||
ensureFocus()
|
|
||||||
editorState.toggleBold()
|
|
||||||
},
|
|
||||||
onItalic = {
|
|
||||||
ensureFocus()
|
|
||||||
editorState.toggleItalic()
|
|
||||||
},
|
|
||||||
onUnderline = {
|
|
||||||
ensureFocus()
|
|
||||||
editorState.toggleUnderline()
|
|
||||||
},
|
|
||||||
onUndo = {
|
|
||||||
editorState.undo()
|
|
||||||
},
|
|
||||||
onRedo = {
|
|
||||||
editorState.redo()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,150 +0,0 @@
|
|||||||
package com.example.notesai.presentation.screens.note.components
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DraggableMiniMarkdownToolbar(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onDrag: (dx: Float, dy: Float) -> Unit,
|
|
||||||
|
|
||||||
// STATE
|
|
||||||
isBoldActive: Boolean,
|
|
||||||
isItalicActive: Boolean,
|
|
||||||
isUnderlineActive: Boolean,
|
|
||||||
canUndo: Boolean = false,
|
|
||||||
canRedo: Boolean = false,
|
|
||||||
|
|
||||||
// ACTIONS
|
|
||||||
onBold: () -> Unit,
|
|
||||||
onItalic: () -> Unit,
|
|
||||||
onUnderline: () -> Unit,
|
|
||||||
onUndo: () -> Unit = {},
|
|
||||||
onRedo: () -> 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
|
|
||||||
)
|
|
||||||
|
|
||||||
// Divider
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(1.dp)
|
|
||||||
.height(24.dp)
|
|
||||||
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f))
|
|
||||||
)
|
|
||||||
|
|
||||||
// Undo
|
|
||||||
ToolbarIcon(
|
|
||||||
icon = Icons.Default.Undo,
|
|
||||||
onClick = onUndo,
|
|
||||||
enabled = canUndo
|
|
||||||
)
|
|
||||||
|
|
||||||
// Redo
|
|
||||||
ToolbarIcon(
|
|
||||||
icon = Icons.Default.Redo,
|
|
||||||
onClick = onRedo,
|
|
||||||
enabled = canRedo
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ToolbarIcon(
|
|
||||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
isActive: Boolean = false,
|
|
||||||
enabled: Boolean = true
|
|
||||||
) {
|
|
||||||
val activeBg = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
|
|
||||||
val activeColor = MaterialTheme.colorScheme.primary
|
|
||||||
val disabledColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
|
|
||||||
|
|
||||||
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),
|
|
||||||
enabled = enabled
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = when {
|
|
||||||
!enabled -> disabledColor
|
|
||||||
isActive -> activeColor
|
|
||||||
else -> MaterialTheme.colorScheme.onSurface
|
|
||||||
},
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,361 +0,0 @@
|
|||||||
package com.example.notesai.presentation.screens.note.editor
|
|
||||||
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.text.*
|
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
|
|
||||||
@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.removeAt(undoStack.lastIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun redo() {
|
|
||||||
if (!canRedo()) return
|
|
||||||
undoStack.add(value)
|
|
||||||
value = redoStack.removeAt(redoStack.lastIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================
|
|
||||||
STICKY TYPING STYLE
|
|
||||||
===================== */
|
|
||||||
private val activeStyles = mutableStateListOf<SpanStyle>()
|
|
||||||
|
|
||||||
/* =====================
|
|
||||||
VALUE CHANGE
|
|
||||||
===================== */
|
|
||||||
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()
|
|
||||||
|
|
||||||
// Build new annotated string by preserving old spans
|
|
||||||
val built = buildPreservingSpans(old, newValue)
|
|
||||||
|
|
||||||
// Auto-convert markdown patterns
|
|
||||||
val converted = autoConvertMarkdown(built)
|
|
||||||
|
|
||||||
value = converted
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildPreservingSpans(old: TextFieldValue, newValue: TextFieldValue): TextFieldValue {
|
|
||||||
val builder = AnnotatedString.Builder(newValue.text)
|
|
||||||
|
|
||||||
val oldText = old.text
|
|
||||||
val newText = newValue.text
|
|
||||||
|
|
||||||
// Detect where text was inserted/deleted
|
|
||||||
val oldLen = oldText.length
|
|
||||||
val newLen = newText.length
|
|
||||||
val delta = newLen - oldLen
|
|
||||||
|
|
||||||
// Find insertion/deletion point
|
|
||||||
var changeStart = 0
|
|
||||||
while (changeStart < minOf(oldLen, newLen) &&
|
|
||||||
oldText.getOrNull(changeStart) == newText.getOrNull(changeStart)) {
|
|
||||||
changeStart++
|
|
||||||
}
|
|
||||||
|
|
||||||
val changeEnd = changeStart + maxOf(0, -delta)
|
|
||||||
|
|
||||||
// Copy and adjust old spans
|
|
||||||
old.annotatedString.spanStyles.forEach { r ->
|
|
||||||
var start = r.start
|
|
||||||
var end = r.end
|
|
||||||
|
|
||||||
// Adjust span positions based on where text changed
|
|
||||||
when {
|
|
||||||
// Span is completely before change
|
|
||||||
end <= changeStart -> {
|
|
||||||
// No adjustment needed
|
|
||||||
}
|
|
||||||
// Span is completely after change
|
|
||||||
start >= changeEnd -> {
|
|
||||||
start += delta
|
|
||||||
end += delta
|
|
||||||
}
|
|
||||||
// Span contains the change point
|
|
||||||
start < changeStart && end > changeEnd -> {
|
|
||||||
end += delta
|
|
||||||
}
|
|
||||||
// Span starts before change, ends in change area
|
|
||||||
start < changeStart && end in (changeStart + 1)..changeEnd -> {
|
|
||||||
end = changeStart
|
|
||||||
}
|
|
||||||
// Span starts in change area, ends after
|
|
||||||
start in changeStart until changeEnd && end >= changeEnd -> {
|
|
||||||
start = changeStart + delta
|
|
||||||
end += delta
|
|
||||||
}
|
|
||||||
// Span is completely inside change area - skip it
|
|
||||||
else -> return@forEach
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clamp to valid range
|
|
||||||
start = start.coerceIn(0, newLen)
|
|
||||||
end = end.coerceIn(0, newLen)
|
|
||||||
|
|
||||||
if (start < end) {
|
|
||||||
builder.addStyle(r.item, start, end)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply sticky styles to newly inserted character(s)
|
|
||||||
if (delta > 0 && activeStyles.isNotEmpty()) {
|
|
||||||
val insertStart = changeStart
|
|
||||||
val insertEnd = (changeStart + delta).coerceIn(0, newLen)
|
|
||||||
if (insertStart < insertEnd) {
|
|
||||||
activeStyles.forEach { st ->
|
|
||||||
builder.addStyle(st, insertStart, insertEnd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================
|
|
||||||
AUTO-CONVERT MARKDOWN
|
|
||||||
===================== */
|
|
||||||
private fun autoConvertMarkdown(v: TextFieldValue): TextFieldValue {
|
|
||||||
var cur = v
|
|
||||||
|
|
||||||
// Bold before italic to avoid conflicts
|
|
||||||
cur = convertBold(cur)
|
|
||||||
cur = convertItalic(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
|
|
||||||
var out = v
|
|
||||||
out = replaceTextPreserveSpansLocal(out, cursor - 2, cursor, "")
|
|
||||||
out = replaceTextPreserveSpansLocal(out, startMarker, startMarker + 2, "")
|
|
||||||
|
|
||||||
// Apply bold style
|
|
||||||
val newStart = startMarker
|
|
||||||
val newEnd = contentEnd - 2
|
|
||||||
out = addStylePreserve(out, SpanStyle(fontWeight = FontWeight.Bold), newStart, newEnd)
|
|
||||||
|
|
||||||
// Adjust cursor
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================
|
|
||||||
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 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,17 +1,13 @@
|
|||||||
package com.example.notesai.presentation.screens.starred
|
package com.example.notesai.presentation.screens.starred
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Search
|
|
||||||
import androidx.compose.material.icons.filled.Star
|
import androidx.compose.material.icons.filled.Star
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.example.notesai.presentation.components.EmptyState
|
import com.example.notesai.presentation.components.EmptyState
|
||||||
import com.example.notesai.presentation.screens.starred.components.StarredNoteCard
|
import com.example.notesai.presentation.screens.starred.components.StarredNoteCard
|
||||||
@ -23,52 +19,25 @@ import com.example.notesai.data.model.Category
|
|||||||
fun StarredNotesScreen(
|
fun StarredNotesScreen(
|
||||||
notes: List<Note>,
|
notes: List<Note>,
|
||||||
categories: List<Category>,
|
categories: List<Category>,
|
||||||
searchQuery: String = "", // Tambahkan parameter ini
|
|
||||||
onNoteClick: (Note) -> Unit,
|
onNoteClick: (Note) -> Unit,
|
||||||
|
onMenuClick: () -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
onUnpin: (Note) -> Unit
|
onUnpin: (Note) -> Unit
|
||||||
) {
|
) {
|
||||||
val starredNotes = notes
|
val starredNotes = notes.filter { it.isPinned && !it.isArchived && !it.isDeleted }
|
||||||
.filter { it.isPinned && !it.isArchived && !it.isDeleted }
|
|
||||||
.sortedByDescending { it.timestamp }
|
|
||||||
|
|
||||||
// Filter berdasarkan search query dari ModernTopBar
|
if (starredNotes.isEmpty()) {
|
||||||
val filteredNotes = if (searchQuery.isBlank()) {
|
|
||||||
starredNotes
|
|
||||||
} else {
|
|
||||||
starredNotes.filter { note ->
|
|
||||||
note.title.contains(searchQuery, ignoreCase = true) ||
|
|
||||||
note.content.contains(searchQuery, ignoreCase = true) ||
|
|
||||||
note.description.contains(searchQuery, ignoreCase = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
// Content
|
|
||||||
if (filteredNotes.isEmpty()) {
|
|
||||||
if (searchQuery.isNotEmpty()) {
|
|
||||||
EmptyState(
|
|
||||||
icon = Icons.Default.Search,
|
|
||||||
message = "Tidak ada hasil",
|
|
||||||
subtitle = "Coba kata kunci lain"
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
EmptyState(
|
EmptyState(
|
||||||
icon = Icons.Default.Star,
|
icon = Icons.Default.Star,
|
||||||
message = "Belum ada catatan berbintang",
|
message = "Belum ada catatan berbintang",
|
||||||
subtitle = "Catatan yang ditandai berbintang akan muncul di sini"
|
subtitle = "Catatan yang ditandai berbintang akan muncul di sini"
|
||||||
)
|
)
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(16.dp),
|
||||||
start = 16.dp,
|
|
||||||
end = 16.dp,
|
|
||||||
top = 8.dp,
|
|
||||||
bottom = 100.dp
|
|
||||||
),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
items(filteredNotes) { note ->
|
items(starredNotes) { note ->
|
||||||
val category = categories.find { it.id == note.categoryId }
|
val category = categories.find { it.id == note.categoryId }
|
||||||
StarredNoteCard(
|
StarredNoteCard(
|
||||||
note = note,
|
note = note,
|
||||||
@ -80,4 +49,3 @@ fun StarredNotesScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
@ -29,27 +29,6 @@ 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 com.example.notesai.data.model.Note
|
import com.example.notesai.data.model.Note
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.jsonObject
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
|
|
||||||
// Helper function untuk extract plain text dari AnnotatedString JSON
|
|
||||||
private fun extractPlainText(jsonContent: String): String {
|
|
||||||
return try {
|
|
||||||
if (jsonContent.trim().startsWith("{")) {
|
|
||||||
val jsonElement = Json.parseToJsonElement(jsonContent)
|
|
||||||
val jsonObject = jsonElement.jsonObject
|
|
||||||
|
|
||||||
// Extract field "text" yang berisi plain text content
|
|
||||||
jsonObject["text"]?.jsonPrimitive?.content ?: ""
|
|
||||||
} else {
|
|
||||||
jsonContent
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Jika parsing gagal, coba tampilkan content mentah
|
|
||||||
jsonContent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StarredNoteCard(
|
fun StarredNoteCard(
|
||||||
@ -58,15 +37,8 @@ fun StarredNoteCard(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onUnpin: () -> Unit
|
onUnpin: () -> Unit
|
||||||
) {
|
) {
|
||||||
// Gunakan description jika ada, jika tidak extract dari content JSON
|
|
||||||
val displayContent = if (note.description.isNotEmpty()) {
|
|
||||||
note.description
|
|
||||||
} else {
|
|
||||||
extractPlainText(note.content)
|
|
||||||
}
|
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier.Companion
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onClick),
|
.clickable(onClick = onClick),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
@ -75,31 +47,31 @@ fun StarredNoteCard(
|
|||||||
),
|
),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.Companion.padding(16.dp)) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.Companion.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.Top
|
verticalAlignment = Alignment.Companion.Top
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.Companion.weight(1f)) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.Companion.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Star,
|
Icons.Default.Star,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = Color(0xFFFBBF24),
|
tint = Color(0xFFFBBF24),
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.Companion.size(16.dp)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.Companion.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
note.title,
|
note.title,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Companion.Bold,
|
||||||
color = Color.White,
|
color = Color.Companion.White,
|
||||||
style = MaterialTheme.typography.titleMedium
|
style = MaterialTheme.typography.titleMedium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.Companion.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
categoryName,
|
categoryName,
|
||||||
color = Color(0xFF64748B),
|
color = Color(0xFF64748B),
|
||||||
@ -108,19 +80,19 @@ fun StarredNoteCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (displayContent.isNotEmpty()) {
|
if (note.content.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.Companion.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
displayContent,
|
note.content,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Companion.Ellipsis,
|
||||||
color = Color(0xFF94A3B8),
|
color = Color(0xFF94A3B8),
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.Companion
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 12.dp),
|
.padding(top = 12.dp),
|
||||||
horizontalArrangement = Arrangement.End
|
horizontalArrangement = Arrangement.End
|
||||||
@ -129,29 +101,29 @@ fun StarredNoteCard(
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Info,
|
Icons.Default.Info,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(18.dp),
|
modifier = Modifier.Companion.size(18.dp),
|
||||||
tint = Color(0xFF6366F1)
|
tint = Color(0xFF6366F1)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.Companion.width(4.dp))
|
||||||
Text(
|
Text(
|
||||||
"Lihat Detail",
|
"Lihat Detail",
|
||||||
color = Color(0xFF6366F1),
|
color = Color(0xFF6366F1),
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Companion.Bold
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.Companion.width(8.dp))
|
||||||
TextButton(onClick = onUnpin) {
|
TextButton(onClick = onUnpin) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.StarBorder,
|
Icons.Outlined.StarBorder,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(18.dp),
|
modifier = Modifier.Companion.size(18.dp),
|
||||||
tint = Color(0xFFFBBF24)
|
tint = Color(0xFFFBBF24)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.Companion.width(4.dp))
|
||||||
Text(
|
Text(
|
||||||
"Hapus Bintang",
|
"Hapus Bintang",
|
||||||
color = Color(0xFFFBBF24),
|
color = Color(0xFFFBBF24),
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Companion.Bold
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,15 @@
|
|||||||
|
// File: presentation/screens/trash/TrashScreen.kt
|
||||||
package com.example.notesai.presentation.screens.trash
|
package com.example.notesai.presentation.screens.trash
|
||||||
|
|
||||||
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.layout.padding
|
|
||||||
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.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Search
|
|
||||||
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
|
||||||
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
|
||||||
@ -27,7 +23,6 @@ import com.example.notesai.data.model.Category
|
|||||||
fun TrashScreen(
|
fun TrashScreen(
|
||||||
notes: List<Note>,
|
notes: List<Note>,
|
||||||
categories: List<Category>,
|
categories: List<Category>,
|
||||||
searchQuery: String = "", // Tambahkan parameter ini
|
|
||||||
onRestoreNote: (Note) -> Unit,
|
onRestoreNote: (Note) -> Unit,
|
||||||
onDeleteNotePermanent: (Note) -> Unit,
|
onDeleteNotePermanent: (Note) -> Unit,
|
||||||
onRestoreCategory: (Category) -> Unit,
|
onRestoreCategory: (Category) -> Unit,
|
||||||
@ -37,65 +32,29 @@ fun TrashScreen(
|
|||||||
val deletedCategories = categories.filter { it.isDeleted }
|
val deletedCategories = categories.filter { it.isDeleted }
|
||||||
val deletedNotes = notes.filter { it.isDeleted }
|
val deletedNotes = notes.filter { it.isDeleted }
|
||||||
|
|
||||||
// Filter berdasarkan search query dari ModernTopBar
|
if (deletedCategories.isEmpty() && deletedNotes.isEmpty()) {
|
||||||
val filteredCategories = if (searchQuery.isBlank()) {
|
|
||||||
deletedCategories
|
|
||||||
} else {
|
|
||||||
deletedCategories.filter { category ->
|
|
||||||
category.name.contains(searchQuery, ignoreCase = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val filteredNotes = if (searchQuery.isBlank()) {
|
|
||||||
deletedNotes
|
|
||||||
} else {
|
|
||||||
deletedNotes.filter { note ->
|
|
||||||
note.title.contains(searchQuery, ignoreCase = true) ||
|
|
||||||
note.content.contains(searchQuery, ignoreCase = true) ||
|
|
||||||
note.description.contains(searchQuery, ignoreCase = true) ||
|
|
||||||
categories.find { it.id == note.categoryId }?.name?.contains(searchQuery, ignoreCase = true) == true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
// Content
|
|
||||||
if (filteredCategories.isEmpty() && filteredNotes.isEmpty()) {
|
|
||||||
if (searchQuery.isNotEmpty()) {
|
|
||||||
EmptyState(
|
|
||||||
icon = Icons.Default.Search,
|
|
||||||
message = "Tidak ada hasil",
|
|
||||||
subtitle = "Coba kata kunci lain"
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
EmptyState(
|
EmptyState(
|
||||||
icon = Icons.Default.Delete,
|
icon = Icons.Default.Delete,
|
||||||
message = "Sampah kosong",
|
message = "Sampah kosong",
|
||||||
subtitle = "Kategori dan catatan yang dihapus akan muncul di sini"
|
subtitle = "Kategori dan catatan yang dihapus akan muncul di sini"
|
||||||
)
|
)
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(16.dp),
|
||||||
start = 16.dp,
|
|
||||||
end = 16.dp,
|
|
||||||
top = 8.dp,
|
|
||||||
bottom = 100.dp
|
|
||||||
),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
// Section: Kategori Terhapus
|
// Section: Kategori Terhapus
|
||||||
if (filteredCategories.isNotEmpty()) {
|
if (deletedCategories.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
"Kategori Terhapus (${filteredCategories.size})",
|
"Kategori Terhapus (${deletedCategories.size})",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = Color(0xFF94A3B8),
|
color = Color(0xFF94A3B8)
|
||||||
modifier = Modifier.padding(vertical = 8.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
items(filteredCategories) { category ->
|
items(deletedCategories) { category ->
|
||||||
val notesInCategory = notes.count {
|
val notesInCategory = notes.count {
|
||||||
it.categoryId == category.id && it.isDeleted
|
it.categoryId == category.id && it.isDeleted
|
||||||
}
|
}
|
||||||
@ -109,18 +68,17 @@ fun TrashScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Section: Catatan Terhapus
|
// Section: Catatan Terhapus
|
||||||
if (filteredNotes.isNotEmpty()) {
|
if (deletedNotes.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
"Catatan Terhapus (${filteredNotes.size})",
|
"Catatan Terhapus (${deletedNotes.size})",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = Color(0xFF94A3B8),
|
color = Color(0xFF94A3B8)
|
||||||
modifier = Modifier.padding(vertical = 8.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
items(filteredNotes) { note ->
|
items(deletedNotes) { note ->
|
||||||
val category = categories.find { it.id == note.categoryId }
|
val category = categories.find { it.id == note.categoryId }
|
||||||
TrashNoteCard(
|
TrashNoteCard(
|
||||||
note = note,
|
note = note,
|
||||||
@ -133,4 +91,3 @@ fun TrashScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
@ -25,24 +25,6 @@ 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 com.example.notesai.data.model.Note
|
import com.example.notesai.data.model.Note
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.jsonObject
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
|
|
||||||
// Helper function untuk extract plain text dari AnnotatedString JSON
|
|
||||||
private fun extractPlainText(jsonContent: String): String {
|
|
||||||
return try {
|
|
||||||
if (jsonContent.trim().startsWith("{")) {
|
|
||||||
val jsonElement = Json.parseToJsonElement(jsonContent)
|
|
||||||
val jsonObject = jsonElement.jsonObject
|
|
||||||
jsonObject["text"]?.jsonPrimitive?.content ?: ""
|
|
||||||
} else {
|
|
||||||
jsonContent
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
jsonContent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TrashNoteCard(
|
fun TrashNoteCard(
|
||||||
@ -51,12 +33,6 @@ fun TrashNoteCard(
|
|||||||
onRestore: () -> Unit,
|
onRestore: () -> Unit,
|
||||||
onDeletePermanent: () -> Unit
|
onDeletePermanent: () -> Unit
|
||||||
) {
|
) {
|
||||||
val displayContent = if (note.description.isNotEmpty()) {
|
|
||||||
note.description
|
|
||||||
} else {
|
|
||||||
extractPlainText(note.content)
|
|
||||||
}
|
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
@ -86,10 +62,10 @@ fun TrashNoteCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (displayContent.isNotEmpty()) {
|
if (note.content.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
displayContent,
|
note.content,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
color = Color(0xFF94A3B8),
|
color = Color(0xFF94A3B8),
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
|||||||
@ -1,66 +0,0 @@
|
|||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class SpanDto(
|
|
||||||
val start: Int,
|
|
||||||
val end: Int,
|
|
||||||
val bold: Boolean = false,
|
|
||||||
val italic: Boolean = false,
|
|
||||||
val underline: Boolean = false
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class RichTextDto(
|
|
||||||
val text: String,
|
|
||||||
val spans: List<SpanDto>
|
|
||||||
)
|
|
||||||
|
|
||||||
object AnnotatedStringSerializer {
|
|
||||||
|
|
||||||
fun toJson(value: AnnotatedString): String {
|
|
||||||
val spans = value.spanStyles.map {
|
|
||||||
SpanDto(
|
|
||||||
start = it.start,
|
|
||||||
end = it.end,
|
|
||||||
bold = it.item.fontWeight != null,
|
|
||||||
italic = it.item.fontStyle != null,
|
|
||||||
underline = it.item.textDecoration != null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Json.encodeToString(
|
|
||||||
RichTextDto(
|
|
||||||
text = value.text,
|
|
||||||
spans = spans
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun fromJson(json: String): AnnotatedString {
|
|
||||||
return try {
|
|
||||||
val dto = Json.decodeFromString<RichTextDto>(json)
|
|
||||||
val builder = AnnotatedString.Builder(dto.text)
|
|
||||||
|
|
||||||
dto.spans.forEach {
|
|
||||||
builder.addStyle(
|
|
||||||
SpanStyle(
|
|
||||||
fontWeight = if (it.bold) androidx.compose.ui.text.font.FontWeight.Bold else null,
|
|
||||||
fontStyle = if (it.italic) androidx.compose.ui.text.font.FontStyle.Italic else null,
|
|
||||||
textDecoration = if (it.underline) androidx.compose.ui.text.style.TextDecoration.Underline else null
|
|
||||||
),
|
|
||||||
it.start,
|
|
||||||
it.end
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.toAnnotatedString()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
AnnotatedString(json) // fallback plain
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,141 +1,53 @@
|
|||||||
|
// 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 AppColors {
|
|
||||||
// Theme state
|
|
||||||
private var isDarkTheme by mutableStateOf(true)
|
|
||||||
|
|
||||||
// Primary Colors
|
|
||||||
val Primary: Color
|
|
||||||
get() = if (isDarkTheme) Color(0xFF6C63FF) else Color(0xFF5A52D5)
|
|
||||||
|
|
||||||
val Secondary: Color
|
|
||||||
get() = if (isDarkTheme) Color(0xFF03DAC6) else Color(0xFF018786)
|
|
||||||
|
|
||||||
val Accent: Color
|
|
||||||
get() = if (isDarkTheme) Color(0xFFFF6B9D) else Color(0xFFE91E63)
|
|
||||||
|
|
||||||
// Background Colors
|
|
||||||
val Background: Color
|
|
||||||
get() = if (isDarkTheme) Color(0xFF121212) else Color(0xFFFAFAFA)
|
|
||||||
|
|
||||||
val Surface: Color
|
|
||||||
get() = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
|
|
||||||
|
|
||||||
val SurfaceVariant: Color
|
|
||||||
get() = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5)
|
|
||||||
|
|
||||||
val SurfaceElevated: Color
|
|
||||||
get() = if (isDarkTheme) Color(0xFF252525) else Color(0xFFFFFFFF)
|
|
||||||
|
|
||||||
// Text Colors
|
|
||||||
val OnBackground: Color
|
|
||||||
get() = if (isDarkTheme) Color(0xFFE1E1E1) else Color(0xFF1C1B1F)
|
|
||||||
|
|
||||||
val OnSurface: Color
|
|
||||||
get() = if (isDarkTheme) Color(0xFFCCCCCC) else Color(0xFF1C1B1F)
|
|
||||||
|
|
||||||
val OnSurfaceVariant: Color
|
|
||||||
get() = if (isDarkTheme) Color(0xFF9E9E9E) else Color(0xFF49454F)
|
|
||||||
|
|
||||||
val OnSurfaceTertiary: Color
|
|
||||||
get() = if (isDarkTheme) Color(0xFF757575) else Color(0xFF79747E)
|
|
||||||
|
|
||||||
// Utility Colors
|
|
||||||
val Error: Color
|
|
||||||
get() = if (isDarkTheme) Color(0xFFCF6679) else Color(0xFFB3261E)
|
|
||||||
|
|
||||||
val Warning: Color
|
|
||||||
get() = if (isDarkTheme) Color(0xFFFFB74D) else Color(0xFFF57C00)
|
|
||||||
|
|
||||||
val Success: Color
|
|
||||||
get() = if (isDarkTheme) Color(0xFF81C784) else Color(0xFF388E3C)
|
|
||||||
|
|
||||||
val Info: Color
|
|
||||||
get() = if (isDarkTheme) Color(0xFF64B5F6) else Color(0xFF1976D2)
|
|
||||||
|
|
||||||
// Border & Divider
|
|
||||||
val Border: Color
|
|
||||||
get() = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0)
|
|
||||||
|
|
||||||
val Divider: Color
|
|
||||||
get() = if (isDarkTheme) Color(0xFF2E2E2E) else Color(0xFFE0E0E0)
|
|
||||||
|
|
||||||
// Function to update theme
|
|
||||||
fun setTheme(darkTheme: Boolean) {
|
|
||||||
isDarkTheme = darkTheme
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current theme state
|
|
||||||
fun isDark(): Boolean = isDarkTheme
|
|
||||||
}
|
|
||||||
|
|
||||||
object Constants {
|
object Constants {
|
||||||
|
// App Info
|
||||||
|
const val APP_NAME = "AI Notes"
|
||||||
|
const val APP_VERSION = "1.0.0"
|
||||||
|
|
||||||
// Animation Durations
|
// DataStore
|
||||||
const val ANIMATION_DURATION_SHORT = 150
|
const val DATASTORE_NAME = "notes_prefs"
|
||||||
const val ANIMATION_DURATION_MEDIUM = 300
|
const val DEBOUNCE_DELAY = 500L
|
||||||
const val ANIMATION_DURATION_LONG = 500
|
|
||||||
|
|
||||||
// Spacing values
|
// UI Constants
|
||||||
object Spacing {
|
const val MAX_NOTE_PREVIEW_LINES = 4
|
||||||
const val ExtraSmall = 4
|
const val MAX_CHAT_PREVIEW_LINES = 2
|
||||||
const val Small = 8
|
const val GRID_COLUMNS = 2
|
||||||
const val Medium = 12
|
|
||||||
const val Large = 16
|
|
||||||
const val ExtraLarge = 24
|
|
||||||
const val ExtraExtraLarge = 32
|
|
||||||
}
|
|
||||||
|
|
||||||
// Border Radius values
|
// Gradients
|
||||||
object Radius {
|
val GRADIENT_PRESETS = listOf(
|
||||||
const val Small = 8
|
Pair(0xFF6366F1L, 0xFFA855F7L),
|
||||||
const val Medium = 12
|
Pair(0xFFEC4899L, 0xFFF59E0BL),
|
||||||
const val Large = 16
|
Pair(0xFF8B5CF6L, 0xFFEC4899L),
|
||||||
const val ExtraLarge = 24
|
Pair(0xFF06B6D4L, 0xFF3B82F6L),
|
||||||
const val ExtraExtraLarge = 32
|
Pair(0xFF10B981L, 0xFF059669L),
|
||||||
}
|
Pair(0xFFF59E0BL, 0xFFEF4444L),
|
||||||
|
Pair(0xFF6366F1L, 0xFF8B5CF6L),
|
||||||
// Elevation values
|
Pair(0xFFEF4444L, 0xFFDC2626L)
|
||||||
object Elevation {
|
|
||||||
const val None = 0
|
|
||||||
const val Small = 2
|
|
||||||
const val Medium = 4
|
|
||||||
const val Large = 8
|
|
||||||
const val ExtraLarge = 12
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reference to AppColors for compatibility
|
|
||||||
val AppColors = com.example.notesai.util.AppColors
|
|
||||||
|
|
||||||
// Category gradient colors - 8 pilihan warna
|
|
||||||
val CategoryColors = listOf(
|
|
||||||
// Purple gradient
|
|
||||||
0xFF6750A4L to 0xFF7E57C2L,
|
|
||||||
|
|
||||||
// Pink gradient
|
|
||||||
0xFF9C27B0L to 0xFFE91E63L,
|
|
||||||
|
|
||||||
// Blue gradient
|
|
||||||
0xFF2196F3L to 0xFF03A9F4L,
|
|
||||||
|
|
||||||
// Green gradient
|
|
||||||
0xFF4CAF50L to 0xFF8BC34AL,
|
|
||||||
|
|
||||||
// Orange gradient
|
|
||||||
0xFFFF9800L to 0xFFFFB74DL,
|
|
||||||
|
|
||||||
// Red gradient
|
|
||||||
0xFFF44336L to 0xFFE91E63L,
|
|
||||||
|
|
||||||
// Teal gradient
|
|
||||||
0xFF009688L to 0xFF26A69AL,
|
|
||||||
|
|
||||||
// Indigo gradient
|
|
||||||
0xFF3F51B5L to 0xFF5C6BC0L
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
object AppColors {
|
||||||
|
val Primary = Color(0xFF6366F1)
|
||||||
|
val Secondary = Color(0xFFA855F7)
|
||||||
|
val Background = Color(0xFF0F172A)
|
||||||
|
val Surface = Color(0xFF1E293B)
|
||||||
|
val SurfaceVariant = Color(0xFF334155)
|
||||||
|
val OnBackground = Color(0xFFE2E8F0)
|
||||||
|
val OnSurface = Color(0xFFE2E8F0)
|
||||||
|
val Success = Color(0xFF10B981)
|
||||||
|
val Error = Color(0xFFEF4444)
|
||||||
|
val Warning = Color(0xFFFBBF24)
|
||||||
|
val TextSecondary = Color(0xFF94A3B8)
|
||||||
|
val TextTertiary = Color(0xFF64748B)
|
||||||
|
val Divider = Color(0xFF334155)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animation
|
||||||
|
const val ANIMATION_DURATION = 300
|
||||||
|
const val FADE_IN_DURATION = 200
|
||||||
|
const val FADE_OUT_DURATION = 200
|
||||||
}
|
}
|
||||||
@ -1,221 +0,0 @@
|
|||||||
package com.example.notesai.util
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
|
||||||
import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
|
|
||||||
import com.tom_roush.pdfbox.pdmodel.PDDocument
|
|
||||||
import com.tom_roush.pdfbox.text.PDFTextStripper
|
|
||||||
import java.io.BufferedReader
|
|
||||||
import java.io.InputStreamReader
|
|
||||||
|
|
||||||
object FileParser {
|
|
||||||
|
|
||||||
private const val TAG = "FileParser"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize PDFBox (call this in Application.onCreate or before first use)
|
|
||||||
*/
|
|
||||||
fun initPDFBox(context: Context) {
|
|
||||||
try {
|
|
||||||
PDFBoxResourceLoader.init(context)
|
|
||||||
Log.d(TAG, "PDFBox initialized successfully")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to initialize PDFBox", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse file berdasarkan tipe
|
|
||||||
*/
|
|
||||||
suspend fun parseFile(context: Context, uri: Uri): FileParseResult {
|
|
||||||
return try {
|
|
||||||
val mimeType = context.contentResolver.getType(uri)
|
|
||||||
val fileName = getFileName(context, uri)
|
|
||||||
|
|
||||||
Log.d(TAG, "Parsing file: $fileName, type: $mimeType")
|
|
||||||
|
|
||||||
val content = when {
|
|
||||||
mimeType == "application/pdf" || fileName.endsWith(".pdf", ignoreCase = true) -> {
|
|
||||||
parsePDF(context, uri)
|
|
||||||
}
|
|
||||||
mimeType == "text/plain" || fileName.endsWith(".txt", ignoreCase = true) -> {
|
|
||||||
parseTXT(context, uri)
|
|
||||||
}
|
|
||||||
mimeType == "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
||||||
|| fileName.endsWith(".docx", ignoreCase = true) -> {
|
|
||||||
parseDOCX(context, uri)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
return FileParseResult.Error("Format file tidak didukung: $mimeType")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content.isBlank()) {
|
|
||||||
FileParseResult.Error("File kosong atau tidak dapat dibaca")
|
|
||||||
} else {
|
|
||||||
FileParseResult.Success(
|
|
||||||
content = content,
|
|
||||||
fileName = fileName,
|
|
||||||
fileType = getFileType(fileName),
|
|
||||||
wordCount = content.split(Regex("\\s+")).size
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error parsing file", e)
|
|
||||||
FileParseResult.Error("Gagal membaca file: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse PDF file
|
|
||||||
*/
|
|
||||||
private fun parsePDF(context: Context, uri: Uri): String {
|
|
||||||
val inputStream = context.contentResolver.openInputStream(uri)
|
|
||||||
?: throw Exception("Cannot open file")
|
|
||||||
|
|
||||||
return inputStream.use { stream ->
|
|
||||||
val document = PDDocument.load(stream)
|
|
||||||
val stripper = PDFTextStripper()
|
|
||||||
val text = stripper.getText(document)
|
|
||||||
document.close()
|
|
||||||
text.trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse TXT file
|
|
||||||
*/
|
|
||||||
private fun parseTXT(context: Context, uri: Uri): String {
|
|
||||||
val inputStream = context.contentResolver.openInputStream(uri)
|
|
||||||
?: throw Exception("Cannot open file")
|
|
||||||
|
|
||||||
return inputStream.use { stream ->
|
|
||||||
BufferedReader(InputStreamReader(stream, Charsets.UTF_8))
|
|
||||||
.readText()
|
|
||||||
.trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse DOCX file - SIMPLIFIED VERSION
|
|
||||||
* Hanya extract text mentah dari XML
|
|
||||||
*/
|
|
||||||
private fun parseDOCX(context: Context, uri: Uri): String {
|
|
||||||
val inputStream = context.contentResolver.openInputStream(uri)
|
|
||||||
?: throw Exception("Cannot open file")
|
|
||||||
|
|
||||||
return inputStream.use { stream ->
|
|
||||||
try {
|
|
||||||
// DOCX adalah ZIP file, kita extract document.xml
|
|
||||||
val zipInputStream = java.util.zip.ZipInputStream(stream)
|
|
||||||
val text = StringBuilder()
|
|
||||||
|
|
||||||
var entry = zipInputStream.nextEntry
|
|
||||||
while (entry != null) {
|
|
||||||
if (entry.name == "word/document.xml") {
|
|
||||||
val xmlContent = zipInputStream.bufferedReader().readText()
|
|
||||||
|
|
||||||
// Extract text dari XML tags
|
|
||||||
// Format: <w:t>text here</w:t>
|
|
||||||
val textPattern = Regex("<w:t[^>]*>([^<]+)</w:t>")
|
|
||||||
textPattern.findAll(xmlContent).forEach { match ->
|
|
||||||
text.append(match.groupValues[1])
|
|
||||||
text.append(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract text dari paragraph tags
|
|
||||||
val paraPattern = Regex("<w:p[^>]*>(.*?)</w:p>", RegexOption.DOT_MATCHES_ALL)
|
|
||||||
paraPattern.findAll(xmlContent).forEach { match ->
|
|
||||||
val paraContent = match.groupValues[1]
|
|
||||||
val textInPara = Regex("<w:t[^>]*>([^<]+)</w:t>")
|
|
||||||
textInPara.findAll(paraContent).forEach { textMatch ->
|
|
||||||
text.append(textMatch.groupValues[1])
|
|
||||||
text.append(" ")
|
|
||||||
}
|
|
||||||
text.append("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
entry = zipInputStream.nextEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
zipInputStream.close()
|
|
||||||
text.toString().trim()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error parsing DOCX", e)
|
|
||||||
throw Exception("Gagal membaca file DOCX: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get file name from URI
|
|
||||||
*/
|
|
||||||
private fun getFileName(context: Context, uri: Uri): String {
|
|
||||||
var fileName = "unknown"
|
|
||||||
|
|
||||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
|
||||||
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
|
||||||
if (cursor.moveToFirst() && nameIndex != -1) {
|
|
||||||
fileName = cursor.getString(nameIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fileName
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get file type display name
|
|
||||||
*/
|
|
||||||
private fun getFileType(fileName: String): String {
|
|
||||||
return when {
|
|
||||||
fileName.endsWith(".pdf", ignoreCase = true) -> "PDF"
|
|
||||||
fileName.endsWith(".txt", ignoreCase = true) -> "Text"
|
|
||||||
fileName.endsWith(".docx", ignoreCase = true) -> "Word"
|
|
||||||
else -> "Unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get file size
|
|
||||||
*/
|
|
||||||
fun getFileSize(context: Context, uri: Uri): Long {
|
|
||||||
var size = 0L
|
|
||||||
|
|
||||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
|
||||||
val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
|
|
||||||
if (cursor.moveToFirst() && sizeIndex != -1) {
|
|
||||||
size = cursor.getLong(sizeIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return size
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format file size untuk display
|
|
||||||
*/
|
|
||||||
fun formatFileSize(bytes: Long): String {
|
|
||||||
return when {
|
|
||||||
bytes < 1024 -> "$bytes B"
|
|
||||||
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
|
|
||||||
else -> "${bytes / (1024 * 1024)} MB"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result dari parsing file
|
|
||||||
*/
|
|
||||||
sealed class FileParseResult {
|
|
||||||
data class Success(
|
|
||||||
val content: String,
|
|
||||||
val fileName: String,
|
|
||||||
val fileType: String,
|
|
||||||
val wordCount: Int
|
|
||||||
) : FileParseResult()
|
|
||||||
|
|
||||||
data class Error(val message: String) : FileParseResult()
|
|
||||||
}
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
package com.example.notesai.util
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility untuk convert markdown text ke plain text
|
|
||||||
*/
|
|
||||||
object MarkdownStripper {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Strip semua markdown formatting dan return plain text
|
|
||||||
*/
|
|
||||||
fun stripMarkdown(text: String): String {
|
|
||||||
var result = text
|
|
||||||
|
|
||||||
// 1. Remove code blocks (```...```)
|
|
||||||
result = result.replace(Regex("""```[\s\S]*?```"""), "")
|
|
||||||
|
|
||||||
// 2. Remove inline code (`...`)
|
|
||||||
result = result.replace(Regex("""`([^`]+)`"""), "$1")
|
|
||||||
|
|
||||||
// 3. Remove bold (**...**)
|
|
||||||
result = result.replace(Regex("""\*\*([^*]+)\*\*"""), "$1")
|
|
||||||
|
|
||||||
// 4. Remove italic (*...*)
|
|
||||||
result = result.replace(Regex("""\*([^*]+)\*"""), "$1")
|
|
||||||
|
|
||||||
// 5. Remove strikethrough (~~...~~)
|
|
||||||
result = result.replace(Regex("""~~([^~]+)~~"""), "$1")
|
|
||||||
|
|
||||||
// 6. Remove headers (# ## ### etc)
|
|
||||||
result = result.replace(Regex("""^#{1,6}\s+""", RegexOption.MULTILINE), "")
|
|
||||||
|
|
||||||
// 7. Remove links [text](url) → text
|
|
||||||
result = result.replace(Regex("""\[([^\]]+)\]\([^)]+\)"""), "$1")
|
|
||||||
|
|
||||||
// 8. Remove images  → alt
|
|
||||||
result = result.replace(Regex("""!\[([^\]]*)\]\([^)]+\)"""), "$1")
|
|
||||||
|
|
||||||
// 9. Remove horizontal rules (---, ***, ___)
|
|
||||||
result = result.replace(Regex("""^[-*_]{3,}$""", RegexOption.MULTILINE), "")
|
|
||||||
|
|
||||||
// 10. Remove blockquotes (> ...)
|
|
||||||
result = result.replace(Regex("""^>\s+""", RegexOption.MULTILINE), "")
|
|
||||||
|
|
||||||
// 11. Remove unordered list markers (-, *, +)
|
|
||||||
result = result.replace(Regex("""^[\s]*[-*+]\s+""", RegexOption.MULTILINE), "")
|
|
||||||
|
|
||||||
// 12. Remove ordered list markers (1. 2. 3.)
|
|
||||||
result = result.replace(Regex("""^[\s]*\d+\.\s+""", RegexOption.MULTILINE), "")
|
|
||||||
|
|
||||||
// 13. Clean up extra whitespace
|
|
||||||
result = result.replace(Regex("""\n{3,}"""), "\n\n") // Max 2 consecutive newlines
|
|
||||||
result = result.trim()
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get preview text (first N characters, stripped)
|
|
||||||
*/
|
|
||||||
fun getPlainPreview(text: String, maxLength: Int = 100): String {
|
|
||||||
val plain = stripMarkdown(text)
|
|
||||||
return if (plain.length > maxLength) {
|
|
||||||
plain.take(maxLength).trim() + "..."
|
|
||||||
} else {
|
|
||||||
plain
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,289 +0,0 @@
|
|||||||
package com.example.notesai.util
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.*
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.example.notesai.util.AppColors
|
|
||||||
import com.example.notesai.util.Constants
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MarkdownText(
|
|
||||||
markdown: String,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = modifier,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
parseMarkdown(markdown).forEach { block ->
|
|
||||||
when (block) {
|
|
||||||
is MarkdownBlock.Paragraph -> {
|
|
||||||
Text(
|
|
||||||
text = buildAnnotatedString {
|
|
||||||
appendInlineMarkdown(block.content)
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = AppColors.OnSurface,
|
|
||||||
lineHeight = 24.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is MarkdownBlock.Header -> {
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = block.content,
|
|
||||||
style = when (block.level) {
|
|
||||||
1 -> MaterialTheme.typography.headlineLarge
|
|
||||||
2 -> MaterialTheme.typography.headlineMedium
|
|
||||||
3 -> MaterialTheme.typography.headlineSmall
|
|
||||||
else -> MaterialTheme.typography.titleLarge
|
|
||||||
},
|
|
||||||
color = AppColors.OnBackground,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
}
|
|
||||||
is MarkdownBlock.CodeBlock -> {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
color = AppColors.SurfaceVariant,
|
|
||||||
shape = RoundedCornerShape(Constants.Radius.Medium.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = block.content,
|
|
||||||
modifier = Modifier.padding(12.dp),
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = AppColors.OnSurface,
|
|
||||||
fontSize = 13.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is MarkdownBlock.ListItem -> {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = if (block.isOrdered) "${block.number}." else "•",
|
|
||||||
color = AppColors.Primary,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.width(20.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = buildAnnotatedString {
|
|
||||||
appendInlineMarkdown(block.content)
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = AppColors.OnSurface,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is MarkdownBlock.Quote -> {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
color = AppColors.Primary.copy(alpha = 0.1f),
|
|
||||||
shape = RoundedCornerShape(Constants.Radius.Medium.dp)
|
|
||||||
) {
|
|
||||||
Row {
|
|
||||||
Spacer(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(4.dp)
|
|
||||||
.fillMaxHeight()
|
|
||||||
.background(AppColors.Primary)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = buildAnnotatedString {
|
|
||||||
appendInlineMarkdown(block.content)
|
|
||||||
},
|
|
||||||
modifier = Modifier.padding(12.dp),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = AppColors.OnSurface,
|
|
||||||
fontStyle = FontStyle.Italic
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Markdown Block Types
|
|
||||||
sealed class MarkdownBlock {
|
|
||||||
data class Paragraph(val content: String) : MarkdownBlock()
|
|
||||||
data class Header(val level: Int, val content: String) : MarkdownBlock()
|
|
||||||
data class CodeBlock(val content: String, val language: String? = null) : MarkdownBlock()
|
|
||||||
data class ListItem(val content: String, val isOrdered: Boolean, val number: Int = 0) : MarkdownBlock()
|
|
||||||
data class Quote(val content: String) : MarkdownBlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse markdown into blocks
|
|
||||||
fun parseMarkdown(text: String): List<MarkdownBlock> {
|
|
||||||
val blocks = mutableListOf<MarkdownBlock>()
|
|
||||||
val lines = text.lines()
|
|
||||||
var i = 0
|
|
||||||
|
|
||||||
while (i < lines.size) {
|
|
||||||
val line = lines[i]
|
|
||||||
|
|
||||||
when {
|
|
||||||
// Code block
|
|
||||||
line.trimStart().startsWith("```") -> {
|
|
||||||
val language = line.trimStart().removePrefix("```").trim()
|
|
||||||
val codeLines = mutableListOf<String>()
|
|
||||||
i++
|
|
||||||
while (i < lines.size && !lines[i].trimStart().startsWith("```")) {
|
|
||||||
codeLines.add(lines[i])
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
blocks.add(MarkdownBlock.CodeBlock(codeLines.joinToString("\n"), language.ifEmpty { null }))
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
// Header
|
|
||||||
line.trimStart().startsWith("#") -> {
|
|
||||||
val level = line.takeWhile { it == '#' }.length
|
|
||||||
val content = line.removePrefix("#".repeat(level)).trim()
|
|
||||||
blocks.add(MarkdownBlock.Header(level, content))
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
// Quote
|
|
||||||
line.trimStart().startsWith(">") -> {
|
|
||||||
val content = line.trimStart().removePrefix(">").trim()
|
|
||||||
blocks.add(MarkdownBlock.Quote(content))
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
// Unordered list
|
|
||||||
line.trimStart().matches(Regex("^[-*+]\\s+.*")) -> {
|
|
||||||
val content = line.trimStart().replaceFirst(Regex("^[-*+]\\s+"), "")
|
|
||||||
blocks.add(MarkdownBlock.ListItem(content, false))
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
// Ordered list
|
|
||||||
line.trimStart().matches(Regex("^\\d+\\.\\s+.*")) -> {
|
|
||||||
val number = line.trimStart().takeWhile { it.isDigit() }.toIntOrNull() ?: 1
|
|
||||||
val content = line.trimStart().replaceFirst(Regex("^\\d+\\.\\s+"), "")
|
|
||||||
blocks.add(MarkdownBlock.ListItem(content, true, number))
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
// Empty line - skip
|
|
||||||
line.isBlank() -> {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
// Paragraph
|
|
||||||
else -> {
|
|
||||||
val paragraphLines = mutableListOf<String>()
|
|
||||||
while (i < lines.size && lines[i].isNotBlank() &&
|
|
||||||
!lines[i].trimStart().startsWith("#") &&
|
|
||||||
!lines[i].trimStart().startsWith(">") &&
|
|
||||||
!lines[i].trimStart().startsWith("```") &&
|
|
||||||
!lines[i].trimStart().matches(Regex("^[-*+]\\s+.*")) &&
|
|
||||||
!lines[i].trimStart().matches(Regex("^\\d+\\.\\s+.*"))) {
|
|
||||||
paragraphLines.add(lines[i])
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
if (paragraphLines.isNotEmpty()) {
|
|
||||||
blocks.add(MarkdownBlock.Paragraph(paragraphLines.joinToString(" ")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return blocks
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse inline markdown (bold, italic, code, links)
|
|
||||||
fun AnnotatedString.Builder.appendInlineMarkdown(text: String) {
|
|
||||||
var currentIndex = 0
|
|
||||||
val inlinePatterns = listOf(
|
|
||||||
// Bold with **
|
|
||||||
Regex("""\*\*(.+?)\*\*""") to { content: String ->
|
|
||||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
|
||||||
append(content)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Bold with __
|
|
||||||
Regex("""__(.+?)__""") to { content: String ->
|
|
||||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
|
||||||
append(content)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Italic with *
|
|
||||||
Regex("""\*(.+?)\*""") to { content: String ->
|
|
||||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
|
||||||
append(content)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Italic with _
|
|
||||||
Regex("""_(.+?)_""") to { content: String ->
|
|
||||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
|
||||||
append(content)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Inline code
|
|
||||||
Regex("""`(.+?)`""") to { content: String ->
|
|
||||||
withStyle(
|
|
||||||
SpanStyle(
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
background = Color(0xFF1E1E1E),
|
|
||||||
color = Color(0xFFE5E5E5)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
append(" $content ")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Links [text](url)
|
|
||||||
Regex("""\[(.+?)]\((.+?)\)""") to { content: String ->
|
|
||||||
withStyle(
|
|
||||||
SpanStyle(
|
|
||||||
color = Color(0xFF3B82F6),
|
|
||||||
textDecoration = TextDecoration.Underline
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
append(content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
var remainingText = text
|
|
||||||
val matches = mutableListOf<Triple<Int, Int, (String) -> Unit>>()
|
|
||||||
|
|
||||||
// Find all matches
|
|
||||||
inlinePatterns.forEach { (regex, styleApplier) ->
|
|
||||||
regex.findAll(remainingText).forEach { match ->
|
|
||||||
val content = match.groupValues[1]
|
|
||||||
matches.add(Triple(match.range.first, match.range.last + 1) { styleApplier(content) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort matches by start position
|
|
||||||
val sortedMatches = matches.sortedBy { it.first }
|
|
||||||
|
|
||||||
// Apply styles
|
|
||||||
var lastIndex = 0
|
|
||||||
sortedMatches.forEach { (start, end, applier) ->
|
|
||||||
if (start >= lastIndex) {
|
|
||||||
// Append text before match
|
|
||||||
append(remainingText.substring(lastIndex, start))
|
|
||||||
// Apply style
|
|
||||||
applier("")
|
|
||||||
lastIndex = end
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append remaining text
|
|
||||||
if (lastIndex < remainingText.length) {
|
|
||||||
append(remainingText.substring(lastIndex))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +1,30 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:height="108dp"
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:viewportHeight="24"
|
android:height="108dp"
|
||||||
android:viewportWidth="24">
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="85.84757"
|
||||||
|
android:endY="92.4963"
|
||||||
|
android:startX="42.9492"
|
||||||
|
android:startY="49.59793"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
<path
|
<path
|
||||||
android:fillColor="#FFFFFF"
|
android:fillColor="#FFFFFF"
|
||||||
android:pathData="M22,10.5V19c0,1.1 -0.9,2 -2,2H4c-1.1,0 -2,-0.9 -2,-2V5c0,-1.1 0.9,-2 2,-2h9.5L22,10.5z M15,5h2v5h5v2l-7,-7z"/>
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:strokeColor="#00000000" />
|
||||||
</vector>
|
</vector>
|
||||||
19
app/src/main/res/layout/activity_main.xml
Normal file
19
app/src/main/res/layout/activity_main.xml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/main"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Hello World!"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
@ -1,3 +1,7 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<!-- Base application theme. -->
|
<!-- Base application theme. -->
|
||||||
|
<style name="Base.Theme.Notesai" parent="Theme.Material3.DayNight.NoActionBar">
|
||||||
|
<!-- Customize your dark theme here. -->
|
||||||
|
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
|
||||||
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
@ -1,2 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources></resources>
|
<resources>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
</resources>
|
||||||
@ -1,3 +1,3 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">NotesAI</string>
|
<string name="app_name">AI Notes</string>
|
||||||
</resources>
|
</resources>
|
||||||
@ -1,5 +1,10 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<style name="Theme.NotesAI" parent="android:Theme.Material.Light.NoActionBar" />
|
<style name="Theme.NotesAI" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
<!-- Base application theme. -->
|
<!-- Base application theme. -->
|
||||||
|
<style name="Base.Theme.Notesai" parent="Theme.Material3.DayNight.NoActionBar">
|
||||||
|
<!-- Customize your light theme here. -->
|
||||||
|
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="Theme.Notesai" parent="Base.Theme.Notesai" />
|
||||||
</resources>
|
</resources>
|
||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB |
@ -9,16 +9,6 @@ appcompat = "1.6.1"
|
|||||||
material = "1.10.0"
|
material = "1.10.0"
|
||||||
activity = "1.8.0"
|
activity = "1.8.0"
|
||||||
constraintlayout = "2.1.4"
|
constraintlayout = "2.1.4"
|
||||||
uiText = "1.10.0"
|
|
||||||
material3 = "1.4.0"
|
|
||||||
animationCore = "1.10.0"
|
|
||||||
firebaseAnnotations = "17.0.0"
|
|
||||||
firebaseFirestoreKtx = "26.0.2"
|
|
||||||
uiGraphics = "1.10.0"
|
|
||||||
roomCompiler = "2.8.4"
|
|
||||||
glance = "1.1.1"
|
|
||||||
animation = "1.10.0"
|
|
||||||
junitKtx = "1.3.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" }
|
||||||
@ -29,16 +19,6 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version
|
|||||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||||
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
|
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
|
||||||
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
|
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
|
||||||
androidx-ui-text = { group = "androidx.compose.ui", name = "ui-text", version.ref = "uiText" }
|
|
||||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
|
|
||||||
androidx-animation-core = { group = "androidx.compose.animation", name = "animation-core", version.ref = "animationCore" }
|
|
||||||
firebase-annotations = { group = "com.google.firebase", name = "firebase-annotations", version.ref = "firebaseAnnotations" }
|
|
||||||
firebase-firestore-ktx = { group = "com.google.firebase", name = "firebase-firestore-ktx", version.ref = "firebaseFirestoreKtx" }
|
|
||||||
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "uiGraphics" }
|
|
||||||
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "roomCompiler" }
|
|
||||||
androidx-glance = { group = "androidx.glance", name = "glance", version.ref = "glance" }
|
|
||||||
androidx-animation = { group = "androidx.compose.animation", name = "animation", version.ref = "animation" }
|
|
||||||
androidx-junit-ktx = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "junitKtx" }
|
|
||||||
|
|
||||||
[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