├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ ├── other-issues.md │ └── task-template.md └── workflows │ └── push.yml ├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── lint.xml ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── mensinator │ │ └── app │ │ └── ExampleInstrumentedTest.kt │ ├── debug │ └── res │ │ ├── mipmap-hdpi │ │ ├── ic_launcher_foreground.webp │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher_foreground.webp │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher_foreground.webp │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher_foreground.webp │ │ └── ic_launcher_monochrome.png │ │ └── mipmap-xxxhdpi │ │ ├── ic_launcher_foreground.webp │ │ └── ic_launcher_monochrome.png │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── mensinator │ │ │ └── app │ │ │ ├── App.kt │ │ │ ├── MainActivity.kt │ │ │ ├── NotificationReceiver.kt │ │ │ ├── business │ │ │ ├── CalculationsHelper.kt │ │ │ ├── ClueImport.kt │ │ │ ├── DatabaseUtils.kt │ │ │ ├── FloImport.kt │ │ │ ├── ICalculationsHelper.kt │ │ │ ├── IClueImport.kt │ │ │ ├── IFloImport.kt │ │ │ ├── IMensinatorExportImport.kt │ │ │ ├── IOvulationPrediction.kt │ │ │ ├── IPeriodDatabaseHelper.kt │ │ │ ├── IPeriodPrediction.kt │ │ │ ├── MensinatorExportImport.kt │ │ │ ├── OvulationPrediction.kt │ │ │ ├── PeriodDatabaseHelper.kt │ │ │ ├── PeriodPrediction.kt │ │ │ └── notifications │ │ │ │ ├── AndroidNotificationScheduler.kt │ │ │ │ ├── IAndroidNotificationScheduler.kt │ │ │ │ ├── INotificationScheduler.kt │ │ │ │ └── NotificationScheduler.kt │ │ │ ├── calendar │ │ │ ├── CalendarScreen.kt │ │ │ ├── CalendarViewModel.kt │ │ │ ├── ColorCombination.kt │ │ │ └── SymptomDialogs.kt │ │ │ ├── data │ │ │ ├── ColorSource.kt │ │ │ ├── ImportSource.kt │ │ │ ├── Setting.kt │ │ │ └── Symptom.kt │ │ │ ├── extensions │ │ │ ├── ColorExtensions.kt │ │ │ ├── DayOfWeekExtensions.kt │ │ │ ├── DoubleExtensions.kt │ │ │ └── YearMonthExtensions.kt │ │ │ ├── settings │ │ │ ├── ExportImportDialog.kt │ │ │ ├── FaqDialog.kt │ │ │ ├── LutealWarningDialog.kt │ │ │ ├── NotificationDialog.kt │ │ │ ├── SettingsScreen.kt │ │ │ └── SettingsViewModel.kt │ │ │ ├── statistics │ │ │ ├── StatisticsScreen.kt │ │ │ └── StatisticsViewModel.kt │ │ │ ├── symptoms │ │ │ ├── ManageSymptomScreen.kt │ │ │ ├── ManageSymptomsDialogs.kt │ │ │ └── ManageSymptomsViewModel.kt │ │ │ ├── ui │ │ │ ├── ResourceMapper.kt │ │ │ ├── navigation │ │ │ │ ├── MensinatorApp.kt │ │ │ │ ├── MensinatorTopBar.kt │ │ │ │ └── NavigationItem.kt │ │ │ └── theme │ │ │ │ ├── Color.kt │ │ │ │ ├── Shapes.kt │ │ │ │ ├── Theme.kt │ │ │ │ └── UiConstants.kt │ │ │ └── utils │ │ │ └── IDispatcherProvider.kt │ └── res │ │ ├── drawable │ │ ├── bar_chart_24px.xml │ │ ├── baseline_bloodtype_24.xml │ │ ├── baseline_calendar_month_24.xml │ │ ├── baseline_save_24.xml │ │ ├── baseline_today_24.xml │ │ ├── home.xml │ │ ├── home_field.xml │ │ ├── keyboard_arrow_down_24px.xml │ │ ├── outline_bar_chart_24.xml │ │ └── settings_24px.xml │ │ ├── mipmap-anydpi │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_monochrome.png │ │ ├── resources.properties │ │ ├── values-ar │ │ └── strings.xml │ │ ├── values-bn │ │ └── strings.xml │ │ ├── values-de │ │ └── strings.xml │ │ ├── values-es │ │ └── strings.xml │ │ ├── values-fr │ │ └── strings.xml │ │ ├── values-hi │ │ └── strings.xml │ │ ├── values-it │ │ └── strings.xml │ │ ├── values-pl │ │ └── strings.xml │ │ ├── values-ro │ │ └── strings.xml │ │ ├── values-ru │ │ └── strings.xml │ │ ├── values-sl │ │ └── strings.xml │ │ ├── values-sv │ │ └── strings.xml │ │ ├── values-ta │ │ └── strings.xml │ │ ├── values-zh-rCN │ │ └── strings.xml │ │ ├── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test │ └── java │ └── com │ └── mensinator │ └── app │ ├── PeriodPredictionTest.kt │ └── business │ └── NotificationSchedulerTest.kt ├── build.gradle.kts ├── fastlane └── metadata │ └── android │ ├── ar │ ├── full_description.txt │ ├── short_description.txt │ └── title.txt │ ├── en-US │ ├── changelogs │ │ ├── 19.txt │ │ ├── 20.txt │ │ ├── 21.txt │ │ ├── 23.txt │ │ └── 24.txt │ ├── full_description.txt │ ├── images │ │ ├── icon.png │ │ └── phoneScreenshots │ │ │ ├── 1.png │ │ │ ├── 2.png │ │ │ ├── 3.png │ │ │ ├── 4.png │ │ │ ├── 5.png │ │ │ ├── 6.png │ │ │ ├── 7.png │ │ │ └── 8.png │ ├── short_description.txt │ └── title.txt │ ├── ru-RU │ ├── full_description.txt │ ├── short_description.txt │ └── title.txt │ └── sv-SE │ ├── full_description.txt │ ├── short_description.txt │ └── title.txt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Describe how we can reproduce the error ourselves. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what was expected to happen. 18 | 19 | **Screenshots** 20 | If applicable, add screenshots to help explain your problem. 21 | 22 | **Smartphone information (please complete the following information):** 23 | - Device: [e.g. Samsung Galaxy S24] 24 | - Android version: [14] 25 | - App version: [22] 26 | 27 | **Additional context** 28 | Add any other context about the problem here. 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/other-issues.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Other issues 3 | about: Additional issues that is not bug/feature 4 | title: "[Additional Request]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/task-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Task template 3 | about: Internal issue for code improvment 4 | title: "[Task]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Description:** 11 | [Clearly state the specific part of the codebase and functionality to be improved] 12 | 13 | **Limitations/Drawbacks:** 14 | [Outline the issues with the current implementation] 15 | 16 | **New implementation description:** 17 | [Detail the suggested changes and new approach] 18 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: CI Pipeline 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | name: Build 12 | runs-on: ubuntu-latest 13 | permissions: 14 | # Required for upload-sarif task 15 | security-events: write 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: set up JDK 17 19 | uses: actions/setup-java@v4 20 | with: 21 | java-version: '17' 22 | distribution: 'temurin' 23 | cache: gradle 24 | - name: Build with Gradle 25 | run: chmod +x gradlew && ./gradlew build 26 | 27 | # Explained at https://medium.com/bumble-tech/android-lint-and-detekt-warnings-in-github-pull-requests-2880df5d32af 28 | - uses: github/codeql-action/upload-sarif@v3 29 | if: success() || failure() 30 | with: 31 | sarif_file: app/build/reports/lint-results-debug.sarif 32 | category: lint -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Android Studio 2 | .idea 3 | *.iml 4 | .gradle 5 | /local.properties 6 | /.idea/caches 7 | /.idea/libraries 8 | /.idea/modules.xml 9 | /.idea/workspace.xml 10 | /.idea/navEditor.xml 11 | /.idea/assetWizardSettings.xml 12 | .DS_Store 13 | 14 | # Gradle 15 | /build 16 | .gradle/ 17 | /out 18 | /buildSrc/build 19 | /buildSrc/.gradle 20 | /.gradle 21 | **/build/ 22 | /!gradle/wrapper/gradle-wrapper.jar 23 | 24 | # Kotlin 25 | *.class 26 | *.jar 27 | *.war 28 | *.ear 29 | 30 | # IntelliJ 31 | out/ 32 | *.ipr 33 | *.iws 34 | *.bak 35 | *.swp 36 | 37 | # Personal configuration files 38 | .idea/misc.xml 39 | .idea/modules.xml 40 | .idea/dictionaries 41 | .idea/vcs.xml 42 | .idea/gradle.xml 43 | .idea/libraries 44 | .idea/tasks.xml 45 | .idea/workspace.xml 46 | .idea/tasks 47 | .idea/assetWizardSettings.xml 48 | .idea/codeStyles 49 | .idea/caches 50 | .idea/inspectionProfiles 51 | .idea/jsLibraryMappings.xml 52 | .idea/encodings.xml 53 | .idea/dataSources 54 | .idea/dataSources.local.xml 55 | .idea/sqlDataSources.xml 56 | .idea/sqlDialects.xml 57 | .idea/dynamic.xml 58 | .idea/uiDesigner.xml 59 | 60 | # APKs 61 | *.apk 62 | *.ap_ 63 | 64 | # Windows 65 | Thumbs.db 66 | ehthumbs.db 67 | Desktop.ini 68 | $RECYCLE.BIN/ 69 | 70 | # Linux 71 | *~ 72 | 73 | # Proguard 74 | proguard/ 75 | 76 | # Logs 77 | *.log 78 | 79 | # Android-specific files 80 | crashlytics-build.properties 81 | fabric.properties 82 | 83 | # Local configuration files 84 | local.properties 85 | 86 | # Java Template 87 | *.zip 88 | *.tar.gz 89 | 90 | # External native build folder generated in Android Studio 2.2 and later 91 | .externalNativeBuild 92 | 93 | # NDK 94 | .cxx 95 | .ndk 96 | 97 | # Auto-generated files by Android Studio 98 | captures/ 99 | output.json 100 | 101 | # Keystore files 102 | *.jks 103 | *.keystore 104 | 105 | # User-specific configurations 106 | .idea/* 107 | 108 | # Kotlin Multiplatform specific 109 | cinterop/ 110 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Emma Tellblom 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mensinator🩸 2 | Private period tracking with custom symptoms. We don't track your data - you do. Period. 3 | 4 | ## 💡 Features 5 | - Period and ovulation tracking and statistics. 6 | - Customizable, colour-coded symptoms. 7 | - Cycle prediction with average period calculation. 8 | - Cycle prediction with average luteal phase calculation for irregular cycles. 9 | - Customisable period reminder text. 10 | - App is available in 14 languages (with Community contributions). 11 | - Data export and import for backups or moving to another device. 12 | - No tracking. No sign-up. Your data is yours. No data is sent anywhere. 13 | - No bloat. No useless or complex features. No emails. 14 | 15 | ## 🫂 Made for friends by friends 16 | Originally, Emma made Mensinator for a friend who was looking for an app that would track unique period symptoms and respect her privacy. 17 | Soon, more like-minded individuals joined the project. 18 | We all wanted a user-friendly, straightforward app made for us, not an app that uses our data for profit. We are now actively building Mensinator together. 19 | 20 | ## 🔮 Future plans 21 | - Whatever you suggest! Discuss ideas with users and developers on **[our Discord](https://discord.gg/tHA2k3bFRN)**. Let us know what features you'd like to see. 22 | 23 | ## 🤝 Active development and user community 24 | Join **[our Discord](https://discord.gg/tHA2k3bFRN)** to share your thoughts, ask questions, and chat with other users and our team. 25 | Mensinator is in early development, made by people like you. Your feedback helps us continue improving it. 26 | Share a quick thought, or stick around to nag the developers to make the app into what you want it to be. You can even help by developing the features directly. 27 | 28 | ## 💾 Get the app 29 | Download the Mensinator app from your preferred source: 30 | - **[F-Droid](https://f-droid.org/en/packages/com.mensinator.app/)** (free and open-source app store) 31 | - **[Google Play](https://play.google.com/store/apps/details?id=com.mensinator.app)** (Google's app store) 32 | - **[IzzyOnDroid](https://apt.izzysoft.de/fdroid/index/apk/com.mensinator.app)** (an F-Droid repository) 33 | - **[latest release on GitHub](https://github.com/EmmaTellblom/Mensinator/releases/latest)** (NO automatic updates) 34 | 35 | ## 🛠️ Contributing for developers 36 | We welcome contributions to improve Mensinator! 37 | Help to translate it on the [Weblate](https://toolate.othing.xyz/projects/mensinator/). 38 | If you'd like to contribute, please **[fork the repository](https://github.com/EmmaTellblom/Mensinator/fork)** and submit a pull request. 39 | For major changes, please **[open an issue](https://github.com/EmmaTellblom/Mensinator/issues/new/choose)** first to discuss your proposed changes. 40 | You are also welcome to **[join our Discord](https://discord.gg/tHA2k3bFRN)** to discuss further or simply to chat with us! 41 | 42 | ## 📜 License 43 | This project is licensed under the **[MIT License](https://github.com/EmmaTellblom/Mensinator/blob/main/LICENSE)**. 44 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | # Android Studio 2 | .idea 3 | *.iml 4 | .gradle 5 | /local.properties 6 | /.idea/libraries 7 | /.idea/modules.xml 8 | /.idea/workspace.xml 9 | /.idea/navEditor.xml 10 | /.idea/assetWizardSettings.xml 11 | .DS_Store 12 | 13 | # Gradle 14 | /build 15 | .gradle/ 16 | /out 17 | /buildSrc/build 18 | /buildSrc/.gradle 19 | /.gradle 20 | **/build/ 21 | /!gradle/wrapper/gradle-wrapper.jar 22 | 23 | # Kotlin 24 | *.class 25 | *.jar 26 | *.war 27 | *.ear 28 | 29 | # IntelliJ 30 | out/ 31 | *.ipr 32 | *.iws 33 | *.bak 34 | *.swp 35 | 36 | # Personal configuration files 37 | .idea/misc.xml 38 | .idea/modules.xml 39 | .idea/dictionaries 40 | .idea/vcs.xml 41 | .idea/gradle.xml 42 | .idea/libraries 43 | .idea/tasks.xml 44 | .idea/workspace.xml 45 | .idea/tasks 46 | .idea/assetWizardSettings.xml 47 | .idea/codeStyles 48 | .idea/caches 49 | .idea/inspectionProfiles 50 | .idea/jsLibraryMappings.xml 51 | .idea/encodings.xml 52 | .idea/dataSources 53 | .idea/dataSources.local.xml 54 | .idea/sqlDataSources.xml 55 | .idea/sqlDialects.xml 56 | .idea/dynamic.xml 57 | .idea/uiDesigner.xml 58 | 59 | # APKs 60 | *.apk 61 | *.ap_ 62 | 63 | # Windows 64 | Thumbs.db 65 | ehthumbs.db 66 | Desktop.ini 67 | $RECYCLE.BIN/ 68 | 69 | # Linux 70 | *~ 71 | 72 | # Proguard 73 | proguard/ 74 | 75 | # Logs 76 | *.log 77 | 78 | # Android-specific files 79 | crashlytics-build.properties 80 | fabric.properties 81 | 82 | # Local configuration files 83 | local.properties 84 | 85 | # Java Template 86 | *.zip 87 | *.tar.gz 88 | 89 | # External native build folder generated in Android Studio 2.2 and later 90 | .externalNativeBuild 91 | 92 | # NDK 93 | .cxx 94 | .ndk 95 | 96 | # Auto-generated files by Android Studio 97 | captures/ 98 | output.json 99 | 100 | # Keystore files 101 | *.jks 102 | *.keystore 103 | 104 | # User-specific configurations 105 | .idea/* 106 | 107 | # Kotlin Multiplatform specific 108 | cinterop/ 109 | 110 | 111 | release/ -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) 3 | alias(libs.plugins.jetbrains.kotlin.android) 4 | alias(libs.plugins.compose.compiler) 5 | } 6 | 7 | android { 8 | namespace = "com.mensinator.app" 9 | compileSdk = 35 10 | 11 | defaultConfig { 12 | applicationId = "com.mensinator.app" 13 | minSdk = 26 14 | targetSdk = 35 15 | versionCode = 24 16 | versionName = "2.0" 17 | 18 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 19 | vectorDrawables { 20 | useSupportLibrary = true 21 | } 22 | } 23 | 24 | buildTypes { 25 | debug { 26 | applicationIdSuffix = ".debug" 27 | } 28 | release { 29 | isMinifyEnabled = false 30 | proguardFiles( 31 | getDefaultProguardFile("proguard-android-optimize.txt"), 32 | "proguard-rules.pro" 33 | ) 34 | } 35 | } 36 | compileOptions { 37 | sourceCompatibility = JavaVersion.VERSION_17 38 | targetCompatibility = JavaVersion.VERSION_17 39 | } 40 | kotlinOptions { 41 | jvmTarget = "17" 42 | } 43 | buildFeatures { 44 | compose = true 45 | } 46 | androidResources { 47 | @Suppress("UnstableApiUsage") 48 | generateLocaleConfig = true 49 | } 50 | packaging { 51 | resources { 52 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 53 | } 54 | } 55 | dependenciesInfo { 56 | // Disables dependency metadata when building APKs. 57 | includeInApk = false 58 | // Disables dependency metadata when building Android App Bundles. 59 | includeInBundle = false 60 | } 61 | lint { 62 | sarifReport = true 63 | } 64 | composeCompiler { 65 | reportsDestination = layout.buildDirectory.dir("compose_compiler") 66 | metricsDestination = layout.buildDirectory.dir("compose_compiler") 67 | } 68 | @Suppress("UnstableApiUsage") 69 | testOptions { 70 | unitTests.isReturnDefaultValues = true 71 | } 72 | } 73 | 74 | dependencies { 75 | implementation(libs.androidx.core.ktx) 76 | implementation(libs.androidx.lifecycle.runtime.ktx) 77 | implementation(libs.androidx.lifecycle.viewmodel.ktx) 78 | implementation(libs.androidx.lifecycle.compose) 79 | implementation(libs.androidx.activity.compose) 80 | implementation(platform(libs.androidx.compose.bom)) 81 | implementation(libs.androidx.ui) 82 | implementation(libs.androidx.ui.graphics) 83 | implementation(libs.androidx.ui.tooling.preview) 84 | implementation(libs.androidx.material3) 85 | implementation(libs.androidx.material3.adaptive) 86 | implementation(libs.androidx.material3.adaptive.layout) 87 | implementation(libs.androidx.material3.window.size.classes) 88 | implementation(libs.androidx.window) 89 | implementation(libs.androidx.appcompat) 90 | implementation(libs.androidx.navigation.runtime.ktx) 91 | implementation(libs.androidx.navigation.compose) 92 | implementation(libs.androidx.navigation.common.ktx) 93 | 94 | implementation(libs.kizitonwose.calendar.compose) 95 | 96 | implementation(platform(libs.koin.bom)) 97 | implementation(libs.koin) 98 | implementation(libs.koin.compose) 99 | 100 | implementation(libs.kotlinx.coroutines) 101 | implementation(libs.kotlinx.collections.immutable) 102 | 103 | testImplementation(libs.junit) 104 | testImplementation(libs.mockk) 105 | testImplementation(libs.kotlinx.coroutines.test) 106 | 107 | androidTestImplementation(libs.androidx.junit) 108 | androidTestImplementation(libs.androidx.espresso.core) 109 | androidTestImplementation(platform(libs.androidx.compose.bom)) 110 | androidTestImplementation(libs.androidx.ui.test.junit4) 111 | 112 | debugImplementation(libs.androidx.ui.tooling) 113 | debugImplementation(libs.androidx.ui.test.manifest) 114 | 115 | // To be used to profile performance. Don't include in release builds 116 | // implementation("androidx.compose.runtime:runtime-tracing") 117 | } 118 | -------------------------------------------------------------------------------- /app/lint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/com/mensinator/app/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.mensinator.app", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-hdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/debug/res/mipmap-hdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-mdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/debug/res/mipmap-mdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-xhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/debug/res/mipmap-xhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-xxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/debug/res/mipmap-xxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-xxxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 18 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/App.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app 2 | 3 | import android.app.AlarmManager 4 | import android.app.Application 5 | import com.mensinator.app.business.* 6 | import com.mensinator.app.business.notifications.AndroidNotificationScheduler 7 | import com.mensinator.app.business.notifications.IAndroidNotificationScheduler 8 | import com.mensinator.app.business.notifications.INotificationScheduler 9 | import com.mensinator.app.business.notifications.NotificationScheduler 10 | import com.mensinator.app.calendar.CalendarViewModel 11 | import com.mensinator.app.settings.SettingsViewModel 12 | import com.mensinator.app.statistics.StatisticsViewModel 13 | import com.mensinator.app.symptoms.ManageSymptomsViewModel 14 | import com.mensinator.app.utils.DefaultDispatcherProvider 15 | import com.mensinator.app.utils.IDispatcherProvider 16 | import org.koin.android.ext.koin.androidContext 17 | import org.koin.android.ext.koin.androidLogger 18 | import org.koin.core.context.startKoin 19 | import org.koin.core.module.dsl.bind 20 | import org.koin.core.module.dsl.singleOf 21 | import org.koin.core.module.dsl.viewModel 22 | import org.koin.dsl.module 23 | 24 | class App : Application() { 25 | 26 | // Koin dependency injection definitions 27 | private val appModule = module { 28 | singleOf(::PeriodDatabaseHelper) { bind() } 29 | singleOf(::CalculationsHelper) { bind() } 30 | singleOf(::OvulationPrediction) { bind() } 31 | singleOf(::PeriodPrediction) { bind() } 32 | singleOf(::MensinatorExportImport) { bind() } 33 | singleOf(::ClueImport) { bind() } 34 | singleOf(::FloImport) { bind() } 35 | singleOf(::NotificationScheduler) { bind() } 36 | singleOf(::DefaultDispatcherProvider) { bind() } 37 | singleOf(::AndroidNotificationScheduler) { bind() } 38 | single { androidContext().getSystemService(ALARM_SERVICE) as AlarmManager } 39 | 40 | viewModel { CalendarViewModel(get(), get(), get(), get()) } 41 | viewModel { ManageSymptomsViewModel(get()) } 42 | viewModel { SettingsViewModel(get(), get(), get(), get(), get(), get()) } 43 | viewModel { StatisticsViewModel(get(), get(), get(), get(), get()) } 44 | } 45 | 46 | override fun onCreate() { 47 | super.onCreate() 48 | 49 | startKoin { 50 | androidLogger() 51 | androidContext(this@App) 52 | modules(appModule) 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app 2 | 3 | import android.app.NotificationChannel 4 | import android.app.NotificationManager 5 | import android.content.Context 6 | import android.os.Bundle 7 | import android.util.Log 8 | import android.view.WindowManager 9 | import androidx.activity.compose.setContent 10 | import androidx.activity.enableEdgeToEdge 11 | import androidx.appcompat.app.AppCompatActivity 12 | import com.mensinator.app.NotificationChannelConstants.channelDescription 13 | import com.mensinator.app.NotificationChannelConstants.channelId 14 | import com.mensinator.app.NotificationChannelConstants.channelName 15 | import com.mensinator.app.ui.navigation.MensinatorApp 16 | import com.mensinator.app.ui.theme.MensinatorTheme 17 | import org.koin.androidx.compose.KoinAndroidContext 18 | 19 | @Suppress("ConstPropertyName") 20 | object NotificationChannelConstants { 21 | const val channelId = "1" 22 | const val channelName = "Mensinator" 23 | const val channelDescription = "Reminders about upcoming periods" 24 | } 25 | 26 | class MainActivity : AppCompatActivity() { 27 | override fun onCreate(savedInstanceState: Bundle?) { 28 | super.onCreate(savedInstanceState) 29 | enableEdgeToEdge() 30 | 31 | setContent { 32 | MensinatorTheme { 33 | KoinAndroidContext { 34 | MensinatorApp(onScreenProtectionChanged = ::handleScreenProtection) 35 | } 36 | } 37 | } 38 | createNotificationChannel(this) 39 | } 40 | 41 | private fun createNotificationChannel(context: Context) { 42 | val importance = NotificationManager.IMPORTANCE_HIGH 43 | val notificationChannel = NotificationChannel(channelId, channelName, importance).apply { 44 | description = channelDescription 45 | } 46 | 47 | val notificationManager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager 48 | notificationManager.createNotificationChannel(notificationChannel) 49 | } 50 | 51 | private fun handleScreenProtection(isScreenProtectionEnabled: Boolean) { 52 | Log.d("screenProtectionUI", "protect screen value $isScreenProtectionEnabled") 53 | // Sets the flags for screen protection if 54 | // isScreenProtectionEnabled == true 55 | // If isScreenProtectionEnabled == false it removes the flags 56 | if (isScreenProtectionEnabled) { 57 | window?.setFlags( 58 | WindowManager.LayoutParams.FLAG_SECURE, 59 | WindowManager.LayoutParams.FLAG_SECURE 60 | ) 61 | } else { 62 | window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) 63 | } 64 | } 65 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/NotificationReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app 2 | 3 | import android.app.PendingIntent 4 | import android.content.BroadcastReceiver 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.util.Log 8 | import androidx.core.app.NotificationCompat 9 | import androidx.core.app.NotificationManagerCompat 10 | 11 | class NotificationReceiver : BroadcastReceiver() { 12 | companion object { 13 | private const val CHANNEL_ID = "1" 14 | private const val NOTIFICATION_ID = 1 15 | const val ACTION_NOTIFICATION = "com.mensinator.app.SEND_NOTIFICATION" 16 | const val MESSAGE_TEXT_KEY = "messageText" 17 | } 18 | 19 | override fun onReceive(context: Context, intent: Intent) { 20 | val messageText = intent.getStringExtra(MESSAGE_TEXT_KEY) 21 | 22 | val launchIntent = Intent(context, MainActivity::class.java).apply { 23 | flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK 24 | } 25 | 26 | val pendingIntent = PendingIntent.getActivity( 27 | context, 28 | 0, 29 | launchIntent, 30 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE // FLAG_IMMUTABLE required for Android 12+ 31 | ) 32 | 33 | val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID) 34 | .setSmallIcon(R.drawable.baseline_bloodtype_24) 35 | .setContentText(messageText) // See discussion at https://github.com/EmmaTellblom/Mensinator/issues/216 36 | .setContentIntent(pendingIntent) 37 | .setPriority(NotificationCompat.PRIORITY_HIGH) 38 | 39 | val notificationManager = NotificationManagerCompat.from(context) 40 | try { 41 | notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()) 42 | Log.d("NotificationReceiver", "Notification sent") 43 | } catch (e: SecurityException) { 44 | Log.e("NotificationReceiver", "Notification permission not available", e) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/business/ClueImport.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.business 2 | 3 | import android.content.Context 4 | import android.widget.Toast 5 | import org.json.JSONArray 6 | import java.io.BufferedReader 7 | import java.io.File 8 | import java.io.InputStreamReader 9 | import java.time.LocalDate 10 | import java.time.format.DateTimeFormatter 11 | import androidx.core.database.sqlite.transaction 12 | import java.io.FileInputStream 13 | 14 | class ClueImport( 15 | private val context: Context, 16 | private val dbHelper: IPeriodDatabaseHelper, 17 | ) : IClueImport { 18 | 19 | override fun importFileToDatabase(filePath: String): Boolean { 20 | val db = dbHelper.writableDb 21 | 22 | // Read JSON data from the file 23 | val file = File(filePath) 24 | val fileInputStream = FileInputStream(file) 25 | val reader = BufferedReader(InputStreamReader(fileInputStream)) 26 | 27 | Toast.makeText(context, "Importing file...", Toast.LENGTH_SHORT).show() 28 | 29 | val fileContent = reader.use { it.readText() } 30 | 31 | // Parse JSON array from the file 32 | val importArray = JSONArray(fileContent) 33 | 34 | // Validate the data before cleanup 35 | if (!validateImportData(importArray)) { 36 | Toast.makeText(context, "Invalid data in import file", Toast.LENGTH_SHORT).show() 37 | return false 38 | } 39 | 40 | val success = try { 41 | db.transaction { 42 | processData(importArray) 43 | } 44 | true 45 | } catch (e: Exception) { 46 | Toast.makeText(context, "Error importing data: ${e.message}", Toast.LENGTH_SHORT).show() 47 | false 48 | } 49 | 50 | // Close the database 51 | db.close() 52 | 53 | return success 54 | } 55 | 56 | private fun validateImportData(importArray: JSONArray): Boolean { 57 | // Check if the array is empty 58 | if (importArray.length() == 0) { 59 | return false 60 | } 61 | 62 | // Check so that at least one entry has "type": "period" 63 | for (i in 0 until importArray.length()) { 64 | val obj = importArray.getJSONObject(i) 65 | if (obj.getString("type") == "period" && obj.has("date") && obj.has("value")) { 66 | return true 67 | } 68 | } 69 | 70 | // If no valid data found, return false 71 | return false 72 | } 73 | 74 | private fun processData(importArray: JSONArray) { 75 | val formatter = DateTimeFormatter.ISO_LOCAL_DATE 76 | 77 | for (i in 0 until importArray.length()) { 78 | val obj = importArray.getJSONObject(i) 79 | val type = obj.getString("type") 80 | val dateString = obj.getString("date") 81 | val date = LocalDate.parse(dateString, formatter) 82 | 83 | when (type) { 84 | "period" -> { 85 | val periodId = dbHelper.newFindOrCreatePeriodID(date) 86 | dbHelper.addDateToPeriod(date, periodId) 87 | } 88 | "tests" -> { 89 | val valueArray = obj.getJSONArray("value") 90 | // we need to loop in case there are several different tests 91 | for (j in 0 until valueArray.length()) { 92 | val optionObj = valueArray.getJSONObject(j) 93 | val option = optionObj.getString("option") 94 | if (option == "ovulation_positive") { 95 | dbHelper.addOvulationDate(date) 96 | break // if a positive ovulation test is found, break the loop 97 | } 98 | } 99 | } 100 | } 101 | } 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/business/DatabaseUtils.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.business 2 | 3 | import android.database.sqlite.SQLiteDatabase 4 | 5 | /* 6 | This file is for the database-structure. Take care when changing anything since 7 | we have to keep track of database-version and how stuff effects the onCreate/upgrade. 8 | */ 9 | 10 | object DatabaseUtils { 11 | 12 | fun createDatabase(db: SQLiteDatabase) { 13 | db.execSQL(""" 14 | CREATE TABLE IF NOT EXISTS periods ( 15 | id INTEGER PRIMARY KEY AUTOINCREMENT, 16 | date TEXT, 17 | period_id INTEGER 18 | ) 19 | """) 20 | 21 | db.execSQL(""" 22 | CREATE TABLE IF NOT EXISTS symptoms ( 23 | id INTEGER PRIMARY KEY AUTOINCREMENT, 24 | symptom_name TEXT NOT NULL, 25 | active INT NOT NULL 26 | ) 27 | """) 28 | 29 | val predefinedSymptoms = listOf("Heavy_Flow", "Medium_Flow", "Light_Flow") 30 | predefinedSymptoms.forEach { symptom -> 31 | db.execSQL( 32 | """ 33 | INSERT INTO symptoms (symptom_name, active) VALUES (?, 1) 34 | """, arrayOf(symptom) 35 | ) 36 | } 37 | 38 | db.execSQL(""" 39 | CREATE TABLE IF NOT EXISTS symptom_date ( 40 | id INTEGER PRIMARY KEY AUTOINCREMENT, 41 | symptom_date TEXT NOT NULL, 42 | symptom_id INT NOT NULL, 43 | FOREIGN KEY (symptom_id) REFERENCES symptoms(id) 44 | ) 45 | """) 46 | 47 | createAppSettingsGroup(db) 48 | createAppSettings(db) 49 | createOvulationStructure(db) 50 | databaseVersion7(db) 51 | databaseVersion8(db) 52 | databaseVersion9(db) 53 | databaseVersion10(db) 54 | } 55 | 56 | fun createAppSettingsGroup(db: SQLiteDatabase) { 57 | db.execSQL(""" 58 | CREATE TABLE IF NOT EXISTS app_settings_group ( 59 | id INTEGER PRIMARY KEY AUTOINCREMENT, 60 | group_label TEXT NOT NULL 61 | ) 62 | """) 63 | 64 | db.execSQL(""" 65 | INSERT INTO app_settings_group (group_label) VALUES 66 | ('Colors'), 67 | ('Reminders'), 68 | ('Other') 69 | """) 70 | } 71 | 72 | fun createAppSettings(db: SQLiteDatabase) { 73 | db.execSQL(""" 74 | CREATE TABLE IF NOT EXISTS app_settings ( 75 | id INTEGER PRIMARY KEY AUTOINCREMENT, 76 | setting_key TEXT NOT NULL, 77 | setting_label TEXT NOT NULL, 78 | setting_value TEXT NOT NULL, 79 | group_label_id INTEGER NOT NULL, 80 | FOREIGN KEY (group_label_id) REFERENCES app_settings_group(id) 81 | ) 82 | """) 83 | 84 | db.execSQL(""" 85 | INSERT INTO app_settings (setting_key, setting_label, setting_value, group_label_id) VALUES 86 | ('period_color', 'Period Color', 'Red', 1), 87 | ('selection_color', 'Selection Color', 'LightGray', 1), 88 | ('expected_period_color', 'Expected Period Color', 'Yellow', 1), 89 | ('reminder_days', 'Days Before Reminder', '0', 2), 90 | ('luteal_period_calculation', 'Luteal Phase Calculation', '0', 3) 91 | """) 92 | // ('period_selection_color', 'Period Selection Color', 'DarkGray', 1), - deprecated 93 | } 94 | 95 | fun createOvulationStructure(db: SQLiteDatabase){ 96 | db.execSQL(""" 97 | CREATE TABLE IF NOT EXISTS ovulations ( 98 | id INTEGER PRIMARY KEY AUTOINCREMENT, 99 | date TEXT 100 | ) 101 | """ 102 | ) 103 | 104 | db.execSQL(""" 105 | INSERT INTO app_settings(setting_key, setting_label, setting_value, group_label_id) VALUES 106 | ('ovulation_color', 'Ovulation Color', 'Blue', '1'), 107 | ('expected_ovulation_color', 'Expected Ovulation Color', 'Magenta', '1') 108 | """) 109 | } 110 | 111 | fun insertLutealSetting(db: SQLiteDatabase){ 112 | db.execSQL(""" 113 | INSERT INTO APP_SETTINGS(setting_key, setting_label, setting_value, group_label_id) VALUES 114 | ('luteal_period_calculation', 'Luteal Phase Calculation', '0', 3) 115 | """) 116 | } 117 | 118 | fun databaseVersion7 (db: SQLiteDatabase) { 119 | 120 | // Update the app_settings to handle which type of setting it is 121 | // LI == List 122 | // NO == Number 123 | // SW == Switch 124 | // TX == Text 125 | db.execSQL(""" 126 | ALTER TABLE app_settings ADD COLUMN setting_type TEXT 127 | """) 128 | db.execSQL(""" 129 | UPDATE app_settings SET setting_type = 'LI' WHERE group_label_id = '1' 130 | """) 131 | db.execSQL(""" 132 | UPDATE app_settings SET setting_type = 'NO' WHERE group_label_id = '2' 133 | """) 134 | db.execSQL(""" 135 | UPDATE app_settings SET setting_type = 'SW' WHERE group_label_id = '3' 136 | """) 137 | 138 | // Insert new row for cycle history 139 | // This will allow the user to fine tune how many cycles back should be used for prediction 140 | // NO because its going to be a number 141 | // Insert new row for language 142 | // Insert new row for showing cycle numbers 143 | db.execSQL(""" 144 | INSERT INTO app_settings (setting_key, setting_label, setting_value, group_label_id, setting_type) 145 | VALUES 146 | ('period_history','Period history','5','3','NO'), 147 | ('ovulation_history','Ovulation history','5','3','NO'), 148 | ('lang', 'Language', 'en', '3', 'LI'), 149 | ('cycle_numbers_show','Show cycle numbers','1','3','SW') 150 | """) 151 | 152 | // Add color to the symptoms table 153 | db.execSQL(""" 154 | ALTER TABLE symptoms ADD COLUMN color TEXT DEFAULT 'Black' 155 | """) 156 | // Set all colors for the standard symptoms 157 | db.execSQL(""" 158 | UPDATE symptoms SET color = 'DarkRed' where symptom_name = 'Heavy_Flow' 159 | """) 160 | db.execSQL(""" 161 | UPDATE symptoms SET color = 'Red' where symptom_name = 'Medium_Flow' 162 | """) 163 | db.execSQL(""" 164 | UPDATE symptoms SET color = 'LightRed' where symptom_name = 'Light_Flow' 165 | """) 166 | 167 | // Fixed symptom colors, so we can remove setting for symptom indicator 168 | db.execSQL(""" 169 | DELETE FROM app_settings WHERE setting_key = 'symptom_color' 170 | """) 171 | 172 | db.execSQL(""" 173 | UPDATE app_settings SET setting_value = 'LightGray' WHERE setting_value = 'Grey' 174 | """) 175 | 176 | } 177 | 178 | fun databaseVersion8(db: SQLiteDatabase) { 179 | //Insert new row for screen protection 180 | db.execSQL(""" 181 | INSERT INTO app_settings(setting_key, setting_label, setting_value, group_label_id, setting_type) 182 | VALUES 183 | ('screen_protection', 'Protect screen', '1', '3', 'SW') 184 | """) 185 | } 186 | fun databaseVersion9(db: SQLiteDatabase) { 187 | //Insert new row for custom period notification message 188 | db.execSQL( 189 | """ 190 | INSERT INTO app_settings(setting_key, setting_label, setting_value, group_label_id, setting_type) 191 | VALUES 192 | ('period_notification_message', 'Period Notification Message', 'Period_Notification_Message', '2', 'TX') 193 | """ 194 | ) 195 | } 196 | 197 | fun databaseVersion10(db: SQLiteDatabase) { 198 | // Remove old setting for period_selection_color 199 | db.execSQL( 200 | """ 201 | DELETE FROM app_settings WHERE setting_key = 'period_selection_color'; 202 | """ 203 | ) 204 | } 205 | 206 | fun databaseVersion11(db: SQLiteDatabase){ 207 | // Remove duplicate date values, keeping the first one based on rowid 208 | db.execSQL(""" 209 | DELETE FROM periods 210 | WHERE rowid NOT IN ( 211 | SELECT MIN(rowid) 212 | FROM periods 213 | GROUP BY date 214 | ); 215 | """) 216 | 217 | // Add a unique constraint to the date column via unique index 218 | db.execSQL(""" 219 | CREATE UNIQUE INDEX IF NOT EXISTS unique_date ON periods(date); 220 | """) 221 | 222 | // Remove duplicate date values, keeping the first one based on rowid 223 | db.execSQL(""" 224 | DELETE FROM ovulations 225 | WHERE rowid NOT IN ( 226 | SELECT MIN(rowid) 227 | FROM ovulations 228 | GROUP BY date 229 | ); 230 | """) 231 | 232 | // Add a unique constraint to the date column via unique index 233 | db.execSQL(""" 234 | CREATE UNIQUE INDEX IF NOT EXISTS unique_date_ovulation ON ovulations(date); 235 | """) 236 | } 237 | 238 | 239 | } 240 | -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/business/FloImport.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.business 2 | 3 | import java.io.File 4 | import android.content.Context 5 | import android.widget.Toast 6 | import java.io.BufferedReader 7 | import java.io.InputStreamReader 8 | import java.time.LocalDate 9 | import androidx.core.database.sqlite.transaction 10 | import org.json.JSONObject 11 | import java.io.FileInputStream 12 | import java.time.format.DateTimeFormatter 13 | 14 | class FloImport ( 15 | private val context: Context, 16 | private val dbHelper: IPeriodDatabaseHelper, 17 | ) : IFloImport { 18 | 19 | override fun importFileToDatabase(filePath: String): Boolean { 20 | val db = dbHelper.writableDb 21 | 22 | // Read JSON data from the file 23 | val file = File(filePath) 24 | val fileInputStream = FileInputStream(file) 25 | val reader = BufferedReader(InputStreamReader(fileInputStream)) 26 | 27 | Toast.makeText(context, "Importing file...", Toast.LENGTH_SHORT).show() 28 | 29 | val fileContent = reader.use { it.readText() } 30 | 31 | // Parse JSON object from the file 32 | val importObject = JSONObject(fileContent) 33 | 34 | // Validate the data before cleanup 35 | if (!validateImportData(importObject)) { 36 | Toast.makeText(context, "Invalid data in import file", Toast.LENGTH_SHORT).show() 37 | return false 38 | } 39 | 40 | val success = try { 41 | db.transaction { 42 | processData(importObject) 43 | } 44 | true 45 | } catch (e: Exception) { 46 | Toast.makeText(context, "Error importing data: ${e.message}", Toast.LENGTH_SHORT).show() 47 | false 48 | } 49 | db.close() 50 | return success 51 | } 52 | 53 | private fun validateImportData(importObject: JSONObject): Boolean { 54 | // Check if the "operationalData" key exists 55 | if (!importObject.has("operationalData")) { 56 | return false 57 | } 58 | 59 | val operationalData = importObject.getJSONObject("operationalData") 60 | 61 | // Check if the "cycles" array exists 62 | if (operationalData.has("cycles")) { 63 | val cyclesArray = operationalData.getJSONArray("cycles") 64 | 65 | // Loop through all cycles 66 | for (i in 0 until cyclesArray.length()) { 67 | val cycle = cyclesArray.getJSONObject(i) 68 | 69 | // Check if both "period_start_date" and "period_end_date" exist in the cycle object 70 | if (cycle.has("period_start_date") && cycle.has("period_end_date")) { 71 | return true // Valid cycle found 72 | } 73 | } 74 | } 75 | 76 | return false // No valid cycle found 77 | } 78 | 79 | // Get all dates in a cycle 80 | private fun getPeriodDates(startDate: LocalDate, endDate: LocalDate): List { 81 | val periodDates = mutableListOf() 82 | var currentDate = startDate 83 | while (!currentDate.isAfter(endDate)) { 84 | periodDates.add(currentDate) 85 | currentDate = currentDate.plusDays(1) 86 | } 87 | return periodDates 88 | } 89 | 90 | private fun processData(importObject: JSONObject) { 91 | val operationalData = importObject.getJSONObject("operationalData") 92 | 93 | // Handle cycles first 94 | val cyclesArray = operationalData.getJSONArray("cycles") 95 | for (i in 0 until cyclesArray.length()) { 96 | val cycle = cyclesArray.getJSONObject(i) 97 | 98 | val startDateString = cycle.optString("period_start_date") 99 | val endDateString = cycle.optString("period_end_date") 100 | 101 | if (startDateString.isNotEmpty() && endDateString.isNotEmpty()) { 102 | val startDate = LocalDate.parse(startDateString, DateTimeFormatter.ISO_LOCAL_DATE) 103 | val endDate = LocalDate.parse(endDateString, DateTimeFormatter.ISO_LOCAL_DATE) 104 | 105 | val dates = getPeriodDates(startDate, endDate) 106 | 107 | for (date in dates) { 108 | val periodId = dbHelper.newFindOrCreatePeriodID(date) 109 | dbHelper.addDateToPeriod(date, periodId) 110 | } 111 | } 112 | } 113 | 114 | // Get Ovulations from either OvulationTests or "own judgement" 115 | val pointEventsArray = operationalData.getJSONArray("point_events_manual_v2") 116 | for (i in 0 until pointEventsArray.length()) { 117 | val event = pointEventsArray.getJSONObject(i) 118 | 119 | val category = event.optString("category") 120 | val subcategory = event.optString("subcategory") 121 | 122 | if ((category == "OvulationTest" && subcategory == "Positive") || 123 | (category == "Ovulation" && subcategory == "OtherMethods")) { 124 | 125 | val dateString = event.optString("date") 126 | if (dateString.isNotEmpty()) { 127 | val date = LocalDate.parse(dateString, DateTimeFormatter.ISO_LOCAL_DATE) 128 | dbHelper.addOvulationDate(date) 129 | } 130 | } 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/business/ICalculationsHelper.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.business 2 | 3 | import java.time.LocalDate 4 | 5 | /** 6 | * This helper provides methods to calculate menstrual cycle related data 7 | * such as next period date, average cycle length, and average luteal length. 8 | */ 9 | interface ICalculationsHelper { 10 | /** 11 | * Calculates the next expected period date. 12 | * 13 | * @return The next expected period date. 14 | */ 15 | fun calculateNextPeriod(): LocalDate? 16 | 17 | /** 18 | * Calculates the average number of days from the first day of the last period to ovulation. 19 | * 20 | * @return The average follicular phase length as a string. 21 | */ 22 | fun averageFollicalGrowthInDays(): Double 23 | 24 | /** 25 | * Calculates the average cycle length using the latest period start dates. 26 | * X comes from app_settings in the database 27 | * @return The average cycle length as a double. 28 | */ 29 | fun averageCycleLength(): Double 30 | 31 | /** 32 | * Calculates the average period length using the latest period start dates. 33 | * 34 | * @return The average period length as a double. 35 | */ 36 | fun averagePeriodLength(): Double 37 | 38 | /** 39 | * Calculates the average luteal phase length using the latest ovulation dates. 40 | * 41 | * @return The average luteal phase length as a double. 42 | */ 43 | fun averageLutealLength(): Double 44 | 45 | /** 46 | * Calculates the luteal phase length for a specific cycle. 47 | * 48 | * @param date The ovulation date. 49 | * @return The luteal phase length as an integer. 50 | */ 51 | fun getLutealLengthForPeriod(date: LocalDate): Int 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/business/IClueImport.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.business 2 | 3 | interface IClueImport { 4 | /** 5 | * Read the content of the given file and attempts to import the data into the database. 6 | * 7 | * @return true if the import was successful, false otherwise 8 | */ 9 | fun importFileToDatabase(filePath: String) : Boolean 10 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/business/IFloImport.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.business 2 | 3 | interface IFloImport { 4 | /** 5 | * Read the content of the given file and attempts to import the data into the database. 6 | * 7 | * @return true if the import was successful, false otherwise 8 | */ 9 | fun importFileToDatabase(filePath: String) : Boolean 10 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/business/IMensinatorExportImport.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.business 2 | 3 | import android.net.Uri 4 | 5 | interface IMensinatorExportImport { 6 | fun generateExportFileName(): String 7 | fun getDefaultImportFilePath(): String 8 | fun exportDatabase(filePath: Uri) 9 | /** 10 | * Read the content of the given file and attempts to import the data into the database. 11 | * 12 | * @return true if the import was successful, false otherwise 13 | */ 14 | fun importDatabase(filePath: String) : Boolean 15 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/business/IOvulationPrediction.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.business 2 | 3 | import java.time.LocalDate 4 | 5 | interface IOvulationPrediction { 6 | fun getPredictedOvulationDate(): LocalDate? 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/business/IPeriodDatabaseHelper.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.business 2 | 3 | import android.database.sqlite.SQLiteDatabase 4 | import com.mensinator.app.data.Setting 5 | import com.mensinator.app.data.Symptom 6 | import java.time.LocalDate 7 | 8 | interface IPeriodDatabaseHelper { 9 | 10 | // TODO: The database should only be accessible via the functions of this interface. 11 | // Refactor this soon! 12 | val readableDb: SQLiteDatabase 13 | 14 | // TODO: The database should only be accessible via the functions of this interface. 15 | // Refactor this soon! 16 | val writableDb: SQLiteDatabase 17 | 18 | // This function is used to add a date together with a period id to the periods table 19 | fun addDateToPeriod(date: LocalDate, periodId: PeriodId) 20 | 21 | // Get all period dates for a given month 22 | fun getPeriodDatesForMonth(year: Int, month: Int): Map 23 | 24 | // NEW! Testing new function for getting all period dates month-1, month, month+1 25 | suspend fun getPeriodDatesForMonthNew(year: Int, month: Int): Map 26 | 27 | // Returns how many periods that are in the database 28 | fun getPeriodCount(): Int 29 | 30 | // This function is used to remove a date from the periods table 31 | fun removeDateFromPeriod(date: LocalDate) 32 | 33 | // This function is used to get all symptoms from the database 34 | suspend fun getAllSymptoms(): List 35 | 36 | // This function inserts new symptom into the Database 37 | fun createNewSymptom(symptomName: String) 38 | 39 | // This function returns all Symptom dates for given month 40 | fun getSymptomDatesForMonth(year: Int, month: Int): Set 41 | // NEW! Testing new function for getting all symptom dates month-1, month, month+1 42 | suspend fun getSymptomDatesForMonthNew(year: Int, month: Int): Set 43 | 44 | suspend fun getSymptomsForDates(): Map> 45 | 46 | // This function is used to update symptom dates in the database 47 | fun updateSymptomDate(dates: List, symptomId: List) 48 | 49 | // This function is used to get symptoms for a given date 50 | suspend fun getActiveSymptomIdsForDate(date: LocalDate): List 51 | 52 | fun getSymptomColorForDate(date: LocalDate): List 53 | 54 | // This function is used to get all settings from the database 55 | fun getAllSettings(): List 56 | 57 | // This function is used for updating settings in the database 58 | suspend fun updateSetting(key: String, value: String): Boolean 59 | 60 | // This function is used to get a setting from the database 61 | suspend fun getSettingByKey(key: String): Setting? 62 | 63 | // This function wraps getSettingByKey to return a valid string 64 | suspend fun getStringSettingByKey(key: String): String 65 | 66 | // This function is used for adding/removing ovulation dates from the database 67 | fun updateOvulationDate(date: LocalDate) 68 | 69 | //Add ovulation to database 70 | fun addOvulationDate(date: LocalDate) 71 | // Remove ovulation from database 72 | fun removeOvulationDate(date: LocalDate) 73 | 74 | // This function is used to get ovulation date for a given month 75 | fun getOvulationDatesForMonth(year: Int, month: Int): Set 76 | 77 | //NEW! Testing new function for getting all ovulation dates month-1, month, month+1 78 | suspend fun getOvulationDatesForMonthNew(year: Int, month: Int): Set 79 | 80 | // This function is used to get the number of ovulations in the database 81 | fun getOvulationCount(): Int 82 | 83 | // This function checks if date input should be included in existing period 84 | // or if a new periodId should be created 85 | fun newFindOrCreatePeriodID(date: LocalDate): PeriodId 86 | 87 | // Retrieve the previous period's start date from a given date 88 | fun getFirstPreviousPeriodDate(date: LocalDate): LocalDate? 89 | 90 | // Retrieve the oldest period date in the database 91 | fun getOldestPeriodDate(): LocalDate? 92 | 93 | // Retrieve the newest ovulation date in the database 94 | fun getNewestOvulationDate(): LocalDate? 95 | 96 | // Update symptom's active status and color by symptom ID 97 | fun updateSymptom(id: Int, active: Int, color: String) 98 | 99 | // Retrieve the latest X ovulation dates where they are followed by a period 100 | fun getLatestXOvulationsWithPeriod(number: Int): List 101 | 102 | // Retrieve the most recent ovulation date 103 | fun getLastOvulation(): LocalDate? 104 | 105 | // Retrieve the latest X period start dates 106 | fun getLatestXPeriodStart(number: Int): List 107 | 108 | // Retrieve the next period start date after a given date 109 | fun getFirstNextPeriodDate(date: LocalDate): LocalDate? 110 | 111 | // Get the number of dates in a given period 112 | fun getNoOfDatesInPeriod(date: LocalDate): Int 113 | 114 | // Retrieve the latest X ovulation dates 115 | fun getXLatestOvulationsDates(number: Int): List 116 | 117 | // Remove a symptom by its ID 118 | fun deleteSymptom(symptomId: Int) 119 | 120 | // Get the database version 121 | fun getDBVersion(): String 122 | 123 | // Rename a symptom by its ID 124 | fun renameSymptom(symptomId: Int, newName: String) 125 | 126 | // Retrieve the latest period start date 127 | fun getLatestPeriodStart(): LocalDate? 128 | } 129 | -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/business/IPeriodPrediction.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.business 2 | 3 | import java.time.LocalDate 4 | 5 | interface IPeriodPrediction { 6 | fun getPredictedPeriodDate(): LocalDate? 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/business/MensinatorExportImport.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.business 2 | 3 | import android.content.ContentValues 4 | import android.content.Context 5 | import android.database.Cursor 6 | import android.database.sqlite.SQLiteDatabase 7 | import android.icu.text.SimpleDateFormat 8 | import android.net.Uri 9 | import android.util.Log 10 | import org.json.JSONArray 11 | import org.json.JSONObject 12 | import java.io.BufferedReader 13 | import java.io.File 14 | import java.io.FileInputStream 15 | import java.io.InputStreamReader 16 | import java.util.Date 17 | import java.util.Locale 18 | 19 | 20 | class MensinatorExportImport( 21 | private val context: Context, 22 | private val dbHelper: IPeriodDatabaseHelper, 23 | ) : IMensinatorExportImport { 24 | 25 | override fun generateExportFileName(): String { 26 | // Create a date formatter to include the current date in the filename 27 | val dateFormat = SimpleDateFormat("yyyyMMdd", Locale.getDefault()) 28 | val dateStr = dateFormat.format(Date()) 29 | 30 | // Generate a random number for the filename 31 | val randomNumber = (1000..9999).random() 32 | 33 | // Construct the filename with date and random number 34 | return "mensinator_${dateStr}_$randomNumber.json" 35 | } 36 | 37 | override fun getDefaultImportFilePath(): String { 38 | return File(context.getExternalFilesDir(null), "import.json").absolutePath 39 | } 40 | 41 | override fun exportDatabase(filePath: Uri) { 42 | val db = dbHelper.readableDb 43 | 44 | val exportData = JSONObject() 45 | 46 | // Export periods table 47 | val periodsCursor = db.query("periods", null, null, null, null, null, null) 48 | exportData.put("periods", cursorToJsonArray(periodsCursor)) 49 | periodsCursor.close() 50 | 51 | // Export symptoms table 52 | val symptomsCursor = db.query("symptoms", null, null, null, null, null, null) 53 | exportData.put("symptoms", cursorToJsonArray(symptomsCursor)) 54 | symptomsCursor.close() 55 | 56 | // Export symptom_date table 57 | val symptomDatesCursor = db.query("symptom_date", null, null, null, null, null, null) 58 | exportData.put("symptom_date", cursorToJsonArray(symptomDatesCursor)) 59 | symptomDatesCursor.close() 60 | 61 | // Export ovulations table 62 | val ovulationsCursor = db.query("ovulations", null, null, null, null, null, null) 63 | exportData.put("ovulations", cursorToJsonArray(ovulationsCursor)) 64 | ovulationsCursor.close() 65 | 66 | // Export app_settings table 67 | val settingsCursor = db.query("app_settings", null, null, null, null, null, null) 68 | exportData.put("app_settings", cursorToJsonArray(settingsCursor)) 69 | settingsCursor.close() 70 | 71 | // Write JSON data to file 72 | val contentResolver = context.contentResolver 73 | contentResolver.openOutputStream(filePath)?.use { outputStream -> 74 | outputStream.write(exportData.toString().toByteArray()) 75 | } 76 | 77 | db.close() 78 | } 79 | 80 | private fun cursorToJsonArray(cursor: Cursor): JSONArray { 81 | val jsonArray = JSONArray() 82 | while (cursor.moveToNext()) { 83 | val jsonObject = JSONObject() 84 | for (i in 0 until cursor.columnCount) { 85 | jsonObject.put(cursor.getColumnName(i), cursor.getString(i)) 86 | } 87 | jsonArray.put(jsonObject) 88 | } 89 | return jsonArray 90 | } 91 | 92 | override fun importDatabase(filePath: String) : Boolean { 93 | val db = dbHelper.writableDb 94 | 95 | // Read JSON data from file 96 | val file = File(filePath) 97 | val fileInputStream = FileInputStream(file) 98 | val reader = BufferedReader(InputStreamReader(fileInputStream)) 99 | val stringBuilder = StringBuilder() 100 | var line: String? = reader.readLine() 101 | while (line != null) { 102 | stringBuilder.append(line) 103 | line = reader.readLine() 104 | } 105 | reader.close() 106 | val importData = JSONObject(stringBuilder.toString()) 107 | 108 | db.beginTransaction() 109 | 110 | var result = true 111 | 112 | try { 113 | // Import periods table 114 | importJsonArrayToTable(db, "periods", importData.getJSONArray("periods")) 115 | 116 | // Check if "symptoms" key exists and import if present 117 | if (importData.has("symptoms")) { 118 | importJsonArrayToTable(db, "symptoms", importData.getJSONArray("symptoms")) 119 | } else { 120 | Log.d("Import", "No symptoms data found in the file.") 121 | } 122 | 123 | // Check if "symptom_date" key exists and import if present 124 | if (importData.has("symptom_date")) { 125 | importJsonArrayToTable(db, "symptom_date", importData.getJSONArray("symptom_date")) 126 | } else { 127 | Log.d("Import", "No symptom_date data found in the file.") 128 | } 129 | 130 | // Check if "ovulations" key exists and import if present 131 | if (importData.has("ovulations")) { 132 | importJsonArrayToTable(db, "ovulations", importData.getJSONArray("ovulations")) 133 | } else { 134 | Log.d("Import", "No ovulations data found in the file.") 135 | } 136 | 137 | // Check if "app_settings" key exists and import if present 138 | if (importData.has("app_settings")) { 139 | importAppSettings(db, importData.getJSONArray("app_settings")) 140 | } else { 141 | Log.d("Import", "No app_settings data found in the file.") 142 | } 143 | 144 | db.setTransactionSuccessful() 145 | } catch (e: Exception) { 146 | Log.e("Import", "Error during import", e) 147 | result = false 148 | } 149 | 150 | db.endTransaction() 151 | db.close() 152 | 153 | return result 154 | 155 | } 156 | 157 | // This function will delete all period, ovulation, symptoms and symptomdates before importing the file 158 | // User should never do any changes before importing their file 159 | private fun importJsonArrayToTable(db: SQLiteDatabase, tableName: String, jsonArray: JSONArray) { 160 | db.delete(tableName, null, null) 161 | for (i in 0 until jsonArray.length()) { 162 | val jsonObject = jsonArray.getJSONObject(i) 163 | val contentValues = ContentValues() 164 | val keys = jsonObject.keys() 165 | while (keys.hasNext()) { 166 | val key = keys.next() 167 | contentValues.put(key, jsonObject.getString(key)) 168 | } 169 | db.insert(tableName, null, contentValues) 170 | } 171 | } 172 | 173 | // This function will only update values of the settings provided in the importfile 174 | // Due to different db-versions there should never be a time where we want data from the file 175 | // to insert into the database. It should always up update based on setting_key 176 | private fun importAppSettings(db: SQLiteDatabase, jsonArray: JSONArray) { 177 | // Loop through each JSON object in the array 178 | for (i in 0 until jsonArray.length()) { 179 | // Get the JSON object 180 | val jsonObject = jsonArray.getJSONObject(i) 181 | 182 | // Extract the setting_key to use as the condition for updating 183 | val settingKey = jsonObject.getString("setting_key") 184 | val settingValue = jsonObject.getString("setting_value") 185 | 186 | // Create a ContentValues object to hold the updated values 187 | val contentValues = ContentValues() 188 | contentValues.put("setting_value", settingValue) 189 | 190 | // Update the setting in the database 191 | db.update("app_settings", contentValues, "setting_key = ?", arrayOf(settingKey)) 192 | 193 | } 194 | } 195 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/business/OvulationPrediction.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.business 2 | 3 | import android.util.Log 4 | import java.time.LocalDate 5 | 6 | class OvulationPrediction( 7 | private val dbHelper: IPeriodDatabaseHelper, 8 | private val calcHelper: ICalculationsHelper, 9 | private val periodPrediction: IPeriodPrediction, 10 | ) : IOvulationPrediction { 11 | 12 | override fun getPredictedOvulationDate(): LocalDate? { 13 | val periodCount = dbHelper.getPeriodCount() 14 | val ovulationCount = dbHelper.getOvulationCount() 15 | val periodPredictionDate = periodPrediction.getPredictedPeriodDate() 16 | val lastOvulationDate = dbHelper.getNewestOvulationDate() 17 | val firstDayOfNextMonth = LocalDate.now().withDayOfMonth(1).plusMonths(1) 18 | val previousFirstPeriodDate = dbHelper.getFirstPreviousPeriodDate(firstDayOfNextMonth) 19 | 20 | // No data at all in database 21 | if (lastOvulationDate == null || previousFirstPeriodDate == null) { 22 | Log.e("TAG", "Null values found: lastOvulationDate=$lastOvulationDate, previousFirstPeriodDate=$previousFirstPeriodDate") 23 | return null 24 | } 25 | 26 | return if (ovulationCount >=2 && periodCount >= 2 && lastOvulationDate < previousFirstPeriodDate) { 27 | Log.d("TAG", "Inside if statement") 28 | // Get average follicleGrowth based on ovulationHistory 29 | // Get latest start of period, add days to that date 30 | val averageOvulationDay = calcHelper.averageFollicalGrowthInDays().toInt() 31 | if (averageOvulationDay > 0) { 32 | val latestPeriodStart = dbHelper.getLatestPeriodStart() 33 | latestPeriodStart?.plusDays(averageOvulationDay.toLong()) 34 | } 35 | else { 36 | // Return a default value, not enough data to predict ovulation, averageFollicalGrowthInDays() returns 0 37 | null 38 | } 39 | } // If Ovulation is after previous first period date and prediction exists for Period, calculate next ovulation based on calculated start of period 40 | else if (lastOvulationDate > previousFirstPeriodDate && (periodPredictionDate != LocalDate.parse("1900-01-01"))) { 41 | Log.d("TAG", "IM ALIVE") 42 | val follicleGrowthDays = calcHelper.averageFollicalGrowthInDays() 43 | val follicleGrowthDaysLong = follicleGrowthDays.toLong() 44 | 45 | if (follicleGrowthDaysLong > 0) { 46 | periodPredictionDate?.plusDays(follicleGrowthDaysLong) 47 | } else { 48 | // Return a default value, not enough data to predict ovulation, averageFollicalGrowthInDays() returns 0 49 | null 50 | } 51 | } 52 | else { 53 | Log.d("TAG", "THERE ARE NOW OVULATIONS") 54 | // Return a default value, not enough data to predict ovulation, ovulationCount < 2 55 | null 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/business/PeriodPrediction.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.business 2 | 3 | import java.time.LocalDate 4 | 5 | class PeriodPrediction( 6 | private val dbHelper: IPeriodDatabaseHelper, 7 | private val calcHelper: ICalculationsHelper, 8 | ) : IPeriodPrediction { 9 | 10 | override fun getPredictedPeriodDate(): LocalDate? { 11 | val periodCount = dbHelper.getPeriodCount() 12 | if (periodCount < 2) { 13 | return null 14 | } 15 | 16 | return calcHelper.calculateNextPeriod() 17 | } 18 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/business/notifications/AndroidNotificationScheduler.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.business.notifications 2 | 3 | import android.app.AlarmManager 4 | import android.app.PendingIntent 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.util.Log 8 | import com.mensinator.app.NotificationReceiver 9 | import java.time.LocalDate 10 | import java.time.ZoneId 11 | 12 | /** 13 | * Allows scheduling/cancelling notifications on Android. 14 | */ 15 | class AndroidNotificationScheduler( 16 | private val context: Context, 17 | private val alarmManager: AlarmManager, 18 | ) : IAndroidNotificationScheduler { 19 | override fun scheduleNotification( 20 | messageText: String, 21 | notificationDate: LocalDate 22 | ) { 23 | val notificationTimeMillis = 24 | notificationDate.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() 25 | alarmManager.set( 26 | AlarmManager.RTC_WAKEUP, 27 | notificationTimeMillis, 28 | getPendingIntent(messageText) 29 | ) 30 | Log.d("NotificationScheduler", "Notification scheduled for $notificationDate") 31 | } 32 | 33 | override fun cancelScheduledNotification() { 34 | alarmManager.cancel(getPendingIntent(messageText = null)) 35 | Log.d("NotificationScheduler", "Notification cancelled") 36 | } 37 | 38 | private fun getPendingIntent(messageText: String?): PendingIntent { 39 | val intent = Intent(context, NotificationReceiver::class.java).apply { 40 | action = NotificationReceiver.ACTION_NOTIFICATION 41 | messageText?.let { putExtra(NotificationReceiver.MESSAGE_TEXT_KEY, it) } 42 | } 43 | return PendingIntent.getBroadcast( 44 | context, 45 | 0, 46 | intent, 47 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE 48 | ) 49 | } 50 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/business/notifications/IAndroidNotificationScheduler.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.business.notifications 2 | 3 | import java.time.LocalDate 4 | 5 | interface IAndroidNotificationScheduler { 6 | fun scheduleNotification( 7 | messageText: String, 8 | notificationDate: LocalDate 9 | ) 10 | 11 | fun cancelScheduledNotification() 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/business/notifications/INotificationScheduler.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.business.notifications 2 | 3 | interface INotificationScheduler { 4 | /** 5 | * Checks if there is enough data to schedule a period reminder notification, then schedules it. 6 | */ 7 | suspend fun schedulePeriodNotification() 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/business/notifications/NotificationScheduler.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.business.notifications 2 | 3 | 4 | import android.content.Context 5 | import android.util.Log 6 | import com.mensinator.app.business.IPeriodDatabaseHelper 7 | import com.mensinator.app.business.IPeriodPrediction 8 | import com.mensinator.app.settings.IntSetting 9 | import com.mensinator.app.settings.StringSetting 10 | import com.mensinator.app.ui.ResourceMapper 11 | import com.mensinator.app.utils.IDispatcherProvider 12 | import kotlinx.coroutines.withContext 13 | import java.time.LocalDate 14 | 15 | /** 16 | * Service that checks whether a notification should be scheduled or cancelled. 17 | * Does not perform the actual scheduling itself, instead it delegates this to [IAndroidNotificationScheduler]. 18 | * This is done to be able to unit test this class without using Robolectric. 19 | */ 20 | class NotificationScheduler( 21 | private val context: Context, 22 | private val dbHelper: IPeriodDatabaseHelper, 23 | private val periodPrediction: IPeriodPrediction, 24 | private val dispatcherProvider: IDispatcherProvider, 25 | private val androidNotificationScheduler: IAndroidNotificationScheduler, 26 | ) : INotificationScheduler { 27 | 28 | private val defaultReminderDays = 2 29 | 30 | // Schedule notification for reminder 31 | // Check that reminders should be scheduled (reminder>0) 32 | // and that it's more then reminderDays left (do not schedule notifications where there's too few reminderDays left until period) 33 | override suspend fun schedulePeriodNotification() { 34 | withContext(dispatcherProvider.IO) { 35 | val periodReminderDays = 36 | dbHelper.getSettingByKey(IntSetting.REMINDER_DAYS.settingDbKey)?.value?.toIntOrNull() ?: defaultReminderDays 37 | val nextPeriodDate = periodPrediction.getPredictedPeriodDate() 38 | val initPeriodKeyOrCustomMessage = 39 | dbHelper.getStringSettingByKey(StringSetting.PERIOD_NOTIFICATION_MESSAGE.settingDbKey) 40 | val periodMessageText = 41 | ResourceMapper.getPeriodReminderMessage(initPeriodKeyOrCustomMessage, context) 42 | 43 | val notificationDate = getNotificationScheduleDate(periodReminderDays, nextPeriodDate) 44 | withContext(dispatcherProvider.Main) { 45 | if (notificationDate != null) { 46 | androidNotificationScheduler.scheduleNotification(periodMessageText, notificationDate) 47 | } else { 48 | // Make sure the scheduled notification is cancelled, if the user data/conditions become invalid. 49 | androidNotificationScheduler.cancelScheduledNotification() 50 | } 51 | } 52 | } 53 | } 54 | 55 | // If the date checks pass, return the notification schedule date. 56 | private fun getNotificationScheduleDate( 57 | periodReminderDays: Int, 58 | nextPeriodDate: LocalDate? 59 | ): LocalDate? { 60 | if (periodReminderDays <= 0 || nextPeriodDate == null) return null 61 | 62 | val notificationDate = nextPeriodDate.minusDays(periodReminderDays.toLong()) 63 | if (notificationDate.isBefore(LocalDate.now())) { 64 | Log.d( 65 | "CalendarScreen", 66 | "Notification not scheduled because the reminder date is in the past" 67 | ) 68 | return null 69 | } 70 | 71 | return nextPeriodDate.minusDays(periodReminderDays.toLong()) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/calendar/CalendarViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.calendar 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import com.kizitonwose.calendar.core.yearMonth 7 | import com.mensinator.app.business.IOvulationPrediction 8 | import com.mensinator.app.business.IPeriodDatabaseHelper 9 | import com.mensinator.app.business.IPeriodPrediction 10 | import com.mensinator.app.business.PeriodId 11 | import com.mensinator.app.business.notifications.INotificationScheduler 12 | import com.mensinator.app.data.ColorSource 13 | import com.mensinator.app.data.Symptom 14 | import com.mensinator.app.data.isActive 15 | import com.mensinator.app.extensions.pickBestContrastTextColorForThisBackground 16 | import com.mensinator.app.settings.BooleanSetting 17 | import com.mensinator.app.settings.ColorSetting 18 | import com.mensinator.app.ui.theme.Black 19 | import com.mensinator.app.ui.theme.DarkGrey 20 | import kotlinx.collections.immutable.* 21 | import kotlinx.coroutines.Dispatchers 22 | import kotlinx.coroutines.flow.MutableStateFlow 23 | import kotlinx.coroutines.flow.StateFlow 24 | import kotlinx.coroutines.flow.asStateFlow 25 | import kotlinx.coroutines.flow.update 26 | import kotlinx.coroutines.launch 27 | import java.time.LocalDate 28 | import java.time.YearMonth 29 | 30 | class CalendarViewModel( 31 | private val dbHelper: IPeriodDatabaseHelper, 32 | private val periodPrediction: IPeriodPrediction, 33 | private val ovulationPrediction: IOvulationPrediction, 34 | private val notificationScheduler: INotificationScheduler, 35 | ) : ViewModel() { 36 | 37 | private val _viewState = MutableStateFlow( 38 | ViewState() 39 | ) 40 | val viewState: StateFlow = _viewState.asStateFlow() 41 | 42 | fun refreshData() { 43 | viewModelScope.launch(Dispatchers.IO) { 44 | val showCycleNumbersSetting = 45 | (dbHelper.getSettingByKey(BooleanSetting.SHOW_CYCLE_NUMBERS.settingDbKey)?.value?.toIntOrNull() ?: 1) == 1 46 | 47 | _viewState.update { 48 | it.copy( 49 | showCycleNumbers = showCycleNumbersSetting, 50 | periodPredictionDate = periodPrediction.getPredictedPeriodDate(), 51 | ovulationPredictionDate = ovulationPrediction.getPredictedOvulationDate(), 52 | periodDates = dbHelper.getPeriodDatesForMonthNew( 53 | it.focusedYearMonth.year, 54 | it.focusedYearMonth.monthValue 55 | ).toPersistentMap(), 56 | symptomDates = dbHelper.getSymptomsForDates().toPersistentMap(), 57 | ovulationDates = dbHelper.getOvulationDatesForMonthNew( 58 | it.focusedYearMonth.year, 59 | it.focusedYearMonth.monthValue 60 | ).toPersistentSet(), 61 | activeSymptoms = dbHelper.getAllSymptoms() 62 | .filter { symptom-> symptom.isActive } 63 | .toPersistentSet(), 64 | ) 65 | } 66 | } 67 | } 68 | 69 | fun updateDarkModeStatus(isDarkMode: Boolean) = viewModelScope.launch { 70 | _viewState.update { 71 | it.copy( 72 | isDarkMode = isDarkMode, 73 | calendarColors = getCalendarColorMap(isDarkMode) 74 | ) 75 | } 76 | } 77 | 78 | fun onAction(uiAction: UiAction): Unit = when (uiAction) { 79 | is UiAction.UpdateFocusedYearMonth -> { 80 | _viewState.update { 81 | it.copy(focusedYearMonth = uiAction.focusedYearMonth) 82 | } 83 | deselectDatesIfFocusChangedTooMuch(uiAction.focusedYearMonth) 84 | refreshData() 85 | } 86 | is UiAction.SelectDays -> { 87 | viewModelScope.launch { 88 | _viewState.update { 89 | val activeSymptoms = if (uiAction.days.isEmpty()) { 90 | persistentSetOf() 91 | } else { 92 | dbHelper.getActiveSymptomIdsForDate(uiAction.days.last()).toPersistentSet() 93 | } 94 | it.copy( 95 | selectedDays = uiAction.days, 96 | activeSymptomIdsForLatestSelectedDay = activeSymptoms 97 | ) 98 | } 99 | } 100 | Unit 101 | } 102 | is UiAction.UpdateSymptomDates -> { 103 | dbHelper.updateSymptomDate(uiAction.days.toList(), uiAction.selectedSymptomIds) 104 | onAction(UiAction.SelectDays(persistentSetOf())) 105 | refreshData() 106 | } 107 | is UiAction.UpdateOvulationDay -> { 108 | dbHelper.updateOvulationDate(uiAction.ovulationDay) 109 | onAction(UiAction.SelectDays(persistentSetOf())) 110 | refreshData() 111 | } 112 | is UiAction.UpdatePeriodDates -> { 113 | /** 114 | * Make sure that if two or more days are selected (and at least one is already marked as period), 115 | * we should make sure that all days are removed. 116 | */ 117 | val datesAlreadyMarkedAsPeriod = 118 | uiAction.selectedDays.intersect(uiAction.currentPeriodDays.keys) 119 | if (datesAlreadyMarkedAsPeriod.isEmpty()) { 120 | uiAction.selectedDays.forEach { 121 | val periodId = dbHelper.newFindOrCreatePeriodID(it) 122 | dbHelper.addDateToPeriod(it, periodId) 123 | } 124 | } else { 125 | datesAlreadyMarkedAsPeriod.forEach { dbHelper.removeDateFromPeriod(it) } 126 | } 127 | viewModelScope.launch { notificationScheduler.schedulePeriodNotification() } 128 | onAction(UiAction.SelectDays(persistentSetOf())) 129 | refreshData() 130 | } 131 | } 132 | 133 | /** 134 | * To avoid the user not seeing what they are editing, we deselect the data when the calendar 135 | * focus was changed too much. Possibly annoying as data could get discarded. 136 | */ 137 | private fun deselectDatesIfFocusChangedTooMuch(newFocus: YearMonth) { 138 | val selectedDateYearMonths = viewState.value.selectedDays.map { it.yearMonth } 139 | if (selectedDateYearMonths.isEmpty()) return 140 | 141 | val minSelectedMonth = selectedDateYearMonths.min() 142 | val maxSelectedMonth = selectedDateYearMonths.max() 143 | 144 | val minAllowedMonth = minSelectedMonth.minusMonths(2) 145 | val maxAllowedMonth = maxSelectedMonth.plusMonths(1) 146 | 147 | if (newFocus >= minAllowedMonth && newFocus <= maxAllowedMonth) return 148 | 149 | _viewState.update { 150 | it.copy( 151 | selectedDays = persistentSetOf(), 152 | activeSymptomIdsForLatestSelectedDay = persistentSetOf() 153 | ) 154 | } 155 | } 156 | 157 | private suspend fun getCalendarColorMap(isDarkMode: Boolean): CalendarColors { 158 | val settingColors = ColorSetting.entries.associateWith { 159 | val backgroundColor = ColorSource.getColor( 160 | isDarkMode, 161 | dbHelper.getSettingByKey(it.settingDbKey)?.value ?: "LightGray" 162 | ) 163 | val textColor = backgroundColor.pickBestContrastTextColorForThisBackground( 164 | isDarkMode, 165 | DarkGrey, 166 | Black 167 | ) 168 | 169 | ColorCombination(backgroundColor, textColor) 170 | } 171 | 172 | val symptomColors = dbHelper.getAllSymptoms().associateWith { 173 | ColorSource.getColor(isDarkMode, it.color) 174 | } 175 | 176 | return CalendarColors(settingColors, symptomColors) 177 | } 178 | 179 | data class ViewState( 180 | val isDarkMode: Boolean = false, 181 | 182 | val showCycleNumbers: Boolean = false, 183 | val focusedYearMonth: YearMonth = YearMonth.now(), 184 | val periodPredictionDate: LocalDate? = null, 185 | val ovulationPredictionDate: LocalDate? = null, 186 | val periodDates: PersistentMap = persistentMapOf(), 187 | val symptomDates: PersistentMap> = persistentMapOf(), 188 | val ovulationDates: PersistentSet = persistentSetOf(), 189 | val activeSymptoms: PersistentSet = persistentSetOf(), 190 | val selectedDays: PersistentSet = persistentSetOf(), 191 | val activeSymptomIdsForLatestSelectedDay: PersistentSet = persistentSetOf(), 192 | val calendarColors: CalendarColors = CalendarColors(mapOf(), mapOf()), 193 | ) 194 | 195 | data class CalendarColors( 196 | val settingColors: Map, 197 | val symptomColors: Map, 198 | ) 199 | 200 | sealed class UiAction { 201 | data class UpdateFocusedYearMonth(val focusedYearMonth: YearMonth) : UiAction() 202 | data class SelectDays(val days: PersistentSet) : UiAction() 203 | data class UpdateSymptomDates( 204 | val days: PersistentSet, 205 | val selectedSymptomIds: PersistentList 206 | ) : UiAction() 207 | 208 | data class UpdateOvulationDay(val ovulationDay: LocalDate) : UiAction() 209 | data class UpdatePeriodDates( 210 | val currentPeriodDays: PersistentMap, 211 | val selectedDays: PersistentSet 212 | ) : UiAction() 213 | } 214 | } 215 | 216 | -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/calendar/ColorCombination.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.calendar 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | data class ColorCombination( 6 | val backgroundColor: Color, 7 | val textColor: Color, 8 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/calendar/SymptomDialogs.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.calendar 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.rememberScrollState 6 | import androidx.compose.foundation.verticalScroll 7 | import androidx.compose.material3.AlertDialog 8 | import androidx.compose.material3.Button 9 | import androidx.compose.material3.Checkbox 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.* 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.res.stringResource 15 | import androidx.compose.ui.tooling.preview.Preview 16 | import androidx.compose.ui.unit.dp 17 | import com.mensinator.app.R 18 | import com.mensinator.app.data.Symptom 19 | import com.mensinator.app.ui.ResourceMapper 20 | import com.mensinator.app.ui.theme.MensinatorTheme 21 | import kotlinx.collections.immutable.PersistentSet 22 | import kotlinx.collections.immutable.persistentSetOf 23 | import kotlinx.collections.immutable.toPersistentSet 24 | import java.time.LocalDate 25 | 26 | @Composable 27 | fun EditSymptomsForDaysDialog( 28 | date: LocalDate, 29 | symptoms: PersistentSet, 30 | currentlyActiveSymptomIds: PersistentSet, 31 | onSave: (PersistentSet) -> Unit, 32 | onCancel: () -> Unit, 33 | modifier: Modifier = Modifier, 34 | ) { 35 | var selectedSymptoms by remember { 36 | mutableStateOf( 37 | symptoms.filter { it.id in currentlyActiveSymptomIds }.toPersistentSet() 38 | ) 39 | } 40 | 41 | AlertDialog( 42 | onDismissRequest = { onCancel() }, 43 | confirmButton = { 44 | Button( 45 | onClick = { 46 | onSave(selectedSymptoms) 47 | }, 48 | modifier = Modifier.fillMaxWidth() 49 | ) { 50 | Text(text = stringResource(id = R.string.save_symptoms_button)) 51 | } 52 | }, 53 | modifier = modifier, 54 | dismissButton = { 55 | Button( 56 | onClick = { 57 | onCancel() 58 | }, 59 | modifier = Modifier.fillMaxWidth() 60 | ) { 61 | Text(text = stringResource(id = R.string.cancel_button)) 62 | } 63 | }, 64 | title = { 65 | Text(text = stringResource(id = R.string.symptoms_dialog_title, date)) 66 | }, 67 | text = { 68 | Column(modifier = Modifier.verticalScroll(rememberScrollState())) { 69 | symptoms.forEach { symptom -> 70 | val symptomKey = ResourceMapper.getStringResourceId(symptom.name) 71 | val symptomDisplayName = symptomKey?.let { stringResource(id = it) } ?: symptom.name 72 | Row( 73 | modifier = Modifier 74 | .fillMaxWidth() 75 | .padding(8.dp) 76 | .clickable { 77 | val newSet = if (selectedSymptoms.contains(symptom)) { 78 | selectedSymptoms - symptom 79 | } else { 80 | selectedSymptoms + symptom 81 | } 82 | selectedSymptoms = newSet.toPersistentSet() 83 | }, 84 | verticalAlignment = Alignment.CenterVertically 85 | ) { 86 | Checkbox( 87 | checked = selectedSymptoms.contains(symptom), 88 | onCheckedChange = null 89 | ) 90 | Spacer(modifier = Modifier.width(8.dp)) 91 | Text(text = symptomDisplayName) 92 | } 93 | } 94 | } 95 | }, 96 | ) 97 | } 98 | 99 | @Preview 100 | @Composable 101 | private fun EditSymptomsForDaysDialog_OneDayPreview() { 102 | val symptoms = persistentSetOf( 103 | Symptom(1, "Light", 0, ""), 104 | Symptom(2, "Medium", 1, ""), 105 | ) 106 | MensinatorTheme { 107 | EditSymptomsForDaysDialog( 108 | date = LocalDate.now(), 109 | symptoms = symptoms, 110 | currentlyActiveSymptomIds = persistentSetOf(2), 111 | onSave = {}, 112 | onCancel = { }, 113 | ) 114 | } 115 | } 116 | 117 | // TODO: Fix within https://github.com/EmmaTellblom/Mensinator/issues/203 118 | @Preview 119 | @Composable 120 | private fun EditSymptomsForDaysDialog_MultipleDaysPreview() { 121 | val symptoms = persistentSetOf( 122 | Symptom(1, "Light", 0, ""), 123 | Symptom(2, "Medium", 1, ""), 124 | ) 125 | MensinatorTheme { 126 | EditSymptomsForDaysDialog( 127 | date = LocalDate.now(), 128 | symptoms = symptoms, 129 | currentlyActiveSymptomIds = persistentSetOf(2), 130 | onSave = {}, 131 | onCancel = { }, 132 | ) 133 | } 134 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/data/ColorSource.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.data 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | object ColorSource { 6 | 7 | fun getColorMap(isDarkTheme: Boolean): Map { 8 | return if (isDarkTheme) darkColorMap else lightColorMap 9 | } 10 | 11 | fun getColor(isDarkTheme: Boolean, colorName: String): Color { 12 | return when (isDarkTheme) { 13 | true -> darkColorMap[colorName] ?: Color.Red 14 | false -> lightColorMap[colorName] ?: Color.Red 15 | } 16 | } 17 | 18 | val colorsGroupedByHue = listOf( 19 | listOf("LightRed", "Red", "DarkRed"), // Red shades 20 | listOf("LightOrange", "Orange", "DarkOrange"), // Orange shades 21 | listOf("LightYellow", "Yellow", "DarkYellow"), // Yellow shades 22 | listOf("LightGreen", "Green", "DarkGreen"), // Green shades 23 | listOf("LightCyan", "Cyan", "DarkCyan"), // Cyan shades 24 | listOf("LightBlue", "Blue", "DarkBlue"), // Blue shades 25 | listOf("LightMagenta", "Magenta", "DarkMagenta"), // Magenta shades 26 | listOf("White", "LightGray", "DarkGray") // Gray and Black shades 27 | ) 28 | 29 | private val lightColorMap = mapOf( 30 | "LightRed" to Color(0xFFF9D3D3), 31 | "Red" to Color(0xFFF2A6A6), 32 | "DarkRed" to Color(0xFFEC7C7B), 33 | 34 | "LightGreen" to Color(0xFFE0F9D3), 35 | "Green" to Color(0xFFC0F2A6), 36 | "DarkGreen" to Color(0xFFA2E87D), 37 | 38 | "LightBlue" to Color(0xFFA6BBF2), 39 | "Blue" to Color(0xFFA6BBF2), 40 | "DarkBlue" to Color(0xFF7999EC), 41 | 42 | "LightYellow" to Color(0xFFFAF7D1), 43 | "Yellow" to Color(0xFFF5EFA3), 44 | "DarkYellow" to Color(0xFFF0E775), 45 | 46 | "LightCyan" to Color(0xFFD2EDF9), 47 | "Cyan" to Color(0xFFA6DAF2), 48 | "DarkCyan" to Color(0xFF79C8EC), 49 | 50 | "LightMagenta" to Color(0xFFE8D6F5), 51 | "Magenta" to Color(0xFFD1ACEA), 52 | "DarkMagenta" to Color(0xFFBA8CD9), 53 | 54 | "LightOrange" to Color(0xFFF9E5D3), 55 | "Orange" to Color(0xFFF2CBA6), 56 | "DarkOrange" to Color(0xFFF0B175), 57 | 58 | "Black" to Color(0xFF212121), 59 | "DarkGray" to Color(0xFFABABAB), 60 | "LightGray" to Color(0xFFDFDDDD), 61 | ) 62 | 63 | private val darkColorMap = mapOf( 64 | "LightRed" to Color(0xFFA97070), 65 | "Red" to Color(0xFFA15E5E), 66 | "DarkRed" to Color(0xFF793B3B), 67 | 68 | "LightGreen" to Color(0xFF78946B), 69 | "Green" to Color(0xFF668E53), 70 | "DarkGreen" to Color(0xFF446336), 71 | 72 | "LightBlue" to Color(0xFF7582A3), 73 | "Blue" to Color(0xFF5E71A1), 74 | "DarkBlue" to Color(0xFF364263), 75 | 76 | "LightYellow" to Color(0xFFAF9C6A), 77 | "Yellow" to Color(0xFFB3974D), 78 | "DarkYellow" to Color(0xFF6B5A2E), 79 | 80 | "LightCyan" to Color(0xFF79929E), 81 | "Cyan" to Color(0xFF5E8CA1), 82 | "DarkCyan" to Color(0xFF365563), 83 | 84 | "LightMagenta" to Color(0xFF9175A3), 85 | "Magenta" to Color(0xFF75568A), 86 | "DarkMagenta" to Color(0xFF513663), 87 | 88 | "LightOrange" to Color(0xFFAF8B6A), 89 | "Orange" to Color(0xFFB37E4D), 90 | "DarkOrange" to Color(0xFF6B4B2E), 91 | 92 | "White" to Color(0xFFF5F5F5), 93 | "DarkGray" to Color(0xFF585858), 94 | "LightGray" to Color(0xFF8F8F8F) 95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/data/ImportSource.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.data 2 | 3 | enum class ImportSource(val displayName: String) { 4 | MENSINATOR("Mensinator"), 5 | CLUE("Clue (measurements.json)"), 6 | FLO("Flo"); 7 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/data/Setting.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.data 2 | 3 | data class Setting( 4 | val key: String, 5 | val value: String, 6 | val label: String, 7 | val groupId: Int, 8 | val type: String 9 | ) 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/data/Symptom.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.data 2 | 3 | data class Symptom( 4 | val id: Int, 5 | val name: String, 6 | val active: Int, // Active: 1, Inactive: 0 7 | val color: String 8 | ) 9 | 10 | val Symptom.isActive: Boolean 11 | get() = this.active == 1 -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/extensions/ColorExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.extensions 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import androidx.compose.ui.graphics.luminance 5 | 6 | // Pick the text color with the best contrast against this background color. 7 | fun Color.pickBestContrastTextColorForThisBackground( 8 | isDarkMode: Boolean, 9 | textColor1: Color, 10 | textColor2: Color 11 | ): Color { 12 | fun calculateContrastRatio(color1: Color, color2: Color): Double { 13 | val lum1 = color1.luminance() 14 | val lum2 = color2.luminance() 15 | 16 | val lighter = maxOf(lum1, lum2) 17 | val darker = minOf(lum1, lum2) 18 | 19 | return (lighter + 0.05) / (darker + 0.05) // +0.05 to avoid null division 20 | } 21 | 22 | val safeBackground = when { 23 | Color.Transparent == this && isDarkMode -> Color.Black 24 | Color.Transparent == this -> Color.White 25 | else -> this 26 | } 27 | 28 | val contrast1 = calculateContrastRatio(safeBackground, textColor1) 29 | val contrast2 = calculateContrastRatio(safeBackground, textColor2) 30 | 31 | return if (contrast1 >= contrast2) textColor1 else textColor2 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/extensions/DayOfWeekExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.extensions 2 | 3 | import androidx.annotation.StringRes 4 | import com.mensinator.app.R 5 | import java.time.DayOfWeek 6 | 7 | @get:StringRes 8 | val DayOfWeek.stringRes: Int 9 | get() = when (this) { 10 | DayOfWeek.MONDAY -> R.string.mon 11 | DayOfWeek.TUESDAY -> R.string.tue 12 | DayOfWeek.WEDNESDAY -> R.string.wed 13 | DayOfWeek.THURSDAY -> R.string.thu 14 | DayOfWeek.FRIDAY -> R.string.fri 15 | DayOfWeek.SATURDAY -> R.string.sat 16 | DayOfWeek.SUNDAY -> R.string.sun 17 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/extensions/DoubleExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.extensions 2 | 3 | import java.util.Locale 4 | import kotlin.math.round 5 | 6 | fun Double.formatToOneDecimalPoint(): String { 7 | if (this.isNaN()) return "-" 8 | return String.format(Locale.getDefault(), "%.1f", this) 9 | } 10 | 11 | fun Double.roundToTwoDecimalPoints(): Double { 12 | return (round(this * 100) / 100) 13 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/extensions/YearMonthExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.extensions 2 | 3 | import androidx.annotation.StringRes 4 | import com.mensinator.app.R 5 | import java.time.Month 6 | import java.time.YearMonth 7 | 8 | @get:StringRes 9 | val Month.stringRes: Int 10 | get() = when (this) { 11 | Month.JANUARY -> R.string.january 12 | Month.FEBRUARY -> R.string.february 13 | Month.MARCH -> R.string.march 14 | Month.APRIL -> R.string.april 15 | Month.MAY -> R.string.may 16 | Month.JUNE -> R.string.june 17 | Month.JULY -> R.string.july 18 | Month.AUGUST -> R.string.august 19 | Month.SEPTEMBER -> R.string.september 20 | Month.OCTOBER -> R.string.october 21 | Month.NOVEMBER -> R.string.november 22 | Month.DECEMBER -> R.string.december 23 | } 24 | 25 | infix fun YearMonth.until(other: YearMonth): Sequence = 26 | generateSequence(this) { if (it < other) it.plusMonths(1) else null } 27 | -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/settings/ExportImportDialog.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.settings 2 | 3 | import android.net.Uri 4 | import android.util.Log 5 | import android.widget.Toast 6 | import androidx.activity.compose.rememberLauncherForActivityResult 7 | import androidx.activity.result.contract.ActivityResultContracts 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.material3.AlertDialog 12 | import androidx.compose.material3.Button 13 | import androidx.compose.material3.DropdownMenuItem 14 | import androidx.compose.material3.ExperimentalMaterial3Api 15 | import androidx.compose.material3.ExposedDropdownMenuBox 16 | import androidx.compose.material3.ExposedDropdownMenuDefaults 17 | import androidx.compose.material3.MaterialTheme 18 | import androidx.compose.material3.MenuAnchorType 19 | import androidx.compose.material3.Text 20 | import androidx.compose.runtime.Composable 21 | import androidx.compose.runtime.getValue 22 | import androidx.compose.runtime.mutableStateOf 23 | import androidx.compose.runtime.remember 24 | import androidx.compose.runtime.setValue 25 | import androidx.compose.ui.Modifier 26 | import androidx.compose.material3.TextField 27 | import androidx.compose.ui.graphics.Color 28 | import androidx.compose.ui.platform.LocalContext 29 | import androidx.compose.ui.res.stringResource 30 | import androidx.compose.ui.tooling.preview.Preview 31 | import androidx.compose.ui.unit.dp 32 | import com.mensinator.app.R 33 | import com.mensinator.app.data.ImportSource 34 | import com.mensinator.app.ui.theme.MensinatorTheme 35 | import java.io.File 36 | import java.io.FileOutputStream 37 | 38 | @Composable 39 | fun ExportDialog( 40 | defaultFileName: String, 41 | onDismissRequest: () -> Unit, 42 | onPathSelect: (exportUri: Uri) -> Unit, 43 | modifier: Modifier = Modifier, 44 | ) { 45 | val jsonMimeType = "application/json" 46 | val filePickerLauncher = 47 | rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument(jsonMimeType)) { uri -> 48 | if (uri == null) { 49 | onDismissRequest() 50 | return@rememberLauncherForActivityResult 51 | } 52 | 53 | onPathSelect(uri) 54 | onDismissRequest() 55 | } 56 | 57 | AlertDialog( 58 | onDismissRequest = onDismissRequest, 59 | confirmButton = { 60 | Button( 61 | onClick = { filePickerLauncher.launch(defaultFileName) }, 62 | ) { 63 | Text(stringResource(id = R.string.export_button)) 64 | } 65 | }, 66 | modifier = modifier, 67 | dismissButton = { 68 | Button( 69 | onClick = { 70 | onDismissRequest() 71 | }, 72 | ) { 73 | Text(stringResource(id = R.string.cancel_button)) 74 | } 75 | }, 76 | title = { 77 | Text(stringResource(id = R.string.export_data)) 78 | }, 79 | text = { 80 | Text(stringResource(id = R.string.export_dialog_message)) 81 | } 82 | ) 83 | } 84 | 85 | @Composable 86 | fun ImportDialog( 87 | defaultImportFilePath: String, 88 | onDismissRequest: () -> Unit, 89 | onImportClick: (String, ImportSource) -> Unit, 90 | modifier: Modifier = Modifier, 91 | ) { 92 | val context = LocalContext.current 93 | //val impSuccess = stringResource(id = R.string.import_success_toast) 94 | val impFailure = stringResource(id = R.string.import_failure_toast) 95 | 96 | var selectedOption by remember { mutableStateOf(ImportSource.MENSINATOR) } 97 | val options = ImportSource.entries.toTypedArray() 98 | 99 | // File import launcher 100 | val importLauncher = 101 | rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> 102 | if (uri == null) return@rememberLauncherForActivityResult 103 | 104 | val inputStream = context.contentResolver.openInputStream(uri) 105 | val file = File(defaultImportFilePath) 106 | val outputStream = FileOutputStream(file) 107 | try { 108 | inputStream?.copyTo(outputStream) 109 | onImportClick(file.absolutePath, selectedOption) 110 | //Toast.makeText(context, impSuccess, Toast.LENGTH_SHORT).show() 111 | } catch (e: Exception) { 112 | Toast.makeText(context, impFailure, Toast.LENGTH_SHORT).show() 113 | Log.d("ImportDialog", "Failed to import file: ${e.message}, ${e.stackTraceToString()}") 114 | } finally { 115 | inputStream?.close() 116 | outputStream.close() 117 | } 118 | onDismissRequest() 119 | } 120 | 121 | // Dialog content 122 | AlertDialog( 123 | text = { 124 | Column { 125 | DropdownMenu( 126 | selectedOption = selectedOption, 127 | onOptionSelected = { option -> 128 | selectedOption = option 129 | }, 130 | options = options, 131 | label = stringResource(R.string.select_source), 132 | modifier = Modifier.fillMaxWidth() 133 | .padding(bottom = 10.dp) 134 | ) 135 | Text(stringResource(R.string.import_dialog_message)) 136 | } 137 | }, 138 | onDismissRequest = onDismissRequest, 139 | confirmButton = { 140 | Button(onClick = { importLauncher.launch("application/json") }) { 141 | Text(stringResource(id = R.string.select_file_button)) 142 | } 143 | }, 144 | modifier = modifier, 145 | dismissButton = { 146 | Button(onClick = onDismissRequest) { 147 | Text(stringResource(id = R.string.cancel_button)) 148 | } 149 | }, 150 | title = { Text(stringResource(id = R.string.import_data)) } 151 | ) 152 | } 153 | 154 | @OptIn(ExperimentalMaterial3Api::class) 155 | @Composable 156 | fun DropdownMenu( 157 | selectedOption: ImportSource, 158 | onOptionSelected: (ImportSource) -> Unit, 159 | options: Array, 160 | label: String, 161 | modifier: Modifier = Modifier 162 | ) { 163 | var expanded by remember { mutableStateOf(false) } 164 | val roundedCornerShape = MaterialTheme.shapes.medium 165 | 166 | Column(modifier = modifier) { 167 | ExposedDropdownMenuBox( 168 | expanded = expanded, 169 | onExpandedChange = { expanded = !expanded }, 170 | modifier = Modifier.fillMaxWidth() 171 | ) { 172 | TextField( 173 | readOnly = true, 174 | value = selectedOption.displayName, 175 | onValueChange = { }, 176 | label = { Text(label) }, 177 | trailingIcon = { 178 | ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) 179 | }, 180 | colors = ExposedDropdownMenuDefaults.textFieldColors( 181 | focusedIndicatorColor = Color.Transparent, 182 | unfocusedIndicatorColor = Color.Transparent, 183 | disabledIndicatorColor = Color.Transparent 184 | ), 185 | shape = roundedCornerShape, 186 | modifier = Modifier.menuAnchor( 187 | type = MenuAnchorType.PrimaryNotEditable, 188 | enabled = true 189 | ) 190 | ) 191 | 192 | ExposedDropdownMenu( 193 | expanded = expanded, 194 | onDismissRequest = { expanded = false } 195 | ) { 196 | options.forEach { option -> 197 | DropdownMenuItem( 198 | text = { Text(option.displayName) }, 199 | onClick = { 200 | onOptionSelected(option) 201 | expanded = false 202 | } 203 | ) 204 | } 205 | } 206 | } 207 | } 208 | } 209 | 210 | @Preview 211 | @Composable 212 | private fun ExportDialogPreview() { 213 | MensinatorTheme { 214 | ExportDialog( 215 | defaultFileName = "mensinator.json", 216 | onDismissRequest = {}, 217 | onPathSelect = {} 218 | ) 219 | } 220 | } 221 | 222 | @Preview 223 | @Composable 224 | private fun ImportDialogPreview() { 225 | MensinatorTheme { 226 | ImportDialog( 227 | defaultImportFilePath = "", 228 | onDismissRequest = {}, 229 | onImportClick = { _, _ -> } 230 | ) 231 | } 232 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/settings/FaqDialog.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.settings 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Spacer 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.height 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.rememberScrollState 9 | import androidx.compose.foundation.verticalScroll 10 | import androidx.compose.material3.AlertDialog 11 | import androidx.compose.material3.Button 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.material3.Text 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.res.stringResource 17 | import androidx.compose.ui.text.font.FontWeight 18 | import androidx.compose.ui.tooling.preview.Preview 19 | import androidx.compose.ui.unit.dp 20 | import com.mensinator.app.R 21 | import com.mensinator.app.ui.theme.MensinatorTheme 22 | 23 | 24 | @Composable 25 | fun FaqDialog( 26 | onDismissRequest: () -> Unit, // Callback to handle the close action 27 | modifier: Modifier = Modifier 28 | ) { 29 | AlertDialog( 30 | onDismissRequest = onDismissRequest, // Call the dismiss callback when dialog is dismissed 31 | confirmButton = { 32 | Button( 33 | onClick = onDismissRequest // Call the dismiss callback when the button is clicked 34 | ) { 35 | Text(stringResource(id = R.string.close_button)) 36 | } 37 | }, 38 | modifier = modifier.fillMaxWidth(), 39 | title = { 40 | Text(text = stringResource(id = R.string.about_app)) 41 | }, 42 | text = { 43 | FAQDialogContent() 44 | }, 45 | ) 46 | } 47 | 48 | @Composable 49 | private fun FAQDialogContent() { 50 | Column( 51 | modifier = Modifier 52 | .padding(16.dp) // Padding around the text content 53 | .fillMaxWidth() 54 | .verticalScroll(rememberScrollState()) // Add vertical scrolling capability 55 | ) { 56 | // User Manual Header 57 | Text( 58 | text = stringResource(id = R.string.user_manual_header), 59 | style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), 60 | color = MaterialTheme.colorScheme.primary 61 | ) 62 | Spacer(modifier = Modifier.height(8.dp)) // Space between sections 63 | 64 | // How to Use 65 | Text( 66 | text = stringResource(id = R.string.how_to_use), 67 | style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold) 68 | ) 69 | Text( 70 | text = stringResource(id = R.string.select_dates), 71 | style = MaterialTheme.typography.bodyMedium 72 | ) 73 | Text( 74 | text = stringResource(id = R.string.add_or_remove_dates), 75 | style = MaterialTheme.typography.bodyMedium 76 | ) 77 | Text( 78 | text = stringResource(id = R.string.symptoms), 79 | style = MaterialTheme.typography.bodyMedium 80 | ) 81 | Text( 82 | text = stringResource(id = R.string.ovulation), 83 | style = MaterialTheme.typography.bodyMedium 84 | ) 85 | Text( 86 | text = stringResource(id = R.string.statistics), 87 | style = MaterialTheme.typography.bodyMedium 88 | ) 89 | Text( 90 | text = stringResource(id = R.string.import_export), 91 | style = MaterialTheme.typography.bodyMedium 92 | ) 93 | Spacer(modifier = Modifier.height(16.dp)) // Space between sections 94 | Text( 95 | text = stringResource(id = R.string.calculations), 96 | style = MaterialTheme.typography.bodyMedium 97 | ) 98 | Text( 99 | text = stringResource(id = R.string.features_coming_soon), 100 | style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold) 101 | ) 102 | Text( 103 | text = stringResource(id = R.string.upcoming_features), 104 | style = MaterialTheme.typography.bodyMedium 105 | ) 106 | Spacer(modifier = Modifier.height(16.dp)) // Space between sections 107 | 108 | // Our Story Header 109 | Text( 110 | text = stringResource(id = R.string.our_story_header), 111 | style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), 112 | color = MaterialTheme.colorScheme.primary 113 | ) 114 | Text( 115 | text = stringResource(id = R.string.our_story), 116 | style = MaterialTheme.typography.bodyMedium 117 | ) 118 | Spacer(modifier = Modifier.height(16.dp)) // Space between sections 119 | 120 | // Disclaimer Header 121 | Text( 122 | text = stringResource(id = R.string.disclaimer_header), 123 | style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold) 124 | ) 125 | Text( 126 | text = stringResource(id = R.string.disclaimer), 127 | style = MaterialTheme.typography.bodyMedium 128 | ) 129 | } 130 | } 131 | 132 | @Preview 133 | @Composable 134 | private fun FAQDialogPreview() { 135 | MensinatorTheme { 136 | FaqDialog(onDismissRequest = {}) 137 | } 138 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/settings/LutealWarningDialog.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.settings 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.material3.AlertDialog 5 | import androidx.compose.material3.Button 6 | import androidx.compose.material3.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.res.stringResource 10 | import androidx.compose.ui.tooling.preview.Preview 11 | import com.mensinator.app.R 12 | import com.mensinator.app.ui.theme.MensinatorTheme 13 | 14 | 15 | @Composable 16 | fun LutealWarningDialog( 17 | onDismissRequest: () -> Unit, // Callback to handle the close action 18 | modifier: Modifier = Modifier 19 | ) { 20 | AlertDialog( 21 | onDismissRequest = onDismissRequest, // Call the dismiss callback when dialog is dismissed 22 | confirmButton = { 23 | Button( 24 | onClick = onDismissRequest // Call the dismiss callback when the button is clicked 25 | ) { 26 | Text(stringResource(id = R.string.close_button)) 27 | } 28 | }, 29 | modifier = modifier.fillMaxWidth(), 30 | title = { 31 | Text(text = stringResource(id = R.string.warning)) 32 | }, 33 | text = { 34 | Text(text = stringResource(id = R.string.luteal_calculation_message)) 35 | }, 36 | ) 37 | } 38 | 39 | @Preview 40 | @Composable 41 | private fun LutealDialogWarningPreview() { 42 | MensinatorTheme { 43 | LutealWarningDialog(onDismissRequest = {}) 44 | } 45 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/settings/NotificationDialog.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.settings 2 | 3 | import androidx.compose.material3.AlertDialog 4 | import androidx.compose.material3.Button 5 | import androidx.compose.material3.Text 6 | import androidx.compose.material3.TextField 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.getValue 9 | import androidx.compose.runtime.mutableStateOf 10 | import androidx.compose.runtime.remember 11 | import androidx.compose.runtime.setValue 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.res.stringResource 14 | import androidx.compose.ui.tooling.preview.Preview 15 | import com.mensinator.app.R 16 | import com.mensinator.app.ui.theme.MensinatorTheme 17 | 18 | @Composable 19 | fun NotificationDialog( 20 | messageText: String, 21 | onDismissRequest: () -> Unit, 22 | onSave: (String) -> Unit, 23 | modifier: Modifier = Modifier, 24 | ) { 25 | var newMessageText by remember { mutableStateOf(messageText) } 26 | 27 | AlertDialog( 28 | onDismissRequest = onDismissRequest, 29 | modifier = modifier, 30 | title = { 31 | Text(text = stringResource(R.string.period_notification_title)) 32 | }, 33 | text = { 34 | TextField( 35 | value = newMessageText, 36 | onValueChange = { newMessageText = it }, 37 | singleLine = true 38 | ) 39 | }, 40 | confirmButton = { 41 | Button( 42 | onClick = { 43 | onSave(newMessageText) 44 | onDismissRequest() 45 | } 46 | ) { 47 | Text(text = stringResource(id = R.string.save_button)) 48 | } 49 | }, 50 | dismissButton = { 51 | Button( 52 | onClick = { 53 | onDismissRequest() 54 | } 55 | ) { 56 | Text(text = stringResource(id = R.string.cancel_button)) 57 | } 58 | } 59 | ) 60 | } 61 | 62 | @Preview 63 | @Composable 64 | private fun NotificationDialogPreview() { 65 | MensinatorTheme { 66 | NotificationDialog( 67 | messageText = "Example message", 68 | onDismissRequest = {}, 69 | onSave = {}, 70 | ) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/statistics/StatisticsScreen.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.statistics 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.foundation.rememberScrollState 5 | import androidx.compose.foundation.verticalScroll 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.LaunchedEffect 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.res.stringResource 13 | import androidx.compose.ui.text.style.TextAlign 14 | import androidx.compose.ui.tooling.preview.Preview 15 | import androidx.compose.ui.unit.dp 16 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 17 | import com.mensinator.app.R 18 | import com.mensinator.app.ui.navigation.displayCutoutExcludingStatusBarsPadding 19 | import com.mensinator.app.ui.theme.MensinatorTheme 20 | import org.koin.androidx.compose.koinViewModel 21 | 22 | @Composable 23 | fun StatisticsScreen( 24 | modifier: Modifier = Modifier, 25 | viewModel: StatisticsViewModel = koinViewModel(), 26 | ) { 27 | val state = viewModel.viewState.collectAsStateWithLifecycle().value 28 | 29 | LaunchedEffect(Unit) { 30 | viewModel.refreshData() 31 | } 32 | 33 | StatisticsScreenContent(modifier, state) 34 | } 35 | 36 | @Composable 37 | private fun StatisticsScreenContent( 38 | modifier: Modifier = Modifier, 39 | state: StatisticsViewModel.ViewState 40 | ) { 41 | Column( 42 | modifier = modifier 43 | .fillMaxSize() 44 | .verticalScroll(rememberScrollState()) 45 | .displayCutoutExcludingStatusBarsPadding() 46 | .padding(horizontal = 16.dp) 47 | ) { 48 | RowOfText( 49 | stringResource(id = R.string.period_count), 50 | state.trackedPeriods 51 | ) 52 | RowOfText( 53 | stringResource(id = R.string.average_cycle_length), 54 | state.averageCycleLength 55 | ) 56 | RowOfText( 57 | stringResource(id = R.string.average_period_length), 58 | state.averagePeriodLength 59 | ) 60 | RowOfText( 61 | stringResource(id = R.string.next_period_start_future), 62 | state.periodPredictionDate 63 | ) 64 | RowOfText( 65 | stringResource(id = R.string.ovulation_count), 66 | state.ovulationCount 67 | ) 68 | RowOfText( 69 | stringResource(id = R.string.average_ovulation_day), 70 | state.follicleGrowthDays 71 | ) 72 | RowOfText( 73 | stringResource(id = R.string.next_predicted_ovulation), 74 | state.ovulationPredictionDate 75 | ) 76 | RowOfText( 77 | stringResource(id = R.string.average_luteal_length), 78 | state.averageLutealLength 79 | ) 80 | } 81 | } 82 | 83 | @Composable 84 | fun RowOfText(stringOne: String, stringTwo: String?) { 85 | Row( 86 | modifier = Modifier 87 | .fillMaxWidth() 88 | .padding(vertical = 16.dp), 89 | verticalAlignment = Alignment.CenterVertically, 90 | horizontalArrangement = Arrangement.Absolute.SpaceAround 91 | ) { 92 | Text( 93 | text = stringOne, 94 | modifier = Modifier 95 | .weight(0.7f) 96 | .padding(end = 8.dp), 97 | ) 98 | stringTwo?.let { 99 | Text( 100 | text = it, 101 | modifier = Modifier.alignByBaseline().widthIn(max = 200.dp), 102 | style = MaterialTheme.typography.titleMedium, 103 | textAlign = TextAlign.End 104 | ) 105 | } 106 | } 107 | } 108 | 109 | @Preview(showBackground = true) 110 | @Composable 111 | private fun RowOfTextPreview() { 112 | RowOfText("firstString", "secondstring") 113 | } 114 | 115 | @Preview(showBackground = true) 116 | @Composable 117 | private fun RowOfTextLongPreview() { 118 | RowOfText("Very long first string, we could even use lorem ipsum here", "secondstring") 119 | } 120 | 121 | @Preview(showBackground = true) 122 | @Composable 123 | private fun RowOfTextLongSecondPreview() { 124 | RowOfText("Short Text", "first string, we could even use lorem ipsum here") 125 | } 126 | 127 | @Preview(showBackground = true) 128 | @Composable 129 | private fun StatisticsScreenPreview() { 130 | MensinatorTheme { 131 | StatisticsScreenContent( 132 | state = StatisticsViewModel.ViewState( 133 | trackedPeriods = "3", 134 | averageCycleLength = "28.5 days", 135 | averagePeriodLength = "5.0 days", 136 | periodPredictionDate = "28 Feb 2024", 137 | ovulationCount = "4", 138 | ovulationPredictionDate = "20 Mar 2024", 139 | follicleGrowthDays = "14.0", 140 | averageLutealLength = "15.0 days" 141 | ) 142 | ) 143 | } 144 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/statistics/StatisticsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.statistics 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import androidx.lifecycle.ViewModel 6 | import com.mensinator.app.R 7 | import com.mensinator.app.business.ICalculationsHelper 8 | import com.mensinator.app.business.IOvulationPrediction 9 | import com.mensinator.app.business.IPeriodDatabaseHelper 10 | import com.mensinator.app.business.IPeriodPrediction 11 | import com.mensinator.app.extensions.formatToOneDecimalPoint 12 | import kotlinx.coroutines.flow.MutableStateFlow 13 | import kotlinx.coroutines.flow.StateFlow 14 | import kotlinx.coroutines.flow.asStateFlow 15 | import kotlinx.coroutines.flow.update 16 | import java.time.format.DateTimeFormatter 17 | import java.time.format.FormatStyle 18 | 19 | class StatisticsViewModel( 20 | @SuppressLint("StaticFieldLeak") private val appContext: Context, 21 | private val periodDatabaseHelper: IPeriodDatabaseHelper, 22 | private val calcHelper: ICalculationsHelper, 23 | private val ovulationPrediction: IOvulationPrediction, 24 | private val periodPrediction: IPeriodPrediction, 25 | ) : ViewModel() { 26 | 27 | private val dash = "-" 28 | private val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) 29 | 30 | private val _viewState = MutableStateFlow( 31 | ViewState() 32 | ) 33 | val viewState: StateFlow = _viewState.asStateFlow() 34 | 35 | data class ViewState( 36 | val trackedPeriods: String? = null, 37 | val averageCycleLength: String? = null, 38 | val averagePeriodLength: String? = null, 39 | val averageLutealLength: String? = null, 40 | val follicleGrowthDays: String? = null, 41 | val ovulationPredictionDate: String? = null, 42 | val periodPredictionDate: String? = null, 43 | val ovulationCount: String? = null, 44 | ) 45 | 46 | fun refreshData() { 47 | _viewState.update { 48 | it.copy( 49 | trackedPeriods = periodDatabaseHelper.getPeriodCount().toString(), 50 | averageCycleLength = formatDays(calcHelper.averageCycleLength().formatToOneDecimalPoint()), 51 | averagePeriodLength = formatDays(calcHelper.averagePeriodLength().formatToOneDecimalPoint()), 52 | averageLutealLength =formatDays(calcHelper.averageLutealLength().formatToOneDecimalPoint()), 53 | follicleGrowthDays = calcHelper.averageFollicalGrowthInDays().formatToOneDecimalPoint(), 54 | ovulationPredictionDate = ovulationPrediction.getPredictedOvulationDate()?.format(dateFormatter) ?: dash, 55 | periodPredictionDate = periodPrediction.getPredictedPeriodDate()?.format(dateFormatter) ?: dash, 56 | ovulationCount = periodDatabaseHelper.getOvulationCount().toString() 57 | ) 58 | } 59 | } 60 | 61 | private fun formatDays(text: String): String { 62 | val days = appContext.getString(R.string.days) 63 | return "$text $days" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/symptoms/ManageSymptomsDialogs.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.symptoms 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.material3.AlertDialog 5 | import androidx.compose.material3.Button 6 | import androidx.compose.material3.Text 7 | import androidx.compose.material3.TextField 8 | import androidx.compose.runtime.* 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.res.stringResource 11 | import androidx.compose.ui.tooling.preview.Preview 12 | import com.mensinator.app.R 13 | import com.mensinator.app.ui.theme.MensinatorTheme 14 | 15 | @Composable 16 | fun CreateNewSymptomDialog( 17 | onSave: (String) -> Unit, 18 | onCancel: () -> Unit, 19 | modifier: Modifier = Modifier, 20 | ) { 21 | var symptomName by remember { mutableStateOf("") } 22 | 23 | AlertDialog( 24 | onDismissRequest = onCancel, 25 | confirmButton = { 26 | Button( 27 | onClick = { 28 | onSave(symptomName) 29 | }, 30 | enabled = symptomName.isNotBlank() 31 | ) { 32 | Text(stringResource(id = R.string.save_button)) 33 | } 34 | }, 35 | modifier = modifier, 36 | dismissButton = { 37 | Button( 38 | onClick = onCancel, 39 | ) { 40 | Text(stringResource(id = R.string.cancel_button)) 41 | } 42 | }, 43 | title = { 44 | Text(text = stringResource(id = R.string.create_new_symptom_dialog_title)) 45 | }, 46 | text = { 47 | TextField( 48 | //value = symptomKey?.let { stringResource(id = it) } ?: "Not Found", 49 | value = symptomName, 50 | onValueChange = { symptomName = it }, 51 | label = { Text(stringResource(R.string.symptom_name_label)) }, 52 | singleLine = true, 53 | ) 54 | }, 55 | ) 56 | } 57 | 58 | @Composable 59 | fun RenameSymptomDialog( 60 | symptomDisplayName: String, 61 | onRename: (String) -> Unit, 62 | onCancel: () -> Unit, 63 | modifier: Modifier = Modifier, 64 | ) { 65 | var newName by remember { mutableStateOf(symptomDisplayName) } 66 | 67 | AlertDialog( 68 | onDismissRequest = onCancel, 69 | confirmButton = { 70 | Button( 71 | onClick = { 72 | onRename(newName) 73 | }, 74 | ) { 75 | Text(text = stringResource(id = R.string.save_button)) 76 | } 77 | }, 78 | modifier = modifier, 79 | dismissButton = { 80 | Button( 81 | onClick = onCancel, 82 | ) { 83 | Text(text = stringResource(id = R.string.cancel_button)) 84 | } 85 | }, 86 | title = { 87 | Text(text = stringResource(id = R.string.rename_symptom)) 88 | }, 89 | text = { 90 | Column { 91 | TextField( 92 | value = newName, 93 | onValueChange = { newName = it }, 94 | label = { Text(stringResource(R.string.symptom_name_label)) }, 95 | singleLine = true, 96 | ) 97 | } 98 | }, 99 | ) 100 | } 101 | 102 | @Composable 103 | fun DeleteSymptomDialog( 104 | onSave: () -> Unit, 105 | onCancel: () -> Unit, 106 | modifier: Modifier = Modifier, 107 | ) { 108 | AlertDialog( 109 | onDismissRequest = onCancel, 110 | confirmButton = { 111 | Button( 112 | onClick = onSave, 113 | ) { 114 | Text(text = stringResource(id = R.string.delete_button)) 115 | } 116 | }, 117 | modifier = modifier, 118 | dismissButton = { 119 | Button( 120 | onClick = onCancel, 121 | ) { 122 | Text(text = stringResource(id = R.string.cancel_button)) 123 | } 124 | }, 125 | title = { 126 | Text(text = stringResource(id = R.string.delete_symptom)) 127 | }, 128 | text = { 129 | Text(text = stringResource(id = R.string.delete_question)) 130 | }, 131 | ) 132 | } 133 | 134 | @Preview 135 | @Composable 136 | private fun CreateNewSymptomDialogPreview() { 137 | MensinatorTheme { 138 | CreateNewSymptomDialog( 139 | onSave = {}, 140 | onCancel = {} 141 | ) 142 | } 143 | } 144 | 145 | @Preview 146 | @Composable 147 | private fun RenameSymptomDialogPreview() { 148 | MensinatorTheme { 149 | RenameSymptomDialog( 150 | symptomDisplayName = "preview", 151 | onRename = {}, 152 | onCancel = {} 153 | ) 154 | } 155 | } 156 | 157 | @Preview 158 | @Composable 159 | private fun DeleteSymptomDialogPreview() { 160 | MensinatorTheme { 161 | DeleteSymptomDialog( 162 | onSave = {}, 163 | onCancel = {} 164 | ) 165 | } 166 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/symptoms/ManageSymptomsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.symptoms 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.mensinator.app.business.IPeriodDatabaseHelper 6 | import com.mensinator.app.data.Symptom 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.flow.MutableStateFlow 9 | import kotlinx.coroutines.flow.StateFlow 10 | import kotlinx.coroutines.flow.asStateFlow 11 | import kotlinx.coroutines.flow.update 12 | import kotlinx.coroutines.launch 13 | import kotlinx.coroutines.withContext 14 | 15 | class ManageSymptomsViewModel( 16 | private val periodDatabaseHelper: IPeriodDatabaseHelper, 17 | ) : ViewModel() { 18 | 19 | private val _viewState = MutableStateFlow( 20 | ViewState() 21 | ) 22 | val viewState: StateFlow = _viewState.asStateFlow() 23 | 24 | data class ViewState( 25 | val allSymptoms: List = listOf(), 26 | val showCreateSymptomDialog: Boolean = false, 27 | val symptomToRename: Symptom? = null, 28 | val symptomToDelete: Symptom? = null, 29 | ) 30 | 31 | fun onAction(uiAction: UiAction) = when (uiAction) { 32 | UiAction.HideCreationDialog -> _viewState.update { it.copy(showCreateSymptomDialog = false) } 33 | UiAction.ShowCreationDialog -> _viewState.update { it.copy(showCreateSymptomDialog = true) } 34 | 35 | UiAction.HideDeletionDialog -> _viewState.update { it.copy(symptomToDelete = null) } 36 | is UiAction.ShowDeletionDialog -> _viewState.update { it.copy(symptomToDelete = uiAction.symptom ) } 37 | 38 | UiAction.HideRenamingDialog -> _viewState.update { it.copy(symptomToRename = null) } 39 | is UiAction.ShowRenamingDialog -> _viewState.update { it.copy( symptomToRename = uiAction.symptom ) } 40 | 41 | is UiAction.CreateSymptom -> createNewSymptom(uiAction.name) 42 | is UiAction.UpdateSymptom -> updateSymptom(uiAction.symptom) 43 | is UiAction.DeleteSymptom -> deleteSymptom(uiAction.symptom) 44 | is UiAction.RenameSymptom -> renameSymptom(uiAction.symptom) 45 | } 46 | 47 | suspend fun refreshData() { 48 | withContext(Dispatchers.IO) { 49 | _viewState.update { 50 | it.copy( 51 | allSymptoms = periodDatabaseHelper.getAllSymptoms(), 52 | ) 53 | } 54 | } 55 | } 56 | 57 | private fun createNewSymptom(name: String) { 58 | periodDatabaseHelper.createNewSymptom(name) 59 | viewModelScope.launch { refreshData() } 60 | } 61 | 62 | private fun updateSymptom(symptom: Symptom) { 63 | periodDatabaseHelper.updateSymptom(symptom.id, symptom.active, symptom.color) 64 | viewModelScope.launch { refreshData() } 65 | } 66 | 67 | private fun renameSymptom(symptom: Symptom) { 68 | periodDatabaseHelper.renameSymptom(symptom.id, symptom.name) 69 | viewModelScope.launch { refreshData() } 70 | } 71 | 72 | private fun deleteSymptom(symptom: Symptom) { 73 | periodDatabaseHelper.deleteSymptom(symptom.id) 74 | viewModelScope.launch { refreshData() } 75 | } 76 | 77 | sealed class UiAction { 78 | data object HideRenamingDialog : UiAction() 79 | data class ShowRenamingDialog(val symptom: Symptom): UiAction() 80 | 81 | data object HideDeletionDialog : UiAction() 82 | data class ShowDeletionDialog(val symptom: Symptom): UiAction() 83 | 84 | data object HideCreationDialog : UiAction() 85 | data object ShowCreationDialog: UiAction() 86 | 87 | data class CreateSymptom(val name: String): UiAction() 88 | data class UpdateSymptom(val symptom: Symptom): UiAction() 89 | data class DeleteSymptom(val symptom: Symptom): UiAction() 90 | data class RenameSymptom(val symptom: Symptom): UiAction() 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/ui/ResourceMapper.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.ui 2 | 3 | import android.content.Context 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.res.stringResource 6 | import com.mensinator.app.R 7 | 8 | //Maps Database keys to res/strings.xml for multilanguage support 9 | object ResourceMapper { 10 | //maps res strings xml file to db keys 11 | private val resourceMap = mapOf( 12 | //settings 13 | "app_settings" to R.string.app_settings, 14 | "period_color" to R.string.period_color, 15 | "selection_color" to R.string.selection_color, 16 | "period_selection_color" to R.string.period_selection_color, 17 | "expected_period_color" to R.string.expected_period_color, 18 | "ovulation_color" to R.string.ovulation_color, 19 | "expected_ovulation_color" to R.string.expected_ovulation_color, 20 | "Period_Notification_Message" to R.string.period_notification_message, 21 | "reminders" to R.string.reminders, 22 | "reminder_days" to R.string.days_before_reminder, 23 | "other_settings" to R.string.other_settings, 24 | "luteal_period_calculation" to R.string.luteal_phase_calculation, 25 | "period_history" to R.string.period_history, 26 | "ovulation_history" to R.string.ovulation_history, 27 | "lang" to R.string.language, 28 | "cycle_numbers_show" to R.string.cycle_numbers_show, 29 | "close" to R.string.close, 30 | "save" to R.string.save, 31 | "Heavy_Flow" to R.string.heavy, 32 | "Medium_Flow" to R.string.medium, 33 | "Light_Flow" to R.string.light, 34 | "screen_protection" to R.string.screen_protection, 35 | // colors 36 | // "Red" to R.string.color_red, 37 | // "Green" to R.string.color_green, 38 | // "Blue" to R.string.color_blue, 39 | // "Yellow" to R.string.color_yellow, 40 | // "Cyan" to R.string.color_cyan, 41 | // "Magenta" to R.string.color_magenta, 42 | // "Black" to R.string.color_black, 43 | // "White" to R.string.color_white, 44 | // "DarkGray" to R.string.color_darkgray, 45 | // "LightGray" to R.string.color_gray, 46 | ) 47 | 48 | fun getStringResourceId(key: String): Int? { 49 | return resourceMap[key] 50 | } 51 | 52 | fun getPeriodReminderMessage(key: String, context: Context): String { 53 | // If we can't retrieve a resource ID via the key, we know that the user has changed the text. 54 | val userHasChangedMessage = getStringResourceId(key) == null 55 | 56 | val appDefaultText = context.getString(R.string.period_notification_message) 57 | val userSetValue = key.takeIf { userHasChangedMessage } 58 | 59 | return if (userSetValue.isNullOrBlank()) { 60 | appDefaultText 61 | } else { 62 | userSetValue 63 | } 64 | } 65 | 66 | @Composable 67 | fun getStringResourceOrCustom(key: String): String { 68 | /** 69 | * - If key is unchanged, return the stringResource value 70 | * - If key has changed (null), return user-set value 71 | */ 72 | val id = getStringResourceId(key) 73 | val text = id?.let { stringResource(id = id) } ?: key 74 | return text 75 | } 76 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/ui/navigation/MensinatorTopBar.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.ui.navigation 2 | 3 | import androidx.annotation.StringRes 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.draw.clip 13 | import androidx.compose.ui.res.stringResource 14 | import androidx.compose.ui.text.font.FontWeight 15 | import androidx.compose.ui.tooling.preview.Preview 16 | import androidx.compose.ui.tooling.preview.PreviewParameter 17 | import androidx.compose.ui.tooling.preview.PreviewParameterProvider 18 | import androidx.compose.ui.unit.dp 19 | import com.mensinator.app.ui.theme.MensinatorTheme 20 | 21 | @Composable 22 | fun MensinatorTopBar( 23 | @StringRes titleStringId: Int, 24 | onTitleClick: (() -> Unit)? = null, 25 | ) { 26 | Column( 27 | modifier = Modifier 28 | //.windowInsetsPadding(WindowInsets.navigationBars) 29 | .padding(horizontal = 16.dp) 30 | .fillMaxWidth() 31 | ) { 32 | val modifier = onTitleClick?.let { 33 | Modifier 34 | .clip(MaterialTheme.shapes.small) 35 | .clickable { it() } 36 | } ?: Modifier 37 | Text( 38 | text = stringResource(titleStringId), 39 | modifier = modifier, 40 | style = MaterialTheme.typography.headlineMedium, 41 | fontWeight = FontWeight.Bold 42 | ) 43 | } 44 | } 45 | 46 | private class ScreenTitleProvider : PreviewParameterProvider { 47 | override val values: Sequence 48 | get() = Screen.entries.map { it.titleRes }.asSequence() 49 | } 50 | 51 | @Preview(showBackground = true) 52 | @Composable 53 | private fun MensinatorTopBarPreview( 54 | @PreviewParameter(ScreenTitleProvider::class) stringId: Int, 55 | ) { 56 | MensinatorTheme { 57 | MensinatorTopBar(stringId) 58 | } 59 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/ui/navigation/NavigationItem.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.ui.navigation 2 | 3 | data class NavigationItem( 4 | val screen: Screen, 5 | val imageSelected: Int, 6 | val imageUnSelected: Int 7 | ) 8 | -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | /** 6 | * Shades of purple and pink colors at 80% opacity. 7 | */ 8 | val Purple80 = Color(0xFFD0BCFF) 9 | val PurpleGrey80 = Color(0xFFCCC2DC) 10 | val Pink80 = Color(0xFFEFB8C8) 11 | 12 | /** 13 | * Shades of purple and pink colors at 40% opacity. 14 | */ 15 | val Purple40 = Color(0xFF6650A4) 16 | val PurpleGrey40 = Color(0xFF625B71) 17 | val Pink40 = Color(0xFF7D5260) 18 | 19 | 20 | val Black = Color.Black 21 | val DarkGrey = Color(29, 27, 32) 22 | val White = Color.White -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/ui/theme/Shapes.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.ui.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material3.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val Shapes = Shapes( 8 | extraSmall = RoundedCornerShape(16.dp), 9 | small = RoundedCornerShape(16.dp), 10 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.ui.theme 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.darkColorScheme 7 | import androidx.compose.material3.dynamicDarkColorScheme 8 | import androidx.compose.material3.dynamicLightColorScheme 9 | import androidx.compose.material3.lightColorScheme 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.platform.LocalContext 12 | 13 | 14 | // Define your custom colors for dark and light themes 15 | private val DarkColorScheme = darkColorScheme( 16 | primary = Purple80, 17 | secondary = PurpleGrey80, 18 | tertiary = Pink80 19 | ) 20 | 21 | private val LightColorScheme = lightColorScheme( 22 | primary = Purple40, 23 | secondary = PurpleGrey40, 24 | tertiary = Pink40 25 | 26 | /* Other default colors to override 27 | background = Color(0xFFFFFBFE), 28 | surface = Color(0xFFFFFBFE), 29 | onPrimary = Color.White, 30 | onSecondary = Color.White, 31 | onTertiary = Color.White, 32 | onBackground = Color(0xFF1C1B1F), 33 | onSurface = Color(0xFF1C1B1F), 34 | */ 35 | ) 36 | 37 | // Composable function to apply the custom theme 38 | @Composable 39 | fun MensinatorTheme( 40 | darkTheme: Boolean = isSystemInDarkTheme(), 41 | // Dynamic color is available on Android 12+ 42 | dynamicColor: Boolean = true, 43 | content: @Composable () -> Unit 44 | ) { 45 | val colorScheme = when { 46 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 47 | val context = LocalContext.current 48 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 49 | } 50 | 51 | darkTheme -> DarkColorScheme 52 | else -> LightColorScheme 53 | } 54 | 55 | MaterialTheme( 56 | colorScheme = colorScheme, 57 | shapes = Shapes, 58 | content = content 59 | ) 60 | } 61 | 62 | @Composable 63 | fun isDarkMode(): Boolean { 64 | return isSystemInDarkTheme() 65 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/ui/theme/UiConstants.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.ui.theme 2 | 3 | import androidx.compose.ui.unit.dp 4 | 5 | object UiConstants { 6 | val floatingActionButtonSize = 56.dp 7 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mensinator/app/utils/IDispatcherProvider.kt: -------------------------------------------------------------------------------- 1 | package com.mensinator.app.utils 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | import kotlinx.coroutines.Dispatchers 5 | 6 | @Suppress("unused", "PropertyName") 7 | interface IDispatcherProvider { 8 | val Main: CoroutineDispatcher 9 | get() = Dispatchers.Main 10 | val Default 11 | get() = Dispatchers.Default 12 | val IO 13 | get() = Dispatchers.IO 14 | val Unconfined 15 | get() = Dispatchers.Unconfined 16 | } 17 | 18 | class DefaultDispatcherProvider : IDispatcherProvider -------------------------------------------------------------------------------- /app/src/main/res/drawable/bar_chart_24px.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/baseline_bloodtype_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/baseline_calendar_month_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/baseline_save_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/baseline_today_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/home.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/home_field.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/keyboard_arrow_down_24px.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/outline_bar_chart_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/settings_24px.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/main/res/mipmap-hdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/main/res/mipmap-mdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmmaTellblom/Mensinator/8bd83bc40f7c7b3951da1b154dddb093c7f7f35a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/resources.properties: -------------------------------------------------------------------------------- 1 | # https://developer.android.com/guide/topics/resources/app-languages 2 | 3 | # We are telling Android that the resources in the values/ folder (without locale specification) 4 | # correspond to english strings/resources. 5 | unqualifiedResLocale=en 6 | -------------------------------------------------------------------------------- /app/src/main/res/values-zh-rCN/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Mensinator 4 | 5 | 6 | 上月 7 | 下月 8 | 月经日 9 | 症状 10 | 排卵日 11 | 12 | 移除月经日 13 | 添加月经日 14 | 15 | 移除排卵日 16 | 添加排卵日 17 | 18 | 19 | 没有可保存或移除的日期 20 | 更改保存成功 21 | 没有选中日期 22 | 只有一天可以是排卵日! 23 | 排卵日保存成功 24 | 未选择排卵日期 25 | 26 | 27 | 主页 28 | 统计 29 | 症状 30 | 设置 31 | 32 | 33 | 删除症状 34 | 请确认是否要删除此症状? 35 | 删除 36 | 37 | 38 | 应用程序设置 39 | 颜色 40 | 月经日 41 | 选中日期 42 | 选中月经日 43 | 预测的月经日 44 | 排卵日 45 | 预测的排卵日 46 | 提醒 47 | 几天前提醒 48 | 经期提醒信息 49 | 经期即将到来 50 | 更改提醒内容 51 | 其他设置 52 | 黄体期计算 53 | 用于预测的经期历史 54 | 用于预测的排卵日历史 55 | 语言 56 | 更改语言 57 | 显示月经周期数字 58 | 禁止截屏 59 | 关闭 60 | 保存 61 | 62 | 63 | 红色 64 | 绿色 65 | 蓝色 66 | 黄色 67 | 青色 68 | 洋红色 69 | 黑色 70 | 白色 71 | 深灰色 72 | 浅灰色 73 | 74 | 75 | 统计 76 | 记录的经期数量: 77 | 平均月经周期长度: 78 | 平均经期长度: 79 | 下个经期的预计开始日期: 80 | 下个经期的预计开始日期: 81 | 记录的排卵日数量: 82 | 平均排卵日: 83 | 下个预计的排卵日: 84 | - 85 | 平均黄体期长度: 86 | 87 | 88 | 89 | 关于%1$s的症状 90 | 创建新症状 91 | 保存症状 92 | 取消 93 | 经血量大 94 | 经血量中等 95 | 经血量少 96 | 97 | 98 | 创建新症状 99 | 症状名称 100 | 保存 101 | 102 | 103 | 管理症状 104 | 105 | 106 | 日历 107 | 周一 108 | 周二 109 | 周三 110 | 周四 111 | 周五 112 | 周六 113 | 周日 114 | 115 | 一月 116 | 二月 117 | 三月 118 | 四月 119 | 五月 120 | 六月 121 | 七月 122 | 八月 123 | 九月 124 | 十月 125 | 十一月 126 | 十二月 127 | 128 | 129 | 导出/导入数据 130 | 导出 131 | 导入 132 | 导出到 %1$s 133 | 文件导入成功 134 | 文件导入失败 135 | 导出文件到:%1$s 136 | 导入路径:%1$s 137 | 138 | 139 | 常见问题 140 | 141 | 142 | 用户手册 143 | 如何使用: 144 | • 选择日期:点击某个日期以选中或取消选中。 145 | • 添加或移除经期日期:在选中日期后点击\'添加或移除日期\' 按钮。 146 | • 症状:点击\'症状\' 按钮以查看或添加某个选中的日期的症状。 147 | • 排卵日:选择单个日期并点击排卵日按钮。若要移除排卵日,选中该日期并再次点击排卵日按钮。 148 | • 统计:点击底部菜单中的统计按钮以查看统计资料。 149 | • 导入/导出:点击底部菜单中的设置按钮,然后在设置中向下滚动到底部。点击导入/导出以导入或导出您的数据。这是将数据迁移到新设备时的必需步骤! 150 | • 计算:\n经期是根据平均周期长度和经期长度计算的。\n经期也可通过平均黄体期来计算。即使用最近的五个周期得出一个平均黄体期,并以此来计算经期。此功能可以在设置中启用。\n排卵日是根据平均排卵周期长度计算的。 151 | 即将推出的功能: 152 | • 查看我们的GitHub 主页获得应用程序开发的最新消息! 153 | 我们的故事 154 | 我们是两位厌倦了将自己的数据出卖给大型企业的女性。我们的应用程序将所有数据本地保存在您的设备上,我们对您的数据没有任何访问权限。\n我们重视您的隐私,不会保存或共享任何个人信息。自从推出这个应用程序以来,我们不断发展,现在已拥有了自己的Discord服务器,那里有许多对这个应用程序充满热情并愿意帮助我们的用户!\n\n加入我们的Discord:https://discord.gg/tHA2k3bFRN 155 | 免责声明: 156 | • 这是一个业余爱好项目,不用于医疗用途。我们制作这个应用程序仅为了我们自己的需要,但我们非常欢迎您提出想法和需求。加入我们的 Discord 或发送电子邮件到:\nmensinator.app@gmail.com 157 | 158 | 159 | 关闭 160 | 备份 161 | 保存的数据 162 | 导入 163 | 导出 164 | 选择要导入的文件 165 | 选择 166 | 导入导出数据 167 | 导入数据 168 | 导出数据 169 | 关于本应用程序 170 | 重命名症状 171 | 172 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |