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