init commit
This commit is contained in:
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
3
.idea/.gitignore
generated
vendored
Normal file
3
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
6
.idea/AndroidProjectSystem.xml
generated
Normal file
6
.idea/AndroidProjectSystem.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AndroidProjectSystem">
|
||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||
</component>
|
||||
</project>
|
||||
26
.idea/appInsightsSettings.xml
generated
Normal file
26
.idea/appInsightsSettings.xml
generated
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AppInsightsSettings">
|
||||
<option name="tabSettings">
|
||||
<map>
|
||||
<entry key="Firebase Crashlytics">
|
||||
<value>
|
||||
<InsightsFilterSettings>
|
||||
<option name="connection">
|
||||
<ConnectionSetting>
|
||||
<option name="appId" value="PLACEHOLDER" />
|
||||
<option name="mobileSdkAppId" value="" />
|
||||
<option name="projectId" value="" />
|
||||
<option name="projectNumber" value="" />
|
||||
</ConnectionSetting>
|
||||
</option>
|
||||
<option name="signal" value="SIGNAL_UNSPECIFIED" />
|
||||
<option name="timeIntervalDays" value="THIRTY_DAYS" />
|
||||
<option name="visibilityType" value="ALL" />
|
||||
</InsightsFilterSettings>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
123
.idea/codeStyles/Project.xml
generated
Normal file
123
.idea/codeStyles/Project.xml
generated
Normal file
@@ -0,0 +1,123 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<JetCodeStyleSettings>
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
<arrangement>
|
||||
<rules>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:android</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:id</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>style</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
</rules>
|
||||
</arrangement>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
6
.idea/compiler.xml
generated
Normal file
6
.idea/compiler.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/copilot.data.migration.agent.xml
generated
Normal file
6
.idea/copilot.data.migration.agent.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AgentMigrationStateService">
|
||||
<option name="migrationStatus" value="COMPLETED" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/copilot.data.migration.ask.xml
generated
Normal file
6
.idea/copilot.data.migration.ask.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AskMigrationStateService">
|
||||
<option name="migrationStatus" value="COMPLETED" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/copilot.data.migration.ask2agent.xml
generated
Normal file
6
.idea/copilot.data.migration.ask2agent.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Ask2AgentMigrationStateService">
|
||||
<option name="migrationStatus" value="COMPLETED" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/copilot.data.migration.edit.xml
generated
Normal file
6
.idea/copilot.data.migration.edit.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="EditMigrationStateService">
|
||||
<option name="migrationStatus" value="COMPLETED" />
|
||||
</component>
|
||||
</project>
|
||||
18
.idea/deploymentTargetSelector.xml
generated
Normal file
18
.idea/deploymentTargetSelector.xml
generated
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetSelector">
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-01-11T22:58:32.450358206Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=R3CT80VPBQZ" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
</project>
|
||||
13
.idea/deviceManager.xml
generated
Normal file
13
.idea/deviceManager.xml
generated
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DeviceTable">
|
||||
<option name="columnSorters">
|
||||
<list>
|
||||
<ColumnSorterState>
|
||||
<option name="column" value="Name" />
|
||||
<option name="order" value="ASCENDING" />
|
||||
</ColumnSorterState>
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
19
.idea/gradle.xml
generated
Normal file
19
.idea/gradle.xml
generated
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
10
.idea/migrations.xml
generated
Normal file
10
.idea/migrations.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectMigrations">
|
||||
<option name="MigrateToGradleLocalJavaHome">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
9
.idea/misc.xml
generated
Normal file
9
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
<option name="id" value="Android" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/render.experimental.xml
generated
Normal file
6
.idea/render.experimental.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="RenderSettings">
|
||||
<option name="showDecorations" value="true" />
|
||||
</component>
|
||||
</project>
|
||||
17
.idea/runConfigurations.xml
generated
Normal file
17
.idea/runConfigurations.xml
generated
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="RunConfigurationProducerService">
|
||||
<option name="ignoredProducers">
|
||||
<set>
|
||||
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
69
.kotlin/errors/errors-1768261907650.log
Normal file
69
.kotlin/errors/errors-1768261907650.log
Normal file
@@ -0,0 +1,69 @@
|
||||
kotlin version: 2.2.0
|
||||
error message: java.nio.file.NoSuchFileException: /home/trevor/AndroidStudioProjects/Elva/app/build/intermediates/compile_and_runtime_not_namespaced_r_class_jar/debug/processDebugResources/R.jar
|
||||
at java.base/sun.nio.fs.UnixException.translateToIOException(UnixException.java:92)
|
||||
at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:106)
|
||||
at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:111)
|
||||
at java.base/sun.nio.fs.UnixFileAttributeViews$Basic.readAttributes(UnixFileAttributeViews.java:55)
|
||||
at java.base/sun.nio.fs.UnixFileSystemProvider.readAttributes(UnixFileSystemProvider.java:171)
|
||||
at java.base/sun.nio.fs.LinuxFileSystemProvider.readAttributes(LinuxFileSystemProvider.java:99)
|
||||
at java.base/java.nio.file.Files.readAttributes(Files.java:1854)
|
||||
at org.jetbrains.kotlin.com.intellij.openapi.vfs.impl.ZipHandler$1.createAccessor(ZipHandler.java:19)
|
||||
at org.jetbrains.kotlin.com.intellij.openapi.vfs.impl.ZipHandler$1.createAccessor(ZipHandler.java:15)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.FileAccessorCache.createHandle(FileAccessorCache.java:45)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.FileAccessorCache.get(FileAccessorCache.java:39)
|
||||
at org.jetbrains.kotlin.com.intellij.openapi.vfs.impl.ZipHandler.acquireZipHandle(ZipHandler.java:46)
|
||||
at org.jetbrains.kotlin.com.intellij.openapi.vfs.impl.ZipHandlerBase.contentsToByteArray(ZipHandlerBase.java:85)
|
||||
at org.jetbrains.kotlin.com.intellij.openapi.vfs.impl.jar.CoreJarVirtualFile.contentsToByteArray(CoreJarVirtualFile.java:101)
|
||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinCliJavaFileManagerImpl.findClass(KotlinCliJavaFileManagerImpl.kt:148)
|
||||
at org.jetbrains.kotlin.resolve.jvm.KotlinJavaPsiFacade$CliFinder.findClass(KotlinJavaPsiFacade.java:538)
|
||||
at org.jetbrains.kotlin.resolve.jvm.KotlinJavaPsiFacade.findClass(KotlinJavaPsiFacade.java:181)
|
||||
at org.jetbrains.kotlin.load.java.JavaClassFinderImpl.findClass(JavaClassFinderImpl.kt:46)
|
||||
at org.jetbrains.kotlin.load.java.lazy.descriptors.LazyJavaPackageScope.classes$lambda$1(LazyJavaPackageScope.kt:80)
|
||||
at org.jetbrains.kotlin.storage.LockBasedStorageManager$MapBasedMemoizedFunction.invoke(LockBasedStorageManager.java:578)
|
||||
at org.jetbrains.kotlin.load.java.lazy.descriptors.LazyJavaPackageScope.findClassifier(LazyJavaPackageScope.kt:145)
|
||||
at org.jetbrains.kotlin.load.java.lazy.descriptors.LazyJavaPackageScope.getContributedClassifier(LazyJavaPackageScope.kt:135)
|
||||
at org.jetbrains.kotlin.load.java.lazy.descriptors.JvmPackageScope.getContributedClassifier(JvmPackageScope.kt:55)
|
||||
at org.jetbrains.kotlin.resolve.scopes.ChainedMemberScope.getContributedClassifier(ChainedMemberScope.kt:35)
|
||||
at org.jetbrains.kotlin.resolve.scopes.AbstractScopeAdapter.getContributedClassifier(AbstractScopeAdapter.kt:44)
|
||||
at org.jetbrains.kotlin.resolve.LazyExplicitImportScope.getContributedClassifier(LazyExplicitImportScope.kt:45)
|
||||
at org.jetbrains.kotlin.resolve.LazyExplicitImportScope.getContributedDescriptors(LazyExplicitImportScope.kt:71)
|
||||
at org.jetbrains.kotlin.resolve.scopes.BaseImportingScope.getContributedDescriptors(Scopes.kt:167)
|
||||
at org.jetbrains.kotlin.resolve.scopes.ResolutionScope$DefaultImpls.getContributedDescriptors$default(ResolutionScope.kt:50)
|
||||
at org.jetbrains.kotlin.resolve.LazyExplicitImportScope.storeReferencesToDescriptors$frontend(LazyExplicitImportScope.kt:120)
|
||||
at org.jetbrains.kotlin.resolve.lazy.LazyImportResolverForKtImportDirective.forceResolveImportDirective$lambda$0(LazyImportScope.kt:172)
|
||||
at org.jetbrains.kotlin.storage.LockBasedStorageManager$MapBasedMemoizedFunction.invoke(LockBasedStorageManager.java:578)
|
||||
at org.jetbrains.kotlin.storage.LockBasedStorageManager$MapBasedMemoizedFunctionToNotNull.invoke(LockBasedStorageManager.java:681)
|
||||
at org.jetbrains.kotlin.resolve.lazy.LazyImportResolverForKtImportDirective.forceResolveImport(LazyImportScope.kt:231)
|
||||
at org.jetbrains.kotlin.resolve.lazy.LazyImportResolverForKtImportDirective.forceResolveNonDefaultImportsTask$lambda$3(LazyImportScope.kt:184)
|
||||
at org.jetbrains.kotlin.storage.LockBasedStorageManager$LockBasedLazyValue.invoke(LockBasedStorageManager.java:408)
|
||||
at org.jetbrains.kotlin.storage.LockBasedStorageManager$LockBasedNotNullLazyValue.invoke(LockBasedStorageManager.java:527)
|
||||
at org.jetbrains.kotlin.resolve.lazy.LazyImportResolverForKtImportDirective.forceResolveNonDefaultImports(LazyImportScope.kt:214)
|
||||
at org.jetbrains.kotlin.resolve.lazy.FileScopeFactory$FilesScopesBuilder$importResolver$1.forceResolveNonDefaultImports(FileScopeFactory.kt:170)
|
||||
at org.jetbrains.kotlin.resolve.LazyTopDownAnalyzer.resolveImportsInFile(LazyTopDownAnalyzer.kt:252)
|
||||
at org.jetbrains.kotlin.kapt.PartialAnalysisHandlerExtension.doAnalysis(PartialAnalysisHandlerExtension.kt:55)
|
||||
at org.jetbrains.kotlin.kapt.AbstractKaptExtension.doAnalysis(KaptExtension.kt:127)
|
||||
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration(TopDownAnalyzerFacadeForJVM.kt:112)
|
||||
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration$default(TopDownAnalyzerFacadeForJVM.kt:75)
|
||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze$lambda$12(KotlinToJVMBytecodeCompiler.kt:345)
|
||||
at org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport.analyzeAndReport(AnalyzerWithCompilerReport.kt:112)
|
||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze(KotlinToJVMBytecodeCompiler.kt:336)
|
||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.runFrontendAndGenerateIrUsingClassicFrontend(KotlinToJVMBytecodeCompiler.kt:170)
|
||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.compileModules$cli(KotlinToJVMBytecodeCompiler.kt:81)
|
||||
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:187)
|
||||
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:44)
|
||||
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:123)
|
||||
at org.jetbrains.kotlin.cli.common.CLICompiler.exec(CLICompiler.kt:352)
|
||||
at org.jetbrains.kotlin.cli.common.CLICompiler.exec(CLICompiler.kt:330)
|
||||
at org.jetbrains.kotlin.cli.common.CLICompiler.execAndOutputXml(CLICompiler.kt:73)
|
||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
|
||||
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
|
||||
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileInProcessImpl(GradleKotlinCompilerWork.kt:388)
|
||||
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.access$compileInProcessImpl(GradleKotlinCompilerWork.kt:85)
|
||||
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork$compileInProcess$future$1.call(GradleKotlinCompilerWork.kt:361)
|
||||
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork$compileInProcess$future$1.call(GradleKotlinCompilerWork.kt:360)
|
||||
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
|
||||
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
|
||||
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
|
||||
at java.base/java.lang.Thread.run(Thread.java:1583)
|
||||
|
||||
|
||||
1
app/.gitignore
vendored
Normal file
1
app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
100
app/build.gradle.kts
Normal file
100
app/build.gradle.kts
Normal file
@@ -0,0 +1,100 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.kotlin.kapt)
|
||||
alias(libs.plugins.android.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.elva"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.example.elva"
|
||||
minSdk = 31
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
java.srcDirs("src/main/java", "src/main/kotlin")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
freeCompilerArgs += listOf(
|
||||
"-Xjvm-default=all",
|
||||
"-opt-in=kotlin.RequiresOptIn"
|
||||
)
|
||||
languageVersion = "2.0" // Ограничиваем до 2.0 для kapt
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
dataBinding = true
|
||||
}
|
||||
}
|
||||
|
||||
kapt {
|
||||
correctErrorTypes = true
|
||||
useBuildCache = true
|
||||
arguments {
|
||||
arg("dagger.fastInit", "enabled")
|
||||
arg("dagger.strictMultibindingValidation", "disabled")
|
||||
}
|
||||
javacOptions {
|
||||
option("-source", "17")
|
||||
option("-target", "17")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Используем Kotlin 2.2.0 из libs.versions.toml
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.material)
|
||||
implementation(libs.androidx.recyclerview)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
implementation(libs.androidx.lifecycle.livedata.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
implementation(libs.androidx.navigation.fragment.ktx)
|
||||
implementation(libs.androidx.navigation.ui.ktx)
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.retrofit.serialization)
|
||||
implementation(libs.okhttp.logging)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.security.crypto)
|
||||
implementation(libs.hilt.android)
|
||||
implementation(libs.play.services.maps)
|
||||
implementation(libs.androidx.fragment)
|
||||
implementation(libs.dots.indicator)
|
||||
implementation("com.google.code.gson:gson:2.10.1")
|
||||
kapt(libs.hilt.compiler)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
}
|
||||
21
app/proguard-rules.pro
vendored
Normal file
21
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.example.elva
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.example.elva", appContext.packageName)
|
||||
}
|
||||
}
|
||||
37
app/src/main/AndroidManifest.xml
Normal file
37
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".ElvaApp"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Elva"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="31">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:resizeableActivity="true"
|
||||
android:theme="@style/Theme.Elva.NoActionBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
3
app/src/main/java/UserInfo.kt
Normal file
3
app/src/main/java/UserInfo.kt
Normal file
@@ -0,0 +1,3 @@
|
||||
// ФАЙЛ УДАЛЕН - используйте AuthModels.kt
|
||||
// Этот класс теперь находится в AuthModels.kt
|
||||
|
||||
8
app/src/main/java/com/example/elva/ElvaApp.kt
Normal file
8
app/src/main/java/com/example/elva/ElvaApp.kt
Normal file
@@ -0,0 +1,8 @@
|
||||
package com.example.elva
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class ElvaApp : Application()
|
||||
|
||||
153
app/src/main/java/com/example/elva/MainActivity.kt
Normal file
153
app/src/main/java/com/example/elva/MainActivity.kt
Normal file
@@ -0,0 +1,153 @@
|
||||
package com.example.elva
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.navigation.ui.AppBarConfiguration
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.example.elva.databinding.ActivityMainBinding
|
||||
import com.example.elva.util.AuthManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var appBarConfiguration: AppBarConfiguration
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
|
||||
@Inject
|
||||
lateinit var authManager: AuthManager
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
// Скрываем ActionBar по умолчанию (будет показан только для основных экранов)
|
||||
supportActionBar?.hide()
|
||||
|
||||
val navController = (supportFragmentManager
|
||||
.findFragmentById(R.id.nav_host_fragment_content_main) as NavHostFragment)
|
||||
.navController
|
||||
|
||||
// Определяем стартовый фрагмент
|
||||
val sharedPref = getSharedPreferences("elva_prefs", MODE_PRIVATE)
|
||||
val onboardingCompleted = sharedPref.getBoolean("onboarding_completed", false)
|
||||
val hasToken = authManager.getAccessToken() != null
|
||||
val isLoggedIn = authManager.isLoggedIn()
|
||||
|
||||
android.util.Log.d("MainActivity", "onCreate: onboardingCompleted=$onboardingCompleted")
|
||||
android.util.Log.d("MainActivity", "onCreate: hasToken=$hasToken")
|
||||
android.util.Log.d("MainActivity", "onCreate: isLoggedIn=$isLoggedIn")
|
||||
|
||||
// Настраиваем граф навигации с правильным стартовым фрагментом
|
||||
val navGraph = navController.navInflater.inflate(R.navigation.mobile_navigation)
|
||||
|
||||
when {
|
||||
!onboardingCompleted -> {
|
||||
// Показываем onboarding
|
||||
android.util.Log.d("MainActivity", "Starting with onboarding")
|
||||
navGraph.setStartDestination(R.id.onboardingFragment)
|
||||
}
|
||||
isLoggedIn -> {
|
||||
// Пользователь авторизован и токен действителен - показываем dashboard
|
||||
android.util.Log.d("MainActivity", "User is logged in with valid token, starting with dashboard")
|
||||
navGraph.setStartDestination(R.id.dashboardFragment)
|
||||
}
|
||||
hasToken -> {
|
||||
// Есть токен, но он истек - показываем экран входа
|
||||
android.util.Log.d("MainActivity", "Token expired, starting with login")
|
||||
navGraph.setStartDestination(R.id.loginFragment)
|
||||
}
|
||||
else -> {
|
||||
// Нет токена - показываем экран входа
|
||||
android.util.Log.d("MainActivity", "No token, starting with login")
|
||||
navGraph.setStartDestination(R.id.loginFragment)
|
||||
}
|
||||
}
|
||||
|
||||
navController.graph = navGraph
|
||||
|
||||
appBarConfiguration = AppBarConfiguration(
|
||||
setOf(
|
||||
R.id.onboardingFragment,
|
||||
R.id.loginFragment,
|
||||
R.id.dashboardFragment,
|
||||
R.id.nav_profile,
|
||||
R.id.nav_sos,
|
||||
R.id.nav_alerts,
|
||||
R.id.nav_contacts,
|
||||
R.id.nav_calendar,
|
||||
R.id.nav_nutrition,
|
||||
R.id.nav_settings,
|
||||
R.id.nav_location,
|
||||
R.id.nav_safety
|
||||
),
|
||||
binding.drawerLayout
|
||||
)
|
||||
binding.navView.setupWithNavController(navController)
|
||||
|
||||
// Скрываем навбар, ActionBar и блокируем drawer для onboarding и auth экранов
|
||||
navController.addOnDestinationChangedListener { _, destination, _ ->
|
||||
android.util.Log.d("MainActivity", "Navigated to: ${destination.label}")
|
||||
val bottomNav = findViewById<android.view.View>(R.id.bottom_navigation)
|
||||
when (destination.id) {
|
||||
R.id.onboardingFragment,
|
||||
R.id.loginFragment -> {
|
||||
// Скрываем навбар и ActionBar для onboarding и auth экранов
|
||||
bottomNav?.visibility = android.view.View.GONE
|
||||
supportActionBar?.hide()
|
||||
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
|
||||
}
|
||||
else -> {
|
||||
// Показываем навбар но оставляем ActionBar скрытым для всех экранов
|
||||
bottomNav?.visibility = android.view.View.VISIBLE
|
||||
supportActionBar?.hide()
|
||||
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED)
|
||||
// Настраиваем кастомный навбар
|
||||
setupBottomNavigation(navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupBottomNavigation(navController: androidx.navigation.NavController) {
|
||||
findViewById<android.view.View>(R.id.nav_cycle)?.setOnClickListener {
|
||||
navController.navigate(R.id.nav_cycle)
|
||||
}
|
||||
|
||||
findViewById<android.view.View>(R.id.nav_health)?.setOnClickListener {
|
||||
navController.navigate(R.id.dashboardFragment)
|
||||
}
|
||||
|
||||
findViewById<android.view.View>(R.id.nav_sos)?.setOnClickListener {
|
||||
navController.navigate(R.id.nav_sos)
|
||||
}
|
||||
|
||||
findViewById<android.view.View>(R.id.nav_chat)?.setOnClickListener {
|
||||
// TODO: Navigate to Chat fragment
|
||||
}
|
||||
|
||||
findViewById<android.view.View>(R.id.nav_profile)?.setOnClickListener {
|
||||
navController.navigate(R.id.nav_profile)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.nav_settings -> {
|
||||
val navController = findNavController(R.id.nav_host_fragment_content_main)
|
||||
navController.navigate(R.id.nav_settings)
|
||||
}
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
21
app/src/main/java/com/example/elva/data/api/ApiConfig.kt
Normal file
21
app/src/main/java/com/example/elva/data/api/ApiConfig.kt
Normal file
@@ -0,0 +1,21 @@
|
||||
package com.example.elva.data.api
|
||||
|
||||
object ApiConfig {
|
||||
// Для реального устройства используйте IP вашего компьютера
|
||||
// Для эмулятора используйте 10.0.2.2
|
||||
const val BASE_HOST = "192.168.0.112" // Замените на IP вашего компьютера
|
||||
|
||||
const val BASE_URL = "http://$BASE_HOST:8000"
|
||||
const val USER_SERVICE_URL = "http://$BASE_HOST:8001"
|
||||
const val EMERGENCY_SERVICE_URL = "http://$BASE_HOST:8002"
|
||||
const val LOCATION_SERVICE_URL = "http://$BASE_HOST:8003"
|
||||
const val CALENDAR_SERVICE_URL = "http://$BASE_HOST:8004"
|
||||
const val NOTIFICATION_SERVICE_URL = "http://$BASE_HOST:8005"
|
||||
|
||||
const val WS_URL = "ws://$BASE_HOST:8002/api/v1/emergency/ws"
|
||||
|
||||
const val CONNECT_TIMEOUT = 30L
|
||||
const val READ_TIMEOUT = 30L
|
||||
const val WRITE_TIMEOUT = 30L
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.example.elva.data.api
|
||||
|
||||
import com.example.elva.data.models.auth.RegisterRequest
|
||||
import com.example.elva.data.models.auth.LoginRequest
|
||||
import com.example.elva.data.models.auth.AuthResponse
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
interface AuthApiService {
|
||||
|
||||
@POST("api/v1/auth/register")
|
||||
suspend fun register(@Body request: RegisterRequest): AuthResponse
|
||||
|
||||
@POST("api/v1/auth/login")
|
||||
suspend fun login(@Body request: LoginRequest): AuthResponse
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.example.elva.data.api
|
||||
|
||||
import com.example.elva.data.models.profile.ProfileUpdateRequest
|
||||
import com.example.elva.data.models.profile.UserProfile
|
||||
import retrofit2.http.*
|
||||
|
||||
interface ProfileApiService {
|
||||
|
||||
@GET("api/v1/profile")
|
||||
suspend fun getProfile(): UserProfile
|
||||
|
||||
@PUT("api/v1/profile")
|
||||
suspend fun updateProfile(@Body request: ProfileUpdateRequest): UserProfile
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.example.elva.data.api
|
||||
|
||||
import com.example.elva.data.models.user.UserProfile
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
|
||||
interface UserApiService {
|
||||
|
||||
@GET("api/v1/users/me")
|
||||
suspend fun getCurrentUser(
|
||||
@Header("Authorization") token: String
|
||||
): UserProfile
|
||||
}
|
||||
|
||||
218
app/src/main/java/com/example/elva/data/local/HealthDataStore.kt
Normal file
218
app/src/main/java/com/example/elva/data/local/HealthDataStore.kt
Normal file
@@ -0,0 +1,218 @@
|
||||
package com.example.elva.data.local
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import com.example.elva.data.models.health.*
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class HealthDataStore @Inject constructor(
|
||||
@ApplicationContext context: Context
|
||||
) {
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences("health_data", Context.MODE_PRIVATE)
|
||||
private val gson = Gson()
|
||||
|
||||
// Period tracking
|
||||
fun savePeriods(periods: List<Period>) {
|
||||
val json = gson.toJson(periods)
|
||||
prefs.edit().putString("periods", json).apply()
|
||||
}
|
||||
|
||||
fun getPeriods(): List<Period> {
|
||||
val json = prefs.getString("periods", null) ?: return emptyList()
|
||||
val type = object : TypeToken<List<Period>>() {}.type
|
||||
return gson.fromJson(json, type)
|
||||
}
|
||||
|
||||
fun addPeriod(period: Period) {
|
||||
val periods = getPeriods().toMutableList()
|
||||
periods.add(period.copy(id = System.currentTimeMillis()))
|
||||
savePeriods(periods)
|
||||
}
|
||||
|
||||
fun updatePeriod(period: Period) {
|
||||
val periods = getPeriods().toMutableList()
|
||||
val index = periods.indexOfFirst { it.id == period.id }
|
||||
if (index != -1) {
|
||||
periods[index] = period
|
||||
savePeriods(periods)
|
||||
}
|
||||
}
|
||||
|
||||
fun deletePeriod(periodId: Long) {
|
||||
val periods = getPeriods().filter { it.id != periodId }
|
||||
savePeriods(periods)
|
||||
}
|
||||
|
||||
// Water intake
|
||||
fun saveWaterIntakes(intakes: List<WaterIntake>) {
|
||||
val json = gson.toJson(intakes)
|
||||
prefs.edit().putString("water_intakes", json).apply()
|
||||
}
|
||||
|
||||
fun getWaterIntakes(): List<WaterIntake> {
|
||||
val json = prefs.getString("water_intakes", null) ?: return emptyList()
|
||||
val type = object : TypeToken<List<WaterIntake>>() {}.type
|
||||
return gson.fromJson(json, type)
|
||||
}
|
||||
|
||||
fun addWater(date: LocalDate, amountMl: Int) {
|
||||
val intakes = getWaterIntakes().toMutableList()
|
||||
val existing = intakes.find { it.date == date }
|
||||
if (existing != null) {
|
||||
intakes.removeIf { it.date == date }
|
||||
intakes.add(existing.copy(amountMl = existing.amountMl + amountMl))
|
||||
} else {
|
||||
intakes.add(WaterIntake(
|
||||
id = System.currentTimeMillis(),
|
||||
date = date,
|
||||
amountMl = amountMl
|
||||
))
|
||||
}
|
||||
saveWaterIntakes(intakes)
|
||||
}
|
||||
|
||||
fun getWaterGoal(): Int = prefs.getInt("water_goal", 2000)
|
||||
fun setWaterGoal(goalMl: Int) = prefs.edit().putInt("water_goal", goalMl).apply()
|
||||
|
||||
// Calories
|
||||
fun saveCalorieEntries(entries: List<CalorieEntry>) {
|
||||
val json = gson.toJson(entries)
|
||||
prefs.edit().putString("calorie_entries", json).apply()
|
||||
}
|
||||
|
||||
fun getCalorieEntries(): List<CalorieEntry> {
|
||||
val json = prefs.getString("calorie_entries", null) ?: return emptyList()
|
||||
val type = object : TypeToken<List<CalorieEntry>>() {}.type
|
||||
return gson.fromJson(json, type)
|
||||
}
|
||||
|
||||
fun addCalorieEntry(entry: CalorieEntry) {
|
||||
val entries = getCalorieEntries().toMutableList()
|
||||
entries.add(entry.copy(id = System.currentTimeMillis()))
|
||||
saveCalorieEntries(entries)
|
||||
}
|
||||
|
||||
fun deleteCalorieEntry(entryId: Long) {
|
||||
val entries = getCalorieEntries().filter { it.id != entryId }
|
||||
saveCalorieEntries(entries)
|
||||
}
|
||||
|
||||
fun getCalorieGoal(): CalorieGoal {
|
||||
val dailyGoal = prefs.getInt("calorie_goal", 2000)
|
||||
val protein = prefs.getInt("protein_goal", 0)
|
||||
val carbs = prefs.getInt("carbs_goal", 0)
|
||||
val fats = prefs.getInt("fats_goal", 0)
|
||||
return CalorieGoal(dailyGoal, protein, carbs, fats)
|
||||
}
|
||||
|
||||
fun setCalorieGoal(goal: CalorieGoal) {
|
||||
prefs.edit()
|
||||
.putInt("calorie_goal", goal.dailyGoal)
|
||||
.putInt("protein_goal", goal.protein)
|
||||
.putInt("carbs_goal", goal.carbs)
|
||||
.putInt("fats_goal", goal.fats)
|
||||
.apply()
|
||||
}
|
||||
|
||||
// Weight
|
||||
fun saveWeightEntries(entries: List<WeightEntry>) {
|
||||
val json = gson.toJson(entries)
|
||||
prefs.edit().putString("weight_entries", json).apply()
|
||||
}
|
||||
|
||||
fun getWeightEntries(): List<WeightEntry> {
|
||||
val json = prefs.getString("weight_entries", null) ?: return emptyList()
|
||||
val type = object : TypeToken<List<WeightEntry>>() {}.type
|
||||
return gson.fromJson(json, type)
|
||||
}
|
||||
|
||||
fun addWeightEntry(entry: WeightEntry) {
|
||||
val entries = getWeightEntries().toMutableList()
|
||||
entries.add(entry.copy(id = System.currentTimeMillis()))
|
||||
saveWeightEntries(entries)
|
||||
}
|
||||
|
||||
fun deleteWeightEntry(entryId: Long) {
|
||||
val entries = getWeightEntries().filter { it.id != entryId }
|
||||
saveWeightEntries(entries)
|
||||
}
|
||||
|
||||
// Sleep
|
||||
fun saveSleepEntries(entries: List<SleepEntry>) {
|
||||
val json = gson.toJson(entries)
|
||||
prefs.edit().putString("sleep_entries", json).apply()
|
||||
}
|
||||
|
||||
fun getSleepEntries(): List<SleepEntry> {
|
||||
val json = prefs.getString("sleep_entries", null) ?: return emptyList()
|
||||
val type = object : TypeToken<List<SleepEntry>>() {}.type
|
||||
return gson.fromJson(json, type)
|
||||
}
|
||||
|
||||
fun addSleepEntry(entry: SleepEntry) {
|
||||
val entries = getSleepEntries().toMutableList()
|
||||
entries.add(entry.copy(id = System.currentTimeMillis()))
|
||||
saveSleepEntries(entries)
|
||||
}
|
||||
|
||||
fun deleteSleepEntry(entryId: Long) {
|
||||
val entries = getSleepEntries().filter { it.id != entryId }
|
||||
saveSleepEntries(entries)
|
||||
}
|
||||
|
||||
fun getSleepGoal(): Float = prefs.getFloat("sleep_goal", 8f)
|
||||
fun setSleepGoal(hours: Float) = prefs.edit().putFloat("sleep_goal", hours).apply()
|
||||
|
||||
// Journal
|
||||
fun saveJournalEntries(entries: List<HealthJournalEntry>) {
|
||||
val json = gson.toJson(entries)
|
||||
prefs.edit().putString("journal_entries", json).apply()
|
||||
}
|
||||
|
||||
fun getJournalEntries(): List<HealthJournalEntry> {
|
||||
val json = prefs.getString("journal_entries", null) ?: return emptyList()
|
||||
val type = object : TypeToken<List<HealthJournalEntry>>() {}.type
|
||||
return gson.fromJson(json, type)
|
||||
}
|
||||
|
||||
fun addJournalEntry(entry: HealthJournalEntry) {
|
||||
val entries = getJournalEntries().toMutableList()
|
||||
entries.add(entry.copy(id = System.currentTimeMillis()))
|
||||
saveJournalEntries(entries)
|
||||
}
|
||||
|
||||
fun deleteJournalEntry(entryId: Long) {
|
||||
val entries = getJournalEntries().filter { it.id != entryId }
|
||||
saveJournalEntries(entries)
|
||||
}
|
||||
|
||||
// Helper methods for today's data
|
||||
fun getTodayWaterIntake(): Int {
|
||||
val today = LocalDate.now()
|
||||
return getWaterIntakes().find { it.date == today }?.amountMl ?: 0
|
||||
}
|
||||
|
||||
fun getTodayCalories(): Int {
|
||||
val today = LocalDate.now()
|
||||
return getCalorieEntries()
|
||||
.filter { it.date == today }
|
||||
.sumOf { it.calories }
|
||||
}
|
||||
|
||||
fun getTodaySleep(): SleepEntry? {
|
||||
val today = LocalDate.now()
|
||||
return getSleepEntries().find { it.date == today }
|
||||
}
|
||||
|
||||
fun getLatestWeight(): WeightEntry? {
|
||||
return getWeightEntries().maxByOrNull { it.date }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.example.elva.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore by preferencesDataStore(name = "elva_prefs")
|
||||
|
||||
class SessionManager(private val context: Context) {
|
||||
|
||||
private val tokenKey = stringPreferencesKey(KEY_TOKEN)
|
||||
|
||||
val token: Flow<String?> = context.dataStore.data.map { it[tokenKey] }
|
||||
|
||||
suspend fun saveToken(value: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[tokenKey] = value
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearToken() {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs.remove(tokenKey)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_TOKEN = "auth_token"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.example.elva.data.models.auth
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Запрос на регистрацию
|
||||
*/
|
||||
@Serializable
|
||||
data class RegisterRequest(
|
||||
val username: String,
|
||||
val email: String,
|
||||
val password: String,
|
||||
@SerialName("full_name")
|
||||
val fullName: String? = null,
|
||||
val phone: String? = null,
|
||||
@SerialName("date_of_birth")
|
||||
val dateOfBirth: String? = null,
|
||||
val bio: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Запрос на вход
|
||||
*/
|
||||
@Serializable
|
||||
data class LoginRequest(
|
||||
val email: String? = null,
|
||||
val username: String? = null,
|
||||
val password: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Ответ от сервера при успешной аутентификации
|
||||
*/
|
||||
@Serializable
|
||||
data class AuthResponse(
|
||||
@SerialName("access_token")
|
||||
val accessToken: String,
|
||||
@SerialName("token_type")
|
||||
val tokenType: String = "bearer"
|
||||
)
|
||||
|
||||
/**
|
||||
* Информация о пользователе
|
||||
*/
|
||||
@Serializable
|
||||
data class UserInfo(
|
||||
val id: Int,
|
||||
val uuid: String? = null,
|
||||
val username: String,
|
||||
val email: String,
|
||||
@SerialName("first_name")
|
||||
val firstName: String? = null,
|
||||
@SerialName("last_name")
|
||||
val lastName: String? = null,
|
||||
val phone: String? = null,
|
||||
@SerialName("avatar_url")
|
||||
val avatarUrl: String? = null
|
||||
)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.example.elva.data.models.dashboard
|
||||
|
||||
data class DashboardSummary(
|
||||
val cycleDay: Int = 0,
|
||||
val nextPeriodDays: Int = 0,
|
||||
val water: Int = 0,
|
||||
val waterGoal: Int = 2000,
|
||||
val calories: Int = 0,
|
||||
val caloriesGoal: Int = 2000,
|
||||
val sleep: Float = 0f,
|
||||
val weight: Float = 0f
|
||||
)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.example.elva.data.models.health
|
||||
|
||||
enum class FlowIntensity(val displayName: String) {
|
||||
LIGHT("Легкая"),
|
||||
MEDIUM("Средняя"),
|
||||
HEAVY("Обильная");
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String?): FlowIntensity {
|
||||
return when (value?.uppercase()) {
|
||||
"LIGHT" -> LIGHT
|
||||
"MEDIUM" -> MEDIUM
|
||||
"HEAVY" -> HEAVY
|
||||
else -> MEDIUM
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.example.elva.data.models.health
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class WaterIntake(
|
||||
val id: Long = 0,
|
||||
val date: LocalDate,
|
||||
val amountMl: Int,
|
||||
val goalMl: Int = 2000
|
||||
)
|
||||
|
||||
data class CalorieEntry(
|
||||
val id: Long = 0,
|
||||
val date: LocalDate,
|
||||
val calories: Int,
|
||||
val mealType: MealType,
|
||||
val description: String = "",
|
||||
val timestamp: LocalDateTime = LocalDateTime.now()
|
||||
)
|
||||
|
||||
enum class MealType {
|
||||
BREAKFAST, LUNCH, DINNER, SNACK
|
||||
}
|
||||
|
||||
data class CalorieGoal(
|
||||
val dailyGoal: Int = 2000,
|
||||
val protein: Int = 0,
|
||||
val carbs: Int = 0,
|
||||
val fats: Int = 0
|
||||
)
|
||||
|
||||
data class WeightEntry(
|
||||
val id: Long = 0,
|
||||
val date: LocalDate,
|
||||
val weightKg: Float,
|
||||
val notes: String = ""
|
||||
)
|
||||
|
||||
data class SleepEntry(
|
||||
val id: Long = 0,
|
||||
val date: LocalDate,
|
||||
val sleepStart: LocalDateTime,
|
||||
val sleepEnd: LocalDateTime,
|
||||
val quality: SleepQuality = SleepQuality.GOOD,
|
||||
val notes: String = ""
|
||||
) {
|
||||
val durationHours: Float
|
||||
get() = java.time.Duration.between(sleepStart, sleepEnd).toMinutes() / 60f
|
||||
}
|
||||
|
||||
enum class SleepQuality {
|
||||
POOR, FAIR, GOOD, EXCELLENT
|
||||
}
|
||||
|
||||
data class HealthJournalEntry(
|
||||
val id: Long = 0,
|
||||
val date: LocalDate,
|
||||
val timestamp: LocalDateTime = LocalDateTime.now(),
|
||||
val type: JournalType,
|
||||
val title: String,
|
||||
val content: String,
|
||||
val mood: Int = 5 // 1-10 scale
|
||||
)
|
||||
|
||||
enum class JournalType {
|
||||
GENERAL, SYMPTOMS, MOOD, EXERCISE, MEDICATION, NOTES
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.example.elva.data.models.health
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
|
||||
// Информация о цикле
|
||||
data class CycleInfo(
|
||||
val averageCycleLength: Int = 28,
|
||||
val averagePeriodLength: Int = 5,
|
||||
val lastPeriodStart: LocalDate?,
|
||||
val nextPeriodPrediction: LocalDate?,
|
||||
val ovulationPrediction: LocalDate?,
|
||||
val currentCycleDay: Int = 1
|
||||
)
|
||||
|
||||
|
||||
// Потребление воды - используем из HealthMetrics.kt
|
||||
// data class WaterIntake уже определён в HealthMetrics.kt
|
||||
|
||||
// Калории - используем из HealthMetrics.kt
|
||||
// data class CalorieEntry уже определён в HealthMetrics.kt
|
||||
// data class CalorieGoal уже определён в HealthMetrics.kt
|
||||
// enum class MealType уже определён в HealthMetrics.kt
|
||||
|
||||
// Для совместимости с HealthViewModel
|
||||
data class CalorieIntake(
|
||||
val date: LocalDate,
|
||||
val calories: Int,
|
||||
val goal: Int = 2000,
|
||||
val meals: List<CalorieEntry> = emptyList()
|
||||
)
|
||||
|
||||
data class WaterIntakeOld(
|
||||
val date: LocalDate,
|
||||
val amount: Int, // в мл
|
||||
val goal: Int = 2000 // в мл
|
||||
)
|
||||
|
||||
// Вес - используем из HealthMetrics.kt
|
||||
// data class WeightEntry уже определён в HealthMetrics.kt
|
||||
|
||||
// Сон - используем из HealthMetrics.kt
|
||||
// data class SleepEntry уже определён в HealthMetrics.kt
|
||||
// enum class SleepQuality уже определён в HealthMetrics.kt
|
||||
|
||||
// Цели здоровья
|
||||
data class HealthGoals(
|
||||
val waterGoal: Int = 2000, // мл
|
||||
val calorieGoal: Int = 2000, // ккал
|
||||
val sleepGoal: Float = 8f, // часы
|
||||
val weightGoal: Float? = null // кг
|
||||
)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.example.elva.data.models.health
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
data class Period(
|
||||
val id: Long = 0,
|
||||
val startDate: LocalDate,
|
||||
val endDate: LocalDate,
|
||||
val flow: FlowIntensity = FlowIntensity.MEDIUM,
|
||||
val symptoms: List<Symptom> = emptyList(),
|
||||
val notes: String = ""
|
||||
)
|
||||
|
||||
enum class FlowIntensity {
|
||||
LIGHT, MEDIUM, HEAVY
|
||||
}
|
||||
|
||||
enum class Symptom {
|
||||
CRAMPS, HEADACHE, MOOD_SWINGS, BLOATING, FATIGUE, BACK_PAIN, NAUSEA
|
||||
}
|
||||
|
||||
data class CycleStats(
|
||||
val averageCycleLength: Int,
|
||||
val averagePeriodLength: Int,
|
||||
val nextPeriodStart: LocalDate?,
|
||||
val nextOvulation: LocalDate?,
|
||||
val fertileWindowStart: LocalDate?,
|
||||
val fertileWindowEnd: LocalDate?
|
||||
)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.example.elva.data.models.profile
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Профиль пользователя
|
||||
*/
|
||||
@Serializable
|
||||
data class UserProfile(
|
||||
val id: Int,
|
||||
val uuid: String,
|
||||
val username: String,
|
||||
val email: String,
|
||||
val phone: String? = null,
|
||||
@SerialName("first_name")
|
||||
val firstName: String? = null,
|
||||
@SerialName("last_name")
|
||||
val lastName: String? = null,
|
||||
@SerialName("date_of_birth")
|
||||
val dateOfBirth: String? = null,
|
||||
val bio: String? = null,
|
||||
@SerialName("avatar_url")
|
||||
val avatarUrl: String? = null,
|
||||
@SerialName("location_sharing_enabled")
|
||||
val locationSharingEnabled: Boolean = true,
|
||||
@SerialName("emergency_notifications_enabled")
|
||||
val emergencyNotificationsEnabled: Boolean = true,
|
||||
@SerialName("push_notifications_enabled")
|
||||
val pushNotificationsEnabled: Boolean = true,
|
||||
@SerialName("email_verified")
|
||||
val emailVerified: Boolean = false,
|
||||
@SerialName("phone_verified")
|
||||
val phoneVerified: Boolean = false,
|
||||
@SerialName("is_active")
|
||||
val isActive: Boolean = true,
|
||||
@SerialName("created_at")
|
||||
val createdAt: String,
|
||||
@SerialName("updated_at")
|
||||
val updatedAt: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Запрос на обновление профиля
|
||||
*/
|
||||
@Serializable
|
||||
data class ProfileUpdateRequest(
|
||||
@SerialName("first_name")
|
||||
val firstName: String? = null,
|
||||
@SerialName("last_name")
|
||||
val lastName: String? = null,
|
||||
val phone: String? = null,
|
||||
@SerialName("date_of_birth")
|
||||
val dateOfBirth: String? = null,
|
||||
val bio: String? = null,
|
||||
@SerialName("location_sharing_enabled")
|
||||
val locationSharingEnabled: Boolean? = null,
|
||||
@SerialName("emergency_notifications_enabled")
|
||||
val emergencyNotificationsEnabled: Boolean? = null,
|
||||
@SerialName("push_notifications_enabled")
|
||||
val pushNotificationsEnabled: Boolean? = null,
|
||||
@SerialName("emergency_contact_1_name")
|
||||
val emergencyContact1Name: String? = null,
|
||||
@SerialName("emergency_contact_1_phone")
|
||||
val emergencyContact1Phone: String? = null,
|
||||
@SerialName("emergency_contact_2_name")
|
||||
val emergencyContact2Name: String? = null,
|
||||
@SerialName("emergency_contact_2_phone")
|
||||
val emergencyContact2Phone: String? = null
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.example.elva.data.models.user
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UserProfile(
|
||||
val id: Int,
|
||||
val uuid: String,
|
||||
val username: String? = null,
|
||||
val email: String,
|
||||
val phone: String? = null,
|
||||
@SerialName("phone_number")
|
||||
val phoneNumber: String? = null,
|
||||
@SerialName("first_name")
|
||||
val firstName: String? = null,
|
||||
@SerialName("last_name")
|
||||
val lastName: String? = null,
|
||||
@SerialName("full_name")
|
||||
val fullName: String? = null,
|
||||
@SerialName("date_of_birth")
|
||||
val dateOfBirth: String? = null,
|
||||
val bio: String? = null,
|
||||
@SerialName("avatar_url")
|
||||
val avatarUrl: String? = null,
|
||||
@SerialName("emergency_contact_1_name")
|
||||
val emergencyContact1Name: String? = null,
|
||||
@SerialName("emergency_contact_1_phone")
|
||||
val emergencyContact1Phone: String? = null,
|
||||
@SerialName("emergency_contact_2_name")
|
||||
val emergencyContact2Name: String? = null,
|
||||
@SerialName("emergency_contact_2_phone")
|
||||
val emergencyContact2Phone: String? = null,
|
||||
@SerialName("location_sharing_enabled")
|
||||
val locationSharingEnabled: Boolean = true,
|
||||
@SerialName("emergency_notifications_enabled")
|
||||
val emergencyNotificationsEnabled: Boolean = true,
|
||||
@SerialName("push_notifications_enabled")
|
||||
val pushNotificationsEnabled: Boolean = true,
|
||||
@SerialName("email_verified")
|
||||
val emailVerified: Boolean = false,
|
||||
@SerialName("phone_verified")
|
||||
val phoneVerified: Boolean = false,
|
||||
@SerialName("is_active")
|
||||
val isActive: Boolean = true
|
||||
)
|
||||
|
||||
11
app/src/main/java/com/example/elva/data/remote/ApiConfig.kt
Normal file
11
app/src/main/java/com/example/elva/data/remote/ApiConfig.kt
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.example.elva.data.remote
|
||||
|
||||
object ApiConfig {
|
||||
const val USER_SERVICE_BASE_URL = "http://10.0.2.2:8001"
|
||||
const val EMERGENCY_SERVICE_BASE_URL = "http://10.0.2.2:8002"
|
||||
const val LOCATION_SERVICE_BASE_URL = "http://10.0.2.2:8003"
|
||||
const val CALENDAR_SERVICE_BASE_URL = "http://10.0.2.2:8004"
|
||||
const val NOTIFICATION_SERVICE_BASE_URL = "http://10.0.2.2:8005"
|
||||
const val NUTRITION_SERVICE_BASE_URL = "http://10.0.2.2:8006"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.example.elva.data.remote
|
||||
|
||||
import com.example.elva.data.remote.model.CalendarEntryDto
|
||||
import com.example.elva.data.remote.model.CreateCalendarEntryRequest
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.POST
|
||||
|
||||
interface CalendarApi {
|
||||
@GET("/api/v1/calendar/entries")
|
||||
suspend fun getEntries(@Header("Authorization") token: String): List<CalendarEntryDto>
|
||||
|
||||
@POST("/api/v1/calendar/entries")
|
||||
suspend fun addEntry(
|
||||
@Header("Authorization") token: String,
|
||||
@Body request: CreateCalendarEntryRequest
|
||||
): CalendarEntryDto
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.example.elva.data.remote
|
||||
|
||||
import com.example.elva.data.remote.model.ContactDto
|
||||
import com.example.elva.data.remote.model.CreateContactRequest
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.POST
|
||||
|
||||
interface ContactsApi {
|
||||
@GET("/api/v1/users/me/emergency-contacts")
|
||||
suspend fun getContacts(
|
||||
@Header("Authorization") token: String
|
||||
): List<ContactDto>
|
||||
|
||||
@POST("/api/v1/users/me/emergency-contacts")
|
||||
suspend fun addContact(
|
||||
@Header("Authorization") token: String,
|
||||
@Body request: CreateContactRequest
|
||||
): ContactDto
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.example.elva.data.remote
|
||||
|
||||
import com.example.elva.data.remote.model.AlertDto
|
||||
import com.example.elva.data.remote.model.CreateAlertRequest
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.POST
|
||||
|
||||
interface EmergencyApi {
|
||||
@POST("/api/v1/alert")
|
||||
suspend fun createAlert(
|
||||
@Header("Authorization") token: String,
|
||||
@Body request: CreateAlertRequest
|
||||
): AlertDto
|
||||
|
||||
@GET("/api/v1/alerts/my")
|
||||
suspend fun getMyAlerts(
|
||||
@Header("Authorization") token: String
|
||||
): List<AlertDto>
|
||||
|
||||
@GET("/api/v1/alerts/responses")
|
||||
suspend fun getResponses(
|
||||
@Header("Authorization") token: String
|
||||
): List<AlertDto>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.example.elva.data.remote
|
||||
|
||||
import com.example.elva.data.remote.model.NearbyUserDto
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface LocationApi {
|
||||
@GET("/api/v1/nearby-users")
|
||||
suspend fun getNearbyUsers(
|
||||
@Header("Authorization") token: String,
|
||||
@Query("latitude") latitude: Double,
|
||||
@Query("longitude") longitude: Double
|
||||
): List<NearbyUserDto>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.example.elva.data.remote
|
||||
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
|
||||
interface NotificationApi {
|
||||
@GET("/api/v1/notifications")
|
||||
suspend fun getNotifications(@Header("Authorization") token: String): List<String>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.example.elva.data.remote
|
||||
|
||||
import com.example.elva.data.remote.model.NutritionEntryDto
|
||||
import com.example.elva.data.remote.model.NutritionEntryRequest
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.POST
|
||||
|
||||
interface NutritionApi {
|
||||
@POST("/api/v1/nutrition/entries")
|
||||
suspend fun addEntry(
|
||||
@Header("Authorization") token: String,
|
||||
@Body request: NutritionEntryRequest
|
||||
): NutritionEntryDto
|
||||
|
||||
@GET("/api/v1/nutrition/entries")
|
||||
suspend fun getEntries(
|
||||
@Header("Authorization") token: String
|
||||
): List<NutritionEntryDto>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// DEPRECATED: Этот файл удален. Используйте com.example.elva.data.api.AuthApiService
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.example.elva.data.remote.api
|
||||
|
||||
import com.example.elva.data.remote.model.emergency.AlertResponse
|
||||
import com.example.elva.data.remote.model.emergency.AlertResponseItem
|
||||
import com.example.elva.data.remote.model.emergency.CreateAlertRequest
|
||||
import com.example.elva.data.remote.model.emergency.NearbyAlertsResponse
|
||||
import com.example.elva.data.remote.model.emergency.RespondToAlertRequest
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface EmergencyApiService {
|
||||
|
||||
@POST("api/v1/alert")
|
||||
suspend fun createAlert(@Body request: CreateAlertRequest): AlertResponse
|
||||
|
||||
@POST("api/v1/alert/{alert_id}/respond")
|
||||
suspend fun respondToAlert(
|
||||
@Path("alert_id") alertId: Int,
|
||||
@Body request: RespondToAlertRequest
|
||||
): AlertResponseItem
|
||||
|
||||
@GET("api/v1/alerts/nearby")
|
||||
suspend fun getNearbyAlerts(
|
||||
@Query("latitude") latitude: Double,
|
||||
@Query("longitude") longitude: Double,
|
||||
@Query("radius_km") radiusKm: Double = 5.0
|
||||
): NearbyAlertsResponse
|
||||
|
||||
@PUT("api/v1/alert/{alert_id}/resolve")
|
||||
suspend fun resolveAlert(@Path("alert_id") alertId: Int): AlertResponse
|
||||
|
||||
@GET("api/v1/alert/{alert_id}")
|
||||
suspend fun getAlertDetails(@Path("alert_id") alertId: Int): AlertResponse
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.example.elva.data.remote.api
|
||||
|
||||
import com.example.elva.data.remote.model.profile.ProfileUpdateRequest
|
||||
import com.example.elva.data.remote.model.profile.UserProfile
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.PUT
|
||||
|
||||
interface ProfileApiService {
|
||||
|
||||
@GET("api/v1/profile")
|
||||
suspend fun getProfile(): UserProfile
|
||||
|
||||
@PUT("api/v1/profile")
|
||||
suspend fun updateProfile(@Body request: ProfileUpdateRequest): UserProfile
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.example.elva.data.remote.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CreateCalendarEntryRequest(
|
||||
@SerialName("entry_date") val entryDate: String,
|
||||
@SerialName("entry_type") val entryType: String,
|
||||
val mood: String? = null,
|
||||
val notes: String? = null,
|
||||
@SerialName("period_symptoms") val periodSymptoms: String? = null,
|
||||
@SerialName("flow_intensity") val flowIntensity: String? = null,
|
||||
@SerialName("energy_level") val energyLevel: String? = null,
|
||||
@SerialName("sleep_hours") val sleepHours: String? = null,
|
||||
val symptoms: String? = null,
|
||||
val medications: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CalendarEntryDto(
|
||||
val id: String? = null,
|
||||
@SerialName("entry_date") val entryDate: String? = null,
|
||||
@SerialName("entry_type") val entryType: String? = null,
|
||||
val mood: String? = null,
|
||||
val notes: String? = null
|
||||
)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.example.elva.data.remote.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CreateContactRequest(
|
||||
val name: String,
|
||||
@SerialName("phone_number") val phoneNumber: String,
|
||||
val relation: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ContactDto(
|
||||
val id: String? = null,
|
||||
val name: String? = null,
|
||||
@SerialName("phone_number") val phoneNumber: String? = null,
|
||||
val relation: String? = null
|
||||
)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.example.elva.data.remote.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CreateAlertRequest(
|
||||
@SerialName("alert_type") val alertType: String,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val address: String,
|
||||
val message: String,
|
||||
@SerialName("contact_emergency_services") val contactEmergencyServices: Boolean = true,
|
||||
@SerialName("notify_emergency_contacts") val notifyEmergencyContacts: Boolean = true
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AlertDto(
|
||||
val id: String? = null,
|
||||
@SerialName("alert_type") val alertType: String? = null,
|
||||
val address: String? = null,
|
||||
val message: String? = null,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
@SerialName("is_resolved") val isResolved: Boolean? = null
|
||||
)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.example.elva.data.remote.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class NearbyUserDto(
|
||||
val id: String? = null,
|
||||
@SerialName("full_name") val fullName: String? = null,
|
||||
val distance: Double? = null,
|
||||
@SerialName("distance_km") val distanceKm: Double? = null
|
||||
)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.example.elva.data.remote.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class NutritionEntryRequest(
|
||||
@SerialName("food_name") val foodName: String,
|
||||
val quantity: Int,
|
||||
val unit: String,
|
||||
val calories: Int,
|
||||
val date: String,
|
||||
val notes: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NutritionEntryDto(
|
||||
val id: String? = null,
|
||||
@SerialName("food_name") val foodName: String? = null,
|
||||
val date: String? = null,
|
||||
val notes: String? = null
|
||||
)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.example.elva.data.remote.model.emergency
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// ==================== REST API MODELS ====================
|
||||
|
||||
@Serializable
|
||||
data class CreateAlertRequest(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
@SerialName("alert_type")
|
||||
val alertType: String,
|
||||
val message: String? = null,
|
||||
val address: String? = null,
|
||||
@SerialName("contact_emergency_services")
|
||||
val contactEmergencyServices: Boolean = false,
|
||||
@SerialName("notify_emergency_contacts")
|
||||
val notifyEmergencyContacts: Boolean = true
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AlertResponse(
|
||||
val id: Int,
|
||||
@SerialName("user_id")
|
||||
val userId: Int,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
@SerialName("alert_type")
|
||||
val alertType: String,
|
||||
val message: String? = null,
|
||||
val address: String? = null,
|
||||
val status: String,
|
||||
@SerialName("created_at")
|
||||
val createdAt: String,
|
||||
@SerialName("updated_at")
|
||||
val updatedAt: String? = null,
|
||||
@SerialName("resolved_at")
|
||||
val resolvedAt: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RespondToAlertRequest(
|
||||
@SerialName("response_type")
|
||||
val responseType: String,
|
||||
val message: String? = null,
|
||||
@SerialName("eta_minutes")
|
||||
val etaMinutes: Int? = null,
|
||||
@SerialName("location_sharing")
|
||||
val locationSharing: Boolean = false
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AlertResponseItem(
|
||||
val id: Int,
|
||||
@SerialName("alert_id")
|
||||
val alertId: Int,
|
||||
@SerialName("responder_id")
|
||||
val responderId: Int,
|
||||
@SerialName("response_type")
|
||||
val responseType: String,
|
||||
val message: String? = null,
|
||||
@SerialName("eta_minutes")
|
||||
val etaMinutes: Int? = null,
|
||||
@SerialName("created_at")
|
||||
val createdAt: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NearbyAlertsResponse(
|
||||
val alerts: List<AlertResponse>,
|
||||
val count: Int
|
||||
)
|
||||
|
||||
// ==================== WEBSOCKET MODELS ====================
|
||||
|
||||
@Serializable
|
||||
data class WebSocketMessage(
|
||||
val type: String,
|
||||
@SerialName("user_id")
|
||||
val userId: Int? = null,
|
||||
val message: String? = null,
|
||||
val timestamp: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class EmergencyAlertWS(
|
||||
val type: String,
|
||||
@SerialName("alert_id")
|
||||
val alertId: Int,
|
||||
@SerialName("alert_type")
|
||||
val alertType: String,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val address: String? = null,
|
||||
val message: String? = null,
|
||||
@SerialName("user_name")
|
||||
val userName: String? = null,
|
||||
@SerialName("user_phone")
|
||||
val userPhone: String? = null,
|
||||
@SerialName("created_at")
|
||||
val createdAt: String,
|
||||
@SerialName("distance_km")
|
||||
val distanceKm: Double? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AlertUpdateWS(
|
||||
val type: String,
|
||||
@SerialName("alert_id")
|
||||
val alertId: Int,
|
||||
val status: String,
|
||||
@SerialName("responded_users_count")
|
||||
val respondedUsersCount: Int,
|
||||
@SerialName("updated_at")
|
||||
val updatedAt: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AlertResponseWS(
|
||||
val type: String,
|
||||
@SerialName("alert_id")
|
||||
val alertId: Int,
|
||||
@SerialName("responder_id")
|
||||
val responderId: Int,
|
||||
@SerialName("responder_name")
|
||||
val responderName: String? = null,
|
||||
@SerialName("response_type")
|
||||
val responseType: String,
|
||||
val message: String? = null,
|
||||
@SerialName("eta_minutes")
|
||||
val etaMinutes: Int? = null,
|
||||
@SerialName("created_at")
|
||||
val createdAt: String
|
||||
)
|
||||
|
||||
// ==================== ENUMS ====================
|
||||
|
||||
object AlertTypes {
|
||||
const val GENERAL = "general"
|
||||
const val MEDICAL = "medical"
|
||||
const val VIOLENCE = "violence"
|
||||
const val HARASSMENT = "harassment"
|
||||
const val UNSAFE_AREA = "unsafe_area"
|
||||
const val ACCIDENT = "accident"
|
||||
const val FIRE = "fire"
|
||||
const val NATURAL_DISASTER = "natural_disaster"
|
||||
}
|
||||
|
||||
object ResponseTypes {
|
||||
const val HELP_ON_WAY = "help_on_way"
|
||||
const val CONTACTED_AUTHORITIES = "contacted_authorities"
|
||||
const val SAFE_NOW = "safe_now"
|
||||
const val FALSE_ALARM = "false_alarm"
|
||||
const val INVESTIGATING = "investigating"
|
||||
const val RESOLVED = "resolved"
|
||||
}
|
||||
|
||||
object AlertStatuses {
|
||||
const val ACTIVE = "active"
|
||||
const val PENDING = "pending"
|
||||
const val RESOLVED = "resolved"
|
||||
const val CANCELLED = "cancelled"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.example.elva.data.remote.model.profile
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UserProfile(
|
||||
val id: Int,
|
||||
val uuid: String,
|
||||
val username: String,
|
||||
val email: String,
|
||||
val phone: String? = null,
|
||||
@SerialName("first_name")
|
||||
val firstName: String? = null,
|
||||
@SerialName("last_name")
|
||||
val lastName: String? = null,
|
||||
@SerialName("date_of_birth")
|
||||
val dateOfBirth: String? = null,
|
||||
val bio: String? = null,
|
||||
@SerialName("avatar_url")
|
||||
val avatarUrl: String? = null,
|
||||
@SerialName("location_sharing_enabled")
|
||||
val locationSharingEnabled: Boolean = true,
|
||||
@SerialName("emergency_notifications_enabled")
|
||||
val emergencyNotificationsEnabled: Boolean = true,
|
||||
@SerialName("push_notifications_enabled")
|
||||
val pushNotificationsEnabled: Boolean = true,
|
||||
@SerialName("email_verified")
|
||||
val emailVerified: Boolean = false,
|
||||
@SerialName("phone_verified")
|
||||
val phoneVerified: Boolean = false,
|
||||
@SerialName("is_active")
|
||||
val isActive: Boolean = true,
|
||||
@SerialName("created_at")
|
||||
val createdAt: String,
|
||||
@SerialName("updated_at")
|
||||
val updatedAt: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ProfileUpdateRequest(
|
||||
@SerialName("first_name")
|
||||
val firstName: String? = null,
|
||||
@SerialName("last_name")
|
||||
val lastName: String? = null,
|
||||
val phone: String? = null,
|
||||
@SerialName("date_of_birth")
|
||||
val dateOfBirth: String? = null,
|
||||
val bio: String? = null,
|
||||
@SerialName("location_sharing_enabled")
|
||||
val locationSharingEnabled: Boolean? = null,
|
||||
@SerialName("emergency_notifications_enabled")
|
||||
val emergencyNotificationsEnabled: Boolean? = null,
|
||||
@SerialName("push_notifications_enabled")
|
||||
val pushNotificationsEnabled: Boolean? = null
|
||||
)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.example.elva.data.repository
|
||||
|
||||
import com.example.elva.data.api.AuthApiService
|
||||
import com.example.elva.data.models.auth.AuthResponse
|
||||
import com.example.elva.data.models.auth.LoginRequest
|
||||
import com.example.elva.data.models.auth.RegisterRequest
|
||||
import com.example.elva.util.AuthManager
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AuthRepository @Inject constructor(
|
||||
private val authApi: AuthApiService,
|
||||
private val authManager: AuthManager
|
||||
) {
|
||||
|
||||
suspend fun register(
|
||||
username: String,
|
||||
email: String,
|
||||
password: String,
|
||||
fullName: String? = null,
|
||||
phone: String? = null
|
||||
): Result<AuthResponse> {
|
||||
return try {
|
||||
val request = RegisterRequest(
|
||||
username = username,
|
||||
email = email,
|
||||
password = password,
|
||||
fullName = fullName,
|
||||
phone = phone
|
||||
)
|
||||
val response = authApi.register(request)
|
||||
|
||||
// Сохраняем токен
|
||||
authManager.saveAuthData(
|
||||
token = response.accessToken,
|
||||
userId = 0, // ID будет получен позже через запрос профиля
|
||||
email = email,
|
||||
username = username
|
||||
)
|
||||
|
||||
Result.success(response)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun login(
|
||||
email: String? = null,
|
||||
username: String? = null,
|
||||
password: String
|
||||
): Result<AuthResponse> {
|
||||
return try {
|
||||
android.util.Log.d("AuthRepository", "login() called with: email=$email, username=$username")
|
||||
|
||||
val request = LoginRequest(
|
||||
email = email,
|
||||
username = username,
|
||||
password = password
|
||||
)
|
||||
|
||||
val response = authApi.login(request)
|
||||
android.util.Log.d("AuthRepository", "login response received: token=${response.accessToken.take(20)}...")
|
||||
|
||||
// Сохраняем токен и данные пользователя
|
||||
val savedEmail = email ?: username ?: ""
|
||||
val savedUsername = username ?: email ?: ""
|
||||
|
||||
android.util.Log.d("AuthRepository", "Saving auth data: email=$savedEmail, username=$savedUsername")
|
||||
|
||||
authManager.saveAuthData(
|
||||
token = response.accessToken,
|
||||
userId = 0, // ID будет получен позже через запрос профиля
|
||||
email = savedEmail,
|
||||
username = savedUsername
|
||||
)
|
||||
|
||||
android.util.Log.d("AuthRepository", "Auth data saved successfully")
|
||||
|
||||
|
||||
Result.success(response)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("AuthRepository", "login failed", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
authManager.logout()
|
||||
}
|
||||
|
||||
fun isLoggedIn(): Boolean {
|
||||
return authManager.isLoggedIn()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.example.elva.data.repository
|
||||
|
||||
import com.example.elva.data.models.dashboard.DashboardSummary
|
||||
import com.example.elva.data.local.HealthDataStore
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class DashboardRepository @Inject constructor(
|
||||
private val healthDataStore: HealthDataStore
|
||||
) {
|
||||
suspend fun getDashboardSummary(): DashboardSummary {
|
||||
val water = healthDataStore.getTodayWaterIntake()
|
||||
val waterGoal = healthDataStore.getWaterGoal()
|
||||
val calories = healthDataStore.getTodayCalories()
|
||||
val caloriesGoal = healthDataStore.getCaloriesGoal()
|
||||
val sleepEntry = healthDataStore.getTodaySleep()
|
||||
val weightEntry = healthDataStore.getLatestWeight()
|
||||
|
||||
return DashboardSummary(
|
||||
cycleDay = 0, // TODO: Calculate from period data
|
||||
nextPeriodDays = 0, // TODO: Calculate from period data
|
||||
water = water,
|
||||
waterGoal = waterGoal,
|
||||
calories = calories,
|
||||
caloriesGoal = caloriesGoal,
|
||||
sleep = sleepEntry?.hours ?: 0f,
|
||||
weight = weightEntry?.weight ?: 0f
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.example.elva.data.repository
|
||||
|
||||
import com.example.elva.data.local.HealthDataStore
|
||||
import com.example.elva.data.models.health.*
|
||||
import java.time.LocalDate
|
||||
import java.time.temporal.ChronoUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class HealthRepository @Inject constructor(
|
||||
private val dataStore: HealthDataStore
|
||||
) {
|
||||
|
||||
// Period tracking
|
||||
fun getPeriods(): List<Period> = dataStore.getPeriods()
|
||||
|
||||
fun addPeriod(period: Period) = dataStore.addPeriod(period)
|
||||
|
||||
fun updatePeriod(period: Period) = dataStore.updatePeriod(period)
|
||||
|
||||
fun deletePeriod(periodId: Long) = dataStore.deletePeriod(periodId)
|
||||
|
||||
fun calculateCycleStats(): CycleStats {
|
||||
val periods = getPeriods().sortedBy { it.startDate }
|
||||
|
||||
if (periods.size < 2) {
|
||||
return CycleStats(
|
||||
averageCycleLength = 28,
|
||||
averagePeriodLength = 5,
|
||||
nextPeriodStart = periods.lastOrNull()?.startDate?.plusDays(28),
|
||||
nextOvulation = periods.lastOrNull()?.startDate?.plusDays(14),
|
||||
fertileWindowStart = periods.lastOrNull()?.startDate?.plusDays(12),
|
||||
fertileWindowEnd = periods.lastOrNull()?.startDate?.plusDays(16)
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate average cycle length
|
||||
val cycleLengths = mutableListOf<Int>()
|
||||
for (i in 0 until periods.size - 1) {
|
||||
val days = ChronoUnit.DAYS.between(
|
||||
periods[i].startDate,
|
||||
periods[i + 1].startDate
|
||||
).toInt()
|
||||
cycleLengths.add(days)
|
||||
}
|
||||
val avgCycleLength = if (cycleLengths.isNotEmpty()) {
|
||||
cycleLengths.average().toInt()
|
||||
} else 28
|
||||
|
||||
// Calculate average period length
|
||||
val periodLengths = periods.map {
|
||||
ChronoUnit.DAYS.between(it.startDate, it.endDate).toInt() + 1
|
||||
}
|
||||
val avgPeriodLength = periodLengths.average().toInt()
|
||||
|
||||
// Predict next period
|
||||
val lastPeriod = periods.last()
|
||||
val nextPeriodStart = lastPeriod.startDate.plusDays(avgCycleLength.toLong())
|
||||
|
||||
// Calculate ovulation (typically 14 days before next period)
|
||||
val nextOvulation = nextPeriodStart.minusDays(14)
|
||||
|
||||
// Fertile window (5 days before ovulation to 1 day after)
|
||||
val fertileWindowStart = nextOvulation.minusDays(5)
|
||||
val fertileWindowEnd = nextOvulation.plusDays(1)
|
||||
|
||||
return CycleStats(
|
||||
averageCycleLength = avgCycleLength,
|
||||
averagePeriodLength = avgPeriodLength,
|
||||
nextPeriodStart = nextPeriodStart,
|
||||
nextOvulation = nextOvulation,
|
||||
fertileWindowStart = fertileWindowStart,
|
||||
fertileWindowEnd = fertileWindowEnd
|
||||
)
|
||||
}
|
||||
|
||||
fun isPeriodDay(date: LocalDate): Boolean {
|
||||
return getPeriods().any { period ->
|
||||
!date.isBefore(period.startDate) && !date.isAfter(period.endDate)
|
||||
}
|
||||
}
|
||||
|
||||
fun isFertileDay(date: LocalDate): Boolean {
|
||||
val stats = calculateCycleStats()
|
||||
return stats.fertileWindowStart?.let { start ->
|
||||
stats.fertileWindowEnd?.let { end ->
|
||||
!date.isBefore(start) && !date.isAfter(end)
|
||||
}
|
||||
} ?: false
|
||||
}
|
||||
|
||||
// Water intake
|
||||
fun getWaterIntakes(): List<WaterIntake> = dataStore.getWaterIntakes()
|
||||
|
||||
fun getWaterIntakeForDate(date: LocalDate): WaterIntake? {
|
||||
return getWaterIntakes().find { it.date == date }
|
||||
}
|
||||
|
||||
fun addWater(date: LocalDate, amountMl: Int) = dataStore.addWater(date, amountMl)
|
||||
|
||||
fun getWaterGoal(): Int = dataStore.getWaterGoal()
|
||||
|
||||
fun setWaterGoal(goalMl: Int) = dataStore.setWaterGoal(goalMl)
|
||||
|
||||
// Calories
|
||||
fun getCalorieEntries(): List<CalorieEntry> = dataStore.getCalorieEntries()
|
||||
|
||||
fun getCalorieEntriesForDate(date: LocalDate): List<CalorieEntry> {
|
||||
return getCalorieEntries().filter { it.date == date }
|
||||
}
|
||||
|
||||
fun addCalorieEntry(entry: CalorieEntry) = dataStore.addCalorieEntry(entry)
|
||||
|
||||
fun deleteCalorieEntry(entryId: Long) = dataStore.deleteCalorieEntry(entryId)
|
||||
|
||||
fun getCalorieGoal(): CalorieGoal = dataStore.getCalorieGoal()
|
||||
|
||||
fun setCalorieGoal(goal: CalorieGoal) = dataStore.setCalorieGoal(goal)
|
||||
|
||||
fun getTotalCaloriesForDate(date: LocalDate): Int {
|
||||
return getCalorieEntriesForDate(date).sumOf { it.calories }
|
||||
}
|
||||
|
||||
// Weight
|
||||
fun getWeightEntries(): List<WeightEntry> = dataStore.getWeightEntries()
|
||||
|
||||
fun addWeightEntry(entry: WeightEntry) = dataStore.addWeightEntry(entry)
|
||||
|
||||
fun deleteWeightEntry(entryId: Long) = dataStore.deleteWeightEntry(entryId)
|
||||
|
||||
fun getLatestWeight(): WeightEntry? {
|
||||
return getWeightEntries().maxByOrNull { it.date }
|
||||
}
|
||||
|
||||
// Sleep
|
||||
fun getSleepEntries(): List<SleepEntry> = dataStore.getSleepEntries()
|
||||
|
||||
fun getSleepEntryForDate(date: LocalDate): SleepEntry? {
|
||||
return getSleepEntries().find { it.date == date }
|
||||
}
|
||||
|
||||
fun addSleepEntry(entry: SleepEntry) = dataStore.addSleepEntry(entry)
|
||||
|
||||
fun deleteSleepEntry(entryId: Long) = dataStore.deleteSleepEntry(entryId)
|
||||
|
||||
fun getSleepGoal(): Float = dataStore.getSleepGoal()
|
||||
|
||||
fun setSleepGoal(hours: Float) = dataStore.setSleepGoal(hours)
|
||||
|
||||
fun getAverageSleepHours(days: Int = 7): Float {
|
||||
val entries = getSleepEntries()
|
||||
.sortedByDescending { it.date }
|
||||
.take(days)
|
||||
|
||||
return if (entries.isEmpty()) 0f
|
||||
else entries.map { it.durationHours }.average().toFloat()
|
||||
}
|
||||
|
||||
// Additional helper methods for ViewModel
|
||||
fun getCycleInfo(): CycleInfo {
|
||||
val periods = getPeriods().sortedBy { it.startDate }
|
||||
val lastPeriod = periods.lastOrNull()
|
||||
val stats = calculateCycleStats()
|
||||
|
||||
return CycleInfo(
|
||||
averageCycleLength = stats.averageCycleLength,
|
||||
averagePeriodLength = stats.averagePeriodLength,
|
||||
lastPeriodStart = lastPeriod?.startDate,
|
||||
nextPeriodPrediction = stats.nextPeriodStart,
|
||||
ovulationPrediction = stats.nextOvulation,
|
||||
currentCycleDay = lastPeriod?.let {
|
||||
java.time.temporal.ChronoUnit.DAYS.between(it.startDate, LocalDate.now()).toInt() + 1
|
||||
} ?: 1
|
||||
)
|
||||
}
|
||||
|
||||
// Journal
|
||||
fun getJournalEntries(): List<HealthJournalEntry> = dataStore.getJournalEntries()
|
||||
|
||||
fun getJournalEntriesForDate(date: LocalDate): List<HealthJournalEntry> {
|
||||
return getJournalEntries().filter { it.date == date }
|
||||
}
|
||||
|
||||
fun addJournalEntry(entry: HealthJournalEntry) = dataStore.addJournalEntry(entry)
|
||||
|
||||
fun deleteJournalEntry(entryId: Long) = dataStore.deleteJournalEntry(entryId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.example.elva.data.repository
|
||||
|
||||
import com.example.elva.data.api.ProfileApiService
|
||||
import com.example.elva.data.models.profile.ProfileUpdateRequest
|
||||
import com.example.elva.data.models.profile.UserProfile
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ProfileRepository @Inject constructor(
|
||||
private val profileApi: ProfileApiService
|
||||
) {
|
||||
|
||||
suspend fun getProfile(): Result<UserProfile> {
|
||||
return try {
|
||||
val profile = profileApi.getProfile()
|
||||
Result.success(profile)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateProfile(request: ProfileUpdateRequest): Result<UserProfile> {
|
||||
return try {
|
||||
val updatedProfile = profileApi.updateProfile(request)
|
||||
Result.success(updatedProfile)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.example.elva.data.repository
|
||||
|
||||
import com.example.elva.data.api.UserApiService
|
||||
import com.example.elva.data.models.user.UserProfile
|
||||
import com.example.elva.util.AuthManager
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class UserRepository @Inject constructor(
|
||||
private val userApiService: UserApiService,
|
||||
private val authManager: AuthManager
|
||||
) {
|
||||
|
||||
suspend fun getCurrentUser(): Result<UserProfile> {
|
||||
return try {
|
||||
val token = authManager.getAccessToken()
|
||||
if (token.isNullOrEmpty()) {
|
||||
android.util.Log.w("UserRepository", "No auth token found")
|
||||
return Result.failure(Exception("No auth token found"))
|
||||
}
|
||||
|
||||
android.util.Log.d("UserRepository", "Loading user profile with token: ${token.take(20)}...")
|
||||
val profile = userApiService.getCurrentUser("Bearer $token")
|
||||
android.util.Log.d("UserRepository", "Profile loaded: id=${profile.id}, username=${profile.username}")
|
||||
|
||||
// Сохраняем полученные данные пользователя
|
||||
authManager.saveUserId(profile.id)
|
||||
profile.uuid?.let { authManager.saveUserUuid(it) }
|
||||
|
||||
android.util.Log.d("UserRepository", "User data saved to AuthManager")
|
||||
|
||||
Result.success(profile)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("UserRepository", "Error loading user profile", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package com.example.elva.data.websocket
|
||||
|
||||
import com.example.elva.data.remote.model.emergency.AlertResponseWS
|
||||
import com.example.elva.data.remote.model.emergency.AlertUpdateWS
|
||||
import com.example.elva.data.remote.model.emergency.EmergencyAlertWS
|
||||
import com.example.elva.data.remote.model.emergency.WebSocketMessage
|
||||
import com.example.elva.util.AuthManager
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class EmergencyWebSocketManager @Inject constructor(
|
||||
private val authManager: AuthManager
|
||||
) {
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
private var isConnected = false
|
||||
private var reconnectAttempts = 0
|
||||
private val maxReconnectAttempts = 5
|
||||
private val listeners = mutableListOf<WebSocketEventListener>()
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
// Замените на ваш IP адрес
|
||||
private companion object {
|
||||
private const val WS_HOST = "192.168.0.112"
|
||||
private const val WS_PORT = 8002
|
||||
}
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.pingInterval(25, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
// ==================== ПОДКЛЮЧЕНИЕ ====================
|
||||
|
||||
fun connect() {
|
||||
val token = authManager.getAccessToken()
|
||||
val userId = authManager.getUserId()
|
||||
|
||||
if (token == null || userId == -1) {
|
||||
notifyError("Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
val wsUrl = "ws://$WS_HOST:$WS_PORT/api/v1/emergency/ws/$userId?token=$token"
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(wsUrl)
|
||||
.build()
|
||||
|
||||
webSocket = client.newWebSocket(request, createWebSocketListener())
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
webSocket?.close(1000, "User disconnected")
|
||||
webSocket = null
|
||||
isConnected = false
|
||||
stopPingJob()
|
||||
}
|
||||
|
||||
// ==================== СЛУШАТЕЛЬ WEBSOCKET ====================
|
||||
|
||||
private fun createWebSocketListener(): WebSocketListener {
|
||||
return object : WebSocketListener() {
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
isConnected = true
|
||||
reconnectAttempts = 0
|
||||
startPingJob()
|
||||
notifyConnectionOpened()
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
handleIncomingMessage(text)
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
isConnected = false
|
||||
stopPingJob()
|
||||
notifyConnectionClosing(code, reason)
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
isConnected = false
|
||||
stopPingJob()
|
||||
notifyConnectionClosed(code, reason)
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
isConnected = false
|
||||
stopPingJob()
|
||||
notifyError("Connection failed: ${t.message}")
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ОБРАБОТКА СООБЩЕНИЙ ====================
|
||||
|
||||
private fun handleIncomingMessage(text: String) {
|
||||
try {
|
||||
val message = json.decodeFromString<WebSocketMessage>(text)
|
||||
|
||||
when (message.type) {
|
||||
"connection_established" -> {
|
||||
notifyConnectionEstablished(message.userId ?: -1)
|
||||
}
|
||||
"emergency_alert" -> {
|
||||
val alert = json.decodeFromString<EmergencyAlertWS>(text)
|
||||
notifyNewEmergencyAlert(alert)
|
||||
}
|
||||
"alert_update" -> {
|
||||
val update = json.decodeFromString<AlertUpdateWS>(text)
|
||||
notifyAlertUpdate(update)
|
||||
}
|
||||
"alert_response" -> {
|
||||
val response = json.decodeFromString<AlertResponseWS>(text)
|
||||
notifyAlertResponse(response)
|
||||
}
|
||||
"pong" -> {
|
||||
// Pong получен, соединение активно
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
notifyError("Failed to parse message: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== PING/PONG ====================
|
||||
|
||||
private var pingJob: Job? = null
|
||||
|
||||
private fun startPingJob() {
|
||||
pingJob = scope.launch {
|
||||
while (isConnected) {
|
||||
delay(30_000) // Ping каждые 30 секунд
|
||||
sendPing()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopPingJob() {
|
||||
pingJob?.cancel()
|
||||
pingJob = null
|
||||
}
|
||||
|
||||
private fun sendPing() {
|
||||
if (isConnected) {
|
||||
webSocket?.send("""{"type": "ping"}""")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ПЕРЕПОДКЛЮЧЕНИЕ ====================
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (reconnectAttempts >= maxReconnectAttempts) {
|
||||
notifyError("Max reconnection attempts reached")
|
||||
return
|
||||
}
|
||||
|
||||
reconnectAttempts++
|
||||
val delay = (reconnectAttempts * 2000).toLong() // Экспоненциальная задержка
|
||||
|
||||
scope.launch {
|
||||
delay(delay)
|
||||
if (!isConnected) {
|
||||
connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ОТПРАВКА СООБЩЕНИЙ ====================
|
||||
|
||||
fun sendAlertReceived(alertId: Int) {
|
||||
val message = """{"type": "alert_received", "alert_id": $alertId}"""
|
||||
webSocket?.send(message)
|
||||
}
|
||||
|
||||
// ==================== СЛУШАТЕЛИ ====================
|
||||
|
||||
fun addListener(listener: WebSocketEventListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeListener(listener: WebSocketEventListener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
private fun notifyConnectionOpened() {
|
||||
listeners.forEach { it.onConnectionOpened() }
|
||||
}
|
||||
|
||||
private fun notifyConnectionEstablished(userId: Int) {
|
||||
listeners.forEach { it.onConnectionEstablished(userId) }
|
||||
}
|
||||
|
||||
private fun notifyNewEmergencyAlert(alert: EmergencyAlertWS) {
|
||||
listeners.forEach { it.onNewEmergencyAlert(alert) }
|
||||
}
|
||||
|
||||
private fun notifyAlertUpdate(update: AlertUpdateWS) {
|
||||
listeners.forEach { it.onAlertUpdate(update) }
|
||||
}
|
||||
|
||||
private fun notifyAlertResponse(response: AlertResponseWS) {
|
||||
listeners.forEach { it.onAlertResponse(response) }
|
||||
}
|
||||
|
||||
private fun notifyConnectionClosing(code: Int, reason: String) {
|
||||
listeners.forEach { it.onConnectionClosing(code, reason) }
|
||||
}
|
||||
|
||||
private fun notifyConnectionClosed(code: Int, reason: String) {
|
||||
listeners.forEach { it.onConnectionClosed(code, reason) }
|
||||
}
|
||||
|
||||
private fun notifyError(error: String) {
|
||||
listeners.forEach { it.onError(error) }
|
||||
}
|
||||
|
||||
// ==================== СОСТОЯНИЕ ====================
|
||||
|
||||
fun isConnected(): Boolean = isConnected
|
||||
|
||||
fun cleanup() {
|
||||
disconnect()
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.example.elva.data.websocket
|
||||
|
||||
import com.example.elva.data.remote.model.emergency.AlertResponseWS
|
||||
import com.example.elva.data.remote.model.emergency.AlertUpdateWS
|
||||
import com.example.elva.data.remote.model.emergency.EmergencyAlertWS
|
||||
|
||||
interface WebSocketEventListener {
|
||||
fun onConnectionOpened()
|
||||
fun onConnectionEstablished(userId: Int)
|
||||
fun onNewEmergencyAlert(alert: EmergencyAlertWS)
|
||||
fun onAlertUpdate(update: AlertUpdateWS)
|
||||
fun onAlertResponse(response: AlertResponseWS)
|
||||
fun onConnectionClosing(code: Int, reason: String)
|
||||
fun onConnectionClosed(code: Int, reason: String)
|
||||
fun onError(error: String)
|
||||
}
|
||||
|
||||
21
app/src/main/java/com/example/elva/di/AppModule.kt
Normal file
21
app/src/main/java/com/example/elva/di/AppModule.kt
Normal file
@@ -0,0 +1,21 @@
|
||||
package com.example.elva.di
|
||||
|
||||
import android.content.Context
|
||||
import com.example.elva.data.local.SessionManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSessionManager(@ApplicationContext context: Context): SessionManager =
|
||||
SessionManager(context)
|
||||
}
|
||||
|
||||
85
app/src/main/java/com/example/elva/di/NetworkModule.kt
Normal file
85
app/src/main/java/com/example/elva/di/NetworkModule.kt
Normal file
@@ -0,0 +1,85 @@
|
||||
package com.example.elva.di
|
||||
|
||||
import com.example.elva.data.api.ApiConfig
|
||||
import com.example.elva.data.api.AuthApiService
|
||||
import com.example.elva.data.api.ProfileApiService
|
||||
import com.example.elva.data.api.UserApiService
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideJson(): Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLoggingInterceptor(): HttpLoggingInterceptor {
|
||||
return HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(
|
||||
loggingInterceptor: HttpLoggingInterceptor
|
||||
): OkHttpClient {
|
||||
return OkHttpClient.Builder()
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.connectTimeout(ApiConfig.CONNECT_TIMEOUT, TimeUnit.SECONDS)
|
||||
.readTimeout(ApiConfig.READ_TIMEOUT, TimeUnit.SECONDS)
|
||||
.writeTimeout(ApiConfig.WRITE_TIMEOUT, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(
|
||||
okHttpClient: OkHttpClient,
|
||||
json: Json
|
||||
): Retrofit {
|
||||
val contentType = "application/json".toMediaType()
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(ApiConfig.BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(json.asConverterFactory(contentType))
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthApiService(retrofit: Retrofit): AuthApiService {
|
||||
return retrofit.create(AuthApiService::class.java)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideProfileApiService(retrofit: Retrofit): ProfileApiService {
|
||||
return retrofit.create(ProfileApiService::class.java)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideUserApiService(retrofit: Retrofit): UserApiService {
|
||||
return retrofit.create(UserApiService::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.example.elva.ui.alerts
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.example.elva.databinding.FragmentAlertsBinding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AlertsFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentAlertsBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: AlertsViewModel by viewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentAlertsBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.alertsList.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.alertsRefresh.setOnClickListener { viewModel.loadAlerts() }
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.example.elva.ui.alerts
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@HiltViewModel
|
||||
class AlertsViewModel @Inject constructor() : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(AlertsState())
|
||||
val state: StateFlow<AlertsState> = _state
|
||||
|
||||
fun loadAlerts() {
|
||||
viewModelScope.launch {
|
||||
_state.value = AlertsState(isLoading = true)
|
||||
_state.value = AlertsState(alerts = listOf("Тестовый алерт"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class AlertsState(
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val alerts: List<String> = emptyList()
|
||||
)
|
||||
|
||||
174
app/src/main/java/com/example/elva/ui/auth/LoginFragment.kt
Normal file
174
app/src/main/java/com/example/elva/ui/auth/LoginFragment.kt
Normal file
@@ -0,0 +1,174 @@
|
||||
package com.example.elva.ui.auth
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.example.elva.R
|
||||
import com.example.elva.databinding.FragmentLoginBinding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@AndroidEntryPoint
|
||||
class LoginFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentLoginBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val viewModel: LoginViewModel by viewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentLoginBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
setupCardHeight()
|
||||
setupAnimation()
|
||||
setupListeners()
|
||||
observeUiState()
|
||||
|
||||
android.util.Log.d("LoginFragment", "LoginFragment loaded, showing login screen")
|
||||
}
|
||||
|
||||
private fun setupCardHeight() {
|
||||
// Устанавливаем высоту карточки в 60% экрана
|
||||
view?.post {
|
||||
val screenHeight = resources.displayMetrics.heightPixels
|
||||
val cardHeight = (screenHeight * 0.6f).toInt()
|
||||
|
||||
val params = binding.loginCard.layoutParams as ViewGroup.MarginLayoutParams
|
||||
params.height = cardHeight
|
||||
binding.loginCard.layoutParams = params
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupAnimation() {
|
||||
// Анимация появления не нужна, т.к. Navigation Component уже делает это
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
binding.apply {
|
||||
loginButton.setOnClickListener {
|
||||
val emailOrUsername = usernameInput.text.toString().trim()
|
||||
val password = passwordInput.text.toString()
|
||||
viewModel.login(emailOrUsername, password)
|
||||
}
|
||||
|
||||
registerLink.setOnClickListener {
|
||||
// Навигация к экрану регистрации
|
||||
findNavController().navigate(R.id.action_loginFragment_to_registerFragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeUiState() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.uiState.collect { state ->
|
||||
android.util.Log.d("LoginFragment", "UI State changed: $state")
|
||||
when (state) {
|
||||
is LoginUiState.Idle -> {
|
||||
binding.progressBar.isVisible = false
|
||||
setInputsEnabled(true)
|
||||
}
|
||||
is LoginUiState.Loading -> {
|
||||
binding.progressBar.isVisible = true
|
||||
setInputsEnabled(false)
|
||||
}
|
||||
is LoginUiState.Success -> {
|
||||
android.util.Log.d("LoginFragment", "=== LOGIN SUCCESS STATE RECEIVED ===")
|
||||
binding.progressBar.isVisible = false
|
||||
setInputsEnabled(false)
|
||||
|
||||
// Проверяем статус авторизации
|
||||
val isLoggedIn = viewModel.checkIfLoggedIn()
|
||||
android.util.Log.d("LoginFragment", "After login, isLoggedIn = $isLoggedIn")
|
||||
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
"Вход выполнен успешно!",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
|
||||
// Навигация с небольшой задержкой для отображения Toast
|
||||
android.util.Log.d("LoginFragment", "Scheduling navigation to dashboard")
|
||||
view?.postDelayed({
|
||||
if (isAdded && view != null) {
|
||||
android.util.Log.d("LoginFragment", "Executing delayed navigation")
|
||||
navigateToDashboard()
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
is LoginUiState.Error -> {
|
||||
android.util.Log.e("LoginFragment", "Login error: ${state.message}")
|
||||
binding.progressBar.isVisible = false
|
||||
setInputsEnabled(true)
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
state.message,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
viewModel.resetState()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setInputsEnabled(enabled: Boolean) {
|
||||
binding.apply {
|
||||
usernameInput.isEnabled = enabled
|
||||
passwordInput.isEnabled = enabled
|
||||
loginButton.isEnabled = enabled
|
||||
registerLink.isEnabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateToDashboard() {
|
||||
android.util.Log.d("LoginFragment", "=== STARTING NAVIGATION TO DASHBOARD ===")
|
||||
|
||||
try {
|
||||
if (!isAdded) {
|
||||
android.util.Log.e("LoginFragment", "Fragment not added, cannot navigate")
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем что NavController доступен
|
||||
val navController = findNavController()
|
||||
android.util.Log.d("LoginFragment", "NavController obtained: ${navController.currentDestination?.label}")
|
||||
|
||||
// Выполняем навигацию
|
||||
navController.navigate(R.id.action_login_to_dashboard)
|
||||
android.util.Log.d("LoginFragment", "=== NAVIGATION TO DASHBOARD COMPLETED ===")
|
||||
|
||||
} catch (e: IllegalStateException) {
|
||||
android.util.Log.e("LoginFragment", "Navigation failed: IllegalStateException", e)
|
||||
Toast.makeText(requireContext(), "Ошибка перехода на главный экран", Toast.LENGTH_LONG).show()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("LoginFragment", "Navigation failed: ${e.javaClass.simpleName}", e)
|
||||
Toast.makeText(requireContext(), "Ошибка навигации: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
|
||||
96
app/src/main/java/com/example/elva/ui/auth/LoginViewModel.kt
Normal file
96
app/src/main/java/com/example/elva/ui/auth/LoginViewModel.kt
Normal file
@@ -0,0 +1,96 @@
|
||||
package com.example.elva.ui.auth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.elva.data.repository.AuthRepository
|
||||
import com.example.elva.data.repository.UserRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class LoginViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val userRepository: UserRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow<LoginUiState>(LoginUiState.Idle)
|
||||
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun login(emailOrUsername: String, password: String) {
|
||||
android.util.Log.d("LoginViewModel", "login() called with: emailOrUsername=$emailOrUsername")
|
||||
|
||||
// Валидация
|
||||
when {
|
||||
emailOrUsername.isBlank() -> {
|
||||
_uiState.value = LoginUiState.Error("Введите email или имя пользователя")
|
||||
return
|
||||
}
|
||||
password.isBlank() -> {
|
||||
_uiState.value = LoginUiState.Error("Введите пароль")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_uiState.value = LoginUiState.Loading
|
||||
android.util.Log.d("LoginViewModel", "Starting login process")
|
||||
|
||||
viewModelScope.launch {
|
||||
// Определяем, это email или username
|
||||
val isEmail = emailOrUsername.contains("@")
|
||||
android.util.Log.d("LoginViewModel", "isEmail: $isEmail")
|
||||
|
||||
val result = if (isEmail) {
|
||||
authRepository.login(
|
||||
email = emailOrUsername,
|
||||
password = password
|
||||
)
|
||||
} else {
|
||||
authRepository.login(
|
||||
username = emailOrUsername,
|
||||
password = password
|
||||
)
|
||||
}
|
||||
|
||||
android.util.Log.d("LoginViewModel", "Login result: success=${result.isSuccess}")
|
||||
|
||||
if (result.isSuccess) {
|
||||
android.util.Log.d("LoginViewModel", "Login successful!")
|
||||
// Сразу переходим на dashboard, профиль загрузится там
|
||||
_uiState.value = LoginUiState.Success
|
||||
} else {
|
||||
val error = when {
|
||||
result.exceptionOrNull()?.message?.contains("401") == true ||
|
||||
result.exceptionOrNull()?.message?.contains("Unauthorized") == true ->
|
||||
"Неверный email/пароль"
|
||||
result.exceptionOrNull()?.message?.contains("Unable to resolve host") == true ->
|
||||
"Не удается подключиться к серверу. Проверьте настройки сети."
|
||||
else -> result.exceptionOrNull()?.message ?: "Ошибка входа"
|
||||
}
|
||||
android.util.Log.e("LoginViewModel", "Login failed: $error", result.exceptionOrNull())
|
||||
_uiState.value = LoginUiState.Error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun checkIfLoggedIn(): Boolean {
|
||||
val isLoggedIn = authRepository.isLoggedIn()
|
||||
android.util.Log.d("LoginViewModel", "checkIfLoggedIn: $isLoggedIn")
|
||||
return isLoggedIn
|
||||
}
|
||||
|
||||
fun resetState() {
|
||||
_uiState.value = LoginUiState.Idle
|
||||
}
|
||||
}
|
||||
|
||||
sealed class LoginUiState {
|
||||
object Idle : LoginUiState()
|
||||
object Loading : LoginUiState()
|
||||
object Success : LoginUiState()
|
||||
data class Error(val message: String) : LoginUiState()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.example.elva.ui.auth
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.example.elva.R
|
||||
import com.example.elva.databinding.DialogRegisterBinding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@AndroidEntryPoint
|
||||
class RegisterDialogFragment : Fragment() {
|
||||
|
||||
private var _binding: DialogRegisterBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val viewModel: RegisterViewModel by viewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = DialogRegisterBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
setupListeners()
|
||||
observeUiState()
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
binding.apply {
|
||||
registerButton.setOnClickListener {
|
||||
val username = usernameInput.text.toString().trim()
|
||||
val email = emailInput.text.toString().trim()
|
||||
val password = passwordInput.text.toString()
|
||||
|
||||
viewModel.register(
|
||||
username = username,
|
||||
email = email,
|
||||
password = password,
|
||||
confirmPassword = password, // Используем тот же пароль, валидацию делаем на клиенте
|
||||
fullName = null,
|
||||
phone = null
|
||||
)
|
||||
}
|
||||
|
||||
loginLink.setOnClickListener {
|
||||
// Вернуться на экран входа
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeUiState() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.uiState.collect { state ->
|
||||
when (state) {
|
||||
is RegisterUiState.Idle -> {
|
||||
binding.progressBar.isVisible = false
|
||||
setInputsEnabled(true)
|
||||
}
|
||||
is RegisterUiState.Loading -> {
|
||||
binding.progressBar.isVisible = true
|
||||
setInputsEnabled(false)
|
||||
}
|
||||
is RegisterUiState.Success -> {
|
||||
binding.progressBar.isVisible = false
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
"Регистрация успешна!",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
|
||||
// Переход на главный экран
|
||||
findNavController().navigate(R.id.action_loginFragment_to_dashboardFragment)
|
||||
}
|
||||
is RegisterUiState.Error -> {
|
||||
binding.progressBar.isVisible = false
|
||||
setInputsEnabled(true)
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
state.message,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
viewModel.resetState()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setInputsEnabled(enabled: Boolean) {
|
||||
binding.apply {
|
||||
usernameInput.isEnabled = enabled
|
||||
emailInput.isEnabled = enabled
|
||||
passwordInput.isEnabled = enabled
|
||||
registerButton.isEnabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.example.elva.ui.auth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.elva.data.repository.AuthRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed class RegisterUiState {
|
||||
object Idle : RegisterUiState()
|
||||
object Loading : RegisterUiState()
|
||||
object Success : RegisterUiState()
|
||||
data class Error(val message: String) : RegisterUiState()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class RegisterViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow<RegisterUiState>(RegisterUiState.Idle)
|
||||
val uiState: StateFlow<RegisterUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun register(
|
||||
username: String,
|
||||
email: String,
|
||||
password: String,
|
||||
confirmPassword: String,
|
||||
fullName: String? = null,
|
||||
phone: String? = null
|
||||
) {
|
||||
// Валидация
|
||||
when {
|
||||
username.isBlank() -> {
|
||||
_uiState.value = RegisterUiState.Error("Введите имя пользователя")
|
||||
return
|
||||
}
|
||||
email.isBlank() -> {
|
||||
_uiState.value = RegisterUiState.Error("Введите email")
|
||||
return
|
||||
}
|
||||
!android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches() -> {
|
||||
_uiState.value = RegisterUiState.Error("Неверный формат email")
|
||||
return
|
||||
}
|
||||
password.length < 8 -> {
|
||||
_uiState.value = RegisterUiState.Error("Пароль должен содержать минимум 8 символов")
|
||||
return
|
||||
}
|
||||
password != confirmPassword -> {
|
||||
_uiState.value = RegisterUiState.Error("Пароли не совпадают")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.value = RegisterUiState.Loading
|
||||
try {
|
||||
val result = authRepository.register(
|
||||
username = username,
|
||||
email = email,
|
||||
password = password,
|
||||
fullName = fullName,
|
||||
phone = phone
|
||||
)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
// Данные уже сохранены в AuthRepository
|
||||
_uiState.value = RegisterUiState.Success
|
||||
},
|
||||
onFailure = { error ->
|
||||
_uiState.value = RegisterUiState.Error(
|
||||
error.message ?: "Ошибка регистрации"
|
||||
)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = RegisterUiState.Error(
|
||||
e.message ?: "Ошибка регистрации"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetState() {
|
||||
_uiState.value = RegisterUiState.Idle
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.example.elva.ui.calendar
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.example.elva.R
|
||||
import com.example.elva.databinding.ItemCalendarDayBinding
|
||||
import java.time.LocalDate
|
||||
|
||||
class CalendarAdapter(
|
||||
private val onDateClick: (LocalDate) -> Unit
|
||||
) : ListAdapter<CalendarDay, CalendarAdapter.DayViewHolder>(DayDiffCallback()) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DayViewHolder {
|
||||
val binding = ItemCalendarDayBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return DayViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: DayViewHolder, position: Int) {
|
||||
holder.bind(getItem(position))
|
||||
}
|
||||
|
||||
inner class DayViewHolder(
|
||||
private val binding: ItemCalendarDayBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(day: CalendarDay) {
|
||||
when (day.type) {
|
||||
CalendarDayType.EMPTY -> {
|
||||
binding.tvDay.text = ""
|
||||
binding.dayContainer.background = null
|
||||
binding.dayContainer.setOnClickListener(null)
|
||||
}
|
||||
else -> {
|
||||
binding.tvDay.text = day.date?.dayOfMonth?.toString() ?: ""
|
||||
|
||||
// Устанавливаем фон в зависимости от типа дня
|
||||
val backgroundRes = when (day.type) {
|
||||
CalendarDayType.PERIOD -> R.drawable.bg_calendar_period
|
||||
CalendarDayType.PREDICTED -> R.drawable.bg_calendar_predicted
|
||||
CalendarDayType.FERTILE -> R.drawable.bg_calendar_fertile
|
||||
CalendarDayType.SELECTED -> R.drawable.bg_calendar_selected
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (backgroundRes != null) {
|
||||
binding.dayContainer.background = ContextCompat.getDrawable(
|
||||
binding.root.context,
|
||||
backgroundRes
|
||||
)
|
||||
binding.tvDay.setTextColor(
|
||||
ContextCompat.getColor(
|
||||
binding.root.context,
|
||||
if (day.type == CalendarDayType.PERIOD)
|
||||
android.R.color.white
|
||||
else
|
||||
R.color.text_primary
|
||||
)
|
||||
)
|
||||
} else {
|
||||
binding.dayContainer.background = null
|
||||
binding.tvDay.setTextColor(
|
||||
ContextCompat.getColor(
|
||||
binding.root.context,
|
||||
R.color.text_primary
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Затемняем дни не из текущего месяца
|
||||
if (day.date?.month != LocalDate.now().month) {
|
||||
binding.tvDay.alpha = 0.3f
|
||||
} else {
|
||||
binding.tvDay.alpha = 1f
|
||||
}
|
||||
|
||||
binding.dayContainer.setOnClickListener {
|
||||
day.date?.let { onDateClick(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class DayDiffCallback : DiffUtil.ItemCallback<CalendarDay>() {
|
||||
override fun areItemsTheSame(oldItem: CalendarDay, newItem: CalendarDay): Boolean {
|
||||
return oldItem.date == newItem.date
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: CalendarDay, newItem: CalendarDay): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.example.elva.ui.calendar
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import com.example.elva.databinding.FragmentCalendarBinding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class CalendarFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentCalendarBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: CalendarViewModel by viewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentCalendarBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
// TODO: Implement calendar entry dialog
|
||||
// binding.addCalendarEntry.setOnClickListener { /* Show dialog */ }
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.example.elva.ui.calendar
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.elva.data.models.health.Period
|
||||
import com.example.elva.data.repository.HealthRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class CalendarViewModel @Inject constructor(
|
||||
private val healthRepository: HealthRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(CalendarState())
|
||||
val state: StateFlow<CalendarState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
loadCalendarData()
|
||||
}
|
||||
|
||||
private fun loadCalendarData() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val cycleInfo = healthRepository.getCycleInfo()
|
||||
val periods = healthRepository.getPeriods()
|
||||
|
||||
_state.value = CalendarState(
|
||||
currentMonth = YearMonth.now(),
|
||||
periods = periods,
|
||||
cycleInfo = cycleInfo,
|
||||
isLoading = false
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(
|
||||
error = e.message,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun previousMonth() {
|
||||
_state.value = _state.value.copy(
|
||||
currentMonth = _state.value.currentMonth.minusMonths(1)
|
||||
)
|
||||
}
|
||||
|
||||
fun nextMonth() {
|
||||
_state.value = _state.value.copy(
|
||||
currentMonth = _state.value.currentMonth.plusMonths(1)
|
||||
)
|
||||
}
|
||||
|
||||
fun selectDate(date: LocalDate) {
|
||||
_state.value = _state.value.copy(selectedDate = date)
|
||||
}
|
||||
|
||||
fun showEditPeriodDialog(startDate: LocalDate? = null) {
|
||||
_state.value = _state.value.copy(
|
||||
showEditDialog = true,
|
||||
editingPeriod = startDate?.let { date ->
|
||||
// Попытка найти существующий период
|
||||
_state.value.periods.find {
|
||||
!date.isBefore(it.startDate) && (it.endDate == null || !date.isAfter(it.endDate))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun dismissEditDialog() {
|
||||
_state.value = _state.value.copy(
|
||||
showEditDialog = false,
|
||||
editingPeriod = null
|
||||
)
|
||||
}
|
||||
|
||||
fun savePeriod(period: Period) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
if (period.id == 0L) {
|
||||
healthRepository.addPeriod(period)
|
||||
} else {
|
||||
healthRepository.updatePeriod(period)
|
||||
}
|
||||
dismissEditDialog()
|
||||
loadCalendarData()
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deletePeriod(periodId: Long) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
healthRepository.deletePeriod(periodId)
|
||||
loadCalendarData()
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class CalendarState(
|
||||
val currentMonth: YearMonth = YearMonth.now(),
|
||||
val selectedDate: LocalDate? = null,
|
||||
val periods: List<Period> = emptyList(),
|
||||
val cycleInfo: com.example.elva.data.models.health.CycleInfo? = null,
|
||||
val showEditDialog: Boolean = false,
|
||||
val editingPeriod: Period? = null,
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.example.elva.ui.calendar
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.example.elva.R
|
||||
import com.example.elva.data.models.health.FlowIntensity
|
||||
import com.example.elva.data.models.health.Period
|
||||
import com.example.elva.databinding.DialogEditPeriodBinding
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
class EditPeriodDialogFragment : DialogFragment() {
|
||||
|
||||
private var _binding: DialogEditPeriodBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private var startDate: LocalDate = LocalDate.now()
|
||||
private var endDate: LocalDate? = null
|
||||
private var selectedFlow: FlowIntensity = FlowIntensity.MEDIUM
|
||||
|
||||
private var onSaveListener: ((Period) -> Unit)? = null
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = DialogEditPeriodBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
setupListeners()
|
||||
updateUI()
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
binding.apply {
|
||||
// Выбор даты начала
|
||||
cardStartDate.setOnClickListener {
|
||||
showDatePicker(startDate) { date ->
|
||||
startDate = date
|
||||
updateUI()
|
||||
}
|
||||
}
|
||||
|
||||
// Выбор даты окончания
|
||||
cardEndDate.setOnClickListener {
|
||||
showDatePicker(endDate ?: startDate.plusDays(5)) { date ->
|
||||
endDate = date
|
||||
updateUI()
|
||||
}
|
||||
}
|
||||
|
||||
// Интенсивность
|
||||
radioGroupFlow.setOnCheckedChangeListener { _, checkedId ->
|
||||
selectedFlow = when (checkedId) {
|
||||
R.id.rbLight -> FlowIntensity.LIGHT
|
||||
R.id.rbMedium -> FlowIntensity.MEDIUM
|
||||
R.id.rbHeavy -> FlowIntensity.HEAVY
|
||||
else -> FlowIntensity.MEDIUM
|
||||
}
|
||||
}
|
||||
|
||||
// Кнопки
|
||||
btnCancel.setOnClickListener {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
btnSave.setOnClickListener {
|
||||
savePeriod()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showDatePicker(currentDate: LocalDate, onDateSelected: (LocalDate) -> Unit) {
|
||||
val picker = DatePickerDialog(
|
||||
requireContext(),
|
||||
{ _, year, month, dayOfMonth ->
|
||||
onDateSelected(LocalDate.of(year, month + 1, dayOfMonth))
|
||||
},
|
||||
currentDate.year,
|
||||
currentDate.monthValue - 1,
|
||||
currentDate.dayOfMonth
|
||||
)
|
||||
picker.show()
|
||||
}
|
||||
|
||||
private fun updateUI() {
|
||||
val formatter = DateTimeFormatter.ofPattern("dd MMMM yyyy")
|
||||
|
||||
binding.apply {
|
||||
tvStartDate.text = startDate.format(formatter)
|
||||
endDate?.let {
|
||||
tvEndDate.text = it.format(formatter)
|
||||
} ?: run {
|
||||
tvEndDate.text = getString(R.string.select_date)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun savePeriod() {
|
||||
val period = Period(
|
||||
startDate = startDate,
|
||||
endDate = endDate,
|
||||
flow = selectedFlow,
|
||||
notes = binding.etNotes.text?.toString()
|
||||
)
|
||||
|
||||
onSaveListener?.invoke(period)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
fun setOnSaveListener(listener: (Period) -> Unit) {
|
||||
onSaveListener = listener
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = EditPeriodDialogFragment()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package com.example.elva.ui.calendar
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import com.example.elva.R
|
||||
import com.example.elva.databinding.FragmentPeriodCalendarBinding
|
||||
import com.example.elva.data.models.health.Period
|
||||
import com.google.android.material.datepicker.MaterialDatePicker
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.TextStyle
|
||||
import java.util.*
|
||||
|
||||
@AndroidEntryPoint
|
||||
class PeriodCalendarFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentPeriodCalendarBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: PeriodCalendarViewModel by viewModels()
|
||||
private lateinit var calendarAdapter: CalendarAdapter
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentPeriodCalendarBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
setupCalendar()
|
||||
setupListeners()
|
||||
observeState()
|
||||
}
|
||||
|
||||
private fun setupCalendar() {
|
||||
calendarAdapter = CalendarAdapter { date ->
|
||||
viewModel.onDateSelected(date)
|
||||
showEditPeriodDialog(date)
|
||||
}
|
||||
|
||||
binding.rvCalendar.apply {
|
||||
layoutManager = GridLayoutManager(requireContext(), 7)
|
||||
adapter = calendarAdapter
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
binding.btnPrevMonth.setOnClickListener {
|
||||
viewModel.previousMonth()
|
||||
}
|
||||
|
||||
binding.btnNextMonth.setOnClickListener {
|
||||
viewModel.nextMonth()
|
||||
}
|
||||
|
||||
binding.fabAddPeriod.setOnClickListener {
|
||||
showAddPeriodDialog()
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeState() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.state.collect { state ->
|
||||
updateUI(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateUI(state: PeriodCalendarState) {
|
||||
// Update month/year header
|
||||
val formatter = DateTimeFormatter.ofPattern("LLLL yyyy", Locale("ru"))
|
||||
binding.tvMonthYear.text = state.currentMonth.format(formatter)
|
||||
.replaceFirstChar { it.uppercase() }
|
||||
|
||||
// Update calendar
|
||||
val days = generateCalendarDays(state)
|
||||
calendarAdapter.submitList(days)
|
||||
|
||||
// Update cycle info
|
||||
state.cycleInfo.lastPeriodStart?.let { lastStart ->
|
||||
val daysSince = java.time.temporal.ChronoUnit.DAYS.between(lastStart, LocalDate.now())
|
||||
binding.tvCycleDay.text = getString(R.string.cycle_day_format, daysSince.toInt() + 1)
|
||||
} ?: run {
|
||||
binding.tvCycleDay.text = getString(R.string.no_cycle_data)
|
||||
}
|
||||
|
||||
state.cycleInfo.nextPeriodPrediction?.let { nextPeriod ->
|
||||
val daysUntil = java.time.temporal.ChronoUnit.DAYS.between(LocalDate.now(), nextPeriod)
|
||||
binding.tvNextPeriod.text = getString(R.string.next_period_format, daysUntil.toInt())
|
||||
} ?: run {
|
||||
binding.tvNextPeriod.text = getString(R.string.no_prediction)
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateCalendarDays(state: PeriodCalendarState): List<CalendarDay> {
|
||||
val days = mutableListOf<CalendarDay>()
|
||||
val yearMonth = state.currentMonth
|
||||
val firstDay = yearMonth.atDay(1)
|
||||
val lastDay = yearMonth.atEndOfMonth()
|
||||
|
||||
// Добавляем пустые ячейки для выравнивания начала месяца
|
||||
val firstDayOfWeek = firstDay.dayOfWeek.value - 1 // 0 = Monday
|
||||
repeat(firstDayOfWeek) {
|
||||
days.add(CalendarDay(null, CalendarDayType.EMPTY))
|
||||
}
|
||||
|
||||
// Добавляем дни месяца
|
||||
var currentDate = firstDay
|
||||
while (currentDate <= lastDay) {
|
||||
val dayType = getDayType(currentDate, state)
|
||||
days.add(CalendarDay(currentDate, dayType))
|
||||
currentDate = currentDate.plusDays(1)
|
||||
}
|
||||
|
||||
return days
|
||||
}
|
||||
|
||||
private fun getDayType(date: LocalDate, state: PeriodCalendarState): CalendarDayType {
|
||||
// Проверяем, является ли день периодом
|
||||
val isPeriod = state.periods.any { period ->
|
||||
!date.isBefore(period.startDate) && !date.isAfter(period.endDate)
|
||||
}
|
||||
if (isPeriod) return CalendarDayType.PERIOD
|
||||
|
||||
// Проверяем, является ли день прогнозируемым периодом
|
||||
state.cycleInfo.nextPeriodPrediction?.let { nextPeriod ->
|
||||
if (date >= nextPeriod && date < nextPeriod.plusDays(5)) {
|
||||
return CalendarDayType.PREDICTED
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем фертильное окно
|
||||
state.cycleInfo.ovulationPrediction?.let { ovulation ->
|
||||
if (date >= ovulation.minusDays(5) && date <= ovulation.plusDays(1)) {
|
||||
return CalendarDayType.FERTILE
|
||||
}
|
||||
}
|
||||
|
||||
return CalendarDayType.NORMAL
|
||||
}
|
||||
|
||||
private fun showAddPeriodDialog() {
|
||||
val datePicker = MaterialDatePicker.Builder.dateRangePicker()
|
||||
.setTitleText(R.string.select_period_dates)
|
||||
.build()
|
||||
|
||||
datePicker.addOnPositiveButtonClickListener { selection ->
|
||||
val startDate = Instant.ofEpochMilli(selection.first)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
val endDate = Instant.ofEpochMilli(selection.second)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
|
||||
val period = Period(
|
||||
id = 0,
|
||||
startDate = startDate,
|
||||
endDate = endDate,
|
||||
flow = FlowIntensity.MEDIUM,
|
||||
notes = ""
|
||||
)
|
||||
viewModel.savePeriod(period)
|
||||
}
|
||||
|
||||
datePicker.show(parentFragmentManager, "date_picker")
|
||||
}
|
||||
|
||||
private fun showEditPeriodDialog(date: LocalDate) {
|
||||
val dialog = EditPeriodDialog.newInstance(date)
|
||||
dialog.setOnSaveListener { period ->
|
||||
viewModel.savePeriod(period)
|
||||
}
|
||||
dialog.setOnDeleteListener { periodId ->
|
||||
viewModel.deletePeriod(periodId)
|
||||
}
|
||||
dialog.show(parentFragmentManager, "edit_period")
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
|
||||
data class CalendarDay(
|
||||
val date: LocalDate?,
|
||||
val type: CalendarDayType
|
||||
)
|
||||
|
||||
enum class CalendarDayType {
|
||||
EMPTY,
|
||||
NORMAL,
|
||||
PERIOD,
|
||||
PREDICTED,
|
||||
FERTILE,
|
||||
SELECTED
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.example.elva.ui.calendar
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.elva.data.models.health.CycleInfo
|
||||
import com.example.elva.data.models.health.Period
|
||||
import com.example.elva.data.repository.HealthRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class PeriodCalendarViewModel @Inject constructor(
|
||||
private val healthRepository: HealthRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(PeriodCalendarState())
|
||||
val state: StateFlow<PeriodCalendarState> = _state
|
||||
|
||||
init {
|
||||
loadData()
|
||||
}
|
||||
|
||||
fun loadData() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val periods = healthRepository.getPeriods()
|
||||
val cycleInfo = healthRepository.getCycleInfo()
|
||||
_state.value = _state.value.copy(
|
||||
periods = periods,
|
||||
cycleInfo = cycleInfo,
|
||||
isLoading = false
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(
|
||||
error = e.message,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun previousMonth() {
|
||||
_state.value = _state.value.copy(
|
||||
currentMonth = _state.value.currentMonth.minusMonths(1)
|
||||
)
|
||||
}
|
||||
|
||||
fun nextMonth() {
|
||||
_state.value = _state.value.copy(
|
||||
currentMonth = _state.value.currentMonth.plusMonths(1)
|
||||
)
|
||||
}
|
||||
|
||||
fun onDateSelected(date: LocalDate) {
|
||||
_state.value = _state.value.copy(selectedDate = date)
|
||||
}
|
||||
|
||||
fun clearSelection() {
|
||||
_state.value = _state.value.copy(selectedDate = null)
|
||||
}
|
||||
|
||||
fun savePeriod(period: Period) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
if (period.id == 0L) {
|
||||
healthRepository.addPeriod(period)
|
||||
} else {
|
||||
healthRepository.updatePeriod(period)
|
||||
}
|
||||
loadData()
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deletePeriod(periodId: Long) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
healthRepository.deletePeriod(periodId)
|
||||
loadData()
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class PeriodCalendarState(
|
||||
val currentMonth: YearMonth = YearMonth.now(),
|
||||
val periods: List<Period> = emptyList(),
|
||||
val cycleInfo: CycleInfo = CycleInfo(
|
||||
lastPeriodStart = null,
|
||||
nextPeriodPrediction = null,
|
||||
ovulationPrediction = null
|
||||
),
|
||||
val selectedDate: LocalDate? = null,
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.example.elva.ui.contacts
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.example.elva.databinding.FragmentContactsBinding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ContactsFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentContactsBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: ContactsViewModel by viewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentContactsBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.contactsList.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.addContactBtn.setOnClickListener {
|
||||
viewModel.addContact(
|
||||
binding.contactName.text.toString(),
|
||||
binding.contactPhone.text.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.example.elva.ui.contacts
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@HiltViewModel
|
||||
class ContactsViewModel @Inject constructor() : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(ContactsState())
|
||||
val state: StateFlow<ContactsState> = _state
|
||||
|
||||
fun addContact(name: String, phone: String) {
|
||||
viewModelScope.launch {
|
||||
val updated = _state.value.contacts + "$name - $phone"
|
||||
_state.value = ContactsState(contacts = updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ContactsState(
|
||||
val contacts: List<String> = emptyList()
|
||||
)
|
||||
12
app/src/main/java/com/example/elva/ui/cycle/CycleData.kt
Normal file
12
app/src/main/java/com/example/elva/ui/cycle/CycleData.kt
Normal file
@@ -0,0 +1,12 @@
|
||||
package com.example.elva.ui.cycle
|
||||
|
||||
import java.util.Date
|
||||
|
||||
data class CycleData(
|
||||
val periodDay: Int = 1,
|
||||
val cycleDays: Int = 30,
|
||||
val ovulationInDays: Int = 12,
|
||||
val nextPeriodInDays: Int = 25,
|
||||
val startDate: Date = Date()
|
||||
)
|
||||
|
||||
135
app/src/main/java/com/example/elva/ui/cycle/CycleFragment.kt
Normal file
135
app/src/main/java/com/example/elva/ui/cycle/CycleFragment.kt
Normal file
@@ -0,0 +1,135 @@
|
||||
package com.example.elva.ui.cycle
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.example.elva.R
|
||||
import com.example.elva.databinding.FragmentCycleBinding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@AndroidEntryPoint
|
||||
class CycleFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentCycleBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val viewModel: CycleViewModel by viewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentCycleBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
setupUI()
|
||||
setupWeekDays()
|
||||
observeData()
|
||||
}
|
||||
|
||||
private fun setupUI() {
|
||||
// Установка текущей даты
|
||||
val dateFormat = SimpleDateFormat("MMMM d", Locale.getDefault())
|
||||
binding.dateText.text = dateFormat.format(Date())
|
||||
|
||||
// Клики
|
||||
binding.editPeriodButton.setOnClickListener {
|
||||
// TODO: Открыть диалог редактирования периода
|
||||
}
|
||||
|
||||
binding.addButton.setOnClickListener {
|
||||
// TODO: Добавить предсказание
|
||||
}
|
||||
|
||||
binding.viewAllButton.setOnClickListener {
|
||||
// TODO: Показать все циклы
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupWeekDays() {
|
||||
// Создаем кружки для дней недели
|
||||
val calendar = Calendar.getInstance()
|
||||
calendar.set(Calendar.DAY_OF_WEEK, Calendar.SUNDAY)
|
||||
|
||||
for (i in 1..7) {
|
||||
val dayView = createDayView(i, i == 5) // 5 - TODAY
|
||||
binding.weekNumbersLayout.addView(dayView)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDayView(day: Int, isToday: Boolean): View {
|
||||
val context = requireContext()
|
||||
val size = (40 * resources.displayMetrics.density).toInt()
|
||||
val margin = (8 * resources.displayMetrics.density).toInt()
|
||||
|
||||
val textView = TextView(context).apply {
|
||||
text = day.toString()
|
||||
textSize = 14f
|
||||
gravity = android.view.Gravity.CENTER
|
||||
|
||||
layoutParams = ViewGroup.MarginLayoutParams(size, size).apply {
|
||||
setMargins(margin / 2, 0, margin / 2, 0)
|
||||
}
|
||||
|
||||
if (isToday) {
|
||||
// Сегодня - заполненный круг
|
||||
setBackgroundResource(R.drawable.day_circle_today)
|
||||
setTextColor(resources.getColor(R.color.white, null))
|
||||
} else {
|
||||
// Обычный день - пунктирный круг
|
||||
setBackgroundResource(R.drawable.day_circle_default)
|
||||
setTextColor(resources.getColor(R.color.text_secondary, null))
|
||||
}
|
||||
}
|
||||
|
||||
return textView
|
||||
}
|
||||
|
||||
private fun observeData() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.cycleData.collect { data ->
|
||||
updateUI(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateUI(data: CycleData) {
|
||||
binding.apply {
|
||||
// Обновление данных о периоде
|
||||
periodDay.text = getString(R.string.period_day, data.periodDay)
|
||||
|
||||
// Предсказания
|
||||
ovulationText.text = getString(R.string.ovulation_in_days, data.ovulationInDays)
|
||||
nextPeriodText.text = getString(R.string.next_period_in_days, data.nextPeriodInDays)
|
||||
|
||||
// Информация о цикле
|
||||
currentCycleText.text = getString(R.string.current_cycle_days, data.cycleDays)
|
||||
|
||||
val dateFormat = SimpleDateFormat("MMM d", Locale.getDefault())
|
||||
startedOnText.text = getString(R.string.started_on, dateFormat.format(data.startDate))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.example.elva.ui.cycle
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class CycleViewModel @Inject constructor() : ViewModel() {
|
||||
|
||||
private val _cycleData = MutableStateFlow(CycleData())
|
||||
val cycleData: StateFlow<CycleData> = _cycleData.asStateFlow()
|
||||
|
||||
init {
|
||||
loadCycleData()
|
||||
}
|
||||
|
||||
private fun loadCycleData() {
|
||||
// TODO: Загрузка данных из репозитория
|
||||
// Пока используем данные по умолчанию
|
||||
_cycleData.value = CycleData(
|
||||
periodDay = 1,
|
||||
cycleDays = 30,
|
||||
ovulationInDays = 12,
|
||||
nextPeriodInDays = 25
|
||||
)
|
||||
}
|
||||
|
||||
fun updatePeriod(day: Int) {
|
||||
_cycleData.value = _cycleData.value.copy(periodDay = day)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.example.elva.ui.dashboard
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.example.elva.R
|
||||
import com.example.elva.databinding.ItemCalendarDayBinding
|
||||
|
||||
data class CalendarDay(
|
||||
val day: Int,
|
||||
val dayType: DayType,
|
||||
val isCurrentDay: Boolean = false
|
||||
)
|
||||
|
||||
enum class DayType {
|
||||
NORMAL,
|
||||
PERIOD,
|
||||
OVULATION,
|
||||
FERTILE,
|
||||
EMPTY
|
||||
}
|
||||
|
||||
class CalendarAdapter : ListAdapter<CalendarDay, CalendarAdapter.DayViewHolder>(DayDiffCallback()) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DayViewHolder {
|
||||
val binding = ItemCalendarDayBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return DayViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: DayViewHolder, position: Int) {
|
||||
holder.bind(getItem(position))
|
||||
}
|
||||
|
||||
class DayViewHolder(private val binding: ItemCalendarDayBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(calendarDay: CalendarDay) {
|
||||
if (calendarDay.dayType == DayType.EMPTY) {
|
||||
binding.dayText.text = ""
|
||||
binding.dayContainer.background = null
|
||||
return
|
||||
}
|
||||
|
||||
binding.dayText.text = calendarDay.day.toString()
|
||||
|
||||
// Устанавливаем фон в зависимости от типа дня
|
||||
val backgroundRes = when (calendarDay.dayType) {
|
||||
DayType.PERIOD -> R.drawable.day_circle_period
|
||||
DayType.OVULATION -> R.drawable.day_circle_ovulation
|
||||
DayType.FERTILE -> R.drawable.day_circle_fertile
|
||||
else -> if (calendarDay.isCurrentDay) R.drawable.day_circle_current else R.drawable.day_circle_default
|
||||
}
|
||||
|
||||
binding.dayContainer.background = ContextCompat.getDrawable(
|
||||
binding.root.context,
|
||||
backgroundRes
|
||||
)
|
||||
|
||||
// Цвет текста для текущего дня
|
||||
if (calendarDay.isCurrentDay && calendarDay.dayType == DayType.NORMAL) {
|
||||
binding.dayText.setTextColor(
|
||||
ContextCompat.getColor(binding.root.context, R.color.primary)
|
||||
)
|
||||
} else {
|
||||
binding.dayText.setTextColor(
|
||||
ContextCompat.getColor(binding.root.context, R.color.text_primary)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DayDiffCallback : DiffUtil.ItemCallback<CalendarDay>() {
|
||||
override fun areItemsTheSame(oldItem: CalendarDay, newItem: CalendarDay): Boolean {
|
||||
return oldItem.day == newItem.day
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: CalendarDay, newItem: CalendarDay): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package com.example.elva.ui.dashboard
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.example.elva.R
|
||||
import com.example.elva.databinding.FragmentDashboardBinding
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.*
|
||||
|
||||
@AndroidEntryPoint
|
||||
class DashboardFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentDashboardBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val viewModel: HealthViewModel by viewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentDashboardBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
setupDate()
|
||||
setupListeners()
|
||||
observeState()
|
||||
}
|
||||
|
||||
private fun setupDate() {
|
||||
val formatter = DateTimeFormatter.ofPattern("d MMMM", Locale("ru"))
|
||||
val today = LocalDate.now()
|
||||
binding.tvDate.text = "Сегодня, ${today.format(formatter)}"
|
||||
}
|
||||
|
||||
|
||||
private fun setupListeners() {
|
||||
binding.apply {
|
||||
// Переход к календарю цикла
|
||||
cardCycle.setOnClickListener {
|
||||
findNavController().navigate(R.id.action_dashboard_to_periodCalendar)
|
||||
}
|
||||
|
||||
// Добавление воды
|
||||
cardWater.setOnClickListener {
|
||||
showWaterDialog()
|
||||
}
|
||||
|
||||
// Добавление калорий
|
||||
cardCalories.setOnClickListener {
|
||||
showCaloriesDialog()
|
||||
}
|
||||
|
||||
// Добавление веса
|
||||
cardWeight.setOnClickListener {
|
||||
showWeightDialog()
|
||||
}
|
||||
|
||||
// Добавление сна
|
||||
cardSleep.setOnClickListener {
|
||||
showSleepDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeState() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.state.collect { state ->
|
||||
updateUI(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateUI(state: HealthState) {
|
||||
binding.apply {
|
||||
// Информация о цикле
|
||||
val daysUntil = viewModel.getDaysUntilNextPeriod()
|
||||
tvCycleInfo.text = if (daysUntil != null) {
|
||||
"До менструации: $daysUntil ${getDaysWord(daysUntil)}"
|
||||
} else {
|
||||
"Нет данных о цикле"
|
||||
}
|
||||
|
||||
// Вода
|
||||
tvWaterAmount.text = state.waterIntake.amount.toString()
|
||||
tvWaterGoal.text = "из ${state.waterIntake.goal} мл"
|
||||
|
||||
// Калории
|
||||
tvCaloriesAmount.text = state.calorieIntake.calories.toString()
|
||||
tvCaloriesGoal.text = "из ${state.calorieIntake.goal} ккал"
|
||||
|
||||
// Вес
|
||||
tvWeightAmount.text = state.latestWeight?.toString() ?: "--"
|
||||
|
||||
// Сон
|
||||
tvSleepAmount.text = state.sleepEntry?.hours?.toString() ?: "--"
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDaysWord(days: Int): String {
|
||||
return when {
|
||||
days % 10 == 1 && days % 100 != 11 -> "день"
|
||||
days % 10 in 2..4 && (days % 100 < 10 || days % 100 >= 20) -> "дня"
|
||||
else -> "дней"
|
||||
}
|
||||
}
|
||||
|
||||
private fun showWaterDialog() {
|
||||
val amounts = arrayOf("250 мл", "500 мл", "750 мл", "1000 мл")
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Добавить воду")
|
||||
.setItems(amounts) { _, which ->
|
||||
val amount = when (which) {
|
||||
0 -> 250
|
||||
1 -> 500
|
||||
2 -> 750
|
||||
else -> 1000
|
||||
}
|
||||
viewModel.addWater(amount)
|
||||
}
|
||||
.setNegativeButton("Отмена", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showCaloriesDialog() {
|
||||
val view = layoutInflater.inflate(R.layout.dialog_input, null)
|
||||
val input = view.findViewById<TextInputEditText>(R.id.editInput)
|
||||
input.hint = "Калории"
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Добавить калории")
|
||||
.setView(view)
|
||||
.setPositiveButton("Добавить") { _, _ ->
|
||||
val calories = input.text.toString().toIntOrNull()
|
||||
if (calories != null && calories > 0) {
|
||||
viewModel.addCalories(calories)
|
||||
}
|
||||
}
|
||||
.setNegativeButton("Отмена", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showWeightDialog() {
|
||||
val view = layoutInflater.inflate(R.layout.dialog_input, null)
|
||||
val input = view.findViewById<TextInputEditText>(R.id.editInput)
|
||||
input.hint = "Вес (кг)"
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Записать вес")
|
||||
.setView(view)
|
||||
.setPositiveButton("Сохранить") { _, _ ->
|
||||
val weight = input.text.toString().toFloatOrNull()
|
||||
if (weight != null && weight > 0) {
|
||||
viewModel.saveWeight(weight)
|
||||
}
|
||||
}
|
||||
.setNegativeButton("Отмена", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showSleepDialog() {
|
||||
val view = layoutInflater.inflate(R.layout.dialog_input, null)
|
||||
val input = view.findViewById<TextInputEditText>(R.id.editInput)
|
||||
input.hint = "Часов сна"
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Записать сон")
|
||||
.setView(view)
|
||||
.setPositiveButton("Сохранить") { _, _ ->
|
||||
val hours = input.text.toString().toFloatOrNull()
|
||||
if (hours != null && hours > 0) {
|
||||
viewModel.saveSleep(hours, com.example.elva.data.models.health.SleepQuality.NORMAL)
|
||||
}
|
||||
}
|
||||
.setNegativeButton("Отмена", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.example.elva.ui.dashboard
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.example.elva.R
|
||||
import com.example.elva.databinding.FragmentDashboardNewBinding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalTime
|
||||
|
||||
@AndroidEntryPoint
|
||||
class DashboardFragmentNew : Fragment() {
|
||||
|
||||
private var _binding: FragmentDashboardNewBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val viewModel: DashboardViewModel by viewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentDashboardNewBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
setupGreeting()
|
||||
setupListeners()
|
||||
observeState()
|
||||
|
||||
// Загружаем данные
|
||||
viewModel.loadDashboardData()
|
||||
}
|
||||
|
||||
private fun setupGreeting() {
|
||||
val hour = LocalTime.now().hour
|
||||
val greeting = when {
|
||||
hour < 12 -> getString(R.string.good_morning)
|
||||
hour < 18 -> getString(R.string.good_afternoon)
|
||||
else -> getString(R.string.good_evening)
|
||||
}
|
||||
binding.tvGreeting.text = greeting
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
binding.apply {
|
||||
// Карточка цикла - открыть календарь периодов
|
||||
cardCycle.setOnClickListener {
|
||||
findNavController().navigate(R.id.action_dashboard_to_periodCalendar)
|
||||
}
|
||||
|
||||
// Карточка календаря - открыть календарь периодов
|
||||
cardCalendar.setOnClickListener {
|
||||
findNavController().navigate(R.id.action_dashboard_to_periodCalendar)
|
||||
}
|
||||
|
||||
// Карточка воды - добавить воду
|
||||
cardWater.setOnClickListener {
|
||||
showAddWaterDialog()
|
||||
}
|
||||
|
||||
// Карточка калорий - открыть экран питания
|
||||
cardCalories.setOnClickListener {
|
||||
findNavController().navigate(R.id.action_dashboardFragment_to_nav_nutrition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeState() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.dashboardSummary.collect { summary ->
|
||||
updateUI(summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateUI(summary: DashboardSummary) {
|
||||
binding.apply {
|
||||
// Обновляем день цикла
|
||||
tvCycleDay.text = getString(R.string.cycle_day_number, summary.cycleDay)
|
||||
|
||||
// Обновляем дни до периода
|
||||
summary.nextPeriodDays?.let { days ->
|
||||
tvNextPeriod.text = days.toString()
|
||||
tvNextPeriodLabel.text = when {
|
||||
days == 0 -> getString(R.string.period_today)
|
||||
days == 1 -> getString(R.string.period_tomorrow)
|
||||
days < 0 -> getString(R.string.period_overdue, -days)
|
||||
else -> resources.getQuantityString(R.plurals.days_until_period, days, days)
|
||||
}
|
||||
} ?: run {
|
||||
tvNextPeriod.text = "—"
|
||||
tvNextPeriodLabel.text = getString(R.string.no_data)
|
||||
}
|
||||
|
||||
// Обновляем воду
|
||||
val waterProgress = (summary.waterProgress * 100).toInt()
|
||||
progressWater.progress = waterProgress
|
||||
tvWaterAmount.text = getString(
|
||||
R.string.water_progress,
|
||||
summary.waterAmount,
|
||||
summary.waterGoal
|
||||
)
|
||||
|
||||
// Обновляем калории
|
||||
val caloriesProgress = (summary.caloriesProgress * 100).toInt()
|
||||
progressCalories.progress = caloriesProgress
|
||||
tvCaloriesAmount.text = getString(
|
||||
R.string.calories_progress,
|
||||
summary.calories,
|
||||
summary.caloriesGoal
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showAddWaterDialog() {
|
||||
val amounts = arrayOf("100 мл", "200 мл", "250 мл", "500 мл")
|
||||
val amountsValues = arrayOf(100, 200, 250, 500)
|
||||
|
||||
androidx.appcompat.app.AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.add_water)
|
||||
.setItems(amounts) { _, which ->
|
||||
viewModel.addWater(amountsValues[which])
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.example.elva.ui.dashboard
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import javax.inject.Inject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import com.example.elva.data.repository.HealthRepository
|
||||
import com.example.elva.data.repository.DashboardSummary
|
||||
import java.time.LocalDate
|
||||
|
||||
@HiltViewModel
|
||||
class DashboardViewModel @Inject constructor(
|
||||
private val healthRepository: HealthRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _dashboardSummary = MutableStateFlow<DashboardSummary?>(null)
|
||||
val dashboardSummary: StateFlow<DashboardSummary?> = _dashboardSummary
|
||||
|
||||
private val _isLoading = MutableStateFlow(true)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error
|
||||
|
||||
init {
|
||||
loadDashboardData()
|
||||
}
|
||||
|
||||
fun loadDashboardData() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_isLoading.value = true
|
||||
val summary = healthRepository.getDashboardSummary()
|
||||
_dashboardSummary.value = summary
|
||||
_error.value = null
|
||||
} catch (e: Exception) {
|
||||
_error.value = e.message
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addWater(amount: Int) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
healthRepository.addWater(LocalDate.now(), amount)
|
||||
loadDashboardData()
|
||||
} catch (e: Exception) {
|
||||
_error.value = e.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
loadDashboardData()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.example.elva.ui.dashboard
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.elva.data.models.health.*
|
||||
import com.example.elva.data.repository.HealthRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class HealthViewModel @Inject constructor(
|
||||
private val healthRepository: HealthRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(HealthState())
|
||||
val state: StateFlow<HealthState> = _state
|
||||
|
||||
init {
|
||||
loadTodayData()
|
||||
}
|
||||
|
||||
fun loadTodayData() {
|
||||
val today = LocalDate.now()
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val waterIntakeData = healthRepository.getWaterIntakeForDate(today)
|
||||
val waterGoal = healthRepository.getWaterGoal()
|
||||
val waterIntake = WaterIntakeOld(
|
||||
date = today,
|
||||
amount = waterIntakeData?.amountMl ?: 0,
|
||||
goal = waterGoal
|
||||
)
|
||||
|
||||
val totalCalories = healthRepository.getTotalCaloriesForDate(today)
|
||||
val calorieGoal = healthRepository.getCalorieGoal()
|
||||
val calories = CalorieIntake(
|
||||
date = today,
|
||||
calories = totalCalories,
|
||||
goal = calorieGoal.dailyGoal
|
||||
)
|
||||
|
||||
val sleep = healthRepository.getSleepEntry(today)
|
||||
val weightEntries = healthRepository.getWeightEntries()
|
||||
val cycleInfo = healthRepository.getCycleInfo()
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
waterIntake = waterIntake,
|
||||
calorieIntake = calories,
|
||||
sleepEntry = sleep,
|
||||
latestWeight = weightEntries.maxByOrNull { it.date }?.weightKg,
|
||||
cycleInfo = cycleInfo,
|
||||
isLoading = false
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(
|
||||
error = e.message,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addWater(amount: Int) {
|
||||
viewModelScope.launch {
|
||||
val today = LocalDate.now()
|
||||
healthRepository.addWater(today, amount)
|
||||
val updated = healthRepository.getWaterIntakeForDate(today)
|
||||
val waterGoal = healthRepository.getWaterGoal()
|
||||
_state.value = _state.value.copy(
|
||||
waterIntake = WaterIntakeOld(
|
||||
date = today,
|
||||
amount = updated?.amountMl ?: amount,
|
||||
goal = waterGoal
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun addCalories(calories: Int) {
|
||||
viewModelScope.launch {
|
||||
val entry = CalorieEntry(
|
||||
date = LocalDate.now(),
|
||||
calories = calories,
|
||||
mealType = MealType.SNACK,
|
||||
description = "Перекус",
|
||||
timestamp = java.time.LocalDateTime.now()
|
||||
)
|
||||
healthRepository.addCalorieEntry(entry)
|
||||
val total = healthRepository.getTotalCaloriesForDate(LocalDate.now())
|
||||
val current = _state.value.calorieIntake
|
||||
_state.value = _state.value.copy(
|
||||
calorieIntake = current.copy(calories = total)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveSleep(hours: Float, quality: SleepQuality, note: String? = null) {
|
||||
viewModelScope.launch {
|
||||
val now = java.time.LocalDateTime.now()
|
||||
val entry = SleepEntry(
|
||||
date = LocalDate.now(),
|
||||
sleepStart = now.minusHours(hours.toLong()),
|
||||
sleepEnd = now,
|
||||
quality = quality,
|
||||
notes = note ?: ""
|
||||
)
|
||||
healthRepository.addSleepEntry(entry)
|
||||
_state.value = _state.value.copy(sleepEntry = entry)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveWeight(weightKg: Float, note: String? = null) {
|
||||
viewModelScope.launch {
|
||||
val entry = WeightEntry(
|
||||
date = LocalDate.now(),
|
||||
weightKg = weightKg,
|
||||
notes = note ?: ""
|
||||
)
|
||||
healthRepository.addWeightEntry(entry)
|
||||
_state.value = _state.value.copy(latestWeight = weightKg)
|
||||
}
|
||||
}
|
||||
|
||||
fun getDaysUntilNextPeriod(): Int? {
|
||||
val nextPeriod = _state.value.cycleInfo.nextPeriodPrediction ?: return null
|
||||
val today = LocalDate.now()
|
||||
return if (nextPeriod.isAfter(today)) {
|
||||
java.time.temporal.ChronoUnit.DAYS.between(today, nextPeriod).toInt()
|
||||
} else null
|
||||
}
|
||||
}
|
||||
|
||||
data class HealthState(
|
||||
val waterIntake: WaterIntakeOld = WaterIntakeOld(date = LocalDate.now(), amount = 0),
|
||||
val calorieIntake: CalorieIntake = CalorieIntake(date = LocalDate.now(), calories = 0),
|
||||
val sleepEntry: SleepEntry? = null,
|
||||
val latestWeight: Float? = null,
|
||||
val cycleInfo: CycleInfo = CycleInfo(
|
||||
lastPeriodStart = null,
|
||||
nextPeriodPrediction = null,
|
||||
ovulationPrediction = null
|
||||
),
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.example.elva.ui.health
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.example.elva.R
|
||||
import com.example.elva.databinding.DialogCaloriesInputBinding
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
|
||||
class CaloriesInputDialog(
|
||||
private val currentValue: Int,
|
||||
private val goal: Int,
|
||||
private val onSave: (consumed: Int, goal: Int) -> Unit
|
||||
) : DialogFragment() {
|
||||
|
||||
private var _binding: DialogCaloriesInputBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
_binding = DialogCaloriesInputBinding.inflate(layoutInflater)
|
||||
|
||||
// Устанавливаем текущие значения
|
||||
binding.etConsumed.setText(currentValue.toString())
|
||||
binding.etGoal.setText(goal.toString())
|
||||
|
||||
return MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.calories)
|
||||
.setView(binding.root)
|
||||
.setPositiveButton(R.string.save) { _, _ ->
|
||||
val consumed = binding.etConsumed.text.toString().toIntOrNull() ?: 0
|
||||
val newGoal = binding.etGoal.text.toString().toIntOrNull() ?: goal
|
||||
onSave(consumed, newGoal)
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.create()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.example.elva.ui.health
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.example.elva.R
|
||||
import com.example.elva.databinding.FragmentHealthDashboardBinding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@AndroidEntryPoint
|
||||
class HealthDashboardFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentHealthDashboardBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val viewModel: HealthDashboardViewModel by viewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentHealthDashboardBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
setupListeners()
|
||||
observeState()
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
binding.apply {
|
||||
// Навигация к календарю
|
||||
calendarIcon.setOnClickListener {
|
||||
findNavController().navigate(R.id.action_healthDashboard_to_periodCalendar)
|
||||
}
|
||||
|
||||
// Клики на карточки для редактирования
|
||||
cardCalories.setOnClickListener {
|
||||
showCaloriesDialog()
|
||||
}
|
||||
|
||||
cardWater.setOnClickListener {
|
||||
showWaterDialog()
|
||||
}
|
||||
|
||||
cardWeight.setOnClickListener {
|
||||
showWeightDialog()
|
||||
}
|
||||
|
||||
cardSleep.setOnClickListener {
|
||||
showSleepDialog()
|
||||
}
|
||||
|
||||
// Журнал
|
||||
btnJournal.setOnClickListener {
|
||||
findNavController().navigate(R.id.action_healthDashboard_to_healthJournal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeState() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.state.collect { state ->
|
||||
updateUI(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateUI(state: HealthDashboardState) {
|
||||
binding.apply {
|
||||
// Калории
|
||||
valueCalories.text = state.caloriesConsumed.toString()
|
||||
goalCalories.text = getString(R.string.calorie_goal_format, state.caloriesGoal)
|
||||
progressCalories.progress = state.caloriesPercent
|
||||
|
||||
// Вода
|
||||
valueWater.text = state.waterConsumed.toString()
|
||||
goalWater.text = getString(R.string.water_goal_format, state.waterGoal)
|
||||
progressWater.progress = state.waterPercent
|
||||
|
||||
// Вес
|
||||
if (state.currentWeight != null) {
|
||||
valueWeight.text = String.format("%.1f", state.currentWeight)
|
||||
|
||||
if (state.weightChange != 0f) {
|
||||
val sign = if (state.weightChange > 0) "+" else ""
|
||||
changeWeight.text = getString(
|
||||
R.string.weight_change_format,
|
||||
sign,
|
||||
String.format("%.1f", state.weightChange)
|
||||
)
|
||||
changeWeight.visibility = View.VISIBLE
|
||||
} else {
|
||||
changeWeight.visibility = View.GONE
|
||||
}
|
||||
} else {
|
||||
valueWeight.text = "—"
|
||||
changeWeight.visibility = View.GONE
|
||||
}
|
||||
|
||||
// Сон
|
||||
valueSleep.text = String.format("%.1f", state.sleepHours)
|
||||
goalSleep.text = getString(R.string.sleep_goal_format, state.sleepGoal)
|
||||
progressSleep.progress = state.sleepPercent
|
||||
}
|
||||
}
|
||||
|
||||
private fun showCaloriesDialog() {
|
||||
// Показываем диалог для ввода калорий
|
||||
CaloriesInputDialog(
|
||||
currentValue = viewModel.state.value.caloriesConsumed,
|
||||
goal = viewModel.state.value.caloriesGoal,
|
||||
onSave = { consumed, goal ->
|
||||
viewModel.updateCalories(consumed)
|
||||
if (goal != viewModel.state.value.caloriesGoal) {
|
||||
viewModel.setCalorieGoal(goal)
|
||||
}
|
||||
}
|
||||
).show(childFragmentManager, "calories_dialog")
|
||||
}
|
||||
|
||||
private fun showWaterDialog() {
|
||||
WaterInputDialog(
|
||||
currentValue = viewModel.state.value.waterConsumed,
|
||||
goal = viewModel.state.value.waterGoal,
|
||||
onSave = { consumed, goal ->
|
||||
viewModel.updateWater(consumed)
|
||||
if (goal != viewModel.state.value.waterGoal) {
|
||||
viewModel.setWaterGoal(goal)
|
||||
}
|
||||
}
|
||||
).show(childFragmentManager, "water_dialog")
|
||||
}
|
||||
|
||||
private fun showWeightDialog() {
|
||||
WeightInputDialog(
|
||||
currentValue = viewModel.state.value.currentWeight,
|
||||
goal = viewModel.state.value.weightGoal,
|
||||
onSave = { weight, goal ->
|
||||
viewModel.updateWeight(weight)
|
||||
if (goal != null && goal != viewModel.state.value.weightGoal) {
|
||||
viewModel.setWeightGoal(goal)
|
||||
}
|
||||
}
|
||||
).show(childFragmentManager, "weight_dialog")
|
||||
}
|
||||
|
||||
private fun showSleepDialog() {
|
||||
SleepInputDialog(
|
||||
currentValue = viewModel.state.value.sleepHours,
|
||||
goal = viewModel.state.value.sleepGoal,
|
||||
quality = viewModel.state.value.sleepQuality,
|
||||
onSave = { hours, goal, quality ->
|
||||
viewModel.updateSleep(hours, quality)
|
||||
if (goal != viewModel.state.value.sleepGoal) {
|
||||
viewModel.setSleepGoal(goal)
|
||||
}
|
||||
}
|
||||
).show(childFragmentManager, "sleep_dialog")
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.loadHealthData()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.example.elva.ui.health
|
||||
|
||||
data class HealthDashboardState(
|
||||
val currentCycleDay: Int = 0,
|
||||
val nextPeriodDays: Int = 0,
|
||||
val nextPeriodDate: String = "",
|
||||
|
||||
// Калории
|
||||
val caloriesConsumed: Int = 0,
|
||||
val caloriesGoal: Int = 2000,
|
||||
val caloriesPercent: Int = 0,
|
||||
|
||||
// Вода
|
||||
val waterConsumed: Int = 0, // в мл
|
||||
val waterGoal: Int = 2000, // в мл
|
||||
val waterPercent: Int = 0,
|
||||
|
||||
// Вес
|
||||
val currentWeight: Float? = null,
|
||||
val weightGoal: Float? = null,
|
||||
val weightChange: Float = 0f,
|
||||
|
||||
// Сон
|
||||
val sleepHours: Float = 0f,
|
||||
val sleepGoal: Float = 8f,
|
||||
val sleepPercent: Int = 0,
|
||||
val sleepQuality: SleepQuality = SleepQuality.GOOD,
|
||||
|
||||
val isLoading: Boolean = false
|
||||
)
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.example.elva.ui.health
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class HealthDashboardViewModel @Inject constructor(
|
||||
private val healthRepository: HealthRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(HealthDashboardState())
|
||||
val state: StateFlow<HealthDashboardState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
loadHealthData()
|
||||
}
|
||||
|
||||
fun loadHealthData() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true)
|
||||
|
||||
// Загрузка данных периода
|
||||
val lastPeriod = healthRepository.getLastPeriod()
|
||||
val (cycleDay, nextPeriodDays, nextPeriodDate) = calculatePeriodInfo(lastPeriod)
|
||||
|
||||
// Загрузка калорий
|
||||
val todayCalories = healthRepository.getTodayCalories()
|
||||
val calorieGoal = healthRepository.getCalorieGoal()
|
||||
val caloriesConsumed = todayCalories?.consumed ?: 0
|
||||
val caloriesPercent = if (calorieGoal > 0)
|
||||
((caloriesConsumed.toFloat() / calorieGoal) * 100).toInt().coerceIn(0, 100)
|
||||
else 0
|
||||
|
||||
// Загрузка воды
|
||||
val todayWater = healthRepository.getTodayWater()
|
||||
val waterGoal = healthRepository.getWaterGoal()
|
||||
val waterConsumed = todayWater?.consumed ?: 0
|
||||
val waterPercent = if (waterGoal > 0)
|
||||
((waterConsumed.toFloat() / waterGoal) * 100).toInt().coerceIn(0, 100)
|
||||
else 0
|
||||
|
||||
// Загрузка веса
|
||||
val latestWeight = healthRepository.getLatestWeight()
|
||||
val weightGoal = healthRepository.getWeightGoal()
|
||||
val weightEntries = healthRepository.getWeightEntries()
|
||||
val weightChange = if (weightEntries.size >= 2) {
|
||||
val current = weightEntries.last().weight
|
||||
val previous = weightEntries[weightEntries.size - 2].weight
|
||||
current - previous
|
||||
} else 0f
|
||||
|
||||
// Загрузка сна
|
||||
val lastSleep = healthRepository.getLastNightSleep()
|
||||
val sleepGoal = healthRepository.getSleepGoal()
|
||||
val sleepHours = lastSleep?.hours ?: 0f
|
||||
val sleepPercent = if (sleepGoal > 0)
|
||||
((sleepHours / sleepGoal) * 100).toInt().coerceIn(0, 100)
|
||||
else 0
|
||||
|
||||
_state.value = HealthDashboardState(
|
||||
currentCycleDay = cycleDay,
|
||||
nextPeriodDays = nextPeriodDays,
|
||||
nextPeriodDate = nextPeriodDate,
|
||||
caloriesConsumed = caloriesConsumed,
|
||||
caloriesGoal = calorieGoal,
|
||||
caloriesPercent = caloriesPercent,
|
||||
waterConsumed = waterConsumed,
|
||||
waterGoal = waterGoal,
|
||||
waterPercent = waterPercent,
|
||||
currentWeight = latestWeight?.weight,
|
||||
weightGoal = weightGoal,
|
||||
weightChange = weightChange,
|
||||
sleepHours = sleepHours,
|
||||
sleepGoal = sleepGoal,
|
||||
sleepPercent = sleepPercent,
|
||||
sleepQuality = lastSleep?.quality ?: SleepQuality.GOOD,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCalories(consumed: Int) {
|
||||
viewModelScope.launch {
|
||||
val goal = healthRepository.getCalorieGoal()
|
||||
healthRepository.saveCalorieEntry(
|
||||
CalorieEntry(consumed = consumed, goal = goal)
|
||||
)
|
||||
loadHealthData()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateWater(consumed: Int) {
|
||||
viewModelScope.launch {
|
||||
val goal = healthRepository.getWaterGoal()
|
||||
healthRepository.saveWaterEntry(
|
||||
WaterEntry(consumed = consumed, goal = goal)
|
||||
)
|
||||
loadHealthData()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateWeight(weight: Float) {
|
||||
viewModelScope.launch {
|
||||
val goal = healthRepository.getWeightGoal()
|
||||
healthRepository.saveWeightEntry(
|
||||
WeightEntry(weight = weight, goal = goal)
|
||||
)
|
||||
loadHealthData()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSleep(hours: Float, quality: SleepQuality) {
|
||||
viewModelScope.launch {
|
||||
val goal = healthRepository.getSleepGoal()
|
||||
healthRepository.saveSleepEntry(
|
||||
SleepEntry(hours = hours, goal = goal, quality = quality)
|
||||
)
|
||||
loadHealthData()
|
||||
}
|
||||
}
|
||||
|
||||
fun setCalorieGoal(goal: Int) {
|
||||
viewModelScope.launch {
|
||||
healthRepository.setCalorieGoal(goal)
|
||||
loadHealthData()
|
||||
}
|
||||
}
|
||||
|
||||
fun setWaterGoal(goal: Int) {
|
||||
viewModelScope.launch {
|
||||
healthRepository.setWaterGoal(goal)
|
||||
loadHealthData()
|
||||
}
|
||||
}
|
||||
|
||||
fun setWeightGoal(goal: Float) {
|
||||
viewModelScope.launch {
|
||||
healthRepository.setWeightGoal(goal)
|
||||
loadHealthData()
|
||||
}
|
||||
}
|
||||
|
||||
fun setSleepGoal(goal: Float) {
|
||||
viewModelScope.launch {
|
||||
healthRepository.setSleepGoal(goal)
|
||||
loadHealthData()
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculatePeriodInfo(lastPeriod: PeriodData?): Triple<Int, Int, String> {
|
||||
if (lastPeriod == null) {
|
||||
return Triple(0, 0, "—")
|
||||
}
|
||||
|
||||
val calendar = Calendar.getInstance()
|
||||
val today = calendar.timeInMillis
|
||||
val startDate = lastPeriod.startDate
|
||||
|
||||
// Вычисляем день цикла
|
||||
val daysDiff = ((today - startDate) / (24 * 60 * 60 * 1000)).toInt()
|
||||
val cycleDay = (daysDiff % lastPeriod.cycleLength) + 1
|
||||
|
||||
// Вычисляем дни до следующего периода
|
||||
val daysUntilNext = lastPeriod.cycleLength - cycleDay + 1
|
||||
|
||||
// Вычисляем дату следующего периода
|
||||
calendar.timeInMillis = startDate
|
||||
calendar.add(Calendar.DAY_OF_MONTH, lastPeriod.cycleLength)
|
||||
val dateFormat = SimpleDateFormat("d MMMM", Locale("ru"))
|
||||
val nextDateString = dateFormat.format(calendar.time)
|
||||
|
||||
return Triple(cycleDay, daysUntilNext, nextDateString)
|
||||
}
|
||||
|
||||
fun openCalendar() {
|
||||
// Навигация к календарю будет обработана во фрагменте
|
||||
}
|
||||
}
|
||||
|
||||
147
app/src/main/java/com/example/elva/ui/health/HealthFragment.kt
Normal file
147
app/src/main/java/com/example/elva/ui/health/HealthFragment.kt
Normal file
@@ -0,0 +1,147 @@
|
||||
package com.example.elva.ui.health
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.example.elva.R
|
||||
import com.example.elva.databinding.FragmentHealthBinding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
|
||||
@AndroidEntryPoint
|
||||
class HealthFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentHealthBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val viewModel: HealthViewModel by viewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentHealthBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
setupClickListeners()
|
||||
observeUiState()
|
||||
}
|
||||
|
||||
private fun setupClickListeners() {
|
||||
binding.cardCalendar.setOnClickListener {
|
||||
// Navigate to calendar fragment
|
||||
findNavController().navigate(R.id.action_health_to_calendar)
|
||||
}
|
||||
|
||||
binding.cardWater.setOnClickListener {
|
||||
showAddWaterDialog()
|
||||
}
|
||||
|
||||
binding.cardCalories.setOnClickListener {
|
||||
showAddCaloriesDialog()
|
||||
}
|
||||
|
||||
binding.cardWeight.setOnClickListener {
|
||||
showAddWeightDialog()
|
||||
}
|
||||
|
||||
binding.cardSleep.setOnClickListener {
|
||||
showAddSleepDialog()
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeUiState() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.uiState.collect { state ->
|
||||
when (state) {
|
||||
is HealthUiState.Loading -> {
|
||||
// Show loading
|
||||
}
|
||||
is HealthUiState.Success -> {
|
||||
updateUI(state)
|
||||
}
|
||||
is HealthUiState.Error -> {
|
||||
// Show error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateUI(state: HealthUiState.Success) {
|
||||
// Update cycle info
|
||||
val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
.withLocale(Locale("ru"))
|
||||
|
||||
binding.tvNextPeriod.text = state.cycleStats.nextPeriodStart?.format(dateFormatter) ?: "-"
|
||||
binding.tvCycleLength.text = "${state.cycleStats.averageCycleLength} дней"
|
||||
|
||||
// Update water
|
||||
val waterPercent = if (state.waterGoal > 0) {
|
||||
(state.waterIntake * 100 / state.waterGoal).coerceIn(0, 100)
|
||||
} else 0
|
||||
binding.progressWater.progress = waterPercent
|
||||
binding.tvWaterProgress.text = "${state.waterIntake}/${state.waterGoal} мл"
|
||||
|
||||
// Update calories
|
||||
val caloriesPercent = if (state.calorieGoal > 0) {
|
||||
(state.caloriesConsumed * 100 / state.calorieGoal).coerceIn(0, 100)
|
||||
} else 0
|
||||
binding.progressCalories.progress = caloriesPercent
|
||||
binding.tvCaloriesProgress.text = "${state.caloriesConsumed}/${state.calorieGoal} ккал"
|
||||
|
||||
// Update weight
|
||||
binding.tvWeight.text = if (state.currentWeight != null) {
|
||||
"%.1f кг".format(state.currentWeight)
|
||||
} else {
|
||||
"Нет данных"
|
||||
}
|
||||
|
||||
// Update sleep
|
||||
val sleepPercent = if (state.sleepGoal > 0 && state.todaySleep != null) {
|
||||
((state.todaySleep / state.sleepGoal) * 100).toInt().coerceIn(0, 100)
|
||||
} else 0
|
||||
binding.progressSleep.progress = sleepPercent
|
||||
binding.tvSleep.text = if (state.todaySleep != null) {
|
||||
"%.1f ч".format(state.todaySleep)
|
||||
} else {
|
||||
"Нет данных"
|
||||
}
|
||||
}
|
||||
|
||||
private fun showAddWaterDialog() {
|
||||
// TODO: Implement water dialog
|
||||
}
|
||||
|
||||
private fun showAddCaloriesDialog() {
|
||||
// TODO: Implement calories dialog
|
||||
}
|
||||
|
||||
private fun showAddWeightDialog() {
|
||||
// TODO: Implement weight dialog
|
||||
}
|
||||
|
||||
private fun showAddSleepDialog() {
|
||||
// TODO: Implement sleep dialog
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
|
||||
90
app/src/main/java/com/example/elva/ui/health/HealthMetric.kt
Normal file
90
app/src/main/java/com/example/elva/ui/health/HealthMetric.kt
Normal file
@@ -0,0 +1,90 @@
|
||||
package com.example.elva.ui.health
|
||||
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Общая модель метрики здоровья
|
||||
*/
|
||||
sealed class HealthMetric(
|
||||
open val id: String,
|
||||
open val timestamp: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
/**
|
||||
* Метрика калорий
|
||||
*/
|
||||
data class CalorieEntry(
|
||||
override val id: String = UUID.randomUUID().toString(),
|
||||
val consumed: Int,
|
||||
val goal: Int,
|
||||
override val timestamp: Long = System.currentTimeMillis()
|
||||
) : HealthMetric(id, timestamp)
|
||||
|
||||
/**
|
||||
* Метрика воды
|
||||
*/
|
||||
data class WaterEntry(
|
||||
override val id: String = UUID.randomUUID().toString(),
|
||||
val consumed: Int, // в мл
|
||||
val goal: Int, // в мл
|
||||
override val timestamp: Long = System.currentTimeMillis()
|
||||
) : HealthMetric(id, timestamp)
|
||||
|
||||
/**
|
||||
* Метрика веса
|
||||
*/
|
||||
data class WeightEntry(
|
||||
override val id: String = UUID.randomUUID().toString(),
|
||||
val weight: Float, // в кг
|
||||
val goal: Float?, // целевой вес в кг
|
||||
override val timestamp: Long = System.currentTimeMillis()
|
||||
) : HealthMetric(id, timestamp)
|
||||
|
||||
/**
|
||||
* Метрика сна
|
||||
*/
|
||||
data class SleepEntry(
|
||||
override val id: String = UUID.randomUUID().toString(),
|
||||
val hours: Float,
|
||||
val goal: Float,
|
||||
val quality: SleepQuality = SleepQuality.GOOD,
|
||||
override val timestamp: Long = System.currentTimeMillis()
|
||||
) : HealthMetric(id, timestamp)
|
||||
|
||||
enum class SleepQuality {
|
||||
EXCELLENT,
|
||||
GOOD,
|
||||
FAIR,
|
||||
POOR
|
||||
}
|
||||
|
||||
/**
|
||||
* Запись в журнале
|
||||
*/
|
||||
data class HealthNote(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val title: String,
|
||||
val content: String,
|
||||
val category: NoteCategory,
|
||||
val timestamp: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
enum class NoteCategory {
|
||||
SYMPTOMS,
|
||||
MOOD,
|
||||
EXERCISE,
|
||||
MEDICATION,
|
||||
OTHER
|
||||
}
|
||||
|
||||
/**
|
||||
* Данные периода для календаря
|
||||
*/
|
||||
data class PeriodData(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val startDate: Long,
|
||||
val endDate: Long?,
|
||||
val cycleLength: Int = 28,
|
||||
val periodLength: Int = 5
|
||||
)
|
||||
|
||||
198
app/src/main/java/com/example/elva/ui/health/HealthRepository.kt
Normal file
198
app/src/main/java/com/example/elva/ui/health/HealthRepository.kt
Normal file
@@ -0,0 +1,198 @@
|
||||
package com.example.elva.ui.health
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Репозиторий для работы с данными здоровья
|
||||
*/
|
||||
@Singleton
|
||||
class HealthRepository @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("health_data", Context.MODE_PRIVATE)
|
||||
private val gson = Gson()
|
||||
|
||||
// Калории
|
||||
fun saveCalorieEntry(entry: CalorieEntry) {
|
||||
val entries = getCalorieEntries().toMutableList()
|
||||
entries.add(entry)
|
||||
saveCalorieEntries(entries)
|
||||
}
|
||||
|
||||
fun getCalorieEntries(): List<CalorieEntry> {
|
||||
val json = prefs.getString("calorie_entries", "[]") ?: "[]"
|
||||
val type = object : TypeToken<List<CalorieEntry>>() {}.type
|
||||
return gson.fromJson(json, type)
|
||||
}
|
||||
|
||||
private fun saveCalorieEntries(entries: List<CalorieEntry>) {
|
||||
prefs.edit().putString("calorie_entries", gson.toJson(entries)).apply()
|
||||
}
|
||||
|
||||
fun getTodayCalories(): CalorieEntry? {
|
||||
return getCalorieEntries()
|
||||
.filter { isToday(it.timestamp) }
|
||||
.maxByOrNull { it.timestamp }
|
||||
}
|
||||
|
||||
// Вода
|
||||
fun saveWaterEntry(entry: WaterEntry) {
|
||||
val entries = getWaterEntries().toMutableList()
|
||||
entries.add(entry)
|
||||
saveWaterEntries(entries)
|
||||
}
|
||||
|
||||
fun getWaterEntries(): List<WaterEntry> {
|
||||
val json = prefs.getString("water_entries", "[]") ?: "[]"
|
||||
val type = object : TypeToken<List<WaterEntry>>() {}.type
|
||||
return gson.fromJson(json, type)
|
||||
}
|
||||
|
||||
private fun saveWaterEntries(entries: List<WaterEntry>) {
|
||||
prefs.edit().putString("water_entries", gson.toJson(entries)).apply()
|
||||
}
|
||||
|
||||
fun getTodayWater(): WaterEntry? {
|
||||
return getWaterEntries()
|
||||
.filter { isToday(it.timestamp) }
|
||||
.maxByOrNull { it.timestamp }
|
||||
}
|
||||
|
||||
// Вес
|
||||
fun saveWeightEntry(entry: WeightEntry) {
|
||||
val entries = getWeightEntries().toMutableList()
|
||||
entries.add(entry)
|
||||
saveWeightEntries(entries)
|
||||
}
|
||||
|
||||
fun getWeightEntries(): List<WeightEntry> {
|
||||
val json = prefs.getString("weight_entries", "[]") ?: "[]"
|
||||
val type = object : TypeToken<List<WeightEntry>>() {}.type
|
||||
return gson.fromJson(json, type)
|
||||
}
|
||||
|
||||
private fun saveWeightEntries(entries: List<WeightEntry>) {
|
||||
prefs.edit().putString("weight_entries", gson.toJson(entries)).apply()
|
||||
}
|
||||
|
||||
fun getLatestWeight(): WeightEntry? {
|
||||
return getWeightEntries().maxByOrNull { it.timestamp }
|
||||
}
|
||||
|
||||
// Сон
|
||||
fun saveSleepEntry(entry: SleepEntry) {
|
||||
val entries = getSleepEntries().toMutableList()
|
||||
entries.add(entry)
|
||||
saveSleepEntries(entries)
|
||||
}
|
||||
|
||||
fun getSleepEntries(): List<SleepEntry> {
|
||||
val json = prefs.getString("sleep_entries", "[]") ?: "[]"
|
||||
val type = object : TypeToken<List<SleepEntry>>() {}.type
|
||||
return gson.fromJson(json, type)
|
||||
}
|
||||
|
||||
private fun saveSleepEntries(entries: List<SleepEntry>) {
|
||||
prefs.edit().putString("sleep_entries", gson.toJson(entries)).apply()
|
||||
}
|
||||
|
||||
fun getLastNightSleep(): SleepEntry? {
|
||||
return getSleepEntries()
|
||||
.filter { isYesterday(it.timestamp) || isToday(it.timestamp) }
|
||||
.maxByOrNull { it.timestamp }
|
||||
}
|
||||
|
||||
// Заметки
|
||||
fun saveNote(note: HealthNote) {
|
||||
val notes = getNotes().toMutableList()
|
||||
notes.add(note)
|
||||
saveNotes(notes)
|
||||
}
|
||||
|
||||
fun getNotes(): List<HealthNote> {
|
||||
val json = prefs.getString("health_notes", "[]") ?: "[]"
|
||||
val type = object : TypeToken<List<HealthNote>>() {}.type
|
||||
return gson.fromJson(json, type)
|
||||
}
|
||||
|
||||
private fun saveNotes(notes: List<HealthNote>) {
|
||||
prefs.edit().putString("health_notes", gson.toJson(notes)).apply()
|
||||
}
|
||||
|
||||
fun deleteNote(noteId: String) {
|
||||
val notes = getNotes().filter { it.id != noteId }
|
||||
saveNotes(notes)
|
||||
}
|
||||
|
||||
// Периоды
|
||||
fun savePeriod(period: PeriodData) {
|
||||
val periods = getPeriods().toMutableList()
|
||||
periods.add(period)
|
||||
savePeriods(periods)
|
||||
}
|
||||
|
||||
fun getPeriods(): List<PeriodData> {
|
||||
val json = prefs.getString("periods", "[]") ?: "[]"
|
||||
val type = object : TypeToken<List<PeriodData>>() {}.type
|
||||
return gson.fromJson(json, type)
|
||||
}
|
||||
|
||||
private fun savePeriods(periods: List<PeriodData>) {
|
||||
prefs.edit().putString("periods", gson.toJson(periods)).apply()
|
||||
}
|
||||
|
||||
fun getLastPeriod(): PeriodData? {
|
||||
return getPeriods().maxByOrNull { it.startDate }
|
||||
}
|
||||
|
||||
// Цели
|
||||
fun setCalorieGoal(goal: Int) {
|
||||
prefs.edit().putInt("calorie_goal", goal).apply()
|
||||
}
|
||||
|
||||
fun getCalorieGoal(): Int = prefs.getInt("calorie_goal", 2000)
|
||||
|
||||
fun setWaterGoal(goal: Int) {
|
||||
prefs.edit().putInt("water_goal", goal).apply()
|
||||
}
|
||||
|
||||
fun getWaterGoal(): Int = prefs.getInt("water_goal", 2000)
|
||||
|
||||
fun setWeightGoal(goal: Float) {
|
||||
prefs.edit().putFloat("weight_goal", goal).apply()
|
||||
}
|
||||
|
||||
fun getWeightGoal(): Float? {
|
||||
val goal = prefs.getFloat("weight_goal", -1f)
|
||||
return if (goal > 0) goal else null
|
||||
}
|
||||
|
||||
fun setSleepGoal(goal: Float) {
|
||||
prefs.edit().putFloat("sleep_goal", goal).apply()
|
||||
}
|
||||
|
||||
fun getSleepGoal(): Float = prefs.getFloat("sleep_goal", 8f)
|
||||
|
||||
// Вспомогательные функции
|
||||
private fun isToday(timestamp: Long): Boolean {
|
||||
val cal1 = java.util.Calendar.getInstance().apply { timeInMillis = timestamp }
|
||||
val cal2 = java.util.Calendar.getInstance()
|
||||
return cal1.get(java.util.Calendar.YEAR) == cal2.get(java.util.Calendar.YEAR) &&
|
||||
cal1.get(java.util.Calendar.DAY_OF_YEAR) == cal2.get(java.util.Calendar.DAY_OF_YEAR)
|
||||
}
|
||||
|
||||
private fun isYesterday(timestamp: Long): Boolean {
|
||||
val cal1 = java.util.Calendar.getInstance().apply { timeInMillis = timestamp }
|
||||
val cal2 = java.util.Calendar.getInstance().apply { add(java.util.Calendar.DAY_OF_YEAR, -1) }
|
||||
return cal1.get(java.util.Calendar.YEAR) == cal2.get(java.util.Calendar.YEAR) &&
|
||||
cal1.get(java.util.Calendar.DAY_OF_YEAR) == cal2.get(java.util.Calendar.DAY_OF_YEAR)
|
||||
}
|
||||
}
|
||||
|
||||
169
app/src/main/java/com/example/elva/ui/health/HealthViewModel.kt
Normal file
169
app/src/main/java/com/example/elva/ui/health/HealthViewModel.kt
Normal file
@@ -0,0 +1,169 @@
|
||||
package com.example.elva.ui.health
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.elva.data.models.health.*
|
||||
import com.example.elva.data.repository.HealthRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class HealthViewModel @Inject constructor(
|
||||
private val healthRepository: HealthRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow<HealthUiState>(HealthUiState.Loading)
|
||||
val uiState: StateFlow<HealthUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _selectedDate = MutableStateFlow(LocalDate.now())
|
||||
val selectedDate: StateFlow<LocalDate> = _selectedDate.asStateFlow()
|
||||
|
||||
init {
|
||||
loadHealthData()
|
||||
}
|
||||
|
||||
fun loadHealthData() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val today = LocalDate.now()
|
||||
|
||||
// Load all data
|
||||
val cycleStats = healthRepository.calculateCycleStats()
|
||||
val waterIntake = healthRepository.getWaterIntakeForDate(today)
|
||||
val waterGoal = healthRepository.getWaterGoal()
|
||||
val calorieEntries = healthRepository.getCalorieEntriesForDate(today)
|
||||
val calorieGoal = healthRepository.getCalorieGoal()
|
||||
val latestWeight = healthRepository.getLatestWeight()
|
||||
val sleepEntry = healthRepository.getSleepEntryForDate(today)
|
||||
val sleepGoal = healthRepository.getSleepGoal()
|
||||
val avgSleep = healthRepository.getAverageSleepHours()
|
||||
|
||||
_uiState.value = HealthUiState.Success(
|
||||
cycleStats = cycleStats,
|
||||
waterIntake = waterIntake?.amountMl ?: 0,
|
||||
waterGoal = waterGoal,
|
||||
caloriesConsumed = calorieEntries.sumOf { it.calories },
|
||||
calorieGoal = calorieGoal.dailyGoal,
|
||||
currentWeight = latestWeight?.weightKg,
|
||||
todaySleep = sleepEntry?.durationHours,
|
||||
sleepGoal = sleepGoal,
|
||||
averageSleep = avgSleep
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = HealthUiState.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setSelectedDate(date: LocalDate) {
|
||||
_selectedDate.value = date
|
||||
}
|
||||
|
||||
// Water tracking
|
||||
fun addWater(amountMl: Int) {
|
||||
viewModelScope.launch {
|
||||
healthRepository.addWater(LocalDate.now(), amountMl)
|
||||
loadHealthData()
|
||||
}
|
||||
}
|
||||
|
||||
fun setWaterGoal(goalMl: Int) {
|
||||
viewModelScope.launch {
|
||||
healthRepository.setWaterGoal(goalMl)
|
||||
loadHealthData()
|
||||
}
|
||||
}
|
||||
|
||||
// Calorie tracking
|
||||
fun addCalorieEntry(calories: Int, mealType: MealType, description: String = "") {
|
||||
viewModelScope.launch {
|
||||
val entry = CalorieEntry(
|
||||
date = LocalDate.now(),
|
||||
calories = calories,
|
||||
mealType = mealType,
|
||||
description = description
|
||||
)
|
||||
healthRepository.addCalorieEntry(entry)
|
||||
loadHealthData()
|
||||
}
|
||||
}
|
||||
|
||||
fun setCalorieGoal(goal: CalorieGoal) {
|
||||
viewModelScope.launch {
|
||||
healthRepository.setCalorieGoal(goal)
|
||||
loadHealthData()
|
||||
}
|
||||
}
|
||||
|
||||
// Weight tracking
|
||||
fun addWeightEntry(weightKg: Float, notes: String = "") {
|
||||
viewModelScope.launch {
|
||||
val entry = WeightEntry(
|
||||
date = LocalDate.now(),
|
||||
weightKg = weightKg,
|
||||
notes = notes
|
||||
)
|
||||
healthRepository.addWeightEntry(entry)
|
||||
loadHealthData()
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep tracking
|
||||
fun addSleepEntry(entry: SleepEntry) {
|
||||
viewModelScope.launch {
|
||||
healthRepository.addSleepEntry(entry)
|
||||
loadHealthData()
|
||||
}
|
||||
}
|
||||
|
||||
fun setSleepGoal(hours: Float) {
|
||||
viewModelScope.launch {
|
||||
healthRepository.setSleepGoal(hours)
|
||||
loadHealthData()
|
||||
}
|
||||
}
|
||||
|
||||
// Period tracking
|
||||
fun addPeriod(period: Period) {
|
||||
viewModelScope.launch {
|
||||
healthRepository.addPeriod(period)
|
||||
loadHealthData()
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePeriod(period: Period) {
|
||||
viewModelScope.launch {
|
||||
healthRepository.updatePeriod(period)
|
||||
loadHealthData()
|
||||
}
|
||||
}
|
||||
|
||||
fun deletePeriod(periodId: Long) {
|
||||
viewModelScope.launch {
|
||||
healthRepository.deletePeriod(periodId)
|
||||
loadHealthData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class HealthUiState {
|
||||
object Loading : HealthUiState()
|
||||
data class Success(
|
||||
val cycleStats: CycleStats,
|
||||
val waterIntake: Int,
|
||||
val waterGoal: Int,
|
||||
val caloriesConsumed: Int,
|
||||
val calorieGoal: Int,
|
||||
val currentWeight: Float?,
|
||||
val todaySleep: Float?,
|
||||
val sleepGoal: Float,
|
||||
val averageSleep: Float
|
||||
) : HealthUiState()
|
||||
data class Error(val message: String) : HealthUiState()
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user