From 2c88acbb49faca02e57c35f18e9e7481fbad82f5 Mon Sep 17 00:00:00 2001 From: lucienshema Date: Mon, 18 Nov 2024 15:59:19 +0200 Subject: [PATCH 1/7] added unit tests --- .idea/androidTestResultsUserPreferences.xml | 27 ++ app/build.gradle | 109 ++++--- .../farmcollector/GreetingTestKtTest.kt | 34 +++ .../farmcollector/HomeIntegrationTest.kt | 47 --- .../org/technoserve/farmcollector/HomeTest.kt | 99 ------ .../farmcollector/FarmCollectorApp.kt | 6 + .../technoserve/farmcollector/MainActivity.kt | 2 + .../farmcollector/database/FarmViewModel.kt | 6 + .../farmcollector/ui/screens/Greeting.kt | 10 + .../farmcollector/CalculatorExampleTest.kt | 36 --- .../org/technoserve/farmcollector/HomeTest.kt | 283 ++++++++++++------ .../farmcollector/MainActivityTest.kt | 8 +- .../farmcollector/MapViewModelUnitTest.kt | 38 +++ .../farmcollector/ValidatorTest.kt | 71 +++++ .../database/FarmViewModelTest.kt | 210 +++++-------- .../converters/AccuracyListConvertTest.kt | 88 ++++++ .../converters/CoordinateListConvertTest.kt | 114 +++++++ .../farmcollector/map/MapViewModelTest.kt | 135 --------- .../farmcollector/ui/screens/AddSiteKtTest.kt | 109 +++++-- build.gradle | 8 +- 20 files changed, 800 insertions(+), 640 deletions(-) create mode 100644 app/src/androidTest/java/org/technoserve/farmcollector/GreetingTestKtTest.kt delete mode 100644 app/src/androidTest/java/org/technoserve/farmcollector/HomeIntegrationTest.kt delete mode 100644 app/src/androidTest/java/org/technoserve/farmcollector/HomeTest.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/ui/screens/Greeting.kt delete mode 100644 app/src/test/java/org/technoserve/farmcollector/CalculatorExampleTest.kt create mode 100644 app/src/test/java/org/technoserve/farmcollector/MapViewModelUnitTest.kt create mode 100644 app/src/test/java/org/technoserve/farmcollector/ValidatorTest.kt create mode 100644 app/src/test/java/org/technoserve/farmcollector/database/converters/AccuracyListConvertTest.kt create mode 100644 app/src/test/java/org/technoserve/farmcollector/database/converters/CoordinateListConvertTest.kt delete mode 100644 app/src/test/java/org/technoserve/farmcollector/map/MapViewModelTest.kt diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml index 946d44c..5862b67 100644 --- a/.idea/androidTestResultsUserPreferences.xml +++ b/.idea/androidTestResultsUserPreferences.xml @@ -3,6 +3,19 @@ diff --git a/app/build.gradle b/app/build.gradle index a54d2d1..927ab06 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -8,8 +8,8 @@ plugins { } -//def googleMapsApiKey = System.getenv("GOOGLE_MAPS_API_KEY") ?: "" -//def baseUrl = System.getenv("BASE_URL") ?: "" +def googleMapsApiKey = System.getenv("GOOGLE_MAPS_API_KEY") ?: "" +def baseUrl = System.getenv("BASE_URL") ?: "" android { namespace 'org.technoserve.farmcollector' @@ -25,8 +25,8 @@ android { vectorDrawables { useSupportLibrary true } -// buildConfigField "String", "GOOGLE_MAPS_API_KEY", "\"${googleMapsApiKey}\"" -// buildConfigField "String", "BASE_URL", "\"${baseUrl}\"" + buildConfigField "String", "GOOGLE_MAPS_API_KEY", "\"${googleMapsApiKey}\"" + buildConfigField "String", "BASE_URL", "\"${baseUrl}\"" } buildTypes { @@ -122,22 +122,23 @@ dependencies { implementation 'androidx.camera:camera-core:1.3.4' implementation 'androidx.camera:camera-lifecycle:1.3.4' implementation 'androidx.camera:camera-view:1.3.4' - implementation ("com.google.accompanist:accompanist-permissions:0.31.1-alpha") + implementation("com.google.accompanist:accompanist-permissions:0.31.1-alpha") implementation "androidx.camera:camera-camera2:1.3.4" implementation 'androidx.media3:media3-exoplayer:1.3.1' implementation "androidx.compose.foundation:foundation:1.6.8" implementation 'com.google.firebase:firebase-crashlytics-buildtools:3.0.2' - implementation 'androidx.test.ext:junit-ktx:1.2.1' - implementation 'androidx.navigation:navigation-testing:2.8.3' - testImplementation 'androidx.compose.ui:ui-test-junit4-android:1.6.8' + +// implementation 'androidx.test.ext:junit-ktx:1.2.1' +// implementation 'androidx.navigation:navigation-testing:2.8.3' +// testImplementation 'androidx.compose.ui:ui-test-junit4-android:1.6.8' def paging_version = "3.3.2" implementation("androidx.paging:paging-runtime:$paging_version") - // alternatively - without Android dependencies for tests - testImplementation("androidx.paging:paging-common:$paging_version") +// // alternatively - without Android dependencies for tests +// testImplementation("androidx.paging:paging-common:$paging_version") // optional - RxJava2 support implementation("androidx.paging:paging-rxjava2:$paging_version") @@ -155,20 +156,19 @@ dependencies { implementation("com.valentinilk.shimmer:compose-shimmer:1.3.1") - def room_version = "2.6.1" implementation "androidx.room:room-runtime:$room_version" kapt "androidx.room:room-compiler:$room_version" implementation "androidx.room:room-ktx:$room_version" implementation "androidx.navigation:navigation-compose:2.8.4" - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00') - androidTestImplementation 'androidx.compose.ui:ui-test-junit4' - debugImplementation 'androidx.compose.ui:ui-tooling' - debugImplementation 'androidx.compose.ui:ui-test-manifest' +// testImplementation 'junit:junit:4.13.2' +// androidTestImplementation 'androidx.test.ext:junit:1.1.5' +// androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' +// androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00') +// androidTestImplementation 'androidx.compose.ui:ui-test-junit4' +// debugImplementation 'androidx.compose.ui:ui-tooling' +// debugImplementation 'androidx.compose.ui:ui-test-manifest' implementation 'com.google.android.gms:play-services-location:21.0.1' implementation "com.google.accompanist:accompanist-permissions:0.14.0" implementation("io.coil-kt:coil-compose:2.4.0") @@ -213,51 +213,64 @@ dependencies { // testing - testImplementation("org.mockito:mockito-core:5.2.1") - testImplementation("org.mockito.kotlin:mockito-kotlin:5.2.1") -// androidTestImplementation "androidx.navigation:navigation-testing:2.8.4" + // Required -- JUnit 4 framework + testImplementation "junit:junit:4.13.2" + +// Optional -- Robolectric environment + testImplementation "androidx.test:core:1.6.1" + +// Optional -- Mockito framework + testImplementation "org.mockito:mockito-core:5.7.0" + +// Optional -- mockito-kotlin + testImplementation "org.mockito.kotlin:mockito-kotlin:5.3.1" + +// Optional -- Mockk framework + testImplementation "io.mockk:mockk:1.13.7" + + // Test rules and transitive dependencies + androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.7.5") - testImplementation "org.robolectric:robolectric:4.5.1" - testImplementation "org.assertj:assertj-core:3.14.0" - androidTestImplementation"androidx.test.espresso:espresso-intents:3.6.1" +// Needed for createComposeRule(), but not for createAndroidComposeRule() + debugImplementation("androidx.compose.ui:ui-test-manifest:1.7.5") - androidTestImplementation("androidx.test:core:kotlinx-coroutines-android-junit4:1.9.0") - androidTestImplementation("org.robolectric:robolectric:4.7.6") - androidTestImplementation "androidx.work:work-testing:2.8.0" - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") - testImplementation("org.mockito:mockito-core:3.12.4") - testImplementation "androidx.arch.core:core-testing:2.1.0" + testImplementation "org.robolectric:robolectric:4.10.3" + testImplementation "io.mockk:mockk:1.13.7" + testImplementation "io.mockk:mockk-android:1.13.5" + + androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.7.5" + + androidTestImplementation "androidx.arch.core:core-testing:2.2.0" + + testImplementation "org.robolectric:robolectric:4.9" + + + implementation 'androidx.navigation:navigation-testing:2.8.3' + + testImplementation 'com.google.dagger:hilt-android-testing:2.48' + + implementation 'androidx.test.ext:junit-ktx:1.2.1' + testImplementation 'androidx.compose.ui:ui-test-junit4-android:1.7.5' + testImplementation 'androidx.arch.core:core-testing:2.2.0' + // Testing WorkManager + androidTestImplementation "androidx.work:work-testing:2.7.1" +// For Making Assertions in Test Cases + androidTestImplementation "com.google.truth:truth:1.1.3" + testImplementation "com.google.truth:truth:1.1.3" - androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.6.8" - debugImplementation "androidx.compose.ui:ui-test-manifest:1.6.8" - // To use the androidx.test.core APIs - androidTestImplementation("androidx.test:core:1.6.1") - // Kotlin extensions for androidx.test.core - androidTestImplementation("androidx.test:core-ktx:1.6.1") + androidTestImplementation "androidx.work:work-testing:2.7.1" - // To use the androidx.test.espresso - androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") - // To use the JUnit Extension APIs - androidTestImplementation("androidx.test.ext:junit:1.2.1") - // Kotlin extensions for androidx.test.ext.junit - androidTestImplementation("androidx.test.ext:junit-ktx:1.2.1") - // To use the Truth Extension APIs - androidTestImplementation("androidx.test.ext:truth:1.6.0") - // To use the androidx.test.runner APIs - androidTestImplementation("androidx.test:runner:1.6.2") - // To use android test orchestrator - androidTestUtil("androidx.test:orchestrator:1.5.1") } \ No newline at end of file diff --git a/app/src/androidTest/java/org/technoserve/farmcollector/GreetingTestKtTest.kt b/app/src/androidTest/java/org/technoserve/farmcollector/GreetingTestKtTest.kt new file mode 100644 index 0000000..d27e0db --- /dev/null +++ b/app/src/androidTest/java/org/technoserve/farmcollector/GreetingTestKtTest.kt @@ -0,0 +1,34 @@ +package org.technoserve.farmcollector + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import org.junit.Assert.* + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.technoserve.farmcollector.ui.screens.Greeting + +@RunWith(AndroidJUnit4::class) +class GreetingTestKtTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun greeting_displaysCorrectText() { + // Set the composable to test + composeTestRule.setContent { + Greeting(name = "Emmanuel") + } + + // Assert the text is displayed correctly + composeTestRule + .onNodeWithText("Hello, Emmanuel!") + .assertExists("The Greeting composable did not display the expected text.") + } +} diff --git a/app/src/androidTest/java/org/technoserve/farmcollector/HomeIntegrationTest.kt b/app/src/androidTest/java/org/technoserve/farmcollector/HomeIntegrationTest.kt deleted file mode 100644 index d2b8fa1..0000000 --- a/app/src/androidTest/java/org/technoserve/farmcollector/HomeIntegrationTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -package org.technoserve.farmcollector - -//import android.app.Application -//import androidx.compose.ui.test.junit4.createAndroidComposeRule -//import androidx.compose.ui.test.onNodeWithText -//import androidx.compose.ui.test.performClick -//import androidx.test.core.app.ApplicationProvider -//import androidx.test.ext.junit.runners.AndroidJUnit4 -//import org.junit.Rule -//import org.junit.Test -//import org.junit.runner.RunWith -//import org.technoserve.farmcollector.ui.screens.Home -//import org.technoserve.farmcollector.utils.Language -//import org.technoserve.farmcollector.utils.LanguageViewModel -// -//@RunWith(AndroidJUnit4::class) -//class HomeIntegrationTest { -// @get:Rule -// val composeTestRule = createAndroidComposeRule() -// -// private val application = ApplicationProvider.getApplicationContext() as Application -// -// @Test -// fun home_languageSelection_updatesCorrectly() { -//// val navController = TestNavHostController(composeTestRule.activity) -// val languageViewModel = LanguageViewModel(application) -//// -//// composeTestRule.setContent { -//// Home( -//// navController = navController, -//// languageViewModel = languageViewModel, -//// languages = listOf(Language("en", "English"), Language("es", "Spanish")) -//// ) -//// } -// -// // Simulate a click on the language selector -// composeTestRule.onNodeWithText("Select Language").performClick() -// -// // Assert that languages are displayed -// composeTestRule.onNodeWithText("English").assertExists() -// composeTestRule.onNodeWithText("Spanish").assertExists() -// -// // Select a language and verify it updates the view model -// composeTestRule.onNodeWithText("Spanish").performClick() -// assert(languageViewModel.currentLanguage.value.displayName == "Spanish") -// } -//} \ No newline at end of file diff --git a/app/src/androidTest/java/org/technoserve/farmcollector/HomeTest.kt b/app/src/androidTest/java/org/technoserve/farmcollector/HomeTest.kt deleted file mode 100644 index 67ad295..0000000 --- a/app/src/androidTest/java/org/technoserve/farmcollector/HomeTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -package org.technoserve.farmcollector - -//import android.app.Application -//import androidx.compose.runtime.Composable -//import androidx.compose.ui.test.* -//import androidx.compose.ui.test.junit4.createComposeRule -//import androidx.navigation.compose.rememberNavController -//import androidx.test.core.app.ApplicationProvider -//import androidx.test.ext.junit.runners.AndroidJUnit4 -//import org.junit.Before -//import org.junit.Rule -//import org.junit.Test -//import org.junit.runner.RunWith -//import org.technoserve.farmcollector.ui.screens.Home -//import org.technoserve.farmcollector.utils.Language -//import org.technoserve.farmcollector.utils.LanguageViewModel -// -//@RunWith(AndroidJUnit4::class) -//class HomeTest { -// @JvmField -// @get:Rule -// val composeTestRule = createComposeRule() -// -// private lateinit var application: Application -// private lateinit var languageViewModel: LanguageViewModel -// private val testLanguages = listOf(Language("en", "English"), Language("es", "Spanish")) -// -// @Before -// fun setUp() { -// application = ApplicationProvider.getApplicationContext() -// languageViewModel = LanguageViewModel(application) -// } -// -// @Test -// fun homeScreen_whenLaunched_displaysAppIconAndTitle() { -// // Arrange & Act -// setHomeContent() -// -// // Assert -// composeTestRule.onNodeWithContentDescription("App Icon") -// .assertExists() -// .assertIsDisplayed() -// -// composeTestRule.onNodeWithText("MyApp") // Replace with actual app name -// .assertExists() -// .assertIsDisplayed() -// } -// -// @Composable -// @Test -// fun GetStartedButton_whenClicked_navigatesToSiteList() { -// // Arrange -// val navController = rememberNavController() -// -// composeTestRule.setContent { -// Home( -// navController = navController, -// languageViewModel = languageViewModel, -// languages = testLanguages -// ) -// } -// -// // Act -// composeTestRule.onNodeWithText("Get Started") -// .assertExists() -// .assertIsDisplayed() -// .performClick() -// -// // Assert -// composeTestRule.waitForIdle() -// assert(navController.currentDestination?.route == "siteList") -// } -// -// @Test -// fun developerInfo_isDisplayedCorrectly() { -// // Arrange & Act -// setHomeContent() -// -// // Assert -// composeTestRule.onNodeWithText("Developed by") -// .assertExists() -// .assertIsDisplayed() -// -// composeTestRule.onNodeWithContentDescription("TNS Labs Logo") -// .assertExists() -// .assertIsDisplayed() -// } -// -// private fun setHomeContent() { -// composeTestRule.setContent { -// Home( -// navController = rememberNavController(), -// languageViewModel = languageViewModel, -// languages = testLanguages -// ) -// } -// composeTestRule.waitForIdle() -// } -//} \ No newline at end of file diff --git a/app/src/main/java/org/technoserve/farmcollector/FarmCollectorApp.kt b/app/src/main/java/org/technoserve/farmcollector/FarmCollectorApp.kt index 1c12012..8c888f6 100644 --- a/app/src/main/java/org/technoserve/farmcollector/FarmCollectorApp.kt +++ b/app/src/main/java/org/technoserve/farmcollector/FarmCollectorApp.kt @@ -1,6 +1,7 @@ package org.technoserve.farmcollector import android.app.Application +import androidx.work.Configuration import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType @@ -12,6 +13,11 @@ import java.util.concurrent.TimeUnit class FarmCollectorApp : Application() { override fun onCreate() { super.onCreate() + val config = Configuration.Builder() + .setMinimumLoggingLevel(android.util.Log.DEBUG) + .build() + WorkManager.initialize(this, config) + android.util.Log.d("WorkManager", "WorkManager initialized successfully") initializeWorkManager() } diff --git a/app/src/main/java/org/technoserve/farmcollector/MainActivity.kt b/app/src/main/java/org/technoserve/farmcollector/MainActivity.kt index 3307c94..9b735fb 100644 --- a/app/src/main/java/org/technoserve/farmcollector/MainActivity.kt +++ b/app/src/main/java/org/technoserve/farmcollector/MainActivity.kt @@ -36,6 +36,8 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState import org.technoserve.farmcollector.database.AppUpdateViewModel import org.technoserve.farmcollector.database.ExitConfirmationDialog +import org.technoserve.farmcollector.database.FarmDAO +import org.technoserve.farmcollector.database.FarmRepository import org.technoserve.farmcollector.database.FarmViewModel import org.technoserve.farmcollector.database.FarmViewModelFactory import org.technoserve.farmcollector.database.UpdateAlert diff --git a/app/src/main/java/org/technoserve/farmcollector/database/FarmViewModel.kt b/app/src/main/java/org/technoserve/farmcollector/database/FarmViewModel.kt index 4479d7c..1e92dfa 100644 --- a/app/src/main/java/org/technoserve/farmcollector/database/FarmViewModel.kt +++ b/app/src/main/java/org/technoserve/farmcollector/database/FarmViewModel.kt @@ -121,6 +121,9 @@ class FarmViewModel( val restoreStatus: LiveData get() = _restoreStatus private val apiService: ApiService + // Declare FarmRepository as a lateinit variable + lateinit var farmRepository: FarmRepository + init { val farmDAO = AppDatabase.getInstance(application).farmsDAO() repository = FarmRepository(farmDAO) @@ -132,6 +135,9 @@ class FarmViewModel( .build() apiService = retrofit.create(ApiService::class.java) + // Initialize the repository by default + // Provide a default DAO for the repository + farmRepository = FarmRepository(farmDAO) } fun readAllData(siteId: Long): LiveData> = repository.readAllFarms(siteId) diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/Greeting.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/Greeting.kt new file mode 100644 index 0000000..eb09cb5 --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/Greeting.kt @@ -0,0 +1,10 @@ +package org.technoserve.farmcollector.ui.screens + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable + +// Example composable function to test +@Composable +fun Greeting(name: String) { + Text("Hello, $name!") +} diff --git a/app/src/test/java/org/technoserve/farmcollector/CalculatorExampleTest.kt b/app/src/test/java/org/technoserve/farmcollector/CalculatorExampleTest.kt deleted file mode 100644 index 12b5307..0000000 --- a/app/src/test/java/org/technoserve/farmcollector/CalculatorExampleTest.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.technoserve.farmcollector - -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mock -import org.mockito.Mockito.`when` -import org.mockito.junit.MockitoJUnitRunner -import org.technoserve.farmcollector.database.CalculatorExample -import org.technoserve.farmcollector.database.Operators - -@RunWith(MockitoJUnitRunner::class) -class CalculatorExampleTest { - - lateinit var CE: CalculatorExample - - @Mock - lateinit var OP: Operators - - @Before - fun onSetup() { - CE = CalculatorExample(OP) - } - - @Test - fun addTwoNumber_PrintValue() { - val a = 100 - val b = 20 - - `when`(OP.addTwoInt(a, b)).thenReturn(a + b) - - val result = CE.addTwoNumbers(a, b) - - println(" after add two number : $result") - } -} diff --git a/app/src/test/java/org/technoserve/farmcollector/HomeTest.kt b/app/src/test/java/org/technoserve/farmcollector/HomeTest.kt index 4b16ec3..9e41c7b 100644 --- a/app/src/test/java/org/technoserve/farmcollector/HomeTest.kt +++ b/app/src/test/java/org/technoserve/farmcollector/HomeTest.kt @@ -1,24 +1,194 @@ package org.technoserve.farmcollector -import androidx.annotation.StringRes +//import androidx.annotation.StringRes +//import androidx.compose.ui.test.* +//import androidx.compose.ui.test.junit4.createComposeRule +//import androidx.navigation.testing.TestNavHostController +//import androidx.test.ext.junit.runners.AndroidJUnit4 +//import androidx.test.platform.app.InstrumentationRegistry +//import junit.framework.TestCase.assertEquals +//import org.junit.Before +//import org.junit.Rule +//import org.junit.Test +//import org.junit.runner.RunWith +//import org.mockito.Mockito.mock +//import org.mockito.Mockito.verify +//import org.technoserve.farmcollector.ui.screens.Home +//import org.technoserve.farmcollector.utils.Language +//import org.technoserve.farmcollector.utils.LanguageViewModel +// +//@RunWith(AndroidJUnit4::class) +//class HomeTest { +// @get:Rule +// val composeTestRule = createComposeRule() +// +// private lateinit var navController: TestNavHostController +// private lateinit var languageViewModel: LanguageViewModel +// private val testLanguages = listOf( +// Language("en", "English"), +// Language("es", "Spanish") +// ) +// +// @Before +// fun setup() { +// navController = TestNavHostController(InstrumentationRegistry.getInstrumentation().targetContext) +// languageViewModel = mock(LanguageViewModel::class.java) +// } +// +// @Test +// fun homeScreen_displaysAppName() { +// composeTestRule.setContent { +// Home( +// navController = navController, +// languageViewModel = languageViewModel, +// languages = testLanguages +// ) +// } +// +// composeTestRule +// .onNodeWithText(getResourceString(R.string.app_name)) +// .assertExists() +// .assertIsDisplayed() +// } +// +// @Test +// fun homeScreen_displaysGetStartedButton() { +// composeTestRule.setContent { +// Home( +// navController = navController, +// languageViewModel = languageViewModel, +// languages = testLanguages +// ) +// } +// +// composeTestRule +// .onNodeWithText(getResourceString(R.string.get_started)) +// .assertExists() +// .assertIsDisplayed() +// .assertHasClickAction() +// } +// +// @Test +// fun homeScreen_clickGetStartedNavigatesToSiteList() { +// composeTestRule.setContent { +// Home( +// navController = navController, +// languageViewModel = languageViewModel, +// languages = testLanguages +// ) +// } +// +// composeTestRule +// .onNodeWithText(getResourceString(R.string.get_started)) +// .performClick() +// +// // Verify navigation occurred +// assertEquals("siteList", navController.currentDestination?.route) +// } +// +// @Test +// fun homeScreen_displaysAppIntro() { +// composeTestRule.setContent { +// Home( +// navController = navController, +// languageViewModel = languageViewModel, +// languages = testLanguages +// ) +// } +// +// composeTestRule +// .onNodeWithText(getResourceString(R.string.app_intro)) +// .assertExists() +// .assertIsDisplayed() +// } +// +// @Test +// fun homeScreen_displaysDeveloperInfo() { +// composeTestRule.setContent { +// Home( +// navController = navController, +// languageViewModel = languageViewModel, +// languages = testLanguages +// ) +// } +// +// composeTestRule +// .onNodeWithText(getResourceString(R.string.developed_by)) +// .assertExists() +// .assertIsDisplayed() +// } +// +// @Test +// fun homeScreen_displaysLanguageSelector() { +// composeTestRule.setContent { +// Home( +// navController = navController, +// languageViewModel = languageViewModel, +// languages = testLanguages +// ) +// } +// +// // Verify LanguageSelector is displayed +// // Note: This assumes LanguageSelector has a testTag. You might need to add one. +// composeTestRule +// .onNodeWithTag("language_selector") +// .assertExists() +// .assertIsDisplayed() +// } +// +// @Test +// fun homeScreen_displaysAppIcon() { +// composeTestRule.setContent { +// Home( +// navController = navController, +// languageViewModel = languageViewModel, +// languages = testLanguages +// ) +// } +// +// composeTestRule +// .onNodeWithContentDescription("app_icon") +// .assertExists() +// .assertIsDisplayed() +// } +// +// @Test +// fun homeScreen_displaysLabsLogo() { +// composeTestRule.setContent { +// Home( +// navController = navController, +// languageViewModel = languageViewModel, +// languages = testLanguages +// ) +// } +// +// composeTestRule +// .onNodeWithContentDescription("tns_labs") +// .assertExists() +// .assertIsDisplayed() +// } +// +// private fun getResourceString(@StringRes resourceId: Int): String { +// return InstrumentationRegistry.getInstrumentation() +// .targetContext.resources.getString(resourceId) +// } +//} + import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.createComposeRule import androidx.navigation.testing.TestNavHostController -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import junit.framework.TestCase.assertEquals +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule import org.junit.Test -import org.junit.runner.RunWith import org.mockito.Mockito.mock -import org.mockito.Mockito.verify import org.technoserve.farmcollector.ui.screens.Home import org.technoserve.farmcollector.utils.Language import org.technoserve.farmcollector.utils.LanguageViewModel -@RunWith(AndroidJUnit4::class) class HomeTest { + @get:Rule val composeTestRule = createComposeRule() @@ -31,7 +201,9 @@ class HomeTest { @Before fun setup() { - navController = TestNavHostController(InstrumentationRegistry.getInstrumentation().targetContext) + // Use ApplicationProvider for a context in unit tests + val context = ApplicationProvider.getApplicationContext() + navController = TestNavHostController(context) languageViewModel = mock(LanguageViewModel::class.java) } @@ -46,7 +218,7 @@ class HomeTest { } composeTestRule - .onNodeWithText(getResourceString(R.string.app_name)) + .onNodeWithText("TerraTrac") // Replace with the actual string if not resource-based .assertExists() .assertIsDisplayed() } @@ -62,7 +234,7 @@ class HomeTest { } composeTestRule - .onNodeWithText(getResourceString(R.string.get_started)) + .onNodeWithText("Get Started") .assertExists() .assertIsDisplayed() .assertHasClickAction() @@ -79,97 +251,10 @@ class HomeTest { } composeTestRule - .onNodeWithText(getResourceString(R.string.get_started)) + .onNodeWithText("Get Started") // Replace with actual string if not resource-based .performClick() // Verify navigation occurred assertEquals("siteList", navController.currentDestination?.route) } - - @Test - fun homeScreen_displaysAppIntro() { - composeTestRule.setContent { - Home( - navController = navController, - languageViewModel = languageViewModel, - languages = testLanguages - ) - } - - composeTestRule - .onNodeWithText(getResourceString(R.string.app_intro)) - .assertExists() - .assertIsDisplayed() - } - - @Test - fun homeScreen_displaysDeveloperInfo() { - composeTestRule.setContent { - Home( - navController = navController, - languageViewModel = languageViewModel, - languages = testLanguages - ) - } - - composeTestRule - .onNodeWithText(getResourceString(R.string.developed_by)) - .assertExists() - .assertIsDisplayed() - } - - @Test - fun homeScreen_displaysLanguageSelector() { - composeTestRule.setContent { - Home( - navController = navController, - languageViewModel = languageViewModel, - languages = testLanguages - ) - } - - // Verify LanguageSelector is displayed - // Note: This assumes LanguageSelector has a testTag. You might need to add one. - composeTestRule - .onNodeWithTag("language_selector") - .assertExists() - .assertIsDisplayed() - } - - @Test - fun homeScreen_displaysAppIcon() { - composeTestRule.setContent { - Home( - navController = navController, - languageViewModel = languageViewModel, - languages = testLanguages - ) - } - - composeTestRule - .onNodeWithContentDescription("app_icon") - .assertExists() - .assertIsDisplayed() - } - - @Test - fun homeScreen_displaysLabsLogo() { - composeTestRule.setContent { - Home( - navController = navController, - languageViewModel = languageViewModel, - languages = testLanguages - ) - } - - composeTestRule - .onNodeWithContentDescription("tns_labs") - .assertExists() - .assertIsDisplayed() - } - - private fun getResourceString(@StringRes resourceId: Int): String { - return InstrumentationRegistry.getInstrumentation() - .targetContext.resources.getString(resourceId) - } -} \ No newline at end of file +} diff --git a/app/src/test/java/org/technoserve/farmcollector/MainActivityTest.kt b/app/src/test/java/org/technoserve/farmcollector/MainActivityTest.kt index e3bc1c7..44807f3 100644 --- a/app/src/test/java/org/technoserve/farmcollector/MainActivityTest.kt +++ b/app/src/test/java/org/technoserve/farmcollector/MainActivityTest.kt @@ -126,7 +126,7 @@ class MainActivityTest { // assertTrue(activity.isDialogVisible ("update_alert_dialog")) // } // } -// + // @Test // fun testExitDialogShownOnBackPress() { // val scenario = ActivityScenario.launch(MainActivity::class.java) @@ -139,7 +139,7 @@ class MainActivityTest { // assertTrue(activity.isDialogVisible("exit_confirmation_dialog")) // } // } -// + // @Test // fun testNavigationToHome() { // val scenario = ActivityScenario.launch(MainActivity::class.java) @@ -151,7 +151,7 @@ class MainActivityTest { // assertThat(navController.currentDestination?.route).isEqualTo(Routes.HOME) // } // } -// + // @Test // fun testLocaleUpdatedOnLanguageChange() = runBlocking { // val languageViewModel = mock(LanguageViewModel::class.java) @@ -165,7 +165,7 @@ class MainActivityTest { // verify(languageViewModel).updateLocale(app, Locale("es")) // } // } -// + // @Test // fun testOpenPlayStoreOnUpdateConfirm() { // val scenario = ActivityScenario.launch(MainActivity::class.java) diff --git a/app/src/test/java/org/technoserve/farmcollector/MapViewModelUnitTest.kt b/app/src/test/java/org/technoserve/farmcollector/MapViewModelUnitTest.kt new file mode 100644 index 0000000..eba806e --- /dev/null +++ b/app/src/test/java/org/technoserve/farmcollector/MapViewModelUnitTest.kt @@ -0,0 +1,38 @@ +package org.technoserve.farmcollector + +import dagger.hilt.android.testing.HiltAndroidRule +import junit.framework.TestCase.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.technoserve.farmcollector.map.MapViewModel + +@RunWith(RobolectricTestRunner::class) +class MapViewModelUnitTest { + + + @get:Rule + var hiltRule = HiltAndroidRule(this) + + private lateinit var viewModel: MapViewModel + + @Before + fun setUp() { + hiltRule.inject() // Inject dependencies + viewModel = MapViewModel() // No dependency to mock since the constructor is empty + } + + @Test + fun testAddCoordinate() { + val lat = 12.345678 + val lng = 98.765432 + + viewModel.addCoordinate(lat, lng) + + val clusterItems = viewModel.state.value.clusterItems + assert(clusterItems.isNotEmpty()) + assertEquals("zone-0", clusterItems.last().id) + } +} diff --git a/app/src/test/java/org/technoserve/farmcollector/ValidatorTest.kt b/app/src/test/java/org/technoserve/farmcollector/ValidatorTest.kt new file mode 100644 index 0000000..af519f5 --- /dev/null +++ b/app/src/test/java/org/technoserve/farmcollector/ValidatorTest.kt @@ -0,0 +1,71 @@ +package org.technoserve.farmcollector + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +object Validator { + + private val EMAIL_REGEX = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$".toRegex() + private val PHONE_NUMBER_REGEX = "^\\+?[0-9]{10,15}$".toRegex() // Supports optional '+' and 10-15 digits + private val NAME_REGEX = "^[A-Za-z\\s'-]+$".toRegex() // Allows letters, spaces, apostrophes, and hyphens + + fun isValidEmail(email: String?): Boolean { + return email != null && EMAIL_REGEX.matches(email) + } + + fun isValidPhoneNumber(phoneNumber: String?): Boolean { + return phoneNumber != null && PHONE_NUMBER_REGEX.matches(phoneNumber) + } + + fun isValidName(name: String?): Boolean { + return name != null && NAME_REGEX.matches(name) + } +} + +class ValidatorTest { + + @Test + fun emailValidator_CorrectEmail_ReturnsTrue() { + assertTrue(Validator.isValidEmail("name@email.com")) + assertTrue(Validator.isValidEmail("example.name+123@gmail.com")) + } + + @Test + fun emailValidator_IncorrectEmail_ReturnsFalse() { + assertFalse(Validator.isValidEmail("nameemail.com")) + assertFalse(Validator.isValidEmail("name.com")) + assertFalse(Validator.isValidEmail(null)) + } + + @Test + fun phoneNumberValidator_CorrectPhoneNumber_ReturnsTrue() { + assertTrue(Validator.isValidPhoneNumber("+1234567890")) + assertTrue(Validator.isValidPhoneNumber("1234567890")) + assertTrue(Validator.isValidPhoneNumber("+123456789012345")) + } + + @Test + fun phoneNumberValidator_IncorrectPhoneNumber_ReturnsFalse() { + assertFalse(Validator.isValidPhoneNumber("12345")) // Too short + assertFalse(Validator.isValidPhoneNumber("+12345abcde")) // Contains letters + assertFalse(Validator.isValidPhoneNumber(null)) // Null + } + + @Test + fun nameValidator_CorrectName_ReturnsTrue() { + assertTrue(Validator.isValidName("John Doe")) + assertTrue(Validator.isValidName("O'Connor")) + assertTrue(Validator.isValidName("Anne-Marie")) + } + + @Test + fun nameValidator_IncorrectName_ReturnsFalse() { + assertFalse(Validator.isValidName("John123")) // Contains numbers + assertFalse(Validator.isValidName("!@#$%^&*()")) // Contains special characters + assertFalse(Validator.isValidName(null)) // Null + } +} + + + diff --git a/app/src/test/java/org/technoserve/farmcollector/database/FarmViewModelTest.kt b/app/src/test/java/org/technoserve/farmcollector/database/FarmViewModelTest.kt index 3200641..e476b6c 100644 --- a/app/src/test/java/org/technoserve/farmcollector/database/FarmViewModelTest.kt +++ b/app/src/test/java/org/technoserve/farmcollector/database/FarmViewModelTest.kt @@ -1,120 +1,73 @@ package org.technoserve.farmcollector.database import android.app.Application -import androidx.lifecycle.LiveData -import androidx.lifecycle.Observer +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.runBlocking import org.junit.Assert.* import org.junit.Before - +import org.junit.Rule import org.junit.Test -import org.mockito.ArgumentMatchers.any import org.mockito.Mock -import org.mockito.Mockito.never -import org.mockito.Mockito.verify -import org.mockito.Mockito.`when` +import org.mockito.Mockito.* import org.mockito.MockitoAnnotations - -object LiveDataTestUtil { - fun getValue(liveData: LiveData): T { - val observer = Observer {} - liveData.observeForever(observer) - val value = liveData.value - liveData.removeObserver(observer) - return value!! - } -} - class FarmViewModelTest { - @Mock - private lateinit var mockFarmRepository: FarmRepository - - @Mock - private lateinit var mockFarmDAO: FarmDAO + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() // Ensures LiveData updates occur synchronously @Mock - private lateinit var mockLiveData: LiveData> + private lateinit var mockFarmRepository: FarmRepository private lateinit var farmViewModel: FarmViewModel + private lateinit var liveDataFarms: MutableLiveData> @Mock private lateinit var mockApplication: Application @Before fun setUp() { - MockitoAnnotations.openMocks(this) // Initialize mocks + MockitoAnnotations.openMocks(this) - // Initialize the repository with the mocked DAO - `when`(mockFarmRepository.readAllFarms(any())).thenReturn(mockLiveData) + // Initialize LiveData + liveDataFarms = MutableLiveData() + `when`(mockFarmRepository.readAllFarms(anyLong())).thenReturn(liveDataFarms) - // Initialize the FarmViewModel with the mocked Application and repository - farmViewModel = FarmViewModel(mockApplication) - } + // Initialize ViewModel + farmViewModel = FarmViewModel(mockApplication).apply { + farmRepository = mockFarmRepository // Inject mock repository + } + } @Test - fun `addFarm adds farm if not duplicate`(): Unit = runBlocking { + fun `addFarm adds farm if not duplicate`() = runBlocking { // Given - val newFarm = Farm( - siteId = 1L, - farmerPhoto = "photo.jpg", - farmerName = "New Farmer", - memberId = "12345", - village = "Village A", - district = "District X", - purchases = 10f, - size = 100f, - latitude = "12.34", - longitude = "56.78", - coordinates = listOf(Pair(12.34, 56.78)), - accuracyArray = listOf(5.0f), - synced = false, - scheduledForSync = false, - createdAt = System.currentTimeMillis(), - updatedAt = System.currentTimeMillis(), - needsUpdate = true - ) - val siteId = 1L - val existingFarm = null // simulate no duplicate farm exists + val newFarm = createTestFarm() + val siteId = newFarm.siteId + liveDataFarms.value = emptyList() // Initial state // When `when`(mockFarmRepository.isFarmDuplicateBoolean(newFarm)).thenReturn(false) - `when`(mockFarmRepository.addFarm(newFarm)).thenReturn(Unit) - `when`(mockFarmRepository.readAllFarms(siteId)).thenReturn(mockLiveData) - farmViewModel.addFarm(newFarm, siteId) + // Simulate repository adding farm + liveDataFarms.value = listOf(newFarm) + // Then verify(mockFarmRepository).addFarm(newFarm) - assertNotNull(farmViewModel.farms.value) + val updatedFarms = farmViewModel.farms.value + assertNotNull(updatedFarms) + assertTrue(updatedFarms!!.contains(newFarm)) } @Test fun `addFarm returns error if duplicate farm exists`() = runBlocking { // Given - val duplicateFarm = Farm( - siteId = 1L, - farmerPhoto = "photo.jpg", - farmerName = "New Farmer", - memberId = "12345", - village = "Village A", - district = "District X", - purchases = 10f, - size = 100f, - latitude = "12.34", - longitude = "56.78", - coordinates = listOf(Pair(12.34, 56.78)), - accuracyArray = listOf(5.0f), - synced = false, - scheduledForSync = false, - createdAt = System.currentTimeMillis(), - updatedAt = System.currentTimeMillis(), - needsUpdate = true - ) - val siteId = 1L - val existingFarm = duplicateFarm + val duplicateFarm = createTestFarm() + val siteId = duplicateFarm.siteId + liveDataFarms.value = listOf(duplicateFarm) // Simulate duplicate // When `when`(mockFarmRepository.isFarmDuplicateBoolean(duplicateFarm)).thenReturn(true) @@ -123,89 +76,74 @@ class FarmViewModelTest { // Then verify(mockFarmRepository, never()).addFarm(duplicateFarm) - assertNull(farmViewModel.farms.value) + val updatedFarms = farmViewModel.farms.value + assertNotNull(updatedFarms) + assertFalse(updatedFarms!!.contains(duplicateFarm)) // Farm was not added } - @Test fun `updateFarm updates farm successfully`() = runBlocking { // Given - val newFarm = Farm( - siteId = 1L, - farmerPhoto = "photo.jpg", - farmerName = "New Farmer", - memberId = "12345", - village = "Village A", - district = "District X", - purchases = 10f, - size = 100f, - latitude = "12.34", - longitude = "56.78", - coordinates = listOf(Pair(12.34, 56.78)), - accuracyArray = listOf(5.0f), - synced = false, - scheduledForSync = false, - createdAt = System.currentTimeMillis(), - updatedAt = System.currentTimeMillis(), - needsUpdate = true - ) - val existingFarm = newFarm + val existingFarm = createTestFarm() val updatedFarm = existingFarm.copy(farmerName = "Updated Farmer") - val siteId = 1L + liveDataFarms.value = listOf(existingFarm) // When `when`(mockFarmRepository.updateFarm(updatedFarm)).thenReturn(Unit) - `when`(mockFarmRepository.readAllFarms(siteId)).thenReturn(mockLiveData) - farmViewModel.updateFarm(updatedFarm) + // Simulate repository updating farm + liveDataFarms.value = listOf(updatedFarm) + // Then verify(mockFarmRepository).updateFarm(updatedFarm) - assertNotNull(farmViewModel.farms.value) - assertTrue(farmViewModel.farms.value?.contains(updatedFarm) == true) + val updatedFarms = farmViewModel.farms.value + assertNotNull(updatedFarms) + assertTrue(updatedFarms!!.contains(updatedFarm)) } - @Test fun `deleteFarmById deletes farm successfully`() = runBlocking { // Given - val newFarm = Farm( - siteId = 1L, - farmerPhoto = "photo.jpg", - farmerName = "New Farmer", - memberId = "12345", - village = "Village A", - district = "District X", - purchases = 10f, - size = 100f, - latitude = "12.34", - longitude = "56.78", - coordinates = listOf(Pair(12.34, 56.78)), - accuracyArray = listOf(5.0f), - synced = false, - scheduledForSync = false, - createdAt = System.currentTimeMillis(), - updatedAt = System.currentTimeMillis(), - needsUpdate = true - ) - val farmToDelete = newFarm + val farmToDelete = createTestFarm() + liveDataFarms.value = listOf(farmToDelete) // When `when`(mockFarmRepository.deleteFarmById(farmToDelete)).thenReturn(Unit) - `when`(mockFarmRepository.readAllFarms(farmToDelete.siteId)).thenReturn(mockLiveData) - farmViewModel.deleteFarmById(farmToDelete) + // Simulate repository deleting farm + liveDataFarms.value = emptyList() + // Then verify(mockFarmRepository).deleteFarmById(farmToDelete) - assertNotNull(farmViewModel.farms.value) + val updatedFarms = farmViewModel.farms.value + assertNotNull(updatedFarms) + assertTrue(updatedFarms!!.isEmpty()) // Farm was deleted } @Test fun `addFarm updates LiveData`() = runBlocking { // Given + val newFarm = createTestFarm() + val siteId = newFarm.siteId + liveDataFarms.value = emptyList() // Initial state + + // When + `when`(mockFarmRepository.isFarmDuplicateBoolean(newFarm)).thenReturn(false) + farmViewModel.addFarm(newFarm, siteId) - val newFarm = Farm( + // Simulate repository adding farm + liveDataFarms.value = listOf(newFarm) + + // Then + val updatedFarms = farmViewModel.farms.value + assertNotNull(updatedFarms) + assertTrue(updatedFarms!!.contains(newFarm)) + } + + private fun createTestFarm(): Farm { + return Farm( siteId = 1L, farmerPhoto = "photo.jpg", farmerName = "New Farmer", @@ -224,17 +162,5 @@ class FarmViewModelTest { updatedAt = System.currentTimeMillis(), needsUpdate = true ) - - val siteId = 1L - - // When - `when`(mockFarmRepository.isFarmDuplicateBoolean(newFarm)).thenReturn(false) - `when`(mockFarmRepository.readAllFarms(siteId)).thenReturn(mockLiveData) - - farmViewModel.addFarm(newFarm, siteId) - - // Then - val updatedFarms = LiveDataTestUtil.getValue(farmViewModel.farms) - assertTrue(updatedFarms.contains(newFarm)) } -} \ No newline at end of file +} diff --git a/app/src/test/java/org/technoserve/farmcollector/database/converters/AccuracyListConvertTest.kt b/app/src/test/java/org/technoserve/farmcollector/database/converters/AccuracyListConvertTest.kt new file mode 100644 index 0000000..0516c8d --- /dev/null +++ b/app/src/test/java/org/technoserve/farmcollector/database/converters/AccuracyListConvertTest.kt @@ -0,0 +1,88 @@ +package org.technoserve.farmcollector.database.converters + +//import org.junit.jupiter.api.Assertions.* +// +//import org.junit.jupiter.api.Test + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull + +import org.junit.Test +class AccuracyListConvertTest { + private val converter = AccuracyListConvert() + + @Test + fun `fromAccuracyList with null input returns null`() { + val result = converter.fromAccuracyList(null) + assertNull(result) + } + + @Test + fun `fromAccuracyList with empty list returns empty brackets`() { + val result = converter.fromAccuracyList(emptyList()) + assertEquals("[]", result) + } + + @Test + fun `fromAccuracyList with list containing only nulls`() { + val input = listOf(null, null, null) + val result = converter.fromAccuracyList(input) + assertEquals("[null,null,null]", result) + } + + @Test + fun `fromAccuracyList with list containing mixed values and nulls`() { + val input = listOf(1.5f, null, 2.7f, null) + val result = converter.fromAccuracyList(input) + assertEquals("[1.5,null,2.7,null]", result) + } + + @Test + fun `fromAccuracyList with list containing only float values`() { + val input = listOf(1.5f, 2.7f, 3.0f) + val result = converter.fromAccuracyList(input) + assertEquals("[1.5,2.7,3.0]", result) + } + + @Test + fun `toAccuracyList with null input returns null`() { + val result = converter.toAccuracyList(null) + assertNull(result) + } + + @Test + fun `toAccuracyList with empty brackets returns empty list`() { + val result = converter.toAccuracyList("[]") + assertEquals(emptyList(), result) + } + + @Test + fun `toAccuracyList with list containing only nulls`() { + val result = converter.toAccuracyList("[null,null,null]") + assertEquals(listOf(null, null, null), result) + } + + @Test + fun `toAccuracyList with list containing mixed values and nulls`() { + val result = converter.toAccuracyList("[1.5,null,2.7,null]") + assertEquals(listOf(1.5f, null, 2.7f, null), result) + } + + @Test + fun `toAccuracyList with list containing only float values`() { + val result = converter.toAccuracyList("[1.5,2.7,3.0]") + assertEquals(listOf(1.5f, 2.7f, 3.0f), result) + } + + @Test + fun `toAccuracyList with whitespace in string`() { + val result = converter.toAccuracyList("[ 1.5, null , 2.7 , null ]") + assertEquals(listOf(1.5f, null, 2.7f, null), result) + } + + @Test + fun `toAccuracyList with invalid float values returns nulls`() { + val result = converter.toAccuracyList("[1.5,invalid,2.7,xyz]") + assertEquals(listOf(1.5f, null, 2.7f, null), result) + } +} \ No newline at end of file diff --git a/app/src/test/java/org/technoserve/farmcollector/database/converters/CoordinateListConvertTest.kt b/app/src/test/java/org/technoserve/farmcollector/database/converters/CoordinateListConvertTest.kt new file mode 100644 index 0000000..24b5867 --- /dev/null +++ b/app/src/test/java/org/technoserve/farmcollector/database/converters/CoordinateListConvertTest.kt @@ -0,0 +1,114 @@ +package org.technoserve.farmcollector.database.converters + +//import org.junit.jupiter.api.Assertions.* +//import com.google.gson.Gson +//import com.google.gson.reflect.TypeToken +//import org.junit.jupiter.api.AfterEach +//import org.junit.jupiter.api.BeforeEach +//import org.junit.jupiter.api.Test + +import com.google.common.reflect.TypeToken +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class CoordinateListConvertTest { + // private lateinit var converter: CoordinateListConvert + + private val converter = CoordinateListConvert() + +// @BeforeEach +// fun setUp() { +// converter = CoordinateListConvert() +// } +// +// @AfterEach +// fun tearDown() { +// // No cleanup needed +// } + + @Test + fun `fromCoordinates with null input returns empty string`() { + val result = converter.fromCoordinates(null) + assertEquals("", result) + } + + @Test + fun `fromCoordinates with empty list returns JSON array`() { + val emptyList = emptyList>() + val result = converter.fromCoordinates(emptyList) + assertEquals("[]", result) + } + + @Test + fun `fromCoordinates with single coordinate pair`() { + val coordinates = listOf(Pair(45.0, -122.0)) + val result = converter.fromCoordinates(coordinates) + // Using Gson to parse back and verify the structure + val gson = Gson() + val listType = object : TypeToken>>() {}.type + val parsedResult: List> = gson.fromJson(result, listType) + + assertEquals(1, parsedResult.size) + assertEquals(45.0, parsedResult[0].first) + assertEquals(-122.0, parsedResult[0].second) + } + + @Test + fun `fromCoordinates with multiple coordinate pairs`() { + val coordinates = listOf( + Pair(45.0, -122.0), + Pair(47.0, -123.0), + Pair(46.0, -121.0) + ) + val result = converter.fromCoordinates(coordinates) + val gson = Gson() + val listType = object : TypeToken>>() {}.type + val parsedResult: List> = gson.fromJson(result, listType) + + assertEquals(3, parsedResult.size) + assertEquals(coordinates, parsedResult) + } + + @Test + fun `toCoordinates with empty string returns empty list`() { + val result = converter.toCoordinates("") + assertTrue(result.isEmpty()) + } + + @Test + fun `toCoordinates with empty JSON array returns empty list`() { + val result = converter.toCoordinates("[]") + assertTrue(result.isEmpty()) + } + + @Test + fun `toCoordinates with single coordinate pair`() { + val json = """[{"first":45.0,"second":-122.0}]""" + val result = converter.toCoordinates(json) + + assertEquals(1, result.size) + assertEquals(45.0, result[0].first) + assertEquals(-122.0, result[0].second) + } + + @Test + fun `toCoordinates with multiple coordinate pairs`() { + val json = """[ + {"first":45.0,"second":-122.0}, + {"first":47.0,"second":-123.0}, + {"first":46.0,"second":-121.0} + ]""" + val result = converter.toCoordinates(json) + + assertEquals(3, result.size) + assertEquals(45.0, result[0].first) + assertEquals(-122.0, result[0].second) + assertEquals(47.0, result[1].first) + assertEquals(-123.0, result[1].second) + assertEquals(46.0, result[2].first) + assertEquals(-121.0, result[2].second) + } +} \ No newline at end of file diff --git a/app/src/test/java/org/technoserve/farmcollector/map/MapViewModelTest.kt b/app/src/test/java/org/technoserve/farmcollector/map/MapViewModelTest.kt deleted file mode 100644 index 1b37f4d..0000000 --- a/app/src/test/java/org/technoserve/farmcollector/map/MapViewModelTest.kt +++ /dev/null @@ -1,135 +0,0 @@ -package org.technoserve.farmcollector.map - -import androidx.activity.viewModels -import org.junit.Assert.* - - - -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.lifecycle.Observer -import com.google.maps.android.compose.MapType -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.Assert.* -import org.mockito.Mockito.* -import org.mockito.kotlin.mock -import org.technoserve.farmcollector.utils.GeoCalculator - -class MapViewModelTest { - - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - - private lateinit var viewModel: MapViewModel - - @Before - fun setUp() { - viewModel = MapViewModel() - } - - @After - fun tearDown() { - } - - @Test - fun `test initial state values`() { - val initialState = viewModel.state.value - assertNull(initialState.lastKnownLocation) - initialState.markers?.let { assertTrue(it.isEmpty()) } - assertTrue(initialState.clusterItems.isEmpty()) - assertFalse(initialState.clearMap) - assertEquals(MapType.NORMAL, initialState.mapType) - } - - @Test - fun `calculateArea sets coordinates and updates calculated area`() { - val coordinates = listOf(Pair(10.0, 20.0), Pair(10.0, 30.0), Pair(20.0, 20.0)) - val area = viewModel.calculateArea(coordinates) - - assertEquals(coordinates, viewModel.coordinates.value) - assertEquals(GeoCalculator.calculateArea(coordinates), area, 0.0) - } - - @Test - fun `showAreaDialog sets calculated area and shows dialog on valid input`() = runBlocking { - val validArea = "100.0" - val enteredArea = "100.0" - - viewModel.showAreaDialog(validArea, enteredArea) - - assertEquals(validArea, viewModel.size.first()) - assertTrue(viewModel.showDialog.first()) - } - - @Test - fun `showAreaDialog shows invalid input on non-numeric area`() = runBlocking { - viewModel.showAreaDialog("100.0", "InvalidInput") - - assertEquals("Invalid input.", viewModel.size.first()) - assertFalse(viewModel.showDialog.first()) - } - - @Test - fun `dismissDialog hides dialog`() = runBlocking { - viewModel.dismissDialog() - assertFalse(viewModel.showDialog.first()) - } - - @Test - fun `addCoordinate adds single coordinate to cluster items`() { - viewModel.addCoordinate(10.0, 20.0) - - val clusterItems = viewModel.state.value.clusterItems - assertTrue(clusterItems.any { it.snippet == "(10.0, 20.0)" }) - } - - @Test - fun `addCoordinates adds polygon options to cluster items`() { - val coordinates = listOf(Pair(10.0, 20.0), Pair(10.0, 30.0), Pair(20.0, 20.0)) - - viewModel.addCoordinates(coordinates) - - val clusterItems = viewModel.state.value.clusterItems - assertTrue(clusterItems.any { it.title == "Central Point" }) - } - - @Test - fun `addMarker adds single marker to markers list`() { - val coordinate = Pair(10.0, 20.0) - viewModel.addMarker(coordinate) - - viewModel.state.value.markers?.let { assertTrue(it.contains(coordinate)) } - } - - @Test - fun `clearCoordinates clears markers and cluster items`() { - viewModel.addMarker(Pair(10.0, 20.0)) - viewModel.addCoordinate(10.0, 20.0) - - viewModel.clearCoordinates() - - viewModel.state.value.markers?.let { assertTrue(it.isEmpty()) } - assertTrue(viewModel.state.value.clusterItems.isEmpty()) - } - - @Test - fun `removeLastCoordinate removes the last marker`() { - viewModel.addMarker(Pair(10.0, 20.0)) - viewModel.addMarker(Pair(15.0, 25.0)) - - viewModel.removeLastCoordinate() - - viewModel.state.value.markers?.let { assertFalse(it.contains(Pair(15.0, 25.0))) } - } - - @Test - fun `onMapTypeChange changes map type`() { - viewModel.onMapTypeChange(MapType.SATELLITE) - - assertEquals(MapType.SATELLITE, viewModel.state.value.mapType) - } -} diff --git a/app/src/test/java/org/technoserve/farmcollector/ui/screens/AddSiteKtTest.kt b/app/src/test/java/org/technoserve/farmcollector/ui/screens/AddSiteKtTest.kt index 325820f..8ae07d2 100644 --- a/app/src/test/java/org/technoserve/farmcollector/ui/screens/AddSiteKtTest.kt +++ b/app/src/test/java/org/technoserve/farmcollector/ui/screens/AddSiteKtTest.kt @@ -1,7 +1,13 @@ package org.technoserve.farmcollector.ui.screens +import android.content.Context +import android.content.res.Configuration import android.os.Build import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.core.app.ApplicationProvider +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkInfo +import androidx.work.WorkManager import org.junit.Assert.* import org.junit.After @@ -15,6 +21,8 @@ import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowBuild +import org.technoserve.farmcollector.database.sync.SyncWorker + data class SiteFormState( val name: String, @@ -26,27 +34,19 @@ data class SiteFormState( ) fun validateForm(formState: SiteFormState): Boolean { - // Extract values from the SiteFormState object - val name = formState.name - val agentName = formState.agentName - val phoneNumber = formState.phoneNumber - val email = formState.email - val village = formState.village - val district = formState.district - - // Validate each field as before - val isNameValid = name.isNotBlank() - val isAgentNameValid = agentName.isNotBlank() - val isPhoneNumberValid = phoneNumber.isNotBlank() && isValidPhoneNumber(phoneNumber) - val isEmailValid = email.isNotBlank() && android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches() - val isVillageValid = village.isNotBlank() - val isDistrictValid = district.isNotBlank() - - // Return true if all validations pass + // Validate each field + val isNameValid = formState.name.isNotBlank() + val isAgentNameValid = formState.agentName.isNotBlank() + val isPhoneNumberValid = formState.phoneNumber.isNotBlank() && isValidPhoneNumber(formState.phoneNumber) + val isEmailValid = formState.email.isNotBlank() && android.util.Patterns.EMAIL_ADDRESS.matcher(formState.email).matches() + val isVillageValid = formState.village.isNotBlank() + val isDistrictValid = formState.district.isNotBlank() + + // Return true only if all validations pass return isNameValid && isAgentNameValid && isPhoneNumberValid && isEmailValid && isVillageValid && isDistrictValid } -// Helper function to validate phone number format (example validation) +// Helper function to validate phone number format fun isValidPhoneNumber(phoneNumber: String): Boolean { val phoneRegex = "^\\+?[0-9]{10,15}\$" return phoneNumber.matches(phoneRegex.toRegex()) @@ -56,24 +56,42 @@ fun isValidPhoneNumber(phoneNumber: String): Boolean { @Config(sdk = [29]) class AddSiteKtTest { + private lateinit var context: Context + @Before fun setUp() { - ShadowBuild.setFingerprint("mocked-fingerprint") + // Initialize WorkManager manually for Robolectric + context = ApplicationProvider.getApplicationContext() + val config = androidx.work.Configuration.Builder() + .setMinimumLoggingLevel(android.util.Log.DEBUG) + .build() + WorkManager.initialize(context, config) } @After fun tearDown() { + // Clean up resources if necessary (Robolectric tests are isolated by default) } - @get:Rule - val composeTestRule = createComposeRule() + @Test + fun `test WorkManager initialization`() { + val workManager = WorkManager.getInstance(context) + val request = OneTimeWorkRequest.Builder(SyncWorker::class.java).build() + + // Enqueue the request + workManager.enqueue(request) + + // Retrieve the work info synchronously for validation + val workInfo = workManager.getWorkInfoById(request.id).get() + com.google.common.truth.Truth.assertThat(workInfo.state).isEqualTo(WorkInfo.State.ENQUEUED) + } @Test - fun testValidateForm() { + fun `test validateForm with valid data`() { val formState = SiteFormState( name = "Farm Name", agentName = "Agent Name", - phoneNumber = "123456789", + phoneNumber = "+1234567890", email = "test@example.com", village = "Village Name", district = "District Name" @@ -81,16 +99,16 @@ class AddSiteKtTest { val result = validateForm(formState) - // Assert that the form is valid - assertTrue(result) + // Assert the form is valid + com.google.common.truth.Truth.assertThat(result).isTrue() } @Test - fun testInvalidPhoneNumber() { + fun `test validateForm with invalid phone number`() { val formState = SiteFormState( name = "Farm Name", agentName = "Agent Name", - phoneNumber = "12345", // Invalid phone number + phoneNumber = "12345", // Invalid phone number email = "test@example.com", village = "Village Name", district = "District Name" @@ -98,7 +116,40 @@ class AddSiteKtTest { val result = validateForm(formState) - // Assert that the form is invalid - assertFalse(result) + // Assert the form is invalid + com.google.common.truth.Truth.assertThat(result).isFalse() + } + + @Test + fun `test validateForm with empty fields`() { + val formState = SiteFormState( + name = "", + agentName = "", + phoneNumber = "", + email = "", + village = "", + district = "" + ) + + val result = validateForm(formState) + + // Assert the form is invalid + com.google.common.truth.Truth.assertThat(result).isFalse() + } + + @Test + fun `test WorkManager scheduling`() { + val workManager = WorkManager.getInstance(context) + val request = OneTimeWorkRequest.Builder(SyncWorker::class.java).build() + + // Enqueue the request + workManager.enqueue(request) + + // Allow some time for the WorkManager to process + Thread.sleep(100) // Simulate scheduler delay + + // Retrieve the work info synchronously for validation + val workInfo = workManager.getWorkInfoById(request.id).get() + com.google.common.truth.Truth.assertThat(workInfo.state).isEqualTo(WorkInfo.State.ENQUEUED) } } diff --git a/build.gradle b/build.gradle index 24cbb1d..054afce 100644 --- a/build.gradle +++ b/build.gradle @@ -10,11 +10,17 @@ buildscript { ext { compose_version = "1.5.14" } +// dependencies { +// testImplementation(kotlin("test")) +// } +// +// tasks.test { +// useJUnitPlatform() +// } } // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { id 'com.android.application' version '8.6.1' apply false id 'com.android.library' version '8.0.2' apply false id 'org.jetbrains.kotlin.android' version '1.9.24' apply false - } \ No newline at end of file From ebd565bd6489434e477b94c503b3f490d05be8de Mon Sep 17 00:00:00 2001 From: lucienshema Date: Wed, 20 Nov 2024 14:31:34 +0200 Subject: [PATCH 2/7] added new units tests and integration tests --- .idea/androidTestResultsUserPreferences.xml | 41 +++++++ .idea/deploymentTargetSelector.xml | 9 ++ app/build.gradle | 41 +++---- .../ui/composes/AreaDialogKtTest.kt | 104 ++++++++++++++++++ .../ui/composes/ConfirmDialogKtTest.kt | 98 +++++++++++++++++ .../ui/screens/BottomBarKtTest.kt | 34 ++++++ .../farmcollector/utils/LanguageUIKtTest.kt | 99 +++++++++++++++++ .../{ => database}/ValidatorTest.kt | 2 +- .../ui/composes/AreaDialogKtTest.kt | 60 ++++++++++ .../ui/composes/ConfirmDialogKtTest.kt | 36 ++++++ .../farmcollector/utils/GeoCalculatorTest.kt | 68 ++++++++++++ .../farmcollector/utils/UpdateLocaleKtTest.kt | 54 +++++++++ 12 files changed, 617 insertions(+), 29 deletions(-) create mode 100644 app/src/androidTest/java/org/technoserve/farmcollector/ui/composes/AreaDialogKtTest.kt create mode 100644 app/src/androidTest/java/org/technoserve/farmcollector/ui/composes/ConfirmDialogKtTest.kt create mode 100644 app/src/androidTest/java/org/technoserve/farmcollector/ui/screens/BottomBarKtTest.kt create mode 100644 app/src/androidTest/java/org/technoserve/farmcollector/utils/LanguageUIKtTest.kt rename app/src/test/java/org/technoserve/farmcollector/{ => database}/ValidatorTest.kt (98%) create mode 100644 app/src/test/java/org/technoserve/farmcollector/ui/composes/AreaDialogKtTest.kt create mode 100644 app/src/test/java/org/technoserve/farmcollector/ui/composes/ConfirmDialogKtTest.kt create mode 100644 app/src/test/java/org/technoserve/farmcollector/utils/GeoCalculatorTest.kt create mode 100644 app/src/test/java/org/technoserve/farmcollector/utils/UpdateLocaleKtTest.kt diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml index 5862b67..b6f2252 100644 --- a/.idea/androidTestResultsUserPreferences.xml +++ b/.idea/androidTestResultsUserPreferences.xml @@ -10,6 +10,7 @@ + @@ -43,6 +44,20 @@ + + + + + + + @@ -70,6 +85,19 @@ + + + + + + + @@ -96,6 +124,19 @@ + + + + + + + diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 24d20cc..ed4f49a 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -13,6 +13,15 @@ + + + + + + \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index cb04544..0ff364a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -139,7 +139,6 @@ dependencies { implementation 'androidx.test:monitor:1.7.2' implementation 'androidx.test.ext:junit-ktx:1.2.1' implementation 'androidx.compose.ui:ui-test-junit4-android:1.7.5' - implementation 'androidx.navigation:navigation-testing:2.4.0-alpha04' testImplementation 'junit:junit:4.13.2' // Paging @@ -202,49 +201,35 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:2.0.20-RC" + + // Testing Dependencies + implementation 'androidx.navigation:navigation-testing:2.4.0-alpha04' + implementation 'androidx.test.ext:junit-ktx:1.2.1' + testImplementation "androidx.test:core:1.6.1" testImplementation "org.mockito:mockito-core:5.7.0" testImplementation "org.mockito.kotlin:mockito-kotlin:5.3.1" testImplementation "io.mockk:mockk:1.13.7" testImplementation "org.robolectric:robolectric:4.10.3" - -// implementation 'androidx.navigation:navigation-testing:2.8.3' - testImplementation 'com.google.dagger:hilt-android-testing:2.48' - - implementation 'androidx.test.ext:junit-ktx:1.2.1' testImplementation 'androidx.compose.ui:ui-test-junit4-android:1.7.5' testImplementation 'androidx.arch.core:core-testing:2.2.0' - - // Testing WorkManager - androidTestImplementation "androidx.work:work-testing:2.7.1" - -// For Making Assertions in Test Cases - androidTestImplementation "com.google.truth:truth:1.1.3" - testImplementation "com.google.truth:truth:1.1.3" - - - - androidTestImplementation "androidx.work:work-testing:2.7.1" - - // Needed for createComposeRule(), but not for createAndroidComposeRule() - debugImplementation("androidx.compose.ui:ui-test-manifest:1.7.5") - testImplementation "org.robolectric:robolectric:4.10.3" - testImplementation "io.mockk:mockk:1.13.7" testImplementation "io.mockk:mockk-android:1.13.5" - - androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.7.5" - - androidTestImplementation "androidx.arch.core:core-testing:2.2.0" - testImplementation "org.robolectric:robolectric:4.9" + androidTestImplementation "androidx.work:work-testing:2.7.1" + androidTestImplementation "com.google.truth:truth:1.1.3" + androidTestImplementation "androidx.work:work-testing:2.7.1" + androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.7.5" + androidTestImplementation "androidx.arch.core:core-testing:2.2.0" + androidTestImplementation "io.mockk:mockk-android:1.13.5" + androidTestImplementation 'com.google.dagger:hilt-android-testing:2.48' - + debugImplementation("androidx.compose.ui:ui-test-manifest:1.7.5") } diff --git a/app/src/androidTest/java/org/technoserve/farmcollector/ui/composes/AreaDialogKtTest.kt b/app/src/androidTest/java/org/technoserve/farmcollector/ui/composes/AreaDialogKtTest.kt new file mode 100644 index 0000000..52021fe --- /dev/null +++ b/app/src/androidTest/java/org/technoserve/farmcollector/ui/composes/AreaDialogKtTest.kt @@ -0,0 +1,104 @@ +package org.technoserve.farmcollector.ui.composes + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class AreaDialogTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val calculatedArea = 123.456789 + private val enteredArea = 100.0 + private val threshold = enteredArea * 0.30 + + @Test + fun dialogDisplaysCorrectTextAndButtons() { + composeTestRule.setContent { + AreaDialog( + showDialog = true, + onDismiss = {}, + onConfirm = {}, + calculatedArea = calculatedArea, + enteredArea = enteredArea + ) + } + + // Check dialog title + composeTestRule.onNodeWithText("Choose Area") // Replace with localized string if using resources + .assertExists() + + // Check warning message visibility + val difference = Math.abs(calculatedArea - enteredArea) + if (difference > threshold) { + composeTestRule.onNodeWithText("Warning: Difference is $difference") // Replace with localized string + .assertExists() + } else { + composeTestRule.onNodeWithText("Warning: Difference is $difference") // Replace with localized string + .assertDoesNotExist() + } + + // Verify buttons + composeTestRule.onNodeWithText("Cancel") // Replace with localized string + .assertExists() + + composeTestRule.onNodeWithText("Calculated Area: 123.456789") // Replace with localized string + .assertExists() + + composeTestRule.onNodeWithText("Entered Area: 100.00") // Replace with localized string + .assertExists() + } + + @Test + fun confirmButtonCallsOnConfirmWithCalculatedAreaOption() { + var confirmedOption: String? = null + + composeTestRule.setContent { + AreaDialog( + showDialog = true, + onDismiss = {}, + onConfirm = { confirmedOption = it }, + calculatedArea = calculatedArea, + enteredArea = enteredArea + ) + } + + // Click the calculated area button + composeTestRule.onNodeWithText("Calculated Area: 123.456789") // Replace with localized string + .performClick() + + // Verify the onConfirm callback was called with the correct option + assertEquals(CALCULATED_AREA_OPTION, confirmedOption) + } + + @Test + fun dismissButtonCallsOnDismiss() { + var dismissed = false + + composeTestRule.setContent { + AreaDialog( + showDialog = true, + onDismiss = { dismissed = true }, + onConfirm = {}, + calculatedArea = calculatedArea, + enteredArea = enteredArea + ) + } + + // Click the dismiss button + composeTestRule.onNodeWithText("Cancel") // Replace with localized string + .performClick() + + // Verify the onDismiss callback was called + assertTrue(dismissed) + } +} diff --git a/app/src/androidTest/java/org/technoserve/farmcollector/ui/composes/ConfirmDialogKtTest.kt b/app/src/androidTest/java/org/technoserve/farmcollector/ui/composes/ConfirmDialogKtTest.kt new file mode 100644 index 0000000..2cbba40 --- /dev/null +++ b/app/src/androidTest/java/org/technoserve/farmcollector/ui/composes/ConfirmDialogKtTest.kt @@ -0,0 +1,98 @@ +package org.technoserve.farmcollector.ui.composes + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class ConfirmDialogKtTest{ + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun confirmDialog_showsTitleAndMessage_whenDialogVisible() { + val showDialog = mutableStateOf(true) + val title = "Confirm Action" + val message = "Are you sure you want to proceed?" + + composeTestRule.setContent { + ConfirmDialog( + title = title, + message = message, + showDialog = showDialog, + onProceedFn = {}, + onCancelFn = {} + ) + } + + // Verify the title and message are displayed + composeTestRule.onNodeWithText(title).assertExists() + composeTestRule.onNodeWithText(message).assertExists() + } + + @Test + fun confirmDialog_callsOnProceedFn_whenYesButtonClicked() { + val showDialog = mutableStateOf(true) + var onProceedCalled = false + + composeTestRule.setContent { + ConfirmDialog( + title = "Confirm Action", + message = "Are you sure?", + showDialog = showDialog, + onProceedFn = { onProceedCalled = true }, + onCancelFn = {} + ) + } + + // Click the "Yes" button + composeTestRule.onNodeWithText("Yes").performClick() + + // Verify the onProceed function is called + assert(onProceedCalled) + } + + @Test + fun confirmDialog_callsOnCancelFn_whenNoButtonClicked() { + val showDialog = mutableStateOf(true) + var onCancelCalled = false + + composeTestRule.setContent { + ConfirmDialog( + title = "Confirm Action", + message = "Are you sure?", + showDialog = showDialog, + onProceedFn = {}, + onCancelFn = { onCancelCalled = true } + ) + } + + // Click the "No" button + composeTestRule.onNodeWithText("No").performClick() + + // Verify the onCancel function is called + assert(onCancelCalled) + } + + @Test + fun confirmDialog_doesNotShow_whenShowDialogIsFalse() { + val showDialog = mutableStateOf(false) + + composeTestRule.setContent { + ConfirmDialog( + title = "Confirm Action", + message = "Are you sure?", + showDialog = showDialog, + onProceedFn = {}, + onCancelFn = {} + ) + } + + // Verify that the dialog content does not exist + composeTestRule.onNodeWithText("Confirm Action").assertDoesNotExist() + composeTestRule.onNodeWithText("Are you sure?").assertDoesNotExist() + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/technoserve/farmcollector/ui/screens/BottomBarKtTest.kt b/app/src/androidTest/java/org/technoserve/farmcollector/ui/screens/BottomBarKtTest.kt new file mode 100644 index 0000000..c3a76c0 --- /dev/null +++ b/app/src/androidTest/java/org/technoserve/farmcollector/ui/screens/BottomBarKtTest.kt @@ -0,0 +1,34 @@ +package org.technoserve.farmcollector.ui.screens + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.navigation.NavController +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import io.mockk.mockk +import io.mockk.verify + + +class BottomBarKtTest{ + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun bottomSidebar_clickSettingsIcon_navigatesToSettings() { + // Mock NavController + val mockNavController = mockk(relaxed = true) + + composeTestRule.setContent { + BottomSidebar(navController = mockNavController) + } + + // Find the settings icon by its content description and click it + composeTestRule.onNodeWithContentDescription("Settings").performClick() + + // Verify that the NavController navigates to "settings" + verify { mockNavController.navigate("settings") } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/technoserve/farmcollector/utils/LanguageUIKtTest.kt b/app/src/androidTest/java/org/technoserve/farmcollector/utils/LanguageUIKtTest.kt new file mode 100644 index 0000000..4fe1b36 --- /dev/null +++ b/app/src/androidTest/java/org/technoserve/farmcollector/utils/LanguageUIKtTest.kt @@ -0,0 +1,99 @@ +package org.technoserve.farmcollector.utils + +import android.annotation.SuppressLint +import android.content.Context +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.lifecycle.ViewModel +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito.verify + + +// Mock Language class +data class Language(val displayName: String) + +// Mock ViewModel +class LanguageViewModel : ViewModel() { + private val _currentLanguage = MutableStateFlow(Language("English")) + val currentLanguage: StateFlow get() = _currentLanguage + + fun selectLanguage(language: Language, context: Context) { + _currentLanguage.value = language + } +} + + +class LanguageUIKtTest{ + @get:Rule + val composeTestRule = createComposeRule() + + private val mockContext = mockk(relaxed = true) + + @Test + fun languageSelector_displaysCurrentLanguage() { + val mockViewModel = mockk(relaxed = true) + val languages = listOf(Language("English"), Language("French")) + val currentLanguage = MutableStateFlow(languages[0]) // English + + every { mockViewModel.currentLanguage } returns currentLanguage + + composeTestRule.setContent { + LanguageSelector(viewModel = mockViewModel, languages = languages) + } + + // Assert the current language is displayed + composeTestRule.onNodeWithText("English").assertExists() + } + + @Test + fun languageSelector_opensDropdownOnClick() { + val mockViewModel = mockk(relaxed = true) + val languages = listOf(Language("English"), Language("French")) + val currentLanguage = MutableStateFlow(languages[0]) // English + + every { mockViewModel.currentLanguage } returns currentLanguage + + composeTestRule.setContent { + LanguageSelector(viewModel = mockViewModel, languages = languages) + } + + // Click on the row to expand the dropdown menu + composeTestRule.onNodeWithText("English").performClick() + + // Assert that the dropdown is expanded and displays the available languages + composeTestRule.onNodeWithText("French").assertExists() + } + + @SuppressLint("CheckResult") + @Test + fun languageSelector_selectsLanguageOnClick() { + val mockViewModel = mockk(relaxed = true) + val languages = listOf(Language("English"), Language("French")) + val currentLanguage = MutableStateFlow(languages[0]) // English + + every { mockViewModel.currentLanguage } returns currentLanguage + every { mockViewModel.selectLanguage(any(), any()) } just Runs + + composeTestRule.setContent { + LanguageSelector(viewModel = mockViewModel, languages = languages) + } + + // Expand the dropdown + composeTestRule.onNodeWithText("English").performClick() + + // Select the "French" option + composeTestRule.onNodeWithText("French").performClick() + + // Verify that selectLanguage was called with the correct language + verify { mockViewModel.selectLanguage(Language("French"), mockContext) } + } +} \ No newline at end of file diff --git a/app/src/test/java/org/technoserve/farmcollector/ValidatorTest.kt b/app/src/test/java/org/technoserve/farmcollector/database/ValidatorTest.kt similarity index 98% rename from app/src/test/java/org/technoserve/farmcollector/ValidatorTest.kt rename to app/src/test/java/org/technoserve/farmcollector/database/ValidatorTest.kt index af519f5..ab5c464 100644 --- a/app/src/test/java/org/technoserve/farmcollector/ValidatorTest.kt +++ b/app/src/test/java/org/technoserve/farmcollector/database/ValidatorTest.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector +package org.technoserve.farmcollector.database import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue diff --git a/app/src/test/java/org/technoserve/farmcollector/ui/composes/AreaDialogKtTest.kt b/app/src/test/java/org/technoserve/farmcollector/ui/composes/AreaDialogKtTest.kt new file mode 100644 index 0000000..b779eb7 --- /dev/null +++ b/app/src/test/java/org/technoserve/farmcollector/ui/composes/AreaDialogKtTest.kt @@ -0,0 +1,60 @@ +package org.technoserve.farmcollector.ui.composes + +import org.junit.Assert.* +import org.junit.Test + + +fun calculateThreshold(enteredArea: Double): Double { + return enteredArea * 0.30 +} + +fun shouldShowWarning(calculatedArea: Double, enteredArea: Double): Boolean { + val threshold = calculateThreshold(enteredArea) + return Math.abs(calculatedArea - enteredArea) > threshold +} + +fun formatArea(area: Double, decimalPlaces: Int): String { + return String.format("%.${decimalPlaces}f", area) +} + + +class AreaDialogKtTest{ + @Test + fun `calculateThreshold returns 30 percent of entered area`() { + val enteredArea = 100.0 + val expectedThreshold = 30.0 + + val result = calculateThreshold(enteredArea) + + assertEquals(expectedThreshold, result, 0.0) + } + + @Test + fun `shouldShowWarning returns true when difference exceeds threshold`() { + val calculatedArea = 150.0 + val enteredArea = 100.0 + + val result = shouldShowWarning(calculatedArea, enteredArea) + + assertTrue(result) + } + + @Test + fun `shouldShowWarning returns false when difference is within threshold`() { + val calculatedArea = 120.0 + val enteredArea = 100.0 + + val result = shouldShowWarning(calculatedArea, enteredArea) + + assertFalse(result) + } + + @Test + fun `formatArea formats area to specified decimal places`() { + val area = 123.456789 + + val result = formatArea(area, 2) + + assertEquals("123.46", result) + } +} \ No newline at end of file diff --git a/app/src/test/java/org/technoserve/farmcollector/ui/composes/ConfirmDialogKtTest.kt b/app/src/test/java/org/technoserve/farmcollector/ui/composes/ConfirmDialogKtTest.kt new file mode 100644 index 0000000..7196290 --- /dev/null +++ b/app/src/test/java/org/technoserve/farmcollector/ui/composes/ConfirmDialogKtTest.kt @@ -0,0 +1,36 @@ +package org.technoserve.farmcollector.ui.composes + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import org.junit.Test + + +fun toggleDialog(showDialog: MutableState) { + showDialog.value = false +} + +fun executeCallback(callback: () -> Unit) { + callback() +} + +class ConfirmDialogKtTest { + @Test + fun `toggleDialog sets showDialog to false`() { + val showDialog = mutableStateOf(true) + + toggleDialog(showDialog) + + assertFalse(showDialog.value) + } + + @Test + fun `executeCallback triggers the provided callback`() { + var callbackCalled = false + + executeCallback { callbackCalled = true } + + assertTrue(callbackCalled) + } +} \ No newline at end of file diff --git a/app/src/test/java/org/technoserve/farmcollector/utils/GeoCalculatorTest.kt b/app/src/test/java/org/technoserve/farmcollector/utils/GeoCalculatorTest.kt new file mode 100644 index 0000000..aae32cf --- /dev/null +++ b/app/src/test/java/org/technoserve/farmcollector/utils/GeoCalculatorTest.kt @@ -0,0 +1,68 @@ +package org.technoserve.farmcollector.utils + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.SphericalUtil +import org.junit.Assert.* +import org.junit.Test + +class GeoCalculatorTest{ + + @Test + fun `calculateArea returns 0_0 for null polygon`() { + val result = GeoCalculator.calculateArea(null) + assertEquals(0.0, result, 0.0) + } + + @Test + fun `calculateArea returns 0_0 for polygon with less than 3 points`() { + val polygon = listOf( + Pair(0.0, 0.0), + Pair(1.0, 1.0) + ) + val result = GeoCalculator.calculateArea(polygon) + assertEquals(0.0, result, 0.0) + } + + @Test + fun `calculateArea calculates correct area for a valid polygon`() { + // Define a simple square polygon (coordinates in latitude and longitude) + val polygon = listOf( + Pair(0.0, 0.0), + Pair(0.0, 1.0), + Pair(1.0, 1.0), + Pair(1.0, 0.0), + Pair(0.0, 0.0) // Closing the polygon + ) + + // Mock the area calculation (optional if you use SphericalUtil directly) + val expectedAreaInSquareMeters = SphericalUtil.computeArea( + polygon.map { LatLng(it.first, it.second) } + ) + val expectedAreaInHectares = expectedAreaInSquareMeters / 10000.0 + + // Call the function + val result = GeoCalculator.calculateArea(polygon) + + // Verify the result + assertEquals(String.format("%.9f", expectedAreaInHectares).toDouble(), result, 1e-9) + } + + @Test + fun `calculateArea formats result to 9 decimal places`() { + // Define a simple polygon + val polygon = listOf( + Pair(0.0, 0.0), + Pair(0.0, 2.0), + Pair(2.0, 2.0), + Pair(2.0, 0.0), + Pair(0.0, 0.0) // Closing the polygon + ) + + // Call the function + val result = GeoCalculator.calculateArea(polygon) + + // Verify that the result is formatted to 9 decimal places + val formattedResult = String.format("%.9f", result) + assertEquals(9, formattedResult.split(".")[1].length) + } +} \ No newline at end of file diff --git a/app/src/test/java/org/technoserve/farmcollector/utils/UpdateLocaleKtTest.kt b/app/src/test/java/org/technoserve/farmcollector/utils/UpdateLocaleKtTest.kt new file mode 100644 index 0000000..b4665d0 --- /dev/null +++ b/app/src/test/java/org/technoserve/farmcollector/utils/UpdateLocaleKtTest.kt @@ -0,0 +1,54 @@ +package org.technoserve.farmcollector.utils + +import android.content.Context +import android.content.res.Configuration +import android.content.res.Resources +import androidx.core.text.layoutDirection +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNotNull +import org.junit.Test +import org.mockito.Mockito.* +import java.util.Locale + + +class UpdateLocaleKtTest{ + @Test + fun `updateLocale updates the locale and layout direction`() { + // Mock Context and Resources + val mockContext = mock(Context::class.java) + val mockResources = mock(Resources::class.java) + val mockConfiguration = Configuration() + + `when`(mockContext.resources).thenReturn(mockResources) + `when`(mockResources.configuration).thenReturn(mockConfiguration) + `when`(mockResources.displayMetrics).thenReturn(mock(Resources::class.java).displayMetrics) + + // Call the function with a specific locale + val locale = Locale("fr", "FR") // French locale + updateLocale(mockContext, locale) + + // Verify that the locale and layout direction were set correctly + assertEquals(locale, mockConfiguration.locales[0]) + assertEquals(locale, Locale.getDefault()) + assertEquals(locale.layoutDirection, mockConfiguration.layoutDirection) + } + + @Test + fun `updateLocale does not crash with null locale`() { + // Mock Context and Resources + val mockContext = mock(Context::class.java) + val mockResources = mock(Resources::class.java) + val mockConfiguration = Configuration() + + `when`(mockContext.resources).thenReturn(mockResources) + `when`(mockResources.configuration).thenReturn(mockConfiguration) + `when`(mockResources.displayMetrics).thenReturn(mock(Resources::class.java).displayMetrics) + + // Call the function with a null locale + updateLocale(mockContext, Locale.getDefault()) + + // Verify the configuration and default locale remain unchanged + assertNotNull(Locale.getDefault()) + assertNotNull(mockConfiguration) + } +} \ No newline at end of file From 582a0eec72130a453f2276f47041761ca286e30a Mon Sep 17 00:00:00 2001 From: lucienshema Date: Thu, 21 Nov 2024 16:48:21 +0200 Subject: [PATCH 3/7] started implementing clean architecture --- .idea/androidTestResultsUserPreferences.xml | 2 + .idea/deploymentTargetSelector.xml | 14 +- .idea/misc.xml | 1 - .../ui/screens/BottomBarKtTest.kt | 2 +- .../assets/migrations/migration_12_16.sql | 44 +++ .../assets/migrations/migration_15_16.sql | 44 +++ .../assets/migrations/migration_16_17.sql | 46 +++ .../assets/migrations/migration_17_18.sql | 51 +++ .../assets/migrations/migration_18_19.sql | 46 +++ .../assets/migrations/migration_19_20.sql | 54 +++ .../farmcollector/FarmCollectorApp.kt | 5 +- .../technoserve/farmcollector/MainActivity.kt | 31 +- .../farmcollector/database/AppDatabase.kt | 323 ++---------------- .../database/CalculatorExample.kt | 12 - .../farmcollector/database/Farm.kt | 211 +----------- .../farmcollector/database/MyPagingSource.kt | 3 +- .../database/dao/CollectionSiteDAO.kt | 46 +++ .../database/{ => dao}/FarmDAO.kt | 4 +- .../database/helpers/ContextProvider.kt | 13 + .../database/helpers/MigrationHelper.kt | 16 + .../database/models/CollectionSite.kt | 34 ++ .../database/models/CollectionSiteDto.kt | 12 + .../database/models/DeviceFarmDto.kt | 7 + .../farmcollector/database/models/Farm.kt | 153 +++++++++ .../database/models/FarmDetailDto.kt | 14 + .../farmcollector/database/sync/SyncWorker.kt | 3 +- .../database/{ => sync}/remote/ApiService.kt | 4 +- .../FarmRepository.kt | 5 +- .../ui/components/CustomPaginationControls.kt | 49 +++ .../farmcollector/ui/components/FarmCard.kt | 139 ++++++++ .../ui/components/KeepPolygonDialog.kt | 75 ++++ .../farmcollector/ui/components/SiteCard.kt | 177 ++++++++++ .../ui/components/SkeletonSiteCard.kt | 118 +++++++ .../ui/composes/UpdateCollectionDialog.kt | 5 +- .../farmcollector/ui/screens/Greeting.kt | 10 - .../screens/{ => collectionsites}/AddSite.kt | 10 +- .../CollectionSiteList.kt | 301 +--------------- .../ui/screens/{ => farms}/AddFarm.kt | 9 +- .../ui/screens/{ => farms}/FarmList.kt | 203 +---------- .../ui/screens/{ => farms}/SetPolygon.kt | 2 +- .../ui/screens/{ => home}/Home.kt | 2 +- .../ui/screens/{ => settings}/BottomBar.kt | 2 +- .../{ => settings}/ScreenWithBottomBar.kt | 2 +- .../ui/screens/{ => settings}/Settings.kt | 2 +- .../{database/sync => utils}/DeviceUtil.kt | 2 +- .../AppUpdateViewModel.kt | 10 +- .../{database => viewmodels}/FarmViewModel.kt | 14 +- .../org/technoserve/farmcollector/HomeTest.kt | 2 +- .../farmcollector/MainActivityTest.kt | 13 - .../database/FarmRepositoryTest.kt | 5 +- .../database/FarmViewModelTest.kt | 2 + 51 files changed, 1276 insertions(+), 1078 deletions(-) create mode 100644 app/src/main/assets/migrations/migration_12_16.sql create mode 100644 app/src/main/assets/migrations/migration_15_16.sql create mode 100644 app/src/main/assets/migrations/migration_16_17.sql create mode 100644 app/src/main/assets/migrations/migration_17_18.sql create mode 100644 app/src/main/assets/migrations/migration_18_19.sql create mode 100644 app/src/main/assets/migrations/migration_19_20.sql delete mode 100644 app/src/main/java/org/technoserve/farmcollector/database/CalculatorExample.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/database/dao/CollectionSiteDAO.kt rename app/src/main/java/org/technoserve/farmcollector/database/{ => dao}/FarmDAO.kt (96%) create mode 100644 app/src/main/java/org/technoserve/farmcollector/database/helpers/ContextProvider.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/database/helpers/MigrationHelper.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/database/models/CollectionSite.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/database/models/CollectionSiteDto.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/database/models/DeviceFarmDto.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/database/models/Farm.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/database/models/FarmDetailDto.kt rename app/src/main/java/org/technoserve/farmcollector/database/{ => sync}/remote/ApiService.kt (84%) rename app/src/main/java/org/technoserve/farmcollector/{database => repositories}/FarmRepository.kt (95%) create mode 100644 app/src/main/java/org/technoserve/farmcollector/ui/components/CustomPaginationControls.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/ui/components/FarmCard.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/ui/components/KeepPolygonDialog.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/ui/components/SiteCard.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/ui/components/SkeletonSiteCard.kt delete mode 100644 app/src/main/java/org/technoserve/farmcollector/ui/screens/Greeting.kt rename app/src/main/java/org/technoserve/farmcollector/ui/screens/{ => collectionsites}/AddSite.kt (97%) rename app/src/main/java/org/technoserve/farmcollector/ui/screens/{ => collectionsites}/CollectionSiteList.kt (67%) rename app/src/main/java/org/technoserve/farmcollector/ui/screens/{ => farms}/AddFarm.kt (99%) rename app/src/main/java/org/technoserve/farmcollector/ui/screens/{ => farms}/FarmList.kt (93%) rename app/src/main/java/org/technoserve/farmcollector/ui/screens/{ => farms}/SetPolygon.kt (99%) rename app/src/main/java/org/technoserve/farmcollector/ui/screens/{ => home}/Home.kt (99%) rename app/src/main/java/org/technoserve/farmcollector/ui/screens/{ => settings}/BottomBar.kt (96%) rename app/src/main/java/org/technoserve/farmcollector/ui/screens/{ => settings}/ScreenWithBottomBar.kt (90%) rename app/src/main/java/org/technoserve/farmcollector/ui/screens/{ => settings}/Settings.kt (98%) rename app/src/main/java/org/technoserve/farmcollector/{database/sync => utils}/DeviceUtil.kt (96%) rename app/src/main/java/org/technoserve/farmcollector/{database => viewmodels}/AppUpdateViewModel.kt (92%) rename app/src/main/java/org/technoserve/farmcollector/{database => viewmodels}/FarmViewModel.kt (98%) diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml index b6f2252..537f8f3 100644 --- a/.idea/androidTestResultsUserPreferences.xml +++ b/.idea/androidTestResultsUserPreferences.xml @@ -11,6 +11,7 @@ + @@ -38,6 +39,7 @@ + diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index ed4f49a..51980c9 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,10 +4,10 @@ diff --git a/.idea/misc.xml b/.idea/misc.xml index 74dd639..b2c751a 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ - diff --git a/app/src/androidTest/java/org/technoserve/farmcollector/ui/screens/BottomBarKtTest.kt b/app/src/androidTest/java/org/technoserve/farmcollector/ui/screens/BottomBarKtTest.kt index c3a76c0..4aac7db 100644 --- a/app/src/androidTest/java/org/technoserve/farmcollector/ui/screens/BottomBarKtTest.kt +++ b/app/src/androidTest/java/org/technoserve/farmcollector/ui/screens/BottomBarKtTest.kt @@ -4,11 +4,11 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.performClick import androidx.navigation.NavController -import org.junit.Assert.* import org.junit.Rule import org.junit.Test import io.mockk.mockk import io.mockk.verify +import org.technoserve.farmcollector.ui.screens.settings.BottomSidebar class BottomBarKtTest{ diff --git a/app/src/main/assets/migrations/migration_12_16.sql b/app/src/main/assets/migrations/migration_12_16.sql new file mode 100644 index 0000000..219139e --- /dev/null +++ b/app/src/main/assets/migrations/migration_12_16.sql @@ -0,0 +1,44 @@ +-- Step 1: Create a new temporary table with the updated schema +CREATE TABLE new_Farms ( + siteId INTEGER NOT NULL, + remote_id BLOB NOT NULL, + farmerPhoto TEXT NOT NULL, + farmerName TEXT NOT NULL, + memberId TEXT NOT NULL, + village TEXT NOT NULL, + district TEXT NOT NULL, + purchases REAL, + size REAL NOT NULL, + latitude TEXT NOT NULL, + longitude TEXT NOT NULL, + coordinates TEXT, + synced INTEGER NOT NULL DEFAULT 0, + scheduledForSync INTEGER NOT NULL DEFAULT 0, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + needsUpdate INTEGER NOT NULL DEFAULT 0, + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + FOREIGN KEY (siteId) + REFERENCES CollectionSites (siteId) + ON UPDATE NO ACTION + ON DELETE CASCADE +); + +-- Step 2: Copy data from the old table to the new table, setting needsUpdate to 0 +INSERT INTO new_Farms ( + siteId, remote_id, farmerPhoto, farmerName, memberId, + village, district, purchases, size, latitude, longitude, + coordinates, synced, scheduledForSync, createdAt, updatedAt, needsUpdate, id +) +SELECT + siteId, remote_id, farmerPhoto, farmerName, memberId, + village, district, purchases, size, latitude, longitude, + coordinates, synced, scheduledForSync, createdAt, updatedAt, 0 AS needsUpdate, id +FROM Farms; + +-- Step 3: Drop the old table +DROP TABLE Farms; + +-- Step 4: Rename the new table to the original table name +ALTER TABLE new_Farms RENAME TO Farms; + diff --git a/app/src/main/assets/migrations/migration_15_16.sql b/app/src/main/assets/migrations/migration_15_16.sql new file mode 100644 index 0000000..503f745 --- /dev/null +++ b/app/src/main/assets/migrations/migration_15_16.sql @@ -0,0 +1,44 @@ + CREATE TABLE new_Farms ( + siteId INTEGER NOT NULL, + remote_id BLOB NOT NULL, + farmerPhoto TEXT NOT NULL, + farmerName TEXT NOT NULL, + memberId TEXT NOT NULL, + village TEXT NOT NULL, + district TEXT NOT NULL, + purchases REAL, + size REAL NOT NULL, + latitude TEXT NOT NULL, + longitude TEXT NOT NULL, + coordinates TEXT, + synced INTEGER NOT NULL DEFAULT 0, + scheduledForSync INTEGER NOT NULL DEFAULT 0, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + needsUpdate INTEGER NOT NULL DEFAULT 0, + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + FOREIGN KEY (siteId) + REFERENCES CollectionSites (siteId) ON UPDATE NO ACTION + ON DELETE CASCADE + ) + + + // Step 2: Copy data from the old table to the new table, setting needsUpdate to 0 + + INSERT INTO new_Farms ( + siteId, remote_id, farmerPhoto, farmerName, memberId, + village, district, purchases, size, latitude, longitude, + coordinates, synced, scheduledForSync, createdAt, updatedAt, needsUpdate, id + ) + SELECT + siteId, remote_id, farmerPhoto, farmerName, memberId, + village, district, purchases, size, latitude, longitude, + coordinates, synced, scheduledForSync, createdAt, updatedAt,0 AS needsUpdate, id + FROM Farms + + + // Step 3: Drop the old table + DROP TABLE Farms + + // Step 4: Rename the new table to the original table name + "ALTER TABLE new_Farms RENAME TO Farms" diff --git a/app/src/main/assets/migrations/migration_16_17.sql b/app/src/main/assets/migrations/migration_16_17.sql new file mode 100644 index 0000000..344d85f --- /dev/null +++ b/app/src/main/assets/migrations/migration_16_17.sql @@ -0,0 +1,46 @@ + // Step 1: Create a new temporary table with the updated schema + + CREATE TABLE new_Farms ( + siteId INTEGER NOT NULL, + remote_id BLOB NOT NULL, + farmerPhoto TEXT NOT NULL, + farmerName TEXT NOT NULL, + memberId TEXT NOT NULL, + village TEXT NOT NULL, + district TEXT NOT NULL, + purchases REAL, + size REAL NOT NULL, + latitude TEXT NOT NULL, + longitude TEXT NOT NULL, + coordinates TEXT, + synced INTEGER NOT NULL DEFAULT 0, + scheduledForSync INTEGER NOT NULL DEFAULT 0, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + needsUpdate INTEGER NOT NULL DEFAULT 0, + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + FOREIGN KEY (siteId) + REFERENCES CollectionSites (siteId) ON UPDATE NO ACTION + ON DELETE CASCADE + ) + + // Step 2: Copy data from the old table to the new table, setting needsUpdate to 0 + + INSERT INTO new_Farms ( + siteId, remote_id, farmerPhoto, farmerName, memberId, + village, district, purchases, size, latitude, longitude, + coordinates, synced, scheduledForSync, createdAt, updatedAt, needsUpdate, id + ) + SELECT + siteId, remote_id, farmerPhoto, farmerName, memberId, + village, district, purchases, size, latitude, longitude, + coordinates, 0 AS synced, 0 AS scheduledForSync, createdAt, updatedAt,0 AS needsUpdate, id + FROM Farms + + + // Step 3: Drop the old table + DROP TABLE Farms + + // Step 4: Rename the new table to the original table name + ALTER TABLE new_Farms RENAME TO Farms + } diff --git a/app/src/main/assets/migrations/migration_17_18.sql b/app/src/main/assets/migrations/migration_17_18.sql new file mode 100644 index 0000000..2324320 --- /dev/null +++ b/app/src/main/assets/migrations/migration_17_18.sql @@ -0,0 +1,51 @@ + // Step 1: Create a new temporary table with the updated schema + db.execSQL( + """ + CREATE TABLE new_Farms ( + siteId INTEGER NOT NULL, + remote_id BLOB NOT NULL, + farmerPhoto TEXT NOT NULL, + farmerName TEXT NOT NULL, + memberId TEXT NOT NULL, + village TEXT NOT NULL, + district TEXT NOT NULL, + purchases REAL, + size REAL NOT NULL, + latitude TEXT NOT NULL, + longitude TEXT NOT NULL, + coordinates TEXT, + synced INTEGER NOT NULL DEFAULT 0, + scheduledForSync INTEGER NOT NULL DEFAULT 0, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + needsUpdate INTEGER NOT NULL DEFAULT 0, + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + FOREIGN KEY (siteId) + REFERENCES CollectionSites (siteId) ON UPDATE NO ACTION + ON DELETE CASCADE + ) + """.trimIndent() + ) + + // Step 2: Copy data from the old table to the new table, setting needsUpdate to 0 + db.execSQL( + """ + INSERT INTO new_Farms ( + siteId, remote_id, farmerPhoto, farmerName, memberId, + village, district, purchases, size, latitude, longitude, + coordinates, synced, scheduledForSync, createdAt, updatedAt, needsUpdate, id + ) + SELECT + siteId, remote_id, farmerPhoto, farmerName, memberId, + village, district, purchases, size, latitude, longitude, + coordinates, 0 AS synced, 0 AS scheduledForSync, createdAt, updatedAt,0 AS needsUpdate, id + FROM Farms + """.trimIndent() + ) + + // Step 3: Drop the old table + db.execSQL("DROP TABLE Farms") + + // Step 4: Rename the new table to the original table name + db.execSQL("ALTER TABLE new_Farms RENAME TO Farms") + } diff --git a/app/src/main/assets/migrations/migration_18_19.sql b/app/src/main/assets/migrations/migration_18_19.sql new file mode 100644 index 0000000..0fb80b5 --- /dev/null +++ b/app/src/main/assets/migrations/migration_18_19.sql @@ -0,0 +1,46 @@ + db.execSQL( + """ + CREATE TABLE new_Farms ( + siteId INTEGER NOT NULL, + remote_id BLOB NOT NULL, + farmerPhoto TEXT NOT NULL, + farmerName TEXT NOT NULL, + memberId TEXT NOT NULL, + village TEXT NOT NULL, + district TEXT NOT NULL, + purchases REAL, + size REAL NOT NULL, + latitude TEXT NOT NULL, + longitude TEXT NOT NULL, + coordinates TEXT, + synced INTEGER NOT NULL DEFAULT 0, + scheduledForSync INTEGER NOT NULL DEFAULT 0, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + needsUpdate INTEGER NOT NULL DEFAULT 0, + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + FOREIGN KEY (siteId) + REFERENCES CollectionSites (siteId) ON UPDATE NO ACTION + ON DELETE CASCADE + ) + """.trimIndent() + ) + + db.execSQL( + """ + INSERT INTO new_Farms ( + siteId, remote_id, farmerPhoto, farmerName, memberId, + village, district, purchases, size, latitude, longitude, + coordinates, synced, scheduledForSync, createdAt, updatedAt, needsUpdate, id + ) + SELECT + siteId, remote_id, farmerPhoto, farmerName, memberId, + village, district, purchases, size, latitude, longitude, + coordinates, 0 AS synced, 0 AS scheduledForSync, createdAt, updatedAt, 0 AS needsUpdate, id + FROM Farms + """.trimIndent() + ) + + db.execSQL("DROP TABLE Farms") + db.execSQL("ALTER TABLE new_Farms RENAME TO Farms") + } diff --git a/app/src/main/assets/migrations/migration_19_20.sql b/app/src/main/assets/migrations/migration_19_20.sql new file mode 100644 index 0000000..99e73fe --- /dev/null +++ b/app/src/main/assets/migrations/migration_19_20.sql @@ -0,0 +1,54 @@ + // 1. Create a new table `new_Farms` with `accuracyArray` field + db.execSQL( + """ + CREATE TABLE new_Farms ( + siteId INTEGER NOT NULL, + remote_id BLOB NOT NULL, + farmerPhoto TEXT NOT NULL, + farmerName TEXT NOT NULL, + memberId TEXT NOT NULL, + village TEXT NOT NULL, + district TEXT NOT NULL, + purchases REAL, + size REAL NOT NULL, + latitude TEXT NOT NULL, + longitude TEXT NOT NULL, + coordinates TEXT, + accuracyArray TEXT, -- For storing accuracy array (one element for point, multiple for polygon) + synced INTEGER NOT NULL DEFAULT 0, + scheduledForSync INTEGER NOT NULL DEFAULT 0, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + needsUpdate INTEGER NOT NULL DEFAULT 0, + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + FOREIGN KEY (siteId) + REFERENCES CollectionSites (siteId) ON UPDATE NO ACTION + ON DELETE CASCADE + ) + """.trimIndent() + ) + + // 2. Copy existing data from `Farms` to `new_Farms`, initializing `accuracyArray` + db.execSQL( + """ + INSERT INTO new_Farms ( + siteId, remote_id, farmerPhoto, farmerName, memberId, + village, district, purchases, size, latitude, longitude, + coordinates, accuracyArray, synced, scheduledForSync, + createdAt, updatedAt, needsUpdate, id + ) + SELECT + siteId, remote_id, farmerPhoto, farmerName, memberId, + village, district, purchases, size, latitude, longitude, + coordinates, '[]' AS accuracyArray, -- Initialize new field as an empty array + synced, scheduledForSync, createdAt, updatedAt, needsUpdate, id + FROM Farms + """.trimIndent() + ) + + // 3. Drop the old `Farms` table + db.execSQL("DROP TABLE Farms") + + // 4. Rename the `new_Farms` table to `Farms` + db.execSQL("ALTER TABLE new_Farms RENAME TO Farms") + } diff --git a/app/src/main/java/org/technoserve/farmcollector/FarmCollectorApp.kt b/app/src/main/java/org/technoserve/farmcollector/FarmCollectorApp.kt index 4a8415c..ab8ada7 100644 --- a/app/src/main/java/org/technoserve/farmcollector/FarmCollectorApp.kt +++ b/app/src/main/java/org/technoserve/farmcollector/FarmCollectorApp.kt @@ -1,15 +1,17 @@ package org.technoserve.farmcollector import android.app.Application -import androidx.work.Configuration import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager +import org.technoserve.farmcollector.database.helpers.ContextProvider import org.technoserve.farmcollector.database.sync.SyncWorker import java.util.concurrent.TimeUnit + + class FarmCollectorApp : Application() { override fun onCreate() { super.onCreate() @@ -18,6 +20,7 @@ class FarmCollectorApp : Application() { // .build() // WorkManager.initialize(this, config) // android.util.Log.d("WorkManager", "WorkManager initialized successfully") + ContextProvider.initialize(this) initializeWorkManager() } diff --git a/app/src/main/java/org/technoserve/farmcollector/MainActivity.kt b/app/src/main/java/org/technoserve/farmcollector/MainActivity.kt index 370b3ea..73f2c3e 100644 --- a/app/src/main/java/org/technoserve/farmcollector/MainActivity.kt +++ b/app/src/main/java/org/technoserve/farmcollector/MainActivity.kt @@ -5,7 +5,6 @@ import android.annotation.SuppressLint import android.app.Activity import android.app.Application import android.content.Intent -import android.hardware.SensorManager import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity @@ -34,24 +33,22 @@ import androidx.navigation.compose.rememberNavController //import androidx.navigation.navArgument import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState -import org.technoserve.farmcollector.database.AppUpdateViewModel -import org.technoserve.farmcollector.database.ExitConfirmationDialog -import org.technoserve.farmcollector.database.FarmDAO -import org.technoserve.farmcollector.database.FarmRepository -import org.technoserve.farmcollector.database.FarmViewModel -import org.technoserve.farmcollector.database.FarmViewModelFactory -import org.technoserve.farmcollector.database.UpdateAlert +import org.technoserve.farmcollector.viewmodels.AppUpdateViewModel +import org.technoserve.farmcollector.viewmodels.ExitConfirmationDialog +import org.technoserve.farmcollector.viewmodels.FarmViewModel +import org.technoserve.farmcollector.viewmodels.FarmViewModelFactory +import org.technoserve.farmcollector.viewmodels.UpdateAlert import org.technoserve.farmcollector.map.LocationHelper import org.technoserve.farmcollector.map.MapViewModel -import org.technoserve.farmcollector.ui.screens.AddFarm -import org.technoserve.farmcollector.ui.screens.AddSite -import org.technoserve.farmcollector.ui.screens.CollectionSiteList -import org.technoserve.farmcollector.ui.screens.FarmList -import org.technoserve.farmcollector.ui.screens.Home -import org.technoserve.farmcollector.ui.screens.ScreenWithSidebar -import org.technoserve.farmcollector.ui.screens.SetPolygon -import org.technoserve.farmcollector.ui.screens.SettingsScreen -import org.technoserve.farmcollector.ui.screens.UpdateFarmForm +import org.technoserve.farmcollector.ui.screens.farms.AddFarm +import org.technoserve.farmcollector.ui.screens.collectionsites.AddSite +import org.technoserve.farmcollector.ui.screens.collectionsites.CollectionSiteList +import org.technoserve.farmcollector.ui.screens.farms.FarmList +import org.technoserve.farmcollector.ui.screens.home.Home +import org.technoserve.farmcollector.ui.screens.settings.ScreenWithSidebar +import org.technoserve.farmcollector.ui.screens.farms.SetPolygon +import org.technoserve.farmcollector.ui.screens.settings.SettingsScreen +import org.technoserve.farmcollector.ui.screens.farms.UpdateFarmForm import org.technoserve.farmcollector.ui.theme.FarmCollectorTheme import org.technoserve.farmcollector.utils.LanguageViewModel import org.technoserve.farmcollector.utils.LanguageViewModelFactory diff --git a/app/src/main/java/org/technoserve/farmcollector/database/AppDatabase.kt b/app/src/main/java/org/technoserve/farmcollector/database/AppDatabase.kt index ff69348..210f09d 100644 --- a/app/src/main/java/org/technoserve/farmcollector/database/AppDatabase.kt +++ b/app/src/main/java/org/technoserve/farmcollector/database/AppDatabase.kt @@ -10,10 +10,16 @@ import androidx.sqlite.db.SupportSQLiteDatabase import org.technoserve.farmcollector.database.converters.AccuracyListConvert import org.technoserve.farmcollector.database.converters.CoordinateListConvert import org.technoserve.farmcollector.database.converters.DateConverter +import org.technoserve.farmcollector.database.dao.FarmDAO +import org.technoserve.farmcollector.database.helpers.ContextProvider +import org.technoserve.farmcollector.database.helpers.MigrationHelper +import org.technoserve.farmcollector.database.models.CollectionSite +import org.technoserve.farmcollector.database.models.Farm /** * This class is used to create app database and to run migrations from one db version to another */ + @Database(entities = [Farm::class, CollectionSite::class], version = 20, exportSchema = true) @TypeConverters(CoordinateListConvert::class, AccuracyListConvert::class, DateConverter::class) abstract class AppDatabase : RoomDatabase() { @@ -24,333 +30,52 @@ abstract class AppDatabase : RoomDatabase() { private val MIGRATION_12_16 = object : Migration(12, 16) { override fun migrate(db: SupportSQLiteDatabase) { - // Step 1: Create a new temporary table with the updated schema - db.execSQL( - """ - CREATE TABLE new_Farms ( - siteId INTEGER NOT NULL, - remote_id BLOB NOT NULL, - farmerPhoto TEXT NOT NULL, - farmerName TEXT NOT NULL, - memberId TEXT NOT NULL, - village TEXT NOT NULL, - district TEXT NOT NULL, - purchases REAL, - size REAL NOT NULL, - latitude TEXT NOT NULL, - longitude TEXT NOT NULL, - coordinates TEXT, - synced INTEGER NOT NULL DEFAULT 0, - scheduledForSync INTEGER NOT NULL DEFAULT 0, - createdAt INTEGER NOT NULL, - updatedAt INTEGER NOT NULL, - needsUpdate INTEGER NOT NULL DEFAULT 0, - id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - FOREIGN KEY (siteId) - REFERENCES CollectionSites (siteId) ON UPDATE NO ACTION - ON DELETE CASCADE - ) - """.trimIndent() - ) - - // Step 2: Copy data from the old table to the new table, setting needsUpdate to 0 - db.execSQL( - """ - INSERT INTO new_Farms ( - siteId, remote_id, farmerPhoto, farmerName, memberId, - village, district, purchases, size, latitude, longitude, - coordinates, synced, scheduledForSync, createdAt, updatedAt, needsUpdate, id - ) - SELECT - siteId, remote_id, farmerPhoto, farmerName, memberId, - village, district, purchases, size, latitude, longitude, - coordinates, synced, scheduledForSync, createdAt, updatedAt, 0 AS needsUpdate, id - FROM Farms - """.trimIndent() - ) - - // Step 3: Drop the old table - db.execSQL("DROP TABLE Farms") - - // Step 4: Rename the new table to the original table name - db.execSQL("ALTER TABLE new_Farms RENAME TO Farms") + val context = ContextProvider.getContext() + MigrationHelper(context).executeSqlFromFile(db, "migration_12_16.sql") } } + + // Define a migration from version 15 to 16 private val MIGRATION_15_16 = object : Migration(15, 16) { override fun migrate(db: SupportSQLiteDatabase) { - // Step 1: Create a new temporary table with the updated schema - db.execSQL( - """ - CREATE TABLE new_Farms ( - siteId INTEGER NOT NULL, - remote_id BLOB NOT NULL, - farmerPhoto TEXT NOT NULL, - farmerName TEXT NOT NULL, - memberId TEXT NOT NULL, - village TEXT NOT NULL, - district TEXT NOT NULL, - purchases REAL, - size REAL NOT NULL, - latitude TEXT NOT NULL, - longitude TEXT NOT NULL, - coordinates TEXT, - synced INTEGER NOT NULL DEFAULT 0, - scheduledForSync INTEGER NOT NULL DEFAULT 0, - createdAt INTEGER NOT NULL, - updatedAt INTEGER NOT NULL, - needsUpdate INTEGER NOT NULL DEFAULT 0, - id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - FOREIGN KEY (siteId) - REFERENCES CollectionSites (siteId) ON UPDATE NO ACTION - ON DELETE CASCADE - ) - """.trimIndent() - ) - - // Step 2: Copy data from the old table to the new table, setting needsUpdate to 0 - db.execSQL( - """ - INSERT INTO new_Farms ( - siteId, remote_id, farmerPhoto, farmerName, memberId, - village, district, purchases, size, latitude, longitude, - coordinates, synced, scheduledForSync, createdAt, updatedAt, needsUpdate, id - ) - SELECT - siteId, remote_id, farmerPhoto, farmerName, memberId, - village, district, purchases, size, latitude, longitude, - coordinates, synced, scheduledForSync, createdAt, updatedAt,0 AS needsUpdate, id - FROM Farms - """.trimIndent() - ) - - // Step 3: Drop the old table - db.execSQL("DROP TABLE Farms") - - // Step 4: Rename the new table to the original table name - db.execSQL("ALTER TABLE new_Farms RENAME TO Farms") + val context = ContextProvider.getContext() + MigrationHelper(context).executeSqlFromFile(db, "migration_15_16.sql") } } + // Define a migration from version 16 to 17 private val MIGRATION_16_17 = object : Migration(16, 17) { override fun migrate(db: SupportSQLiteDatabase) { - // Step 1: Create a new temporary table with the updated schema - db.execSQL( - """ - CREATE TABLE new_Farms ( - siteId INTEGER NOT NULL, - remote_id BLOB NOT NULL, - farmerPhoto TEXT NOT NULL, - farmerName TEXT NOT NULL, - memberId TEXT NOT NULL, - village TEXT NOT NULL, - district TEXT NOT NULL, - purchases REAL, - size REAL NOT NULL, - latitude TEXT NOT NULL, - longitude TEXT NOT NULL, - coordinates TEXT, - synced INTEGER NOT NULL DEFAULT 0, - scheduledForSync INTEGER NOT NULL DEFAULT 0, - createdAt INTEGER NOT NULL, - updatedAt INTEGER NOT NULL, - needsUpdate INTEGER NOT NULL DEFAULT 0, - id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - FOREIGN KEY (siteId) - REFERENCES CollectionSites (siteId) ON UPDATE NO ACTION - ON DELETE CASCADE - ) - """.trimIndent() - ) - - // Step 2: Copy data from the old table to the new table, setting needsUpdate to 0 - db.execSQL( - """ - INSERT INTO new_Farms ( - siteId, remote_id, farmerPhoto, farmerName, memberId, - village, district, purchases, size, latitude, longitude, - coordinates, synced, scheduledForSync, createdAt, updatedAt, needsUpdate, id - ) - SELECT - siteId, remote_id, farmerPhoto, farmerName, memberId, - village, district, purchases, size, latitude, longitude, - coordinates, 0 AS synced, 0 AS scheduledForSync, createdAt, updatedAt,0 AS needsUpdate, id - FROM Farms - """.trimIndent() - ) - - // Step 3: Drop the old table - db.execSQL("DROP TABLE Farms") - - // Step 4: Rename the new table to the original table name - db.execSQL("ALTER TABLE new_Farms RENAME TO Farms") + val context = ContextProvider.getContext() + MigrationHelper(context).executeSqlFromFile(db, "migration_16_17.sql") } } // Define a migration from version 17 to 18 private val MIGRATION_17_18 = object : Migration(17, 18) { override fun migrate(db: SupportSQLiteDatabase) { - // Step 1: Create a new temporary table with the updated schema - db.execSQL( - """ - CREATE TABLE new_Farms ( - siteId INTEGER NOT NULL, - remote_id BLOB NOT NULL, - farmerPhoto TEXT NOT NULL, - farmerName TEXT NOT NULL, - memberId TEXT NOT NULL, - village TEXT NOT NULL, - district TEXT NOT NULL, - purchases REAL, - size REAL NOT NULL, - latitude TEXT NOT NULL, - longitude TEXT NOT NULL, - coordinates TEXT, - synced INTEGER NOT NULL DEFAULT 0, - scheduledForSync INTEGER NOT NULL DEFAULT 0, - createdAt INTEGER NOT NULL, - updatedAt INTEGER NOT NULL, - needsUpdate INTEGER NOT NULL DEFAULT 0, - id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - FOREIGN KEY (siteId) - REFERENCES CollectionSites (siteId) ON UPDATE NO ACTION - ON DELETE CASCADE - ) - """.trimIndent() - ) - - // Step 2: Copy data from the old table to the new table, setting needsUpdate to 0 - db.execSQL( - """ - INSERT INTO new_Farms ( - siteId, remote_id, farmerPhoto, farmerName, memberId, - village, district, purchases, size, latitude, longitude, - coordinates, synced, scheduledForSync, createdAt, updatedAt, needsUpdate, id - ) - SELECT - siteId, remote_id, farmerPhoto, farmerName, memberId, - village, district, purchases, size, latitude, longitude, - coordinates, 0 AS synced, 0 AS scheduledForSync, createdAt, updatedAt,0 AS needsUpdate, id - FROM Farms - """.trimIndent() - ) - - // Step 3: Drop the old table - db.execSQL("DROP TABLE Farms") - - // Step 4: Rename the new table to the original table name - db.execSQL("ALTER TABLE new_Farms RENAME TO Farms") + val context = ContextProvider.getContext() + MigrationHelper(context).executeSqlFromFile(db, "migration_17_18.sql") } + } private val MIGRATION_18_19 = object : Migration(18, 19) { override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE new_Farms ( - siteId INTEGER NOT NULL, - remote_id BLOB NOT NULL, - farmerPhoto TEXT NOT NULL, - farmerName TEXT NOT NULL, - memberId TEXT NOT NULL, - village TEXT NOT NULL, - district TEXT NOT NULL, - purchases REAL, - size REAL NOT NULL, - latitude TEXT NOT NULL, - longitude TEXT NOT NULL, - coordinates TEXT, - synced INTEGER NOT NULL DEFAULT 0, - scheduledForSync INTEGER NOT NULL DEFAULT 0, - createdAt INTEGER NOT NULL, - updatedAt INTEGER NOT NULL, - needsUpdate INTEGER NOT NULL DEFAULT 0, - id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - FOREIGN KEY (siteId) - REFERENCES CollectionSites (siteId) ON UPDATE NO ACTION - ON DELETE CASCADE - ) - """.trimIndent() - ) - - db.execSQL( - """ - INSERT INTO new_Farms ( - siteId, remote_id, farmerPhoto, farmerName, memberId, - village, district, purchases, size, latitude, longitude, - coordinates, synced, scheduledForSync, createdAt, updatedAt, needsUpdate, id - ) - SELECT - siteId, remote_id, farmerPhoto, farmerName, memberId, - village, district, purchases, size, latitude, longitude, - coordinates, 0 AS synced, 0 AS scheduledForSync, createdAt, updatedAt, 0 AS needsUpdate, id - FROM Farms - """.trimIndent() - ) - - db.execSQL("DROP TABLE Farms") - db.execSQL("ALTER TABLE new_Farms RENAME TO Farms") + val context = ContextProvider.getContext() + MigrationHelper(context).executeSqlFromFile(db, "migration_18_19.sql") } - } + } private val MIGRATION_19_20 = object : Migration(19, 20) { - override fun migrate(db: SupportSQLiteDatabase) { - // 1. Create a new table `new_Farms` with `accuracyArray` field - db.execSQL( - """ - CREATE TABLE new_Farms ( - siteId INTEGER NOT NULL, - remote_id BLOB NOT NULL, - farmerPhoto TEXT NOT NULL, - farmerName TEXT NOT NULL, - memberId TEXT NOT NULL, - village TEXT NOT NULL, - district TEXT NOT NULL, - purchases REAL, - size REAL NOT NULL, - latitude TEXT NOT NULL, - longitude TEXT NOT NULL, - coordinates TEXT, - accuracyArray TEXT, -- For storing accuracy array (one element for point, multiple for polygon) - synced INTEGER NOT NULL DEFAULT 0, - scheduledForSync INTEGER NOT NULL DEFAULT 0, - createdAt INTEGER NOT NULL, - updatedAt INTEGER NOT NULL, - needsUpdate INTEGER NOT NULL DEFAULT 0, - id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - FOREIGN KEY (siteId) - REFERENCES CollectionSites (siteId) ON UPDATE NO ACTION - ON DELETE CASCADE - ) - """.trimIndent() - ) - // 2. Copy existing data from `Farms` to `new_Farms`, initializing `accuracyArray` - db.execSQL( - """ - INSERT INTO new_Farms ( - siteId, remote_id, farmerPhoto, farmerName, memberId, - village, district, purchases, size, latitude, longitude, - coordinates, accuracyArray, synced, scheduledForSync, - createdAt, updatedAt, needsUpdate, id - ) - SELECT - siteId, remote_id, farmerPhoto, farmerName, memberId, - village, district, purchases, size, latitude, longitude, - coordinates, '[]' AS accuracyArray, -- Initialize new field as an empty array - synced, scheduledForSync, createdAt, updatedAt, needsUpdate, id - FROM Farms - """.trimIndent() - ) - - // 3. Drop the old `Farms` table - db.execSQL("DROP TABLE Farms") - - // 4. Rename the `new_Farms` table to `Farms` - db.execSQL("ALTER TABLE new_Farms RENAME TO Farms") + override fun migrate(db: SupportSQLiteDatabase) { + val context = ContextProvider.getContext() + MigrationHelper(context).executeSqlFromFile(db, "migration_19_20.sql") } } diff --git a/app/src/main/java/org/technoserve/farmcollector/database/CalculatorExample.kt b/app/src/main/java/org/technoserve/farmcollector/database/CalculatorExample.kt deleted file mode 100644 index 94b0fb5..0000000 --- a/app/src/main/java/org/technoserve/farmcollector/database/CalculatorExample.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.technoserve.farmcollector.database - - - -object Operators { - fun addTwoInt(m: Int, n: Int): Int = m + n -} - - -class CalculatorExample ( val operators:Operators) { - fun addTwoNumbers(a: Int, b: Int): Int = operators.addTwoInt(a,b) -} \ No newline at end of file diff --git a/app/src/main/java/org/technoserve/farmcollector/database/Farm.kt b/app/src/main/java/org/technoserve/farmcollector/database/Farm.kt index 83ce070..e72cd6f 100644 --- a/app/src/main/java/org/technoserve/farmcollector/database/Farm.kt +++ b/app/src/main/java/org/technoserve/farmcollector/database/Farm.kt @@ -1,190 +1,15 @@ package org.technoserve.farmcollector.database -import android.os.Parcel -import android.os.Parcelable -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.PrimaryKey -import androidx.room.TypeConverters -import kotlinx.parcelize.Parceler -import kotlinx.parcelize.Parcelize -import org.technoserve.farmcollector.database.converters.AccuracyListConvert -import org.technoserve.farmcollector.database.converters.CoordinateListConvert -import org.technoserve.farmcollector.database.converters.DateConverter -import org.technoserve.farmcollector.ui.screens.ParcelablePair -import java.util.UUID +import org.technoserve.farmcollector.database.dao.FarmDAO +import org.technoserve.farmcollector.database.models.CollectionSiteDto +import org.technoserve.farmcollector.database.models.DeviceFarmDto +import org.technoserve.farmcollector.database.models.Farm +import org.technoserve.farmcollector.database.models.FarmDetailDto /** * This file contains the information about the entities in the database including farms and collection sites */ -@Entity( - tableName = "Farms", - foreignKeys = [ - ForeignKey( - entity = CollectionSite::class, - parentColumns = ["siteId"], - childColumns = ["siteId"], - onDelete = ForeignKey.CASCADE, - ), - ], -) -@Parcelize -@TypeConverters(CoordinateListConvert::class, AccuracyListConvert::class) -data class Farm( - @ColumnInfo(name = "siteId") - var siteId: Long, - @ColumnInfo(name = "remote_id") - var remoteId: UUID = UUID.randomUUID(), - @ColumnInfo(name = "farmerPhoto") - var farmerPhoto: String, - @ColumnInfo(name = "farmerName") - var farmerName: String, - @ColumnInfo(name = "memberId") - var memberId: String, - @ColumnInfo(name = "village") - var village: String, - @ColumnInfo(name = "district") - var district: String, - @ColumnInfo(name = "purchases") - var purchases: Float?, - @ColumnInfo(name = "size") - var size: Float, - @ColumnInfo(name = "latitude") - var latitude: String, - @ColumnInfo(name = "longitude") - var longitude: String, - @ColumnInfo(name = "coordinates") - @TypeConverters(CoordinateListConvert::class) - var coordinates: List>?, - @ColumnInfo(name = "accuracyArray") // New field - @TypeConverters(AccuracyListConvert::class) - var accuracyArray: List?, // List to store accuracies - @ColumnInfo(name = "synced", defaultValue = "0") - val synced: Boolean = false, - @ColumnInfo(name = "scheduledForSync", defaultValue = "0") - val scheduledForSync: Boolean = false, - @ColumnInfo(name = "createdAt") - @TypeConverters(DateConverter::class) - val createdAt: Long, - @ColumnInfo(name = "updatedAt") - @TypeConverters(DateConverter::class) - var updatedAt: Long, - @ColumnInfo(name = "needsUpdate", defaultValue = "0") - var needsUpdate: Boolean = false, -) : Parcelable { - @PrimaryKey(autoGenerate = true) - var id: Long = 0L - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as Farm - - return id == other.id - } - - override fun hashCode(): Int = id.hashCode() - - constructor(parcel: Parcel) : this( - parcel.readLong(), - UUID.fromString(parcel.readString()), - parcel.readString()!!, - parcel.readString()!!, - parcel.readString()!!, - parcel.readString()!!, - parcel.readString()!!, - parcel.readValue(Float::class.java.classLoader) as? Float, - parcel.readFloat(), - parcel.readString()!!, - parcel.readString()!!, - parcel.createTypedArrayList(ParcelablePair.CREATOR)?.map { Pair(it.first, it.second) }, - parcel.createFloatArray()?.toList(), // Read accuracyArray as a List - parcel.readByte() != 0.toByte(), - parcel.readByte() != 0.toByte(), - parcel.readLong(), - parcel.readLong(), - parcel.readByte() != 0.toByte() - ) { - id = parcel.readLong() - } - - override fun describeContents(): Int = 0 - - companion object : Parceler { - - override fun Farm.write(parcel: Parcel, flags: Int) { - parcel.writeLong(siteId) - parcel.writeString(remoteId.toString()) - parcel.writeString(farmerPhoto) - parcel.writeString(farmerName) - parcel.writeString(memberId) - parcel.writeString(village) - parcel.writeString(district) - parcel.writeValue(purchases) - parcel.writeFloat(size) - parcel.writeString(latitude) - parcel.writeString(longitude) - parcel.writeTypedList(coordinates?.map { - it.first?.let { it1 -> - it.second?.let { it2 -> - ParcelablePair( - it1, - it2 - ) - } - } - }) - parcel.writeFloatArray( - accuracyArray?.filterNotNull()?.toFloatArray() - ) // Write accuracyArray - parcel.writeByte(if (synced) 1 else 0) - parcel.writeByte(if (scheduledForSync) 1 else 0) - parcel.writeLong(createdAt) - parcel.writeLong(updatedAt) - parcel.writeByte(if (needsUpdate) 1 else 0) - parcel.writeLong(id) - } - - override fun create(parcel: Parcel): Farm { - return Farm(parcel) - } - } -} - - -data class CollectionSiteDto( - val local_cs_id: Long, - val name: String, - val agent_name: String, - val phone_number: String?, - val email: String?, - val village: String?, - val district: String? -) - -data class FarmDetailDto( - val remote_id: String, - val farmer_name: String, - val member_id: String, - val village: String, - val district: String, - val size: Float, - val latitude: Double, - val longitude: Double, - val coordinates: List>?, - val accuracies: List?, -) - -data class DeviceFarmDto( - val device_id: String, - val collection_site: CollectionSiteDto, - val farms: List -) - - fun List.toDeviceFarmDtoList(deviceId: String, farmDao: FarmDAO): List { return this.groupBy { it.siteId } // Group by siteId .mapNotNull { (siteId, farms) -> @@ -233,27 +58,5 @@ fun List.toDeviceFarmDtoList(deviceId: String, farmDao: FarmDAO): List + + @Transaction + @Query("SELECT * FROM CollectionSites ORDER BY createdAt DESC") + fun getSites(): LiveData> + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insertSite(site: CollectionSite): Long + + @Query("SELECT * FROM CollectionSites WHERE siteId = :siteId") + suspend fun getSiteById(siteId: Long): CollectionSite? + + @Update + fun updateSite(site: CollectionSite) + + @Query("SELECT * FROM CollectionSites WHERE siteId = :siteId LIMIT 1") + fun getCollectionSiteById(siteId: Long): CollectionSite? + + @Query("DELETE FROM CollectionSites WHERE siteId IN (:ids)") + fun deleteListSite(ids: List) + + @Query("SELECT * FROM CollectionSites WHERE siteId = :localCsId OR (name = :siteName AND village = :village AND district = :district) LIMIT 1") + suspend fun getSiteByDetails( + localCsId: Long, + siteName: String, + village: String, + district: String + ): CollectionSite? + + + @Query("SELECT * FROM CollectionSites LIMIT :limit OFFSET :offset") + fun getCollectionSites(offset: Int, limit: Int): List +} \ No newline at end of file diff --git a/app/src/main/java/org/technoserve/farmcollector/database/FarmDAO.kt b/app/src/main/java/org/technoserve/farmcollector/database/dao/FarmDAO.kt similarity index 96% rename from app/src/main/java/org/technoserve/farmcollector/database/FarmDAO.kt rename to app/src/main/java/org/technoserve/farmcollector/database/dao/FarmDAO.kt index b4918e4..a1a49a6 100644 --- a/app/src/main/java/org/technoserve/farmcollector/database/FarmDAO.kt +++ b/app/src/main/java/org/technoserve/farmcollector/database/dao/FarmDAO.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.database +package org.technoserve.farmcollector.database.dao import androidx.lifecycle.LiveData import androidx.room.Dao @@ -8,6 +8,8 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import androidx.room.Update +import org.technoserve.farmcollector.database.models.CollectionSite +import org.technoserve.farmcollector.database.models.Farm import java.util.UUID diff --git a/app/src/main/java/org/technoserve/farmcollector/database/helpers/ContextProvider.kt b/app/src/main/java/org/technoserve/farmcollector/database/helpers/ContextProvider.kt new file mode 100644 index 0000000..7605ce0 --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/database/helpers/ContextProvider.kt @@ -0,0 +1,13 @@ +package org.technoserve.farmcollector.database.helpers + +import android.content.Context + +object ContextProvider { + private lateinit var context: Context + + fun initialize(context: Context) { + ContextProvider.context = context.applicationContext + } + + fun getContext(): Context = context +} diff --git a/app/src/main/java/org/technoserve/farmcollector/database/helpers/MigrationHelper.kt b/app/src/main/java/org/technoserve/farmcollector/database/helpers/MigrationHelper.kt new file mode 100644 index 0000000..01d194e --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/database/helpers/MigrationHelper.kt @@ -0,0 +1,16 @@ +package org.technoserve.farmcollector.database.helpers + +import android.content.Context +import androidx.sqlite.db.SupportSQLiteDatabase + +class MigrationHelper(private val context: Context) { + fun executeSqlFromFile(database: SupportSQLiteDatabase, fileName: String) { + val sql = context.assets.open("migrations/$fileName").bufferedReader().use { it.readText() } + sql.split(";").forEach { statement -> + val trimmed = statement.trim() + if (trimmed.isNotEmpty()) { + database.execSQL(trimmed) + } + } + } +} diff --git a/app/src/main/java/org/technoserve/farmcollector/database/models/CollectionSite.kt b/app/src/main/java/org/technoserve/farmcollector/database/models/CollectionSite.kt new file mode 100644 index 0000000..7c6f271 --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/database/models/CollectionSite.kt @@ -0,0 +1,34 @@ +package org.technoserve.farmcollector.database.models + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverters +import org.technoserve.farmcollector.database.converters.DateConverter + +@Entity(tableName = "CollectionSites") +data class CollectionSite( + @ColumnInfo(name = "name") + var name: String, + @ColumnInfo(name = "agentName") + var agentName: String, + @ColumnInfo(name = "phoneNumber") + var phoneNumber: String, + @ColumnInfo(name = "email") + var email: String, + @ColumnInfo(name = "village") + var village: String, + @ColumnInfo(name = "district") + var district: String, + @ColumnInfo(name = "createdAt") + @TypeConverters(DateConverter::class) + val createdAt: Long, + @ColumnInfo(name = "updatedAt") + @TypeConverters(DateConverter::class) + var updatedAt: Long, +) { + @PrimaryKey(autoGenerate = true) + var siteId: Long = 0L +} + + diff --git a/app/src/main/java/org/technoserve/farmcollector/database/models/CollectionSiteDto.kt b/app/src/main/java/org/technoserve/farmcollector/database/models/CollectionSiteDto.kt new file mode 100644 index 0000000..60d67ae --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/database/models/CollectionSiteDto.kt @@ -0,0 +1,12 @@ +package org.technoserve.farmcollector.database.models + +data class CollectionSiteDto( + val local_cs_id: Long, + val name: String, + val agent_name: String, + val phone_number: String?, + val email: String?, + val village: String?, + val district: String? +) + diff --git a/app/src/main/java/org/technoserve/farmcollector/database/models/DeviceFarmDto.kt b/app/src/main/java/org/technoserve/farmcollector/database/models/DeviceFarmDto.kt new file mode 100644 index 0000000..b710e2d --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/database/models/DeviceFarmDto.kt @@ -0,0 +1,7 @@ +package org.technoserve.farmcollector.database.models + +data class DeviceFarmDto( + val device_id: String, + val collection_site: CollectionSiteDto, + val farms: List +) diff --git a/app/src/main/java/org/technoserve/farmcollector/database/models/Farm.kt b/app/src/main/java/org/technoserve/farmcollector/database/models/Farm.kt new file mode 100644 index 0000000..7932f3d --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/database/models/Farm.kt @@ -0,0 +1,153 @@ +package org.technoserve.farmcollector.database.models + +import android.os.Parcel +import android.os.Parcelable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import androidx.room.TypeConverters +import kotlinx.parcelize.Parceler +import kotlinx.parcelize.Parcelize +import org.technoserve.farmcollector.database.converters.AccuracyListConvert +import org.technoserve.farmcollector.database.converters.CoordinateListConvert +import org.technoserve.farmcollector.database.converters.DateConverter +import org.technoserve.farmcollector.ui.screens.farms.ParcelablePair +import java.util.UUID + +@Entity( + tableName = "Farms", + foreignKeys = [ + ForeignKey( + entity = CollectionSite::class, + parentColumns = ["siteId"], + childColumns = ["siteId"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +@Parcelize +@TypeConverters(CoordinateListConvert::class, AccuracyListConvert::class) +data class Farm( + @ColumnInfo(name = "siteId") + var siteId: Long, + @ColumnInfo(name = "remote_id") + var remoteId: UUID = UUID.randomUUID(), + @ColumnInfo(name = "farmerPhoto") + var farmerPhoto: String, + @ColumnInfo(name = "farmerName") + var farmerName: String, + @ColumnInfo(name = "memberId") + var memberId: String, + @ColumnInfo(name = "village") + var village: String, + @ColumnInfo(name = "district") + var district: String, + @ColumnInfo(name = "purchases") + var purchases: Float?, + @ColumnInfo(name = "size") + var size: Float, + @ColumnInfo(name = "latitude") + var latitude: String, + @ColumnInfo(name = "longitude") + var longitude: String, + @ColumnInfo(name = "coordinates") + @TypeConverters(CoordinateListConvert::class) + var coordinates: List>?, + @ColumnInfo(name = "accuracyArray") // New field + @TypeConverters(AccuracyListConvert::class) + var accuracyArray: List?, // List to store accuracies + @ColumnInfo(name = "synced", defaultValue = "0") + val synced: Boolean = false, + @ColumnInfo(name = "scheduledForSync", defaultValue = "0") + val scheduledForSync: Boolean = false, + @ColumnInfo(name = "createdAt") + @TypeConverters(DateConverter::class) + val createdAt: Long, + @ColumnInfo(name = "updatedAt") + @TypeConverters(DateConverter::class) + var updatedAt: Long, + @ColumnInfo(name = "needsUpdate", defaultValue = "0") + var needsUpdate: Boolean = false, +) : Parcelable { + @PrimaryKey(autoGenerate = true) + var id: Long = 0L + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Farm + + return id == other.id + } + + override fun hashCode(): Int = id.hashCode() + + constructor(parcel: Parcel) : this( + parcel.readLong(), + UUID.fromString(parcel.readString()), + parcel.readString()!!, + parcel.readString()!!, + parcel.readString()!!, + parcel.readString()!!, + parcel.readString()!!, + parcel.readValue(Float::class.java.classLoader) as? Float, + parcel.readFloat(), + parcel.readString()!!, + parcel.readString()!!, + parcel.createTypedArrayList(ParcelablePair.CREATOR)?.map { Pair(it.first, it.second) }, + parcel.createFloatArray()?.toList(), // Read accuracyArray as a List + parcel.readByte() != 0.toByte(), + parcel.readByte() != 0.toByte(), + parcel.readLong(), + parcel.readLong(), + parcel.readByte() != 0.toByte() + ) { + id = parcel.readLong() + } + + override fun describeContents(): Int = 0 + + companion object : Parceler { + + override fun Farm.write(parcel: Parcel, flags: Int) { + parcel.writeLong(siteId) + parcel.writeString(remoteId.toString()) + parcel.writeString(farmerPhoto) + parcel.writeString(farmerName) + parcel.writeString(memberId) + parcel.writeString(village) + parcel.writeString(district) + parcel.writeValue(purchases) + parcel.writeFloat(size) + parcel.writeString(latitude) + parcel.writeString(longitude) + parcel.writeTypedList(coordinates?.map { + it.first?.let { it1 -> + it.second?.let { it2 -> + ParcelablePair( + it1, + it2 + ) + } + } + }) + parcel.writeFloatArray( + accuracyArray?.filterNotNull()?.toFloatArray() + ) // Write accuracyArray + parcel.writeByte(if (synced) 1 else 0) + parcel.writeByte(if (scheduledForSync) 1 else 0) + parcel.writeLong(createdAt) + parcel.writeLong(updatedAt) + parcel.writeByte(if (needsUpdate) 1 else 0) + parcel.writeLong(id) + } + + override fun create(parcel: Parcel): Farm { + return Farm(parcel) + } + } +} + + diff --git a/app/src/main/java/org/technoserve/farmcollector/database/models/FarmDetailDto.kt b/app/src/main/java/org/technoserve/farmcollector/database/models/FarmDetailDto.kt new file mode 100644 index 0000000..95e51ee --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/database/models/FarmDetailDto.kt @@ -0,0 +1,14 @@ +package org.technoserve.farmcollector.database.models + +data class FarmDetailDto( + val remote_id: String, + val farmer_name: String, + val member_id: String, + val village: String, + val district: String, + val size: Float, + val latitude: Double, + val longitude: Double, + val coordinates: List>?, + val accuracies: List?, +) diff --git a/app/src/main/java/org/technoserve/farmcollector/database/sync/SyncWorker.kt b/app/src/main/java/org/technoserve/farmcollector/database/sync/SyncWorker.kt index 09044be..65dfe75 100644 --- a/app/src/main/java/org/technoserve/farmcollector/database/sync/SyncWorker.kt +++ b/app/src/main/java/org/technoserve/farmcollector/database/sync/SyncWorker.kt @@ -16,8 +16,9 @@ import okhttp3.OkHttpClient import org.technoserve.farmcollector.BuildConfig import org.technoserve.farmcollector.R import org.technoserve.farmcollector.database.AppDatabase -import org.technoserve.farmcollector.database.remote.ApiService +import org.technoserve.farmcollector.database.sync.remote.ApiService import org.technoserve.farmcollector.database.toDeviceFarmDtoList +import org.technoserve.farmcollector.utils.DeviceIdUtil import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.util.concurrent.TimeUnit diff --git a/app/src/main/java/org/technoserve/farmcollector/database/remote/ApiService.kt b/app/src/main/java/org/technoserve/farmcollector/database/sync/remote/ApiService.kt similarity index 84% rename from app/src/main/java/org/technoserve/farmcollector/database/remote/ApiService.kt rename to app/src/main/java/org/technoserve/farmcollector/database/sync/remote/ApiService.kt index 082c5c9..403a398 100644 --- a/app/src/main/java/org/technoserve/farmcollector/database/remote/ApiService.kt +++ b/app/src/main/java/org/technoserve/farmcollector/database/sync/remote/ApiService.kt @@ -1,6 +1,6 @@ -package org.technoserve.farmcollector.database.remote +package org.technoserve.farmcollector.database.sync.remote -import org.technoserve.farmcollector.database.DeviceFarmDto +import org.technoserve.farmcollector.database.models.DeviceFarmDto import retrofit2.Response import retrofit2.http.Body import retrofit2.http.POST diff --git a/app/src/main/java/org/technoserve/farmcollector/database/FarmRepository.kt b/app/src/main/java/org/technoserve/farmcollector/repositories/FarmRepository.kt similarity index 95% rename from app/src/main/java/org/technoserve/farmcollector/database/FarmRepository.kt rename to app/src/main/java/org/technoserve/farmcollector/repositories/FarmRepository.kt index 5285137..e11f699 100644 --- a/app/src/main/java/org/technoserve/farmcollector/database/FarmRepository.kt +++ b/app/src/main/java/org/technoserve/farmcollector/repositories/FarmRepository.kt @@ -1,10 +1,13 @@ -package org.technoserve.farmcollector.database +package org.technoserve.farmcollector.repositories import android.content.ContentValues.TAG import android.util.Log import androidx.lifecycle.LiveData import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.technoserve.farmcollector.database.dao.FarmDAO +import org.technoserve.farmcollector.database.models.CollectionSite +import org.technoserve.farmcollector.database.models.Farm /** * this class represents FarmRepository that contains information about the FarmRepository a diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/components/CustomPaginationControls.kt b/app/src/main/java/org/technoserve/farmcollector/ui/components/CustomPaginationControls.kt new file mode 100644 index 0000000..65b85f2 --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/ui/components/CustomPaginationControls.kt @@ -0,0 +1,49 @@ +package org.technoserve.farmcollector.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import org.technoserve.farmcollector.R + +@Composable +fun CustomPaginationControls( + currentPage: Int, + totalPages: Int, + onPageChange: (Int) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = { if (currentPage > 1) onPageChange(currentPage - 1) }, + enabled = currentPage > 1 + ) { + Icon( + painter = painterResource(R.drawable.previous), + contentDescription = "Previous Page" + ) + } + + Text("Page $currentPage of $totalPages", modifier = Modifier.padding(horizontal = 16.dp)) + + IconButton( + onClick = { if (currentPage < totalPages) onPageChange(currentPage + 1) }, + enabled = currentPage < totalPages + ) { + Icon(painter = painterResource(R.drawable.next), contentDescription = "Next Page") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/components/FarmCard.kt b/app/src/main/java/org/technoserve/farmcollector/ui/components/FarmCard.kt new file mode 100644 index 0000000..e3e1e81 --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/ui/components/FarmCard.kt @@ -0,0 +1,139 @@ +package org.technoserve.farmcollector.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.technoserve.farmcollector.R +import org.technoserve.farmcollector.database.models.Farm +import org.technoserve.farmcollector.ui.screens.farms.formatInput + +@Composable +fun FarmCard( + farm: Farm, + onCardClick: () -> Unit, + onDeleteClick: () -> Unit, +) { + val textColor = MaterialTheme.colorScheme.onBackground + Column( + modifier = + Modifier + .fillMaxSize() + .padding(top = 8.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ElevatedCard( + elevation = + CardDefaults.cardElevation( + defaultElevation = 6.dp, + ), + modifier = + Modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxWidth() + .padding(8.dp), + onClick = { + onCardClick() + }, + ) { + Column( + modifier = + Modifier + .background(MaterialTheme.colorScheme.background) + .padding(16.dp), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = farm.farmerName, + style = + MaterialTheme.typography.bodySmall.copy( + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = textColor, + ), + modifier = + Modifier + .weight(1.1f) + .padding(bottom = 4.dp), + ) + Text( + text = "${stringResource(id = R.string.size)}: ${formatInput(farm.size.toString())} ${ + stringResource(id = R.string.ha) + }", + style = MaterialTheme.typography.bodySmall.copy(color = textColor), + modifier = + Modifier + .weight(0.9f) + .padding(bottom = 4.dp), + ) + IconButton( + onClick = { + onDeleteClick() + }, + modifier = + Modifier + .size(24.dp) + .padding(4.dp), + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + tint = Color.Red, + ) + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "${stringResource(id = R.string.village)}: ${farm.village}", + style = MaterialTheme.typography.bodySmall.copy(color = textColor), + modifier = Modifier.weight(1f), + ) + Text( + text = "${stringResource(id = R.string.district)}: ${farm.district}", + style = MaterialTheme.typography.bodySmall.copy(color = textColor), + modifier = Modifier.weight(1f), + ) + } + + // Show the label if the farm needs an update + if (farm.needsUpdate) { + Text( + text = stringResource(id = R.string.needs_update), + color = Color.Blue, + fontWeight = FontWeight.Bold, + fontSize = 12.sp, // Adjust font size + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/components/KeepPolygonDialog.kt b/app/src/main/java/org/technoserve/farmcollector/ui/components/KeepPolygonDialog.kt new file mode 100644 index 0000000..ad067ad --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/ui/components/KeepPolygonDialog.kt @@ -0,0 +1,75 @@ +package org.technoserve.farmcollector.ui.components + +import androidx.compose.foundation.background +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import org.technoserve.farmcollector.R +import org.technoserve.farmcollector.map.MapViewModel + +/** + * This function is used to allow the user to either keep the existing polygon or capture a new polygon + */ + + +@Composable +fun KeepPolygonDialog( + onDismiss: () -> Unit, + onKeepExisting: () -> Unit, + onCaptureNew: () -> Unit, +) { + + val mapViewModel: MapViewModel = viewModel() + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(id = R.string.update_polygon), + color = MaterialTheme.colorScheme.onBackground + ) + }, + text = { + Text( + text = stringResource(id = R.string.keep_existing_polygon_or_capture_new), + color = MaterialTheme.colorScheme.onBackground + ) + }, + confirmButton = { + Button( + onClick = onKeepExisting, + modifier = Modifier.background(MaterialTheme.colorScheme.background), + colors = ButtonDefaults.buttonColors() + ) { + Text( + text = stringResource(id = R.string.keep_existing), + color = MaterialTheme.colorScheme.onBackground + ) + } + }, + dismissButton = { + Button( + onClick = { + mapViewModel.clearCoordinates() + onCaptureNew() + }, + modifier = Modifier.background(MaterialTheme.colorScheme.background), + colors = ButtonDefaults.buttonColors() + ) { + Text( + text = stringResource(id = R.string.capture_new), + color = MaterialTheme.colorScheme.onBackground + ) + } + }, + containerColor = MaterialTheme.colorScheme.background, + tonalElevation = 6.dp + ) +} diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/components/SiteCard.kt b/app/src/main/java/org/technoserve/farmcollector/ui/components/SiteCard.kt new file mode 100644 index 0000000..2a5cd10 --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/ui/components/SiteCard.kt @@ -0,0 +1,177 @@ +package org.technoserve.farmcollector.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.technoserve.farmcollector.R +import org.technoserve.farmcollector.database.models.CollectionSite +import org.technoserve.farmcollector.ui.composes.UpdateCollectionDialog +import org.technoserve.farmcollector.viewmodels.FarmViewModel + +@Composable +fun SiteCard( + site: CollectionSite, + onCardClick: () -> Unit, + totalFarms: Int, + farmsWithIncompleteData: Int, + onDeleteClick: () -> Unit, + farmViewModel: FarmViewModel, +) { + val showDialog = remember { mutableStateOf(false) } + if (showDialog.value) { + UpdateCollectionDialog( + site = site, + showDialog = showDialog, + farmViewModel = farmViewModel, + ) + } + val textColor = MaterialTheme.colorScheme.onBackground + val iconColor = MaterialTheme.colorScheme.onBackground + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(top = 8.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ElevatedCard( + elevation = + CardDefaults.cardElevation( + defaultElevation = 6.dp, + ), + modifier = + Modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxWidth() + .padding(8.dp), + onClick = { + onCardClick() + }, + ) { + Column( + modifier = + Modifier + .background(MaterialTheme.colorScheme.background) + .padding(16.dp), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = + Modifier + .weight(1.1f) + .padding(bottom = 4.dp), + ) { + Text( + text = site.name, + style = + MaterialTheme.typography.bodySmall.copy( + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = textColor, + ), + modifier = + Modifier + .padding(bottom = 1.dp), + ) + Text( + text = "${stringResource(id = R.string.agent_name)}: ${site.agentName}", + style = MaterialTheme.typography.bodySmall.copy(color = textColor), + modifier = + Modifier + .padding(bottom = 1.dp), + ) + if (site.phoneNumber.isNotEmpty()) { + Text( + text = "${stringResource(id = R.string.phone_number)}: ${site.phoneNumber}", + style = MaterialTheme.typography.bodySmall.copy(color = textColor), + ) + } + + Text( + text = stringResource( + id = R.string.total_farms, + totalFarms + ), + style = MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.Bold, + ), + ) + + Text( + text = stringResource( + id = R.string.total_farms_with_incomplete_data, + farmsWithIncompleteData + ), + style = MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.Bold, + color = Color.Blue + ), + ) + } + IconButton( + onClick = { + showDialog.value = true + }, + modifier = + Modifier + .size(24.dp) + .padding(4.dp), + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Update", + tint = iconColor, + ) + } + Spacer(modifier = Modifier.padding(10.dp)) + IconButton( + onClick = { + onDeleteClick() + }, + modifier = + Modifier + .size(24.dp) + .padding(4.dp), + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + tint = Color.Red, + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/components/SkeletonSiteCard.kt b/app/src/main/java/org/technoserve/farmcollector/ui/components/SkeletonSiteCard.kt new file mode 100644 index 0000000..0972617 --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/ui/components/SkeletonSiteCard.kt @@ -0,0 +1,118 @@ +package org.technoserve.farmcollector.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.valentinilk.shimmer.shimmer +import org.technoserve.farmcollector.ui.screens.farms.isSystemInDarkTheme + +@Composable +fun SkeletonSiteCard() { + val isDarkTheme = isSystemInDarkTheme() + val backgroundColor = if (isDarkTheme) Color.Black else Color.White + val placeholderColor = if (isDarkTheme) Color.DarkGray else Color.LightGray + + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = 8.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ElevatedCard( + elevation = CardDefaults.cardElevation(defaultElevation = 6.dp), + modifier = Modifier + .background(backgroundColor) + .fillMaxWidth() + .padding(2.dp) + .shimmer() + ) { + Column( + modifier = Modifier + .background(backgroundColor) + .padding(8.dp), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .background(backgroundColor) + .padding(2.dp) + .fillMaxWidth() + ) { + // Checkbox placeholder with shimmer + Box( + modifier = Modifier + .size(24.dp) + .background(placeholderColor, shape = CircleShape) + ) + Spacer(modifier = Modifier.width(8.dp)) + + // Placeholder for site info + Column( + modifier = Modifier + .weight(1f) + .padding(start = 2.dp) + ) { + repeat(5) { // Repeat placeholders for each text line + Spacer( + modifier = Modifier + .height(16.dp) + .fillMaxWidth(0.8f) + .background(placeholderColor, shape = RoundedCornerShape(4.dp)) + .padding(bottom = 4.dp) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Placeholder for farm info + Box( + modifier = Modifier + .height(16.dp) + .fillMaxWidth(0.5f) + .background(placeholderColor, shape = RoundedCornerShape(4.dp)) + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // Placeholder for farms with incomplete data + Box( + modifier = Modifier + .height(16.dp) + .fillMaxWidth(0.6f) + .background(placeholderColor, shape = RoundedCornerShape(4.dp)) + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Icon placeholder with shimmer + Box( + modifier = Modifier + .size(24.dp) + .background(placeholderColor, shape = CircleShape) + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/composes/UpdateCollectionDialog.kt b/app/src/main/java/org/technoserve/farmcollector/ui/composes/UpdateCollectionDialog.kt index b3f5abd..baa4fef 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/composes/UpdateCollectionDialog.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/composes/UpdateCollectionDialog.kt @@ -30,8 +30,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import org.technoserve.farmcollector.R -import org.technoserve.farmcollector.database.CollectionSite -import org.technoserve.farmcollector.database.FarmViewModel +import org.technoserve.farmcollector.database.models.CollectionSite + +import org.technoserve.farmcollector.viewmodels.FarmViewModel fun isValidPhoneNumber(phoneNumber: String): Boolean { val regex = Regex("^\\+?(?:[0-9] ?){6,14}[0-9]\$") diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/Greeting.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/Greeting.kt deleted file mode 100644 index eb09cb5..0000000 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/Greeting.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.technoserve.farmcollector.ui.screens - -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable - -// Example composable function to test -@Composable -fun Greeting(name: String) { - Text("Hello, $name!") -} diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/AddSite.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/collectionsites/AddSite.kt similarity index 97% rename from app/src/main/java/org/technoserve/farmcollector/ui/screens/AddSite.kt rename to app/src/main/java/org/technoserve/farmcollector/ui/screens/collectionsites/AddSite.kt index 06a58ae..ebaedc4 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/AddSite.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/collectionsites/AddSite.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.ui.screens +package org.technoserve.farmcollector.ui.screens.collectionsites import android.annotation.SuppressLint import android.app.Activity @@ -51,9 +51,11 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import org.joda.time.Instant import org.technoserve.farmcollector.R -import org.technoserve.farmcollector.database.CollectionSite -import org.technoserve.farmcollector.database.FarmViewModel -import org.technoserve.farmcollector.database.FarmViewModelFactory +import org.technoserve.farmcollector.database.models.CollectionSite +import org.technoserve.farmcollector.ui.screens.farms.FarmListHeader +import org.technoserve.farmcollector.ui.screens.farms.isSystemInDarkTheme +import org.technoserve.farmcollector.viewmodels.FarmViewModel +import org.technoserve.farmcollector.viewmodels.FarmViewModelFactory /** * This function is used to add a collection site diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/CollectionSiteList.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/collectionsites/CollectionSiteList.kt similarity index 67% rename from app/src/main/java/org/technoserve/farmcollector/ui/screens/CollectionSiteList.kt rename to app/src/main/java/org/technoserve/farmcollector/ui/screens/collectionsites/CollectionSiteList.kt index 0a0dec3..4d1fe69 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/CollectionSiteList.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/collectionsites/CollectionSiteList.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.ui.screens +package org.technoserve.farmcollector.ui.screens.collectionsites import android.app.Application import android.widget.Toast @@ -16,20 +16,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.Button -import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold @@ -55,27 +48,28 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.media3.common.C import androidx.navigation.NavController import androidx.paging.LoadState import androidx.paging.compose.collectAsLazyPagingItems -import com.valentinilk.shimmer.shimmer import kotlinx.coroutines.delay import org.technoserve.farmcollector.R -import org.technoserve.farmcollector.database.CollectionSite -import org.technoserve.farmcollector.database.FarmViewModel -import org.technoserve.farmcollector.database.FarmViewModelFactory -import org.technoserve.farmcollector.database.RestoreDataAlert -import org.technoserve.farmcollector.database.RestoreStatus -import org.technoserve.farmcollector.database.UndoDeleteSnackbar -import org.technoserve.farmcollector.database.sync.DeviceIdUtil -import org.technoserve.farmcollector.ui.composes.UpdateCollectionDialog +import org.technoserve.farmcollector.database.models.CollectionSite +import org.technoserve.farmcollector.ui.components.CustomPaginationControls +import org.technoserve.farmcollector.ui.components.SiteCard +import org.technoserve.farmcollector.ui.components.SkeletonSiteCard +import org.technoserve.farmcollector.viewmodels.FarmViewModel +import org.technoserve.farmcollector.viewmodels.FarmViewModelFactory +import org.technoserve.farmcollector.viewmodels.RestoreDataAlert +import org.technoserve.farmcollector.viewmodels.RestoreStatus +import org.technoserve.farmcollector.viewmodels.UndoDeleteSnackbar +import org.technoserve.farmcollector.utils.DeviceIdUtil import org.technoserve.farmcollector.ui.composes.isValidPhoneNumber +import org.technoserve.farmcollector.ui.screens.farms.FarmListHeader +import org.technoserve.farmcollector.ui.screens.farms.SiteDeleteAllDialogPresenter +import org.technoserve.farmcollector.ui.screens.farms.isSystemInDarkTheme /** @@ -552,274 +546,7 @@ fun CollectionSiteList(navController: NavController) { } -@Composable -fun SiteCard( - site: CollectionSite, - onCardClick: () -> Unit, - totalFarms: Int, - farmsWithIncompleteData: Int, - onDeleteClick: () -> Unit, - farmViewModel: FarmViewModel, -) { - val showDialog = remember { mutableStateOf(false) } - if (showDialog.value) { - UpdateCollectionDialog( - site = site, - showDialog = showDialog, - farmViewModel = farmViewModel, - ) - } - val textColor = MaterialTheme.colorScheme.onBackground - val iconColor = MaterialTheme.colorScheme.onBackground - - Column( - modifier = - Modifier - .fillMaxSize() - .padding(top = 8.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - ElevatedCard( - elevation = - CardDefaults.cardElevation( - defaultElevation = 6.dp, - ), - modifier = - Modifier - .background(MaterialTheme.colorScheme.background) - .fillMaxWidth() - .padding(8.dp), - onClick = { - onCardClick() - }, - ) { - Column( - modifier = - Modifier - .background(MaterialTheme.colorScheme.background) - .padding(16.dp), - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = - Modifier - .weight(1.1f) - .padding(bottom = 4.dp), - ) { - Text( - text = site.name, - style = - MaterialTheme.typography.bodySmall.copy( - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - color = textColor, - ), - modifier = - Modifier - .padding(bottom = 1.dp), - ) - Text( - text = "${stringResource(id = R.string.agent_name)}: ${site.agentName}", - style = MaterialTheme.typography.bodySmall.copy(color = textColor), - modifier = - Modifier - .padding(bottom = 1.dp), - ) - if (site.phoneNumber.isNotEmpty()) { - Text( - text = "${stringResource(id = R.string.phone_number)}: ${site.phoneNumber}", - style = MaterialTheme.typography.bodySmall.copy(color = textColor), - ) - } - Text( - text = stringResource( - id = R.string.total_farms, - totalFarms - ), - style = MaterialTheme.typography.bodySmall.copy( - fontWeight = FontWeight.Bold, - ), - ) - Text( - text = stringResource( - id = R.string.total_farms_with_incomplete_data, - farmsWithIncompleteData - ), - style = MaterialTheme.typography.bodySmall.copy( - fontWeight = FontWeight.Bold, - color = Color.Blue - ), - ) - } - IconButton( - onClick = { - showDialog.value = true - }, - modifier = - Modifier - .size(24.dp) - .padding(4.dp), - ) { - Icon( - imageVector = Icons.Default.Edit, - contentDescription = "Update", - tint = iconColor, - ) - } - Spacer(modifier = Modifier.padding(10.dp)) - IconButton( - onClick = { - onDeleteClick() - }, - modifier = - Modifier - .size(24.dp) - .padding(4.dp), - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = "Delete", - tint = Color.Red, - ) - } - } - } - } - } -} -@Composable -fun SkeletonSiteCard() { - val isDarkTheme = isSystemInDarkTheme() - val backgroundColor = if (isDarkTheme) Color.Black else Color.White - val placeholderColor = if (isDarkTheme) Color.DarkGray else Color.LightGray - - Column( - modifier = Modifier - .fillMaxSize() - .padding(top = 8.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - ElevatedCard( - elevation = CardDefaults.cardElevation(defaultElevation = 6.dp), - modifier = Modifier - .background(backgroundColor) - .fillMaxWidth() - .padding(2.dp) - .shimmer() - ) { - Column( - modifier = Modifier - .background(backgroundColor) - .padding(8.dp), - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .background(backgroundColor) - .padding(2.dp) - .fillMaxWidth() - ) { - // Checkbox placeholder with shimmer - Box( - modifier = Modifier - .size(24.dp) - .background(placeholderColor, shape = CircleShape) - ) - Spacer(modifier = Modifier.width(8.dp)) - - // Placeholder for site info - Column( - modifier = Modifier - .weight(1f) - .padding(start = 2.dp) - ) { - repeat(5) { // Repeat placeholders for each text line - Spacer( - modifier = Modifier - .height(16.dp) - .fillMaxWidth(0.8f) - .background(placeholderColor, shape = RoundedCornerShape(4.dp)) - .padding(bottom = 4.dp) - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - // Placeholder for farm info - Box( - modifier = Modifier - .height(16.dp) - .fillMaxWidth(0.5f) - .background(placeholderColor, shape = RoundedCornerShape(4.dp)) - ) - - Spacer(modifier = Modifier.height(4.dp)) - - // Placeholder for farms with incomplete data - Box( - modifier = Modifier - .height(16.dp) - .fillMaxWidth(0.6f) - .background(placeholderColor, shape = RoundedCornerShape(4.dp)) - ) - } - - Spacer(modifier = Modifier.width(8.dp)) - - // Icon placeholder with shimmer - Box( - modifier = Modifier - .size(24.dp) - .background(placeholderColor, shape = CircleShape) - ) - } - } - } - } -} - - -@Composable -fun CustomPaginationControls( - currentPage: Int, - totalPages: Int, - onPageChange: (Int) -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - IconButton( - onClick = { if (currentPage > 1) onPageChange(currentPage - 1) }, - enabled = currentPage > 1 - ) { - Icon( - painter = painterResource(R.drawable.previous), - contentDescription = "Previous Page" - ) - } - - Text("Page $currentPage of $totalPages", modifier = Modifier.padding(horizontal = 16.dp)) - - IconButton( - onClick = { if (currentPage < totalPages) onPageChange(currentPage + 1) }, - enabled = currentPage < totalPages - ) { - Icon(painter = painterResource(R.drawable.next), contentDescription = "Next Page") - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/AddFarm.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/AddFarm.kt similarity index 99% rename from app/src/main/java/org/technoserve/farmcollector/ui/screens/AddFarm.kt rename to app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/AddFarm.kt index 90dbc38..5a67f88 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/AddFarm.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/AddFarm.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.ui.screens +package org.technoserve.farmcollector.ui.screens.farms import android.Manifest import android.annotation.SuppressLint @@ -68,9 +68,10 @@ import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.LatLngBounds import org.joda.time.Instant import org.technoserve.farmcollector.R -import org.technoserve.farmcollector.database.Farm -import org.technoserve.farmcollector.database.FarmViewModel -import org.technoserve.farmcollector.database.FarmViewModelFactory +import org.technoserve.farmcollector.database.models.Farm + +import org.technoserve.farmcollector.viewmodels.FarmViewModel +import org.technoserve.farmcollector.viewmodels.FarmViewModelFactory import org.technoserve.farmcollector.map.LocationHelper import org.technoserve.farmcollector.map.LocationState import org.technoserve.farmcollector.map.MapViewModel diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/FarmList.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/FarmList.kt similarity index 93% rename from app/src/main/java/org/technoserve/farmcollector/ui/screens/FarmList.kt rename to app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/FarmList.kt index d208447..4e561d9 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/FarmList.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/FarmList.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.ui.screens +package org.technoserve.farmcollector.ui.screens.farms import android.annotation.SuppressLint import android.app.Activity @@ -10,7 +10,6 @@ import android.os.Build import android.os.Environment import android.os.Parcel import android.os.Parcelable -import android.util.Log import android.view.KeyEvent import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult @@ -35,7 +34,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState @@ -116,26 +114,29 @@ import androidx.compose.ui.unit.sp import androidx.core.content.FileProvider import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController -import com.google.android.gms.location.LocationServices import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.joda.time.Instant import org.technoserve.farmcollector.R -import org.technoserve.farmcollector.database.CollectionSite -import org.technoserve.farmcollector.database.Farm -import org.technoserve.farmcollector.database.FarmViewModel -import org.technoserve.farmcollector.database.FarmViewModelFactory -import org.technoserve.farmcollector.database.RestoreStatus -import org.technoserve.farmcollector.database.sync.DeviceIdUtil +import org.technoserve.farmcollector.database.models.CollectionSite +import org.technoserve.farmcollector.database.models.Farm + +import org.technoserve.farmcollector.viewmodels.FarmViewModel +import org.technoserve.farmcollector.viewmodels.FarmViewModelFactory +import org.technoserve.farmcollector.viewmodels.RestoreStatus +import org.technoserve.farmcollector.utils.DeviceIdUtil import org.technoserve.farmcollector.hasLocationPermission import org.technoserve.farmcollector.map.LocationHelper import org.technoserve.farmcollector.map.MapViewModel +import org.technoserve.farmcollector.ui.components.CustomPaginationControls +import org.technoserve.farmcollector.ui.components.FarmCard +import org.technoserve.farmcollector.ui.components.KeepPolygonDialog import org.technoserve.farmcollector.ui.composes.isValidPhoneNumber + import org.technoserve.farmcollector.utils.convertSize import java.io.BufferedWriter import java.io.File import java.io.IOException -import java.io.OutputStream import java.io.OutputStreamWriter import java.text.SimpleDateFormat import java.util.Date @@ -202,65 +203,6 @@ data class ParcelableFarmData(val farm: Farm, val view: String) : Parcelable { } } -/** - * This function is used to allow the user to either keep the existing polygon or capture a new polygon - */ - - -@Composable -fun KeepPolygonDialog( - onDismiss: () -> Unit, - onKeepExisting: () -> Unit, - onCaptureNew: () -> Unit, -) { - - val mapViewModel: MapViewModel = viewModel() - - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text( - text = stringResource(id = R.string.update_polygon), - color = MaterialTheme.colorScheme.onBackground - ) - }, - text = { - Text( - text = stringResource(id = R.string.keep_existing_polygon_or_capture_new), - color = MaterialTheme.colorScheme.onBackground - ) - }, - confirmButton = { - Button( - onClick = onKeepExisting, - modifier = Modifier.background(MaterialTheme.colorScheme.background), - colors = ButtonDefaults.buttonColors() - ) { - Text( - text = stringResource(id = R.string.keep_existing), - color = MaterialTheme.colorScheme.onBackground - ) - } - }, - dismissButton = { - Button( - onClick = { - mapViewModel.clearCoordinates() - onCaptureNew() - }, - modifier = Modifier.background(MaterialTheme.colorScheme.background), - colors = ButtonDefaults.buttonColors() - ) { - Text( - text = stringResource(id = R.string.capture_new), - color = MaterialTheme.colorScheme.onBackground - ) - } - }, - containerColor = MaterialTheme.colorScheme.background, - tonalElevation = 6.dp - ) -} @Composable @@ -1875,127 +1817,6 @@ fun FarmListHeaderPlots( } } -@Composable -fun FarmCard( - farm: Farm, - onCardClick: () -> Unit, - onDeleteClick: () -> Unit, -) { - val textColor = MaterialTheme.colorScheme.onBackground - Column( - modifier = - Modifier - .fillMaxSize() - .padding(top = 8.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - ElevatedCard( - elevation = - CardDefaults.cardElevation( - defaultElevation = 6.dp, - ), - modifier = - Modifier - .background(MaterialTheme.colorScheme.background) - .fillMaxWidth() - .padding(8.dp), - onClick = { - onCardClick() - }, - ) { - Column( - modifier = - Modifier - .background(MaterialTheme.colorScheme.background) - .padding(16.dp), - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = farm.farmerName, - style = - MaterialTheme.typography.bodySmall.copy( - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - color = textColor, - ), - modifier = - Modifier - .weight(1.1f) - .padding(bottom = 4.dp), - ) - Text( - text = "${stringResource(id = R.string.size)}: ${formatInput(farm.size.toString())} ${ - stringResource(id = R.string.ha) - }", - style = MaterialTheme.typography.bodySmall.copy(color = textColor), - modifier = - Modifier - .weight(0.9f) - .padding(bottom = 4.dp), - ) - IconButton( - onClick = { - onDeleteClick() - }, - modifier = - Modifier - .size(24.dp) - .padding(4.dp), - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = "Delete", - tint = Color.Red, - ) - } - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = "${stringResource(id = R.string.village)}: ${farm.village}", - style = MaterialTheme.typography.bodySmall.copy(color = textColor), - modifier = Modifier.weight(1f), - ) - Text( - text = "${stringResource(id = R.string.district)}: ${farm.district}", - style = MaterialTheme.typography.bodySmall.copy(color = textColor), - modifier = Modifier.weight(1f), - ) - } - - // Show the label if the farm needs an update - if (farm.needsUpdate) { - Text( - text = stringResource(id = R.string.needs_update), - color = Color.Blue, - fontWeight = FontWeight.Bold, - fontSize = 12.sp, // Adjust font size - modifier = Modifier.padding(top = 4.dp) - ) - } - } - } - } -} - - -fun OutputStream.writeCsv(farms: List) { - val writer = bufferedWriter() - writer.write(""""Farmer Name", "Village", "District"""") - writer.newLine() - farms.forEach { - writer.write("${it.farmerName}, ${it.village}, \"${it.district}\"") - writer.newLine() - } - writer.flush() -} @SuppressLint("MissingPermission") diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/SetPolygon.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/SetPolygon.kt similarity index 99% rename from app/src/main/java/org/technoserve/farmcollector/ui/screens/SetPolygon.kt rename to app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/SetPolygon.kt index 8c8a24c..732d8b1 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/SetPolygon.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/SetPolygon.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.ui.screens +package org.technoserve.farmcollector.ui.screens.farms import android.annotation.SuppressLint import android.app.Activity diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/Home.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/home/Home.kt similarity index 99% rename from app/src/main/java/org/technoserve/farmcollector/ui/screens/Home.kt rename to app/src/main/java/org/technoserve/farmcollector/ui/screens/home/Home.kt index 60aef3b..78ffb77 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/Home.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/home/Home.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.ui.screens +package org.technoserve.farmcollector.ui.screens.home import androidx.compose.foundation.Image import androidx.compose.foundation.background diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/BottomBar.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/BottomBar.kt similarity index 96% rename from app/src/main/java/org/technoserve/farmcollector/ui/screens/BottomBar.kt rename to app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/BottomBar.kt index 43804b0..4e0defc 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/BottomBar.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/BottomBar.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.ui.screens +package org.technoserve.farmcollector.ui.screens.settings import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/ScreenWithBottomBar.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/ScreenWithBottomBar.kt similarity index 90% rename from app/src/main/java/org/technoserve/farmcollector/ui/screens/ScreenWithBottomBar.kt rename to app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/ScreenWithBottomBar.kt index 47acf6d..061e5f2 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/ScreenWithBottomBar.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/ScreenWithBottomBar.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.ui.screens +package org.technoserve.farmcollector.ui.screens.settings import android.annotation.SuppressLint import androidx.compose.material3.Scaffold diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/Settings.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/Settings.kt similarity index 98% rename from app/src/main/java/org/technoserve/farmcollector/ui/screens/Settings.kt rename to app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/Settings.kt index 4937487..9915197 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/Settings.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/Settings.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.ui.screens +package org.technoserve.farmcollector.ui.screens.settings import android.content.Context import androidx.compose.foundation.background diff --git a/app/src/main/java/org/technoserve/farmcollector/database/sync/DeviceUtil.kt b/app/src/main/java/org/technoserve/farmcollector/utils/DeviceUtil.kt similarity index 96% rename from app/src/main/java/org/technoserve/farmcollector/database/sync/DeviceUtil.kt rename to app/src/main/java/org/technoserve/farmcollector/utils/DeviceUtil.kt index ae7005a..44898e6 100644 --- a/app/src/main/java/org/technoserve/farmcollector/database/sync/DeviceUtil.kt +++ b/app/src/main/java/org/technoserve/farmcollector/utils/DeviceUtil.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.database.sync +package org.technoserve.farmcollector.utils import android.annotation.SuppressLint diff --git a/app/src/main/java/org/technoserve/farmcollector/database/AppUpdateViewModel.kt b/app/src/main/java/org/technoserve/farmcollector/viewmodels/AppUpdateViewModel.kt similarity index 92% rename from app/src/main/java/org/technoserve/farmcollector/database/AppUpdateViewModel.kt rename to app/src/main/java/org/technoserve/farmcollector/viewmodels/AppUpdateViewModel.kt index 1d38090..fd96b39 100644 --- a/app/src/main/java/org/technoserve/farmcollector/database/AppUpdateViewModel.kt +++ b/app/src/main/java/org/technoserve/farmcollector/viewmodels/AppUpdateViewModel.kt @@ -1,26 +1,18 @@ -package org.technoserve.farmcollector.database +package org.technoserve.farmcollector.viewmodels import android.app.Activity -import android.content.Intent -import android.net.Uri -import androidx.activity.compose.BackHandler import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.compose.viewModel -import com.google.android.play.core.appupdate.AppUpdateInfo import com.google.android.play.core.appupdate.AppUpdateManager import com.google.android.play.core.appupdate.AppUpdateManagerFactory -import com.google.android.play.core.install.InstallStateUpdatedListener import com.google.android.play.core.install.model.AppUpdateType import com.google.android.play.core.install.model.UpdateAvailability import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch import org.technoserve.farmcollector.R class AppUpdateViewModel : ViewModel() { diff --git a/app/src/main/java/org/technoserve/farmcollector/database/FarmViewModel.kt b/app/src/main/java/org/technoserve/farmcollector/viewmodels/FarmViewModel.kt similarity index 98% rename from app/src/main/java/org/technoserve/farmcollector/database/FarmViewModel.kt rename to app/src/main/java/org/technoserve/farmcollector/viewmodels/FarmViewModel.kt index 1e92dfa..da3b65d 100644 --- a/app/src/main/java/org/technoserve/farmcollector/database/FarmViewModel.kt +++ b/app/src/main/java/org/technoserve/farmcollector/viewmodels/FarmViewModel.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.database +package org.technoserve.farmcollector.viewmodels import android.annotation.SuppressLint import android.app.Application @@ -27,9 +27,15 @@ import org.joda.time.Instant import org.json.JSONObject import org.technoserve.farmcollector.BuildConfig import org.technoserve.farmcollector.R -import org.technoserve.farmcollector.database.remote.ApiService -import org.technoserve.farmcollector.database.remote.FarmRequest -import org.technoserve.farmcollector.ui.screens.truncateToDecimalPlaces +import org.technoserve.farmcollector.database.AppDatabase +import org.technoserve.farmcollector.repositories.FarmRepository +import org.technoserve.farmcollector.database.MyPagingSource +import org.technoserve.farmcollector.database.RefreshableLiveData +import org.technoserve.farmcollector.database.models.CollectionSite +import org.technoserve.farmcollector.database.models.Farm +import org.technoserve.farmcollector.database.sync.remote.ApiService +import org.technoserve.farmcollector.database.sync.remote.FarmRequest +import org.technoserve.farmcollector.ui.screens.farms.truncateToDecimalPlaces import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.io.BufferedReader diff --git a/app/src/test/java/org/technoserve/farmcollector/HomeTest.kt b/app/src/test/java/org/technoserve/farmcollector/HomeTest.kt index 459f471..7774d11 100644 --- a/app/src/test/java/org/technoserve/farmcollector/HomeTest.kt +++ b/app/src/test/java/org/technoserve/farmcollector/HomeTest.kt @@ -183,7 +183,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.Mockito.mock -import org.technoserve.farmcollector.ui.screens.Home +import org.technoserve.farmcollector.ui.screens.home.Home import org.technoserve.farmcollector.utils.Language import org.technoserve.farmcollector.utils.LanguageViewModel diff --git a/app/src/test/java/org/technoserve/farmcollector/MainActivityTest.kt b/app/src/test/java/org/technoserve/farmcollector/MainActivityTest.kt index 47cbaa2..35a5ed0 100644 --- a/app/src/test/java/org/technoserve/farmcollector/MainActivityTest.kt +++ b/app/src/test/java/org/technoserve/farmcollector/MainActivityTest.kt @@ -1,33 +1,20 @@ package org.technoserve.farmcollector -import org.junit.Assert.* - import android.Manifest import android.app.Application -import android.content.Intent import android.content.SharedPreferences -import android.net.Uri -import androidx.activity.ComponentActivity import androidx.appcompat.app.AppCompatDelegate import androidx.compose.ui.test.junit4.createComposeRule -import androidx.lifecycle.SavedStateHandle -import androidx.navigation.Navigation.findNavController import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mockito.* -import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -import org.technoserve.farmcollector.database.AppUpdateViewModel -import org.technoserve.farmcollector.utils.LanguageViewModel -import java.util.Locale @RunWith(AndroidJUnit4::class) //@RunWith(RobolectricTestRunner::class) diff --git a/app/src/test/java/org/technoserve/farmcollector/database/FarmRepositoryTest.kt b/app/src/test/java/org/technoserve/farmcollector/database/FarmRepositoryTest.kt index ff060ea..f4fef35 100644 --- a/app/src/test/java/org/technoserve/farmcollector/database/FarmRepositoryTest.kt +++ b/app/src/test/java/org/technoserve/farmcollector/database/FarmRepositoryTest.kt @@ -3,17 +3,16 @@ package org.technoserve.farmcollector.database import org.junit.Assert.* import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.runBlocking -import org.junit.Assert.* import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.Mock import org.mockito.Mockito.* import org.mockito.MockitoAnnotations -import org.mockito.junit.MockitoJUnit +import org.technoserve.farmcollector.database.dao.FarmDAO +import org.technoserve.farmcollector.repositories.FarmRepository import java.util.UUID class FarmRepositoryTest { diff --git a/app/src/test/java/org/technoserve/farmcollector/database/FarmViewModelTest.kt b/app/src/test/java/org/technoserve/farmcollector/database/FarmViewModelTest.kt index e476b6c..d257439 100644 --- a/app/src/test/java/org/technoserve/farmcollector/database/FarmViewModelTest.kt +++ b/app/src/test/java/org/technoserve/farmcollector/database/FarmViewModelTest.kt @@ -11,6 +11,8 @@ import org.junit.Test import org.mockito.Mock import org.mockito.Mockito.* import org.mockito.MockitoAnnotations +import org.technoserve.farmcollector.repositories.FarmRepository +import org.technoserve.farmcollector.viewmodels.FarmViewModel class FarmViewModelTest { From 42a93d54575195929fbfdd7b7e98a8b4bb123b8f Mon Sep 17 00:00:00 2001 From: lucienshema Date: Fri, 22 Nov 2024 11:58:02 +0200 Subject: [PATCH 4/7] separated concerns and added unit tests --- app/build.gradle | 5 +- .../ui/components/KeepPolygonDialogKtTest.kt | 49 + .../farmcollector/database/AppDatabase.kt | 1 - .../database/{ => helpers}/MyPagingSource.kt | 2 +- .../{ => helpers}/RefreshableLiveData.kt | 2 +- .../{Farm.kt => mappers/FarmMappers.kt} | 11 +- .../database/models/CollectionSiteRestore.kt | 15 + .../farmcollector/database/models/Farm.kt | 6 +- .../database/models/FarmAddResult.kt | 7 + .../database/models/FarmRestore.kt | 19 + .../database/models/ImportResult.kt | 10 + .../database/models/ParcelableFarmData.kt | 28 + .../database/models/ParcelablePair.kt | 30 + .../database/models/ParsedFarms.kt | 6 + .../database/models/ServerFarmResponse.kt | 7 + .../farmcollector/database/sync/SyncWorker.kt | 2 +- .../farmcollector/map/LocationHelper.kt | 85 +- .../farmcollector/map/LocationState.kt | 10 + .../CustomizedConfirmationDialog.kt | 65 + .../ui/components/DeleteAllDialogPresenter.kt | 65 + .../farmcollector/ui/components/FarmForm.kt | 739 +++++++ .../ui/components/FarmListHeader.kt | 195 ++ .../ui/components/FarmListHeaderPlots.kt | 225 +++ .../ui/components/FormatSelectionDialog.kt | 66 + .../ui/components/ImportFileDialog.kt | 191 ++ .../ui/components/InvalidPolygonDialog.kt | 38 + .../ui/components/KeepPolygonDialog.kt | 1 - .../ui/components/RestoreDataAlert.kt | 64 + .../SiteDeleteAllDialogPresenter.kt | 115 ++ .../ui/components/SkeletonSiteCard.kt | 3 +- .../ui/screens/collectionsites/AddSite.kt | 5 +- .../collectionsites/CollectionSiteList.kt | 8 +- .../farmcollector/ui/screens/farms/AddFarm.kt | 657 +------ .../ui/screens/farms/FarmList.kt | 1691 +---------------- .../ui/screens/farms/SetPolygon.kt | 30 +- .../ui/screens/farms/UpdateFarmForm.kt | 693 +++++++ .../farmcollector/utils/FileCreator.kt | 251 +++ .../utils/isSystemInDarkTheme.kt | 12 + .../viewmodels/AppUpdateViewModel.kt | 48 - .../farmcollector/viewmodels/FarmViewModel.kt | 69 +- .../database/FarmRepositoryTest.kt | 2 + .../database/FarmViewModelTest.kt | 1 + .../ui/components/KeepPolygonDialogKtTest.kt | 63 + build.gradle | 1 + 44 files changed, 3033 insertions(+), 2560 deletions(-) create mode 100644 app/src/androidTest/java/org/technoserve/farmcollector/ui/components/KeepPolygonDialogKtTest.kt rename app/src/main/java/org/technoserve/farmcollector/database/{ => helpers}/MyPagingSource.kt (96%) rename app/src/main/java/org/technoserve/farmcollector/database/{ => helpers}/RefreshableLiveData.kt (89%) rename app/src/main/java/org/technoserve/farmcollector/database/{Farm.kt => mappers/FarmMappers.kt} (93%) create mode 100644 app/src/main/java/org/technoserve/farmcollector/database/models/CollectionSiteRestore.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/database/models/FarmAddResult.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/database/models/FarmRestore.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/database/models/ImportResult.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/database/models/ParcelableFarmData.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/database/models/ParcelablePair.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/database/models/ParsedFarms.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/database/models/ServerFarmResponse.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/map/LocationState.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/ui/components/CustomizedConfirmationDialog.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/ui/components/DeleteAllDialogPresenter.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/ui/components/FarmForm.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/ui/components/FarmListHeader.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/ui/components/FarmListHeaderPlots.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/ui/components/FormatSelectionDialog.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/ui/components/ImportFileDialog.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/ui/components/InvalidPolygonDialog.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/ui/components/RestoreDataAlert.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/ui/components/SiteDeleteAllDialogPresenter.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/UpdateFarmForm.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/utils/FileCreator.kt create mode 100644 app/src/main/java/org/technoserve/farmcollector/utils/isSystemInDarkTheme.kt create mode 100644 app/src/test/java/org/technoserve/farmcollector/ui/components/KeepPolygonDialogKtTest.kt diff --git a/app/build.gradle b/app/build.gradle index 0ff364a..91b0eb9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -217,7 +217,6 @@ dependencies { testImplementation 'androidx.compose.ui:ui-test-junit4-android:1.7.5' testImplementation 'androidx.arch.core:core-testing:2.2.0' testImplementation "com.google.truth:truth:1.1.3" - testImplementation "org.robolectric:robolectric:4.10.3" testImplementation "io.mockk:mockk:1.13.7" testImplementation "io.mockk:mockk-android:1.13.5" testImplementation "org.robolectric:robolectric:4.9" @@ -231,5 +230,9 @@ dependencies { androidTestImplementation "io.mockk:mockk-android:1.13.5" androidTestImplementation 'com.google.dagger:hilt-android-testing:2.48' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.7.5" + debugImplementation("androidx.compose.ui:ui-test-manifest:1.7.5") } diff --git a/app/src/androidTest/java/org/technoserve/farmcollector/ui/components/KeepPolygonDialogKtTest.kt b/app/src/androidTest/java/org/technoserve/farmcollector/ui/components/KeepPolygonDialogKtTest.kt new file mode 100644 index 0000000..001c01e --- /dev/null +++ b/app/src/androidTest/java/org/technoserve/farmcollector/ui/components/KeepPolygonDialogKtTest.kt @@ -0,0 +1,49 @@ +package org.technoserve.farmcollector.ui.components + +import org.junit.Assert.* + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +class FakeMapViewModel : ViewModel() { + var coordinatesCleared = false + + fun clearCoordinates() { + coordinatesCleared = true + } +} + +@RunWith(AndroidJUnit4::class) +class KeepPolygonDialogIntegrationTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testKeepPolygonDialogClearsCoordinatesAndCapturesNew() { + val fakeViewModel = FakeMapViewModel() + + composeTestRule.setContent { + KeepPolygonDialog( + onDismiss = {}, + onKeepExisting = {}, + onCaptureNew = { + fakeViewModel.clearCoordinates() + } + ) + } + + // Click the "Capture New" button + composeTestRule.onNodeWithText("Capture New").performClick() + + // Assert that coordinates were cleared + assert(fakeViewModel.coordinatesCleared) + } +} diff --git a/app/src/main/java/org/technoserve/farmcollector/database/AppDatabase.kt b/app/src/main/java/org/technoserve/farmcollector/database/AppDatabase.kt index 210f09d..3f54c7b 100644 --- a/app/src/main/java/org/technoserve/farmcollector/database/AppDatabase.kt +++ b/app/src/main/java/org/technoserve/farmcollector/database/AppDatabase.kt @@ -36,7 +36,6 @@ abstract class AppDatabase : RoomDatabase() { } - // Define a migration from version 15 to 16 private val MIGRATION_15_16 = object : Migration(15, 16) { override fun migrate(db: SupportSQLiteDatabase) { diff --git a/app/src/main/java/org/technoserve/farmcollector/database/MyPagingSource.kt b/app/src/main/java/org/technoserve/farmcollector/database/helpers/MyPagingSource.kt similarity index 96% rename from app/src/main/java/org/technoserve/farmcollector/database/MyPagingSource.kt rename to app/src/main/java/org/technoserve/farmcollector/database/helpers/MyPagingSource.kt index bc5d76f..0e68982 100644 --- a/app/src/main/java/org/technoserve/farmcollector/database/MyPagingSource.kt +++ b/app/src/main/java/org/technoserve/farmcollector/database/helpers/MyPagingSource.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.database +package org.technoserve.farmcollector.database.helpers import androidx.paging.PagingSource import androidx.paging.PagingState diff --git a/app/src/main/java/org/technoserve/farmcollector/database/RefreshableLiveData.kt b/app/src/main/java/org/technoserve/farmcollector/database/helpers/RefreshableLiveData.kt similarity index 89% rename from app/src/main/java/org/technoserve/farmcollector/database/RefreshableLiveData.kt rename to app/src/main/java/org/technoserve/farmcollector/database/helpers/RefreshableLiveData.kt index 10568b0..5f9877a 100644 --- a/app/src/main/java/org/technoserve/farmcollector/database/RefreshableLiveData.kt +++ b/app/src/main/java/org/technoserve/farmcollector/database/helpers/RefreshableLiveData.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.database +package org.technoserve.farmcollector.database.helpers import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData diff --git a/app/src/main/java/org/technoserve/farmcollector/database/Farm.kt b/app/src/main/java/org/technoserve/farmcollector/database/mappers/FarmMappers.kt similarity index 93% rename from app/src/main/java/org/technoserve/farmcollector/database/Farm.kt rename to app/src/main/java/org/technoserve/farmcollector/database/mappers/FarmMappers.kt index e72cd6f..4dd68c4 100644 --- a/app/src/main/java/org/technoserve/farmcollector/database/Farm.kt +++ b/app/src/main/java/org/technoserve/farmcollector/database/mappers/FarmMappers.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.database +package org.technoserve.farmcollector.database.mappers import org.technoserve.farmcollector.database.dao.FarmDAO import org.technoserve.farmcollector.database.models.CollectionSiteDto @@ -6,10 +6,6 @@ import org.technoserve.farmcollector.database.models.DeviceFarmDto import org.technoserve.farmcollector.database.models.Farm import org.technoserve.farmcollector.database.models.FarmDetailDto -/** - * This file contains the information about the entities in the database including farms and collection sites - */ - fun List.toDeviceFarmDtoList(deviceId: String, farmDao: FarmDAO): List { return this.groupBy { it.siteId } // Group by siteId .mapNotNull { (siteId, farms) -> @@ -56,7 +52,4 @@ fun List.toDeviceFarmDtoList(deviceId: String, farmDao: FarmDAO): List>, + val accuracyArray: List?, + val created_at: String, + val updated_at: String, + val site_id: Long +) diff --git a/app/src/main/java/org/technoserve/farmcollector/database/models/ImportResult.kt b/app/src/main/java/org/technoserve/farmcollector/database/models/ImportResult.kt new file mode 100644 index 0000000..29daab5 --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/database/models/ImportResult.kt @@ -0,0 +1,10 @@ +package org.technoserve.farmcollector.database.models + +data class ImportResult( + val success: Boolean, + val message: String, + val importedFarms: List, + val duplicateFarms: List = emptyList(), + val farmsNeedingUpdate: List = emptyList(), + val invalidFarms: List = emptyList() +) diff --git a/app/src/main/java/org/technoserve/farmcollector/database/models/ParcelableFarmData.kt b/app/src/main/java/org/technoserve/farmcollector/database/models/ParcelableFarmData.kt new file mode 100644 index 0000000..a268e4d --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/database/models/ParcelableFarmData.kt @@ -0,0 +1,28 @@ +package org.technoserve.farmcollector.database.models + +import android.os.Parcel +import android.os.Parcelable + +data class ParcelableFarmData(val farm: Farm, val view: String) : Parcelable { + constructor(parcel: Parcel) : this( + parcel.readParcelable(Farm::class.java.classLoader)!!, + parcel.readString()!! + ) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeParcelable(farm, flags) + parcel.writeString(view) + } + + override fun describeContents(): Int = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): ParcelableFarmData { + return ParcelableFarmData(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } +} diff --git a/app/src/main/java/org/technoserve/farmcollector/database/models/ParcelablePair.kt b/app/src/main/java/org/technoserve/farmcollector/database/models/ParcelablePair.kt new file mode 100644 index 0000000..8bd99b0 --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/database/models/ParcelablePair.kt @@ -0,0 +1,30 @@ +package org.technoserve.farmcollector.database.models + +import android.os.Parcel +import android.os.Parcelable + +data class ParcelablePair(val first: Double, val second: Double) : Parcelable { + constructor(parcel: Parcel) : this( + parcel.readDouble(), + parcel.readDouble() + ) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeDouble(first) + parcel.writeDouble(second) + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): ParcelablePair { + return ParcelablePair(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } +} diff --git a/app/src/main/java/org/technoserve/farmcollector/database/models/ParsedFarms.kt b/app/src/main/java/org/technoserve/farmcollector/database/models/ParsedFarms.kt new file mode 100644 index 0000000..5162ddc --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/database/models/ParsedFarms.kt @@ -0,0 +1,6 @@ +package org.technoserve.farmcollector.database.models + +data class ParsedFarms( + val validFarms: List, + val invalidFarms: List +) \ No newline at end of file diff --git a/app/src/main/java/org/technoserve/farmcollector/database/models/ServerFarmResponse.kt b/app/src/main/java/org/technoserve/farmcollector/database/models/ServerFarmResponse.kt new file mode 100644 index 0000000..4f881cd --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/database/models/ServerFarmResponse.kt @@ -0,0 +1,7 @@ +package org.technoserve.farmcollector.database.models + +data class ServerFarmResponse( + val device_id: String, + val collection_site: CollectionSiteRestore, + val farms: List +) diff --git a/app/src/main/java/org/technoserve/farmcollector/database/sync/SyncWorker.kt b/app/src/main/java/org/technoserve/farmcollector/database/sync/SyncWorker.kt index 65dfe75..de2ba7d 100644 --- a/app/src/main/java/org/technoserve/farmcollector/database/sync/SyncWorker.kt +++ b/app/src/main/java/org/technoserve/farmcollector/database/sync/SyncWorker.kt @@ -16,8 +16,8 @@ import okhttp3.OkHttpClient import org.technoserve.farmcollector.BuildConfig import org.technoserve.farmcollector.R import org.technoserve.farmcollector.database.AppDatabase +import org.technoserve.farmcollector.database.mappers.toDeviceFarmDtoList import org.technoserve.farmcollector.database.sync.remote.ApiService -import org.technoserve.farmcollector.database.toDeviceFarmDtoList import org.technoserve.farmcollector.utils.DeviceIdUtil import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory diff --git a/app/src/main/java/org/technoserve/farmcollector/map/LocationHelper.kt b/app/src/main/java/org/technoserve/farmcollector/map/LocationHelper.kt index b931c2f..4c0ec4a 100644 --- a/app/src/main/java/org/technoserve/farmcollector/map/LocationHelper.kt +++ b/app/src/main/java/org/technoserve/farmcollector/map/LocationHelper.kt @@ -41,15 +41,6 @@ import kotlin.coroutines.resumeWithException * */ -data class LocationState( - val latitude: Double = 0.0, - val longitude: Double = 0.0, - val isLoading: Boolean = false, - val error: String? = null, - val isUsingGPS: Boolean = true, - val accuracy: Float = 0f -) - suspend fun Task.await(): T { return suspendCancellableCoroutine { continuation -> addOnSuccessListener { result -> @@ -102,56 +93,7 @@ class LocationHelper(private val context: Context) : SensorEventListener { ) == PackageManager.PERMISSION_GRANTED } -// // Primary location update function -// @SuppressLint("MissingPermission") -// fun getLocationUpdates(): Flow = callbackFlow { -// if (!hasLocationPermission()) { -// showPermissionRequest.value = true -// throw LocationException("Missing location permission") -// } -// -// val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager -// val isGpsEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) -// val isNetworkEnabled = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) -// -// if (!isGpsEnabled && !isNetworkEnabled) { -// showLocationDialog.value = true -// startDeadReckoning() -// throw LocationException("GPS and Network are disabled") -// } -// -// val locationCallback = object : LocationCallback() { -// override fun onLocationResult(result: LocationResult) { -// result.lastLocation?.let { location -> -// trySend(location) -// updateLocationState( -// location.latitude, -// location.longitude, -// location.accuracy, -// isUsingGPS = true -// ) -// } -// } -// } -// -// fusedLocationClient.requestLocationUpdates( -// locationRequest, -// locationCallback, -// Looper.getMainLooper() -// ).addOnFailureListener { -// _locationState.value = LocationState( -// error = it.message, -// isLoading = false, -// isUsingGPS = false -// ) -// startDeadReckoning() -// } -// -// awaitClose { -// fusedLocationClient.removeLocationUpdates(locationCallback) -// stopSensorUpdates() -// } -// } + // Fallback method using dead reckoning with sensors private fun startDeadReckoning() { @@ -365,30 +307,6 @@ class LocationHelper(private val context: Context) : SensorEventListener { } } -// @SuppressLint("MissingPermission") -// fun requestLocationUpdates(onLocationUpdate: (Location?) -> Unit) { -// if (!hasLocationPermission()) { -// showPermissionRequest.value = true -// return -// } -// -// val locationCallback = object : LocationCallback() { -// override fun onLocationResult(locationResult: LocationResult) { -// val location = locationResult.lastLocation -// onLocationUpdate(location) -// location?.let { -// updateLocationState(it.latitude, it.longitude, it.accuracy, true) -// } -// } -// } -// -// fusedLocationClient.requestLocationUpdates( -// locationRequest, -// locationCallback, -// Looper.getMainLooper() -// ) -// } - @SuppressLint("MissingPermission") fun requestLocationUpdates(onLocationUpdate: (Location?) -> Unit) { // Check location permission @@ -461,7 +379,6 @@ class LocationHelper(private val context: Context) : SensorEventListener { } } -//class LocationException(message: String) : Exception(message) diff --git a/app/src/main/java/org/technoserve/farmcollector/map/LocationState.kt b/app/src/main/java/org/technoserve/farmcollector/map/LocationState.kt new file mode 100644 index 0000000..0be6237 --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/map/LocationState.kt @@ -0,0 +1,10 @@ +package org.technoserve.farmcollector.map + +data class LocationState( + val latitude: Double = 0.0, + val longitude: Double = 0.0, + val isLoading: Boolean = false, + val error: String? = null, + val isUsingGPS: Boolean = true, + val accuracy: Float = 0f +) diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/components/CustomizedConfirmationDialog.kt b/app/src/main/java/org/technoserve/farmcollector/ui/components/CustomizedConfirmationDialog.kt new file mode 100644 index 0000000..8231c9c --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/ui/components/CustomizedConfirmationDialog.kt @@ -0,0 +1,65 @@ +package org.technoserve.farmcollector.ui.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.technoserve.farmcollector.R +import org.technoserve.farmcollector.database.models.Farm +import org.technoserve.farmcollector.ui.screens.farms.Action + +@Composable +fun CustomizedConfirmationDialog( + listItems: List, + action: Action, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + fun validateFarms(farms: List): Pair> { + val incompleteFarms = + farms.filter { farm -> + farm.farmerName.isEmpty() || + farm.district.isEmpty() || + farm.village.isEmpty() || + farm.latitude == "0.0" || + farm.longitude == "0.0" || + farm.size == 0.0f || + farm.remoteId.toString().isEmpty() + } + return Pair(farms.size, incompleteFarms) + } + val (totalFarms, incompleteFarms) = validateFarms(listItems) + val message = + when (action) { + Action.Export -> stringResource( + R.string.confirm_export, + totalFarms, + incompleteFarms.size + ) + + Action.Share -> stringResource(R.string.confirm_share, totalFarms, incompleteFarms.size) + } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(text = stringResource(R.string.confirm)) }, + text = { Text(text = message) }, + confirmButton = { + Button(onClick = { + onConfirm() + onDismiss() + }) { + Text(text = stringResource(R.string.yes)) + } + }, + dismissButton = { + Button(onClick = { onDismiss() }) { + Text(text = stringResource(R.string.no)) + } + }, + containerColor = MaterialTheme.colorScheme.background, + tonalElevation = 6.dp + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/components/DeleteAllDialogPresenter.kt b/app/src/main/java/org/technoserve/farmcollector/ui/components/DeleteAllDialogPresenter.kt new file mode 100644 index 0000000..3872c3c --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/ui/components/DeleteAllDialogPresenter.kt @@ -0,0 +1,65 @@ +package org.technoserve.farmcollector.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.technoserve.farmcollector.R + +@Composable +fun DeleteAllDialogPresenter( + showDeleteDialog: MutableState, + onProceedFn: () -> Unit, +) { + if (showDeleteDialog.value) { + AlertDialog( + modifier = Modifier.padding(horizontal = 32.dp), + onDismissRequest = { showDeleteDialog.value = false }, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Warning, // Use a built-in warning icon + contentDescription = stringResource(id = R.string.warning), + tint = MaterialTheme.colorScheme.error, // Use error color for the icon + modifier = Modifier.size(24.dp) // Adjust the size of the icon + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = stringResource(id = R.string.delete_this_farm)) + } + }, + text = { + Column { + Text(stringResource(id = R.string.are_you_sure)) + Text(stringResource(id = R.string.farm_will_be_deleted)) + } + }, + confirmButton = { + TextButton(onClick = { onProceedFn() }) { + Text(text = stringResource(id = R.string.yes)) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog.value = false }) { + Text(text = stringResource(id = R.string.no)) + } + }, + containerColor = MaterialTheme.colorScheme.background, // Background that adapts to light/dark + tonalElevation = 6.dp // Adds a subtle shadow for better UX + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/components/FarmForm.kt b/app/src/main/java/org/technoserve/farmcollector/ui/components/FarmForm.kt new file mode 100644 index 0000000..5a62211 --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/ui/components/FarmForm.kt @@ -0,0 +1,739 @@ +package org.technoserve.farmcollector.ui.components + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.Intent +import android.view.KeyEvent +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import com.google.android.gms.maps.model.LatLngBounds +import org.technoserve.farmcollector.R +import org.technoserve.farmcollector.map.LocationHelper +import org.technoserve.farmcollector.map.LocationState +import org.technoserve.farmcollector.map.MapViewModel +import org.technoserve.farmcollector.map.getCenterOfPolygon +import org.technoserve.farmcollector.ui.screens.farms.LocationPermissionRequest +import org.technoserve.farmcollector.ui.screens.farms.addFarm +import org.technoserve.farmcollector.ui.screens.farms.formatInput +import org.technoserve.farmcollector.ui.screens.farms.isLocationEnabled +import org.technoserve.farmcollector.ui.screens.farms.promptEnableLocation +import org.technoserve.farmcollector.ui.screens.farms.readStoredValue +import org.technoserve.farmcollector.ui.screens.farms.toLatLngList +import org.technoserve.farmcollector.ui.screens.farms.truncateToDecimalPlaces +import org.technoserve.farmcollector.ui.screens.farms.validateNumber +import org.technoserve.farmcollector.ui.screens.farms.validateSize +import org.technoserve.farmcollector.utils.convertSize +import org.technoserve.farmcollector.utils.isSystemInDarkTheme +import org.technoserve.farmcollector.viewmodels.FarmViewModel +import org.technoserve.farmcollector.viewmodels.FarmViewModelFactory +import java.math.BigDecimal +import java.util.UUID +import java.util.regex.Pattern + +@SuppressLint("MissingPermission") +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +fun FarmForm( + navController: NavController, + siteId: Long, + coordinatesData: List>?, + accuracyArrayData: List? +) { + val context = LocalContext.current as Activity + var isValid by remember { mutableStateOf(true) } + var farmerName by rememberSaveable { mutableStateOf("") } + var memberId by rememberSaveable { mutableStateOf("") } + val farmerPhoto by rememberSaveable { mutableStateOf("") } + var village by rememberSaveable { mutableStateOf("") } + var district by rememberSaveable { mutableStateOf("") } + var latitude by rememberSaveable { mutableStateOf("") } + var longitude by rememberSaveable { mutableStateOf("") } + var accuracyArray by rememberSaveable { mutableStateOf(listOf()) } + val items = listOf("Ha", "Acres", "Sqm", "Timad", "Fichesa", "Manzana", "Tarea") + var expanded by remember { mutableStateOf(false) } + val sharedPref = context.getSharedPreferences("FarmCollector", Context.MODE_PRIVATE) + val farmViewModel: FarmViewModel = viewModel( + factory = FarmViewModelFactory(context.applicationContext as Application) + ) + val mapViewModel: MapViewModel = viewModel() + var size by rememberSaveable { mutableStateOf(readStoredValue(sharedPref)) } + var selectedUnit by rememberSaveable { + mutableStateOf( + sharedPref.getString( + "selectedUnit", + items[0] + ) ?: items[0] + ) + } + var isValidSize by remember { mutableStateOf(true) } + var isFormSubmitted by remember { mutableStateOf(false) } + val scientificNotationPattern = Pattern.compile("([+-]?\\d*\\.?\\d+)[eE][+-]?\\d+") + val showDialog = remember { mutableStateOf(false) } + val showLocationDialog = remember { mutableStateOf(false) } + val showLocationDialogNew = remember { mutableStateOf(false) } + // Function to update the selected unit + fun updateSelectedUnit(newUnit: String) { + selectedUnit = newUnit + sharedPref.edit().putString("selectedUnit", newUnit).apply() + } + val locationHelper = LocationHelper(context) + var locationState by remember { mutableStateOf(null) } + + LaunchedEffect(locationHelper) { + locationHelper.locationState.collect { state -> + locationState = state + } + } + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + size = sharedPref.getString("plot_size", "") ?: "" + selectedUnit = sharedPref.getString("selectedUnit", "Ha") ?: "Ha" + with(sharedPref.edit()) { + remove("plot_size") + remove("selectedUnit") + apply() + } + } + } + + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + if (showLocationDialog.value) { + AlertDialog( + onDismissRequest = { showLocationDialog.value = false }, + title = { Text(stringResource(id = R.string.enable_location)) }, + text = { Text(stringResource(id = R.string.enable_location_msg)) }, + confirmButton = { + Button(onClick = { + showLocationDialog.value = false + promptEnableLocation(context) + }) { + Text(stringResource(id = R.string.yes)) + } + }, + dismissButton = { + Button(onClick = { + showLocationDialog.value = false + Toast.makeText( + context, + R.string.location_permission_denied_message, + Toast.LENGTH_SHORT + ).show() + }) { + Text(stringResource(id = R.string.no)) + } + }, + containerColor = MaterialTheme.colorScheme.background, + tonalElevation = 6.dp + ) + } + + fun saveFarm() { + // Validate size input if the size is empty we use the default size 0 + if (size.isEmpty()) { + size = "0.0" + } + val sizeInHa = convertSize(size.toDouble(), selectedUnit) + val newUUID = UUID.randomUUID() + val coordinatesSize = + coordinatesData?.size ?: 0 + val finalAccuracyArray = when { + accuracyArray.isEmpty() -> emptyList() + coordinatesSize == 0 -> listOf(accuracyArray[0]) + else -> { + val result = accuracyArrayData!!.toMutableList() + if (coordinatesSize > 1) { + result.add(accuracyArrayData.last()) + } + result + } + } + addFarm( + farmViewModel, + siteId, + remote_id = newUUID, + farmerPhoto, + farmerName, + memberId, + village, + district, + 0.toFloat(), + sizeInHa.toFloat(), + latitude, + longitude, + coordinates = coordinatesData, + accuracyArray = finalAccuracyArray + ) + val returnIntent = Intent() + context.setResult(Activity.RESULT_OK, returnIntent) + navController.navigate("farmList/${siteId}") + } + if (showDialog.value) { + AlertDialog( + modifier = Modifier.padding(horizontal = 32.dp), + onDismissRequest = { showDialog.value = false }, + title = { Text(text = stringResource(id = R.string.add_farm)) }, + text = { + Column { + Text(text = stringResource(id = R.string.confirm_add_farm)) + } + }, + confirmButton = { + TextButton(onClick = { + saveFarm() + }) { + Text(text = stringResource(id = R.string.add_farm)) + } + }, + dismissButton = { + TextButton(onClick = + { + showDialog.value = false + navController.navigate("setPolygon") + }) { + Text(text = stringResource(id = R.string.set_polygon)) + } + }, + containerColor = MaterialTheme.colorScheme.background, + tonalElevation = 6.dp + ) + } + + fun validateForm(): Boolean { + isValid = true + val textWithNumbersRegex = Regex(".*[a-zA-Z]+.*") + if (farmerName.isBlank() || !farmerName.matches(textWithNumbersRegex)) { + isValid = false + } + if (village.isBlank() || !village.matches(textWithNumbersRegex)) { + isValid = false + } + if (district.isBlank() || !district.matches(textWithNumbersRegex)) { + isValid = false + } + if (size.isBlank() || size.toFloatOrNull() == null || size.toFloat() <= 0) { + isValid = false + } + if (selectedUnit.isBlank()) { + isValid = false + } + if (latitude.isBlank() || longitude.isBlank()) { + isValid = false + } + return isValid + } + + val scrollState = rememberScrollState() + val fillForm = stringResource(id = R.string.fill_form) + val showPermissionRequest = remember { mutableStateOf(false) } + val (focusRequester1) = FocusRequester.createRefs() + val (focusRequester2) = FocusRequester.createRefs() + val (focusRequester3) = FocusRequester.createRefs() + val isDarkTheme = isSystemInDarkTheme() + val inputLabelColor = MaterialTheme.colorScheme.onBackground + val inputTextColor = if (isDarkTheme) Color.White else Color.Black + val inputBorder = if (isDarkTheme) Color.LightGray else Color.DarkGray + val textWithNumbersRegex = Regex(".*[a-zA-Z]+.*") + var isfarmerNameValid by remember { mutableStateOf(true) } + var isvillageValid by remember { mutableStateOf(true) } + var isDistrictValid by remember { mutableStateOf(true) } + + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .padding(16.dp) + .verticalScroll(state = scrollState) + ) { + TextField( + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { focusRequester1.requestFocus() } + ), + value = farmerName, + onValueChange = { + farmerName = it + isfarmerNameValid = + farmerName.isNotBlank() && farmerName.matches(textWithNumbersRegex) + }, + label = { + Text( + stringResource(id = R.string.farm_name) + " (*)", + color = inputLabelColor + ) + }, + supportingText = { + if (!isfarmerNameValid) { + Text(stringResource(R.string.error_farmer_name_empty) + " (*)") + } + }, + isError = !isfarmerNameValid, + colors = TextFieldDefaults.colors( + errorLeadingIconColor = Color.Red, + cursorColor = inputTextColor, + errorCursorColor = Color.Red, + focusedIndicatorColor = inputBorder, + unfocusedIndicatorColor = inputBorder, + errorIndicatorColor = Color.Red + ), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .onKeyEvent { + if (it.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_ENTER) { + focusRequester1.requestFocus() + } + false + } + ) + TextField( + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { focusRequester1.requestFocus() } + ), + value = memberId, + onValueChange = { memberId = it }, + label = { Text(stringResource(id = R.string.member_id), color = inputLabelColor) }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .onKeyEvent { + if (it.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_ENTER) { + focusRequester1.requestFocus() + } + false + } + ) + TextField( + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { focusRequester2.requestFocus() } + ), + value = village, + onValueChange = { + village = it + isvillageValid = village.isNotBlank() && village.matches(textWithNumbersRegex) + }, + label = { + Text( + stringResource(id = R.string.village) + " (*)", + color = inputLabelColor + ) + }, + supportingText = { + if (!isvillageValid) { + Text(stringResource(R.string.error_village_empty)) + } + }, + isError = !isvillageValid, + colors = TextFieldDefaults.colors( + errorLeadingIconColor = Color.Red, + cursorColor = inputTextColor, + errorCursorColor = Color.Red, + focusedIndicatorColor = inputBorder, + unfocusedIndicatorColor = inputBorder, + errorIndicatorColor = Color.Red + ), + modifier = Modifier + .focusRequester(focusRequester1) + .fillMaxWidth() + .padding(bottom = 16.dp) + ) + TextField( + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { focusRequester3.requestFocus() } + ), + value = district, + onValueChange = { + district = it + isDistrictValid = district.isNotBlank() && district.matches(textWithNumbersRegex) + }, + label = { + Text( + stringResource(id = R.string.district) + " (*)", + color = inputLabelColor + ) + }, + supportingText = { + if (!isDistrictValid) { + Text(text = stringResource(R.string.error_district_empty)) + } + }, + isError = !isDistrictValid, + colors = TextFieldDefaults.colors( + errorLeadingIconColor = Color.Red, + cursorColor = inputTextColor, + errorCursorColor = Color.Red, + focusedIndicatorColor = inputBorder, + unfocusedIndicatorColor = inputBorder, + errorIndicatorColor = Color.Red + ), + modifier = Modifier + .focusRequester(focusRequester2) + .fillMaxWidth() + .padding(bottom = 16.dp) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextField( + singleLine = true, + value = truncateToDecimalPlaces(size, 9), + onValueChange = { inputValue -> + val formattedValue = when { + validateSize(inputValue) -> inputValue + scientificNotationPattern.matcher(inputValue).matches() -> { + truncateToDecimalPlaces(formatInput(inputValue), 9) + } + + else -> inputValue + } + size = formattedValue + isValidSize = validateSize(formattedValue) + with(sharedPref.edit()) { + putString("plot_size", formattedValue) + apply() + } + }, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Number + ), + label = { + Text( + text = stringResource(id = R.string.size_in_hectares) + " (*)", + color = inputLabelColor + ) + }, + supportingText = { + when { + isFormSubmitted && size.isBlank() -> { + Text(stringResource(R.string.error_farm_size_empty)) + } + + isFormSubmitted && !isValidSize -> { + Text(stringResource(R.string.error_farm_size_invalid)) + } + } + }, + isError = isFormSubmitted && (!isValidSize || size.isBlank()), + colors = TextFieldDefaults.colors( + errorLeadingIconColor = Color.Red, + cursorColor = inputTextColor, + errorCursorColor = Color.Red, + focusedIndicatorColor = inputBorder, + unfocusedIndicatorColor = inputBorder, + errorIndicatorColor = Color.Red + ), + modifier = Modifier + .focusRequester(focusRequester3) + .weight(1f) + .padding(end = 16.dp) + ) + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = Modifier.weight(1f) + ) { + TextField( + readOnly = true, + value = selectedUnit, + onValueChange = { }, + label = { Text(stringResource(R.string.unit)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = expanded + ) + }, + colors = ExposedDropdownMenuDefaults.textFieldColors(), + modifier = Modifier.menuAnchor() + ) + ExposedDropdownMenu( + expanded = expanded, + modifier = Modifier.background(MaterialTheme.colorScheme.background), + onDismissRequest = { expanded = false } + ) { + items.forEach { selectionOption -> + DropdownMenuItem( + text = { Text(text = selectionOption) }, + onClick = { + updateSelectedUnit(selectionOption) + expanded = false + } + ) + } + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + // If coordinatesData exists and latitude/longitude are empty, calculate the center + if (coordinatesData?.isNotEmpty() == true && latitude.isBlank() && longitude.isBlank()) { + val center = coordinatesData.toLatLngList().getCenterOfPolygon() + val bounds: LatLngBounds = center + longitude = bounds.northeast.longitude.toString() + latitude = bounds.southwest.latitude.toString() + } + + if ((size.toDoubleOrNull()?.let { convertSize(it, selectedUnit).toFloat() } ?: 0f) < 4f) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TextField( + readOnly = true, + value = latitude, + onValueChange = { it -> + val formattedValue = when { + validateNumber(it) -> it + scientificNotationPattern.matcher(it).matches() -> { + truncateToDecimalPlaces(formatInput(it), 9) + } + + else -> { + // Show a Toast message if the input does not meet the requirements + Toast.makeText( + context, + R.string.error_latitude_decimal_places, + Toast.LENGTH_SHORT + ).show() + null + } + } + formattedValue?.let { + latitude = it + } + }, + label = { + Text( + stringResource(id = R.string.latitude) + " (*)", + color = inputLabelColor + ) + }, + supportingText = { + if (!isValid && latitude.split(".").last().length < 6) Text( + stringResource(R.string.error_latitude_decimal_places) + ) + }, + isError = !isValid && latitude.split(".").last().length < 6, + colors = TextFieldDefaults.colors( + errorLeadingIconColor = Color.Red, + ), + modifier = Modifier + .weight(1f) + .padding(bottom = 16.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + TextField( + readOnly = true, + value = longitude, + onValueChange = { it -> + val formattedValue = when { + validateNumber(it) -> it + scientificNotationPattern.matcher(it).matches() -> { + truncateToDecimalPlaces(formatInput(it), 9) + } + + else -> { + // Show a Toast message if the input does not meet the requirements + Toast.makeText( + context, + R.string.error_longitude_decimal_places, + Toast.LENGTH_SHORT + ).show() + null + } + } + formattedValue?.let { + longitude = it + } + }, + label = { + Text( + stringResource(id = R.string.longitude) + " (*)", + color = inputLabelColor + ) + }, + supportingText = { + if (!isValid && longitude.split(".").last().length < 6) Text( + stringResource(R.string.error_longitude_decimal_places) + "" + ) + }, + isError = !isValid && longitude.split(".").last().length < 6, + colors = TextFieldDefaults.colors( + errorLeadingIconColor = Color.Red, + ), + modifier = Modifier + .weight(1f) + .padding(bottom = 16.dp) + ) + } + } + if (showPermissionRequest.value) { + LocationPermissionRequest( + onLocationEnabled = { + showLocationDialog.value = true + }, + onPermissionsGranted = { + showPermissionRequest.value = false + }, + showLocationDialogNew = showLocationDialogNew, + hasToShowDialog = showLocationDialogNew.value + ) + } + + + fun roundToDecimalPlaces(value: Double): String { + val bigDecimal = BigDecimal.valueOf(value) + return bigDecimal.setScale(9, BigDecimal.ROUND_DOWN).toString() + } + + /** + * Function to handle location permission and coordinate calculation + */ + /** + * Function to handle location permission and coordinate calculation + */ + fun handleLocationAndNavigate(size: String, selectedUnit: String) { + val enteredSize = + size.toDoubleOrNull()?.let { convertSize(it, selectedUnit).toFloat() } ?: 0f + if (coordinatesData?.isNotEmpty() == true && latitude.isBlank() && longitude.isBlank()) { + val center = coordinatesData.toLatLngList().getCenterOfPolygon() + val bounds: LatLngBounds = center + latitude = roundToDecimalPlaces(bounds.northeast.longitude.toString().toDouble()) + longitude = roundToDecimalPlaces(bounds.southwest.latitude.toString().toDouble()) + } + locationHelper.requestLocationPermissionAndUpdateCoordinates( + enteredSize = enteredSize, + navController = navController, + mapViewModel = mapViewModel, + onLocationResult = { newLatitude, newLongitude, accuracy -> + latitude = newLatitude + longitude = newLongitude + accuracyArray = accuracyArray + accuracy.toFloat() + + } + ) + } + + Button( + onClick = { + if (isLocationEnabled(context)) { + handleLocationAndNavigate(size, selectedUnit) + } + else + showPermissionRequest.value = true + }, + modifier = Modifier + .background(MaterialTheme.colorScheme.background) + .align(Alignment.CenterHorizontally) + .fillMaxWidth(0.7f) + .height(50.dp) + .padding(bottom = 5.dp), + enabled = size.isNotBlank() + ) { + val enteredSize = + size.toDoubleOrNull()?.let { convertSize(it, selectedUnit).toFloat() } ?: 0f + + Text( + text = if (enteredSize >= 4f) { + stringResource(id = R.string.set_polygon) + } else { + stringResource(id = R.string.get_coordinates) + } + ) + } + Button( + onClick = { + isFormSubmitted = true + // Finding the center of the polygon captured + if (coordinatesData?.isNotEmpty() == true && latitude.isBlank() && longitude.isBlank()) { + val center = coordinatesData.toLatLngList().getCenterOfPolygon() + val bounds: LatLngBounds = center + longitude = bounds.northeast.longitude.toString() + latitude = bounds.southwest.latitude.toString() + } + if (validateForm()) { + // Ask user to confirm before adding farm + if (coordinatesData?.isNotEmpty() == true) saveFarm() + else showDialog.value = true + } else { + Toast.makeText(context, fillForm, Toast.LENGTH_SHORT).show() + } + }, + modifier = Modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxWidth() + .height(50.dp) + ) { + Text(text = stringResource(id = R.string.add_farm)) + } + } + DisposableEffect(locationHelper) { + onDispose { + locationHelper.cleanup() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/components/FarmListHeader.kt b/app/src/main/java/org/technoserve/farmcollector/ui/components/FarmListHeader.kt new file mode 100644 index 0000000..120ef1e --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/ui/components/FarmListHeader.kt @@ -0,0 +1,195 @@ +package org.technoserve.farmcollector.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.technoserve.farmcollector.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FarmListHeader( + title: String, + onSearchQueryChanged: (String) -> Unit, + onBackClicked: () -> Unit, + showSearch: Boolean, + showRestore: Boolean, + onRestoreClicked: () -> Unit +) { + // State to hold the search query + var searchQuery by remember { mutableStateOf("") } + + // State to determine if the search mode is active + var isSearchVisible by remember { mutableStateOf(false) } + + TopAppBar( + modifier = Modifier + .background(MaterialTheme.colorScheme.primary) + .fillMaxWidth(), + navigationIcon = { + IconButton(onClick = { + if (isSearchVisible) { + // Exit search mode, clear search query + searchQuery = "" + onSearchQueryChanged("") + isSearchVisible = false + } else { + // Navigate back normally + onBackClicked() + } + }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onPrimary + ) + } + }, + title = { + Text( + text = title, + color = MaterialTheme.colorScheme.onPrimary, + fontSize = 22.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + actions = { + + if (showRestore) { + IconButton( + onClick = { onRestoreClicked() }, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Restore", + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + if (showSearch) { + IconButton(onClick = { + isSearchVisible = !isSearchVisible + }, modifier = Modifier.size(36.dp)) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + }, + ) + + // Show search field when search mode is active + if (isSearchVisible) { + Box( + modifier = Modifier + .padding(top = 54.dp) + .fillMaxWidth(), + contentAlignment = Alignment.Center // Center the Row within the Box + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, // Center the contents within the Row + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = searchQuery, + onValueChange = { + searchQuery = it + onSearchQueryChanged(it) + }, + modifier = Modifier + .fillMaxWidth() // Center with a smaller width + .padding(8.dp) + .clip(RoundedCornerShape(0.dp)), // Add rounded corners + placeholder = { + Text( + stringResource(R.string.search), + color = MaterialTheme.colorScheme.onBackground + ) + }, + leadingIcon = { + IconButton(onClick = { + // Exit search mode and clear search + searchQuery = "" + onSearchQueryChanged("") + isSearchVisible = false + }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onSurface + ) + } + }, + trailingIcon = { + if (searchQuery != "") { + IconButton(onClick = { + searchQuery = "" + onSearchQueryChanged("") + }) { + Icon( + Icons.Default.Clear, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + }, + singleLine = true, +// colors = TextFieldDefaults.outlinedTextFieldColors( +// cursorColor = MaterialTheme.colorScheme.onSurface, +// focusedBorderColor = MaterialTheme.colorScheme.primary, +// unfocusedBorderColor = MaterialTheme.colorScheme.onSurfaceVariant, +// focusedTextColor = MaterialTheme.colorScheme.onSurface +// ), + colors = TextFieldDefaults.colors( + cursorColor = MaterialTheme.colorScheme.onSurface, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + errorCursorColor = Color.Red, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = MaterialTheme.colorScheme.onSurfaceVariant, + errorIndicatorColor = Color.Red + ), + shape = RoundedCornerShape(0.dp) + ) + + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/components/FarmListHeaderPlots.kt b/app/src/main/java/org/technoserve/farmcollector/ui/components/FarmListHeaderPlots.kt new file mode 100644 index 0000000..5f6e43e --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/ui/components/FarmListHeaderPlots.kt @@ -0,0 +1,225 @@ +package org.technoserve.farmcollector.ui.components + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.technoserve.farmcollector.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FarmListHeaderPlots( + title: String, + onBackClicked: () -> Unit, + onExportClicked: () -> Unit, + onShareClicked: () -> Unit, + onImportClicked: () -> Unit, + onSearchQueryChanged: (String) -> Unit, + showExport: Boolean, + showShare: Boolean, + showSearch: Boolean, + onRestoreClicked: () -> Unit +) { + + var searchQuery by remember { mutableStateOf("") } + var isSearchVisible by remember { mutableStateOf(false) } + var isImportDisabled by remember { mutableStateOf(false) } + + TopAppBar( + title = { + Text( + text = title, + fontSize = 22.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + navigationIcon = { + IconButton(onClick = { + if (isSearchVisible) { + searchQuery = "" + onSearchQueryChanged("") + isSearchVisible = false + } else { + onBackClicked() + } + }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onPrimary + ) + } + }, + actions = { + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.horizontalScroll(rememberScrollState()) + ) { + IconButton( + onClick = { onRestoreClicked() }, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Restore", + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onPrimary + ) + } + if (showExport) { + IconButton(onClick = onExportClicked, modifier = Modifier.size(36.dp)) { + Icon( + painter = painterResource(id = R.drawable.save), + contentDescription = "Export", + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } + if (showShare) { + IconButton(onClick = onShareClicked, modifier = Modifier.size(36.dp)) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "Share", + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + IconButton( + onClick = { + if (!isImportDisabled) { + onImportClicked() + } + }, + modifier = Modifier.size(36.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.icons8_import_file_48), + contentDescription = "Import", + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + if (showSearch) { + IconButton(onClick = { + isSearchVisible = !isSearchVisible + }, modifier = Modifier.size(36.dp)) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + } + }, + ) + if (isSearchVisible) { + Box( + modifier = Modifier + .padding(top = 54.dp) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = searchQuery, + onValueChange = { + searchQuery = it + onSearchQueryChanged(it) + }, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + .clip(RoundedCornerShape(0.dp)), + placeholder = { Text(stringResource(R.string.search)) }, + leadingIcon = { + IconButton(onClick = { + searchQuery = "" + onSearchQueryChanged("") + isSearchVisible = false + }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onSurface + ) + } + }, + trailingIcon = { + if (searchQuery != "") { + IconButton(onClick = { + searchQuery = "" + onSearchQueryChanged("") + }) { + Icon( + Icons.Default.Clear, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + }, + singleLine = true, +// colors = TextFieldDefaults.outlinedTextFieldColors( +// cursorColor = MaterialTheme.colorScheme.onSurface, +// focusedBorderColor = MaterialTheme.colorScheme.primary, +// unfocusedBorderColor = MaterialTheme.colorScheme.onSurfaceVariant, +// focusedTextColor = MaterialTheme.colorScheme.onSurface +// ), + colors = TextFieldDefaults.colors( + cursorColor = MaterialTheme.colorScheme.onSurface, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + errorCursorColor = Color.Red, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = MaterialTheme.colorScheme.onSurfaceVariant, + errorIndicatorColor = Color.Red + ), + shape = RoundedCornerShape(0.dp) + ) + + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/components/FormatSelectionDialog.kt b/app/src/main/java/org/technoserve/farmcollector/ui/components/FormatSelectionDialog.kt new file mode 100644 index 0000000..e51f8df --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/ui/components/FormatSelectionDialog.kt @@ -0,0 +1,66 @@ +package org.technoserve.farmcollector.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.technoserve.farmcollector.R + +@Composable +fun FormatSelectionDialog( + onDismiss: () -> Unit, + onFormatSelected: (String) -> Unit, +) { + var selectedFormat by remember { mutableStateOf("CSV") } + + AlertDialog( + onDismissRequest = { onDismiss() }, + title = { Text(text = stringResource(R.string.select_file_format)) }, + text = { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = selectedFormat == "CSV", + onClick = { selectedFormat = "CSV" }, + ) + Text(stringResource(R.string.csv)) + } + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = selectedFormat == "GeoJSON", + onClick = { selectedFormat = "GeoJSON" }, + ) + Text(stringResource(R.string.geojson)) + } + } + }, + confirmButton = { + Button( + onClick = { + onFormatSelected(selectedFormat) + onDismiss() + }, + ) { + Text(stringResource(R.string.confirm)) + } + }, + dismissButton = { + Button(onClick = { onDismiss() }) { + Text(stringResource(R.string.cancel)) + } + }, + containerColor = MaterialTheme.colorScheme.background, + tonalElevation = 6.dp + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/components/ImportFileDialog.kt b/app/src/main/java/org/technoserve/farmcollector/ui/components/ImportFileDialog.kt new file mode 100644 index 0000000..162d6a7 --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/ui/components/ImportFileDialog.kt @@ -0,0 +1,191 @@ +package org.technoserve.farmcollector.ui.components + +import android.net.Uri +import android.os.Build +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.result.contract.ActivityResultContracts.CreateDocument +import androidx.annotation.RequiresApi +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import kotlinx.coroutines.launch +import org.technoserve.farmcollector.R +import org.technoserve.farmcollector.viewmodels.FarmViewModel + +@RequiresApi(Build.VERSION_CODES.N) +@Composable +fun ImportFileDialog( + siteId: Long, + onDismiss: () -> Unit, + navController: NavController, +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + val farmViewModel: FarmViewModel = viewModel() + var selectedFileType by remember { mutableStateOf("") } + var isDropdownMenuExpanded by remember { mutableStateOf(false) } + // var importCompleted by remember { mutableStateOf(false) } + + // Create a launcher to handle the file picker result + val importLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent(), + ) { uri: Uri? -> + uri?.let { + coroutineScope.launch { + try { + val result = farmViewModel.importFile(context, it, siteId) + Toast.makeText(context, result.message, Toast.LENGTH_SHORT).show() + navController.navigate("farmList/$siteId") // Navigate to the refreshed farm list + onDismiss() // Dismiss the dialog after import is complete + } catch (e: Exception) { + Toast.makeText(context, R.string.import_failed, Toast.LENGTH_SHORT).show() + } + } + } + } + + // Create a launcher to handle the file creation result + val createDocumentLauncher = + rememberLauncherForActivityResult( + CreateDocument("todo/todo"), + ) { uri: Uri? -> + uri?.let { + // Get the template content based on the selected file type + val templateContent = farmViewModel.getTemplateContent(selectedFileType) + // Save the template content to the created document + coroutineScope.launch { + try { + farmViewModel.saveFileToUri(context, it, templateContent) + } catch (e: Exception) { + Toast.makeText( + context, + R.string.template_download_failed, + Toast.LENGTH_SHORT + ).show() + } + onDismiss() // Dismiss the dialog + } + } + } + + // Function to download the template file + fun downloadTemplate() { + coroutineScope.launch { + try { + // Prompt the user to select where to save the file + createDocumentLauncher.launch( + when (selectedFileType) { + "csv" -> "farm_template.csv" + "geojson" -> "farm_template.geojson" + else -> throw IllegalArgumentException("Unsupported file type: $selectedFileType") + }, + ) + } catch (e: Exception) { + Toast.makeText(context, R.string.template_download_failed, Toast.LENGTH_SHORT) + .show() + } + } + } + + AlertDialog( + onDismissRequest = { +// onDismiss() + }, + title = { Text(text = stringResource(R.string.import_file)) }, + text = { + Column( + modifier = + Modifier + .padding(8.dp) + .fillMaxWidth(), + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .border(1.dp, Color.Gray, RoundedCornerShape(4.dp)) + .clickable { isDropdownMenuExpanded = true } + .padding(16.dp), + ) { + Text( + text = if (selectedFileType.isNotEmpty()) selectedFileType else stringResource( + R.string.select_file_type + ), + color = if (selectedFileType.isNotEmpty()) Color.Black else Color.Gray, + ) + DropdownMenu( + expanded = isDropdownMenuExpanded, + onDismissRequest = { isDropdownMenuExpanded = false }, + ) { + DropdownMenuItem(onClick = { + selectedFileType = "csv" + isDropdownMenuExpanded = false + }, text = { Text("CSV") }) + DropdownMenuItem(onClick = { + selectedFileType = "geojson" + isDropdownMenuExpanded = false + }, text = { Text("GeoJSON") }) + } + } + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { downloadTemplate() }, + enabled = selectedFileType.isNotEmpty(), + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + ) { + Text(stringResource(R.string.download_template)) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.select_file_to_import), + modifier = Modifier.padding(bottom = 8.dp), + ) + } + }, + confirmButton = { + Button(onClick = { + importLauncher.launch("*/*") + }) { + Text(stringResource(R.string.select_file)) + } + }, + dismissButton = { + Button(onClick = { onDismiss() }) { + Text(stringResource(R.string.cancel)) + } + }, + containerColor = MaterialTheme.colorScheme.background, // Background that adapts to light/dark + tonalElevation = 6.dp // Adds a subtle shadow for better UX + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/components/InvalidPolygonDialog.kt b/app/src/main/java/org/technoserve/farmcollector/ui/components/InvalidPolygonDialog.kt new file mode 100644 index 0000000..623ef16 --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/ui/components/InvalidPolygonDialog.kt @@ -0,0 +1,38 @@ +package org.technoserve.farmcollector.ui.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.technoserve.farmcollector.R + +/** + * This function is used to display the message when the user captures the invalid polygon + */ + +@Composable +fun InvalidPolygonDialog( + showDialog: MutableState, + onDismiss: () -> Unit +) { + if (showDialog.value) { + AlertDialog( + onDismissRequest = { showDialog.value = false }, + title = { Text(text = stringResource(id = R.string.invalid_polygon_title)) }, + text = { Text(text = stringResource(id = R.string.invalid_polygon_message)) }, + confirmButton = { + TextButton(onClick = { + onDismiss() + }) { + Text(text = stringResource(id = R.string.ok)) + } + }, + containerColor = MaterialTheme.colorScheme.background, + tonalElevation = 6.dp + ) + } +} diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/components/KeepPolygonDialog.kt b/app/src/main/java/org/technoserve/farmcollector/ui/components/KeepPolygonDialog.kt index ad067ad..310c68a 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/components/KeepPolygonDialog.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/components/KeepPolygonDialog.kt @@ -18,7 +18,6 @@ import org.technoserve.farmcollector.map.MapViewModel * This function is used to allow the user to either keep the existing polygon or capture a new polygon */ - @Composable fun KeepPolygonDialog( onDismiss: () -> Unit, diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/components/RestoreDataAlert.kt b/app/src/main/java/org/technoserve/farmcollector/ui/components/RestoreDataAlert.kt new file mode 100644 index 0000000..4068f1c --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/ui/components/RestoreDataAlert.kt @@ -0,0 +1,64 @@ +package org.technoserve.farmcollector.ui.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import org.technoserve.farmcollector.R +import org.technoserve.farmcollector.viewmodels.FarmViewModel + +@Composable +fun RestoreDataAlert( + showDialog: Boolean, + onDismiss: () -> Unit, + deviceId: String, + farmViewModel: FarmViewModel, +) { + val context = LocalContext.current + var finalMessage by remember { mutableStateOf("") } + var showFinalMessage by remember { mutableStateOf(false) } + var showRestorePrompt by remember { mutableStateOf(false) } + + if (showDialog) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Data Restoration") }, + text = { + Text("During restoration, you will recover some of the previously deleted records. Do you want to continue?") + }, + confirmButton = { + Button( + onClick = { + farmViewModel.restoreData( + deviceId = deviceId, + phoneNumber = "", + email = "", + farmViewModel = farmViewModel + ) { success -> + if (success) { + finalMessage = context.getString(R.string.data_restored_successfully) + } else { + showFinalMessage = true + showRestorePrompt = true + } + onDismiss() + } + } + ) { + Text("Continue") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/components/SiteDeleteAllDialogPresenter.kt b/app/src/main/java/org/technoserve/farmcollector/ui/components/SiteDeleteAllDialogPresenter.kt new file mode 100644 index 0000000..e0f5307 --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/ui/components/SiteDeleteAllDialogPresenter.kt @@ -0,0 +1,115 @@ +package org.technoserve.farmcollector.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.technoserve.farmcollector.R +import org.technoserve.farmcollector.database.models.CollectionSite +import org.technoserve.farmcollector.viewmodels.FarmViewModel + +@Composable +fun SiteDeleteAllDialogPresenter( + showDeleteDialog: MutableState, + site: CollectionSite, + farmViewModel: FarmViewModel, + snackbarHostState: SnackbarHostState, + onProceedFn: () -> Unit, +) { + val scope = rememberCoroutineScope() + var deletedSite by remember { mutableStateOf(null) } + + if (showDeleteDialog.value) { + AlertDialog( + modifier = Modifier.padding(horizontal = 32.dp), + onDismissRequest = { showDeleteDialog.value = false }, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = stringResource(id = R.string.warning), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = stringResource(id = R.string.delete_this_site)) + } + }, + text = { + Column { + Text(stringResource(id = R.string.are_you_sure)) + Text(stringResource(id = R.string.site_will_be_deleted)) + } + }, + confirmButton = { + TextButton( + onClick = { + // Store the site before deletion + deletedSite = site + + // Proceed with the deletion action + onProceedFn() + + // Show snackbar with undo option + scope.launch { + val result = snackbarHostState.showSnackbar( + message = "Site deleted", + actionLabel = "UNDO", + duration = SnackbarDuration.Long + ) + + when (result) { + SnackbarResult.ActionPerformed -> { + // Undo the deletion + deletedSite?.let { site -> + farmViewModel.restoreSite(site) + deletedSite = null + } + } + SnackbarResult.Dismissed -> { + // Clear the deleted site reference + deletedSite = null + } + } + } + + showDeleteDialog.value = false + } + ) { + Text(text = stringResource(id = R.string.yes)) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog.value = false }) { + Text(text = stringResource(id = R.string.no)) + } + }, + containerColor = MaterialTheme.colorScheme.background, + tonalElevation = 6.dp + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/components/SkeletonSiteCard.kt b/app/src/main/java/org/technoserve/farmcollector/ui/components/SkeletonSiteCard.kt index 0972617..5dee8f7 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/components/SkeletonSiteCard.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/components/SkeletonSiteCard.kt @@ -22,7 +22,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.valentinilk.shimmer.shimmer -import org.technoserve.farmcollector.ui.screens.farms.isSystemInDarkTheme +import org.technoserve.farmcollector.utils.isSystemInDarkTheme + @Composable fun SkeletonSiteCard() { diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/collectionsites/AddSite.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/collectionsites/AddSite.kt index ebaedc4..2fb85c8 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/collectionsites/AddSite.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/collectionsites/AddSite.kt @@ -52,8 +52,9 @@ import androidx.navigation.NavController import org.joda.time.Instant import org.technoserve.farmcollector.R import org.technoserve.farmcollector.database.models.CollectionSite -import org.technoserve.farmcollector.ui.screens.farms.FarmListHeader -import org.technoserve.farmcollector.ui.screens.farms.isSystemInDarkTheme +import org.technoserve.farmcollector.ui.components.FarmListHeader +import org.technoserve.farmcollector.utils.isSystemInDarkTheme + import org.technoserve.farmcollector.viewmodels.FarmViewModel import org.technoserve.farmcollector.viewmodels.FarmViewModelFactory diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/collectionsites/CollectionSiteList.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/collectionsites/CollectionSiteList.kt index 4d1fe69..3ba46bb 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/collectionsites/CollectionSiteList.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/collectionsites/CollectionSiteList.kt @@ -58,18 +58,18 @@ import kotlinx.coroutines.delay import org.technoserve.farmcollector.R import org.technoserve.farmcollector.database.models.CollectionSite import org.technoserve.farmcollector.ui.components.CustomPaginationControls +import org.technoserve.farmcollector.ui.components.FarmListHeader +import org.technoserve.farmcollector.ui.components.RestoreDataAlert import org.technoserve.farmcollector.ui.components.SiteCard +import org.technoserve.farmcollector.ui.components.SiteDeleteAllDialogPresenter import org.technoserve.farmcollector.ui.components.SkeletonSiteCard import org.technoserve.farmcollector.viewmodels.FarmViewModel import org.technoserve.farmcollector.viewmodels.FarmViewModelFactory -import org.technoserve.farmcollector.viewmodels.RestoreDataAlert import org.technoserve.farmcollector.viewmodels.RestoreStatus import org.technoserve.farmcollector.viewmodels.UndoDeleteSnackbar import org.technoserve.farmcollector.utils.DeviceIdUtil import org.technoserve.farmcollector.ui.composes.isValidPhoneNumber -import org.technoserve.farmcollector.ui.screens.farms.FarmListHeader -import org.technoserve.farmcollector.ui.screens.farms.SiteDeleteAllDialogPresenter -import org.technoserve.farmcollector.ui.screens.farms.isSystemInDarkTheme +import org.technoserve.farmcollector.utils.isSystemInDarkTheme /** diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/AddFarm.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/AddFarm.kt index 5a67f88..d531ac6 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/AddFarm.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/AddFarm.kt @@ -69,6 +69,7 @@ import com.google.android.gms.maps.model.LatLngBounds import org.joda.time.Instant import org.technoserve.farmcollector.R import org.technoserve.farmcollector.database.models.Farm +import org.technoserve.farmcollector.database.models.ParcelablePair import org.technoserve.farmcollector.viewmodels.FarmViewModel import org.technoserve.farmcollector.viewmodels.FarmViewModelFactory @@ -76,7 +77,10 @@ import org.technoserve.farmcollector.map.LocationHelper import org.technoserve.farmcollector.map.LocationState import org.technoserve.farmcollector.map.MapViewModel import org.technoserve.farmcollector.map.getCenterOfPolygon +import org.technoserve.farmcollector.ui.components.FarmForm +import org.technoserve.farmcollector.ui.components.FarmListHeader import org.technoserve.farmcollector.utils.convertSize +import org.technoserve.farmcollector.utils.isSystemInDarkTheme import java.math.BigDecimal import java.math.RoundingMode import java.util.UUID @@ -167,660 +171,7 @@ fun validateNumber(number: String): Boolean { } -@SuppressLint("MissingPermission") -@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) -@Composable -fun FarmForm( - navController: NavController, - siteId: Long, - coordinatesData: List>?, - accuracyArrayData: List? -) { - val context = LocalContext.current as Activity - var isValid by remember { mutableStateOf(true) } - var farmerName by rememberSaveable { mutableStateOf("") } - var memberId by rememberSaveable { mutableStateOf("") } - val farmerPhoto by rememberSaveable { mutableStateOf("") } - var village by rememberSaveable { mutableStateOf("") } - var district by rememberSaveable { mutableStateOf("") } - var latitude by rememberSaveable { mutableStateOf("") } - var longitude by rememberSaveable { mutableStateOf("") } - var accuracyArray by rememberSaveable { mutableStateOf(listOf()) } - val items = listOf("Ha", "Acres", "Sqm", "Timad", "Fichesa", "Manzana", "Tarea") - var expanded by remember { mutableStateOf(false) } - val sharedPref = context.getSharedPreferences("FarmCollector", Context.MODE_PRIVATE) - val farmViewModel: FarmViewModel = viewModel( - factory = FarmViewModelFactory(context.applicationContext as Application) - ) - val mapViewModel: MapViewModel = viewModel() - var size by rememberSaveable { mutableStateOf(readStoredValue(sharedPref)) } - var selectedUnit by rememberSaveable { - mutableStateOf( - sharedPref.getString( - "selectedUnit", - items[0] - ) ?: items[0] - ) - } - var isValidSize by remember { mutableStateOf(true) } - var isFormSubmitted by remember { mutableStateOf(false) } - val scientificNotationPattern = Pattern.compile("([+-]?\\d*\\.?\\d+)[eE][+-]?\\d+") - val showDialog = remember { mutableStateOf(false) } - val showLocationDialog = remember { mutableStateOf(false) } - val showLocationDialogNew = remember { mutableStateOf(false) } - // Function to update the selected unit - fun updateSelectedUnit(newUnit: String) { - selectedUnit = newUnit - sharedPref.edit().putString("selectedUnit", newUnit).apply() - } - val locationHelper = LocationHelper(context) - var locationState by remember { mutableStateOf(null) } - - LaunchedEffect(locationHelper) { - locationHelper.locationState.collect { state -> - locationState = state - } - } - - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - size = sharedPref.getString("plot_size", "") ?: "" - selectedUnit = sharedPref.getString("selectedUnit", "Ha") ?: "Ha" - with(sharedPref.edit()) { - remove("plot_size") - remove("selectedUnit") - apply() - } - } - } - - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } - } - - if (showLocationDialog.value) { - AlertDialog( - onDismissRequest = { showLocationDialog.value = false }, - title = { Text(stringResource(id = R.string.enable_location)) }, - text = { Text(stringResource(id = R.string.enable_location_msg)) }, - confirmButton = { - Button(onClick = { - showLocationDialog.value = false - promptEnableLocation(context) - }) { - Text(stringResource(id = R.string.yes)) - } - }, - dismissButton = { - Button(onClick = { - showLocationDialog.value = false - Toast.makeText( - context, - R.string.location_permission_denied_message, - Toast.LENGTH_SHORT - ).show() - }) { - Text(stringResource(id = R.string.no)) - } - }, - containerColor = MaterialTheme.colorScheme.background, - tonalElevation = 6.dp - ) - } - - fun saveFarm() { - // Validate size input if the size is empty we use the default size 0 - if (size.isEmpty()) { - size = "0.0" - } - val sizeInHa = convertSize(size.toDouble(), selectedUnit) - val newUUID = UUID.randomUUID() - val coordinatesSize = - coordinatesData?.size ?: 0 - val finalAccuracyArray = when { - accuracyArray.isEmpty() -> emptyList() - coordinatesSize == 0 -> listOf(accuracyArray[0]) - else -> { - val result = accuracyArrayData!!.toMutableList() - if (coordinatesSize > 1) { - result.add(accuracyArrayData.last()) - } - result - } - } - addFarm( - farmViewModel, - siteId, - remote_id = newUUID, - farmerPhoto, - farmerName, - memberId, - village, - district, - 0.toFloat(), - sizeInHa.toFloat(), - latitude, - longitude, - coordinates = coordinatesData, - accuracyArray = finalAccuracyArray - ) - val returnIntent = Intent() - context.setResult(Activity.RESULT_OK, returnIntent) - navController.navigate("farmList/${siteId}") - } - if (showDialog.value) { - AlertDialog( - modifier = Modifier.padding(horizontal = 32.dp), - onDismissRequest = { showDialog.value = false }, - title = { Text(text = stringResource(id = R.string.add_farm)) }, - text = { - Column { - Text(text = stringResource(id = R.string.confirm_add_farm)) - } - }, - confirmButton = { - TextButton(onClick = { - saveFarm() - }) { - Text(text = stringResource(id = R.string.add_farm)) - } - }, - dismissButton = { - TextButton(onClick = - { - showDialog.value = false - navController.navigate("setPolygon") - }) { - Text(text = stringResource(id = R.string.set_polygon)) - } - }, - containerColor = MaterialTheme.colorScheme.background, - tonalElevation = 6.dp - ) - } - - fun validateForm(): Boolean { - isValid = true - val textWithNumbersRegex = Regex(".*[a-zA-Z]+.*") - if (farmerName.isBlank() || !farmerName.matches(textWithNumbersRegex)) { - isValid = false - } - if (village.isBlank() || !village.matches(textWithNumbersRegex)) { - isValid = false - } - if (district.isBlank() || !district.matches(textWithNumbersRegex)) { - isValid = false - } - if (size.isBlank() || size.toFloatOrNull() == null || size.toFloat() <= 0) { - isValid = false - } - if (selectedUnit.isBlank()) { - isValid = false - } - if (latitude.isBlank() || longitude.isBlank()) { - isValid = false - } - return isValid - } - - val scrollState = rememberScrollState() - val fillForm = stringResource(id = R.string.fill_form) - val showPermissionRequest = remember { mutableStateOf(false) } - val (focusRequester1) = FocusRequester.createRefs() - val (focusRequester2) = FocusRequester.createRefs() - val (focusRequester3) = FocusRequester.createRefs() - val isDarkTheme = isSystemInDarkTheme() - val inputLabelColor = MaterialTheme.colorScheme.onBackground - val inputTextColor = if (isDarkTheme) Color.White else Color.Black - val inputBorder = if (isDarkTheme) Color.LightGray else Color.DarkGray - val textWithNumbersRegex = Regex(".*[a-zA-Z]+.*") - var isfarmerNameValid by remember { mutableStateOf(true) } - var isvillageValid by remember { mutableStateOf(true) } - var isDistrictValid by remember { mutableStateOf(true) } - - Column( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.background) - .padding(16.dp) - .verticalScroll(state = scrollState) - ) { - TextField( - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions( - onDone = { focusRequester1.requestFocus() } - ), - value = farmerName, - onValueChange = { - farmerName = it - isfarmerNameValid = - farmerName.isNotBlank() && farmerName.matches(textWithNumbersRegex) - }, - label = { - Text( - stringResource(id = R.string.farm_name) + " (*)", - color = inputLabelColor - ) - }, - supportingText = { - if (!isfarmerNameValid) { - Text(stringResource(R.string.error_farmer_name_empty) + " (*)") - } - }, - isError = !isfarmerNameValid, - colors = TextFieldDefaults.colors( - errorLeadingIconColor = Color.Red, - cursorColor = inputTextColor, - errorCursorColor = Color.Red, - focusedIndicatorColor = inputBorder, - unfocusedIndicatorColor = inputBorder, - errorIndicatorColor = Color.Red - ), - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - .onKeyEvent { - if (it.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_ENTER) { - focusRequester1.requestFocus() - } - false - } - ) - TextField( - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions( - onDone = { focusRequester1.requestFocus() } - ), - value = memberId, - onValueChange = { memberId = it }, - label = { Text(stringResource(id = R.string.member_id), color = inputLabelColor) }, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - .onKeyEvent { - if (it.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_ENTER) { - focusRequester1.requestFocus() - } - false - } - ) - TextField( - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions( - onDone = { focusRequester2.requestFocus() } - ), - value = village, - onValueChange = { - village = it - isvillageValid = village.isNotBlank() && village.matches(textWithNumbersRegex) - }, - label = { - Text( - stringResource(id = R.string.village) + " (*)", - color = inputLabelColor - ) - }, - supportingText = { - if (!isvillageValid) { - Text(stringResource(R.string.error_village_empty)) - } - }, - isError = !isvillageValid, - colors = TextFieldDefaults.colors( - errorLeadingIconColor = Color.Red, - cursorColor = inputTextColor, - errorCursorColor = Color.Red, - focusedIndicatorColor = inputBorder, - unfocusedIndicatorColor = inputBorder, - errorIndicatorColor = Color.Red - ), - modifier = Modifier - .focusRequester(focusRequester1) - .fillMaxWidth() - .padding(bottom = 16.dp) - ) - TextField( - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions( - onDone = { focusRequester3.requestFocus() } - ), - value = district, - onValueChange = { - district = it - isDistrictValid = district.isNotBlank() && district.matches(textWithNumbersRegex) - }, - label = { - Text( - stringResource(id = R.string.district) + " (*)", - color = inputLabelColor - ) - }, - supportingText = { - if (!isDistrictValid) { - Text(text = stringResource(R.string.error_district_empty)) - } - }, - isError = !isDistrictValid, - colors = TextFieldDefaults.colors( - errorLeadingIconColor = Color.Red, - cursorColor = inputTextColor, - errorCursorColor = Color.Red, - focusedIndicatorColor = inputBorder, - unfocusedIndicatorColor = inputBorder, - errorIndicatorColor = Color.Red - ), - modifier = Modifier - .focusRequester(focusRequester2) - .fillMaxWidth() - .padding(bottom = 16.dp) - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - TextField( - singleLine = true, - value = truncateToDecimalPlaces(size, 9), - onValueChange = { inputValue -> - val formattedValue = when { - validateSize(inputValue) -> inputValue - scientificNotationPattern.matcher(inputValue).matches() -> { - truncateToDecimalPlaces(formatInput(inputValue), 9) - } - - else -> inputValue - } - size = formattedValue - isValidSize = validateSize(formattedValue) - with(sharedPref.edit()) { - putString("plot_size", formattedValue) - apply() - } - }, - keyboardOptions = KeyboardOptions.Default.copy( - keyboardType = KeyboardType.Number - ), - label = { - Text( - text = stringResource(id = R.string.size_in_hectares) + " (*)", - color = inputLabelColor - ) - }, - supportingText = { - when { - isFormSubmitted && size.isBlank() -> { - Text(stringResource(R.string.error_farm_size_empty)) - } - - isFormSubmitted && !isValidSize -> { - Text(stringResource(R.string.error_farm_size_invalid)) - } - } - }, - isError = isFormSubmitted && (!isValidSize || size.isBlank()), - colors = TextFieldDefaults.colors( - errorLeadingIconColor = Color.Red, - cursorColor = inputTextColor, - errorCursorColor = Color.Red, - focusedIndicatorColor = inputBorder, - unfocusedIndicatorColor = inputBorder, - errorIndicatorColor = Color.Red - ), - modifier = Modifier - .focusRequester(focusRequester3) - .weight(1f) - .padding(end = 16.dp) - ) - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = !expanded }, - modifier = Modifier.weight(1f) - ) { - TextField( - readOnly = true, - value = selectedUnit, - onValueChange = { }, - label = { Text(stringResource(R.string.unit)) }, - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon( - expanded = expanded - ) - }, - colors = ExposedDropdownMenuDefaults.textFieldColors(), - modifier = Modifier.menuAnchor() - ) - ExposedDropdownMenu( - expanded = expanded, - modifier = Modifier.background(MaterialTheme.colorScheme.background), - onDismissRequest = { expanded = false } - ) { - items.forEach { selectionOption -> - DropdownMenuItem( - text = { Text(text = selectionOption) }, - onClick = { - updateSelectedUnit(selectionOption) - expanded = false - } - ) - } - } - } - } - Spacer(modifier = Modifier.height(16.dp)) - // If coordinatesData exists and latitude/longitude are empty, calculate the center - if (coordinatesData?.isNotEmpty() == true && latitude.isBlank() && longitude.isBlank()) { - val center = coordinatesData.toLatLngList().getCenterOfPolygon() - val bounds: LatLngBounds = center - longitude = bounds.northeast.longitude.toString() - latitude = bounds.southwest.latitude.toString() - } - - if ((size.toDoubleOrNull()?.let { convertSize(it, selectedUnit).toFloat() } ?: 0f) < 4f) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - TextField( - readOnly = true, - value = latitude, - onValueChange = { it -> - val formattedValue = when { - validateNumber(it) -> it - scientificNotationPattern.matcher(it).matches() -> { - truncateToDecimalPlaces(formatInput(it), 9) - } - - else -> { - // Show a Toast message if the input does not meet the requirements - Toast.makeText( - context, - R.string.error_latitude_decimal_places, - Toast.LENGTH_SHORT - ).show() - null - } - } - formattedValue?.let { - latitude = it - } - }, - label = { - Text( - stringResource(id = R.string.latitude) + " (*)", - color = inputLabelColor - ) - }, - supportingText = { - if (!isValid && latitude.split(".").last().length < 6) Text( - stringResource(R.string.error_latitude_decimal_places) - ) - }, - isError = !isValid && latitude.split(".").last().length < 6, - colors = TextFieldDefaults.colors( - errorLeadingIconColor = Color.Red, - ), - modifier = Modifier - .weight(1f) - .padding(bottom = 16.dp) - ) - Spacer(modifier = Modifier.width(16.dp)) - TextField( - readOnly = true, - value = longitude, - onValueChange = { it -> - val formattedValue = when { - validateNumber(it) -> it - scientificNotationPattern.matcher(it).matches() -> { - truncateToDecimalPlaces(formatInput(it), 9) - } - - else -> { - // Show a Toast message if the input does not meet the requirements - Toast.makeText( - context, - R.string.error_longitude_decimal_places, - Toast.LENGTH_SHORT - ).show() - null - } - } - formattedValue?.let { - longitude = it - } - }, - label = { - Text( - stringResource(id = R.string.longitude) + " (*)", - color = inputLabelColor - ) - }, - supportingText = { - if (!isValid && longitude.split(".").last().length < 6) Text( - stringResource(R.string.error_longitude_decimal_places) + "" - ) - }, - isError = !isValid && longitude.split(".").last().length < 6, - colors = TextFieldDefaults.colors( - errorLeadingIconColor = Color.Red, - ), - modifier = Modifier - .weight(1f) - .padding(bottom = 16.dp) - ) - } - } - if (showPermissionRequest.value) { - LocationPermissionRequest( - onLocationEnabled = { - showLocationDialog.value = true - }, - onPermissionsGranted = { - showPermissionRequest.value = false - }, - showLocationDialogNew = showLocationDialogNew, - hasToShowDialog = showLocationDialogNew.value - ) - } - - - fun roundToDecimalPlaces(value: Double): String { - val bigDecimal = BigDecimal.valueOf(value) - return bigDecimal.setScale(9, BigDecimal.ROUND_DOWN).toString() - } - - /** - * Function to handle location permission and coordinate calculation - */ - fun handleLocationAndNavigate(size: String, selectedUnit: String) { - val enteredSize = - size.toDoubleOrNull()?.let { convertSize(it, selectedUnit).toFloat() } ?: 0f - if (coordinatesData?.isNotEmpty() == true && latitude.isBlank() && longitude.isBlank()) { - val center = coordinatesData.toLatLngList().getCenterOfPolygon() - val bounds: LatLngBounds = center - latitude = roundToDecimalPlaces(bounds.northeast.longitude.toString().toDouble()) - longitude = roundToDecimalPlaces(bounds.southwest.latitude.toString().toDouble()) - } - locationHelper.requestLocationPermissionAndUpdateCoordinates( - enteredSize = enteredSize, - navController = navController, - mapViewModel = mapViewModel, - onLocationResult = { newLatitude, newLongitude, accuracy -> - latitude = newLatitude - longitude = newLongitude - accuracyArray = accuracyArray + accuracy.toFloat() - } - ) - } - - Button( - onClick = { - if (isLocationEnabled(context)) { - handleLocationAndNavigate(size, selectedUnit) - } - else - showPermissionRequest.value = true - }, - modifier = Modifier - .background(MaterialTheme.colorScheme.background) - .align(Alignment.CenterHorizontally) - .fillMaxWidth(0.7f) - .height(50.dp) - .padding(bottom = 5.dp), - enabled = size.isNotBlank() - ) { - val enteredSize = - size.toDoubleOrNull()?.let { convertSize(it, selectedUnit).toFloat() } ?: 0f - - Text( - text = if (enteredSize >= 4f) { - stringResource(id = R.string.set_polygon) - } else { - stringResource(id = R.string.get_coordinates) - } - ) - } - Button( - onClick = { - isFormSubmitted = true - // Finding the center of the polygon captured - if (coordinatesData?.isNotEmpty() == true && latitude.isBlank() && longitude.isBlank()) { - val center = coordinatesData.toLatLngList().getCenterOfPolygon() - val bounds: LatLngBounds = center - longitude = bounds.northeast.longitude.toString() - latitude = bounds.southwest.latitude.toString() - } - if (validateForm()) { - // Ask user to confirm before adding farm - if (coordinatesData?.isNotEmpty() == true) saveFarm() - else showDialog.value = true - } else { - Toast.makeText(context, fillForm, Toast.LENGTH_SHORT).show() - } - }, - modifier = Modifier - .background(MaterialTheme.colorScheme.background) - .fillMaxWidth() - .height(50.dp) - ) { - Text(text = stringResource(id = R.string.add_farm)) - } - } - DisposableEffect(locationHelper) { - onDispose { - locationHelper.cleanup() - } - } -} fun addFarm( farmViewModel: FarmViewModel, diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/FarmList.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/FarmList.kt index 4e561d9..87787fe 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/FarmList.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/FarmList.kt @@ -120,6 +120,8 @@ import org.joda.time.Instant import org.technoserve.farmcollector.R import org.technoserve.farmcollector.database.models.CollectionSite import org.technoserve.farmcollector.database.models.Farm +import org.technoserve.farmcollector.database.models.ParcelableFarmData +import org.technoserve.farmcollector.database.models.ParcelablePair import org.technoserve.farmcollector.viewmodels.FarmViewModel import org.technoserve.farmcollector.viewmodels.FarmViewModelFactory @@ -129,11 +131,19 @@ import org.technoserve.farmcollector.hasLocationPermission import org.technoserve.farmcollector.map.LocationHelper import org.technoserve.farmcollector.map.MapViewModel import org.technoserve.farmcollector.ui.components.CustomPaginationControls +import org.technoserve.farmcollector.ui.components.CustomizedConfirmationDialog +import org.technoserve.farmcollector.ui.components.DeleteAllDialogPresenter import org.technoserve.farmcollector.ui.components.FarmCard +import org.technoserve.farmcollector.ui.components.FarmListHeaderPlots +import org.technoserve.farmcollector.ui.components.FormatSelectionDialog +import org.technoserve.farmcollector.ui.components.ImportFileDialog import org.technoserve.farmcollector.ui.components.KeepPolygonDialog import org.technoserve.farmcollector.ui.composes.isValidPhoneNumber import org.technoserve.farmcollector.utils.convertSize +import org.technoserve.farmcollector.utils.createFile +import org.technoserve.farmcollector.utils.createFileForSharing +import org.technoserve.farmcollector.utils.isSystemInDarkTheme import java.io.BufferedWriter import java.io.File import java.io.IOException @@ -151,167 +161,6 @@ enum class Action { Share, } -private const val KEY_HAS_NEW_POLYGON = "has_new_polygon" - -data class ParcelablePair(val first: Double, val second: Double) : Parcelable { - constructor(parcel: Parcel) : this( - parcel.readDouble(), - parcel.readDouble() - ) - - override fun writeToParcel(parcel: Parcel, flags: Int) { - parcel.writeDouble(first) - parcel.writeDouble(second) - } - - override fun describeContents(): Int { - return 0 - } - - companion object CREATOR : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): ParcelablePair { - return ParcelablePair(parcel) - } - - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } - } -} - -data class ParcelableFarmData(val farm: Farm, val view: String) : Parcelable { - constructor(parcel: Parcel) : this( - parcel.readParcelable(Farm::class.java.classLoader)!!, - parcel.readString()!! - ) - - override fun writeToParcel(parcel: Parcel, flags: Int) { - parcel.writeParcelable(farm, flags) - parcel.writeString(view) - } - - override fun describeContents(): Int = 0 - - companion object CREATOR : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): ParcelableFarmData { - return ParcelableFarmData(parcel) - } - - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } - } -} - - - -@Composable -fun isSystemInDarkTheme(): Boolean { - val context = LocalContext.current - val sharedPreferences = context.getSharedPreferences("theme_mode", Context.MODE_PRIVATE) - return sharedPreferences.getBoolean("dark_mode", false) -} - -@Composable -fun FormatSelectionDialog( - onDismiss: () -> Unit, - onFormatSelected: (String) -> Unit, -) { - var selectedFormat by remember { mutableStateOf("CSV") } - - AlertDialog( - onDismissRequest = { onDismiss() }, - title = { Text(text = stringResource(R.string.select_file_format)) }, - text = { - Column { - Row(verticalAlignment = Alignment.CenterVertically) { - RadioButton( - selected = selectedFormat == "CSV", - onClick = { selectedFormat = "CSV" }, - ) - Text(stringResource(R.string.csv)) - } - Row(verticalAlignment = Alignment.CenterVertically) { - RadioButton( - selected = selectedFormat == "GeoJSON", - onClick = { selectedFormat = "GeoJSON" }, - ) - Text(stringResource(R.string.geojson)) - } - } - }, - confirmButton = { - Button( - onClick = { - onFormatSelected(selectedFormat) - onDismiss() - }, - ) { - Text(stringResource(R.string.confirm)) - } - }, - dismissButton = { - Button(onClick = { onDismiss() }) { - Text(stringResource(R.string.cancel)) - } - }, - containerColor = MaterialTheme.colorScheme.background, - tonalElevation = 6.dp - ) -} - -@Composable -fun ConfirmationDialog( - listItems: List, - action: Action, - onConfirm: () -> Unit, - onDismiss: () -> Unit, -) { - fun validateFarms(farms: List): Pair> { - val incompleteFarms = - farms.filter { farm -> - farm.farmerName.isEmpty() || - farm.district.isEmpty() || - farm.village.isEmpty() || - farm.latitude == "0.0" || - farm.longitude == "0.0" || - farm.size == 0.0f || - farm.remoteId.toString().isEmpty() - } - return Pair(farms.size, incompleteFarms) - } - val (totalFarms, incompleteFarms) = validateFarms(listItems) - val message = - when (action) { - Action.Export -> stringResource( - R.string.confirm_export, - totalFarms, - incompleteFarms.size - ) - - Action.Share -> stringResource(R.string.confirm_share, totalFarms, incompleteFarms.size) - } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(text = stringResource(R.string.confirm)) }, - text = { Text(text = message) }, - confirmButton = { - Button(onClick = { - onConfirm() - onDismiss() - }) { - Text(text = stringResource(R.string.yes)) - } - }, - dismissButton = { - Button(onClick = { onDismiss() }) { - Text(text = stringResource(R.string.no)) - } - }, - containerColor = MaterialTheme.colorScheme.background, - tonalElevation = 6.dp - ) -} /** * This function is used to display the list of farm Plots */ @@ -377,220 +226,17 @@ fun FarmList( isLoading.value = false } - fun createFileForSharing(): File? { - val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) - val getSiteById = cwsListItems.find { it.siteId == siteID } - val siteName = getSiteById?.name ?: "SiteName" - val filename = - if (exportFormat == "CSV") "farms_${siteName}_$timestamp.csv" else "farms_${siteName}_$timestamp.geojson" - val downloadsDir = - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) - val file = File(downloadsDir, filename) - - try { - file.bufferedWriter().use { writer -> - if (exportFormat == "CSV") { - writer.write( - "remote_id,farmer_name,member_id,collection_site,agent_name,farm_village,farm_district,farm_size,latitude,longitude,polygon,accuracyArray,created_at,updated_at\n", - ) - listItems.forEach { farm -> - val regex = "\\(([^,]+), ([^)]+)\\)".toRegex() - val matches = regex.findAll(farm.coordinates.toString()) - val reversedCoordinates = - matches - .map { match -> - val (lat, lon) = match.destructured - "[$lon, $lat]" - }.toList() - .let { coordinates -> - if (coordinates.isNotEmpty()) { - // Always include brackets, even for a single point - coordinates.joinToString(", ", prefix = "[", postfix = "]") - } else { - "" - } - } - - val line = - "${farm.remoteId},\"${ - farm.farmerName.split(" ").joinToString(" ") - }\",${farm.memberId},\"${getSiteById?.name}\",\"${getSiteById?.agentName}\",\"${farm.village}\",\"${farm.district}\",${farm.size},${farm.latitude},${farm.longitude},\"${reversedCoordinates}\",\"${farm.accuracyArray}\",${ - Date( - farm.createdAt, - ) - },${Date(farm.updatedAt)}\n" - writer.write(line) - } - } else { - val geoJson = - buildString { - append("{\"type\": \"FeatureCollection\", \"features\": [") - listItems.forEachIndexed { index, farm -> - val regex = "\\(([^,]+), ([^)]+)\\)".toRegex() - val matches = regex.findAll(farm.coordinates.toString()) - val geoJsonCoordinates = - matches - .map { match -> - val (lat, lon) = match.destructured - "[$lon, $lat]" - }.joinToString(", ", prefix = "[", postfix = "]") - val latitude = - farm.latitude.toDoubleOrNull()?.takeIf { it != 0.0 } ?: 0.0 - val longitude = - farm.longitude.toDoubleOrNull()?.takeIf { it != 0.0 } ?: 0.0 - - val feature = - """ - { - "type": "Feature", - "properties": { - "remote_id": "${farm.remoteId}", - "farmer_name":"${ - farm.farmerName.split(" ").joinToString(" ") - }", - "member_id": "${farm.memberId}", - "collection_site": "${getSiteById?.name ?: ""}", - "agent_name": "${getSiteById?.agentName ?: ""}", - "farm_village": "${farm.village}", - "farm_district": "${farm.district}", - "farm_size": ${farm.size}, - "latitude": $latitude, - "longitude": $longitude, - "accuracyArray": "${farm.accuracyArray ?: ""}", - "created_at": "${Date(farm.createdAt)}", - "updated_at": "${Date(farm.updatedAt)} - }, - "geometry": { - "type": "${if ((farm.coordinates?.size ?: 0) > 1) "Polygon" else "Point"}", - "coordinates": ${if ((farm.coordinates?.size ?: 0) > 1) "[$geoJsonCoordinates]" else "[$latitude, $longitude]"} - } - } - """.trimIndent() - append(feature) - if (index < listItems.size - 1) append(",") - } - append("]}") - } - writer.write(geoJson) - } - } - return file - } catch (e: IOException) { - Toast.makeText(context, R.string.error_export_msg, Toast.LENGTH_SHORT).show() - return null - } - } - - fun createFile( - context: Context, - uri: Uri, - ): Boolean { - val getSiteById = cwsListItems.find { it.siteId == siteID } - try { - context.contentResolver.openOutputStream(uri)?.use { outputStream -> - BufferedWriter(OutputStreamWriter(outputStream)).use { writer -> - if (exportFormat == "CSV") { - writer.write( - "remote_id,farmer_name,member_id,collection_site,agent_name,farm_village,farm_district,farm_size,latitude,longitude,polygon,accuracyArray,created_at,updated_at\n", - ) - listItems.forEach { farm -> - val regex = "\\(([^,]+), ([^)]+)\\)".toRegex() - val matches = regex.findAll(farm.coordinates.toString()) - val reversedCoordinates = - matches - .map { match -> - val (lat, lon) = match.destructured - "[$lon, $lat]" - }.toList() - .let { coordinates -> - if (coordinates.isNotEmpty()) { - // Always include brackets, even for a single point - coordinates.joinToString( - ", ", - prefix = "[", - postfix = "]" - ) - } else { - "" - } - } - - val line = - "${farm.remoteId},\"${ - farm.farmerName.split(" ").joinToString(" ") - }\",${farm.memberId},${getSiteById?.name},\"${getSiteById?.agentName}\",\"${farm.village}\",\"${farm.district}\",${farm.size},${farm.latitude},${farm.longitude},\"${reversedCoordinates}\",\"${farm.accuracyArray}\",${ - Date(farm.createdAt) - },${Date(farm.updatedAt)}\n" - writer.write(line) - } - } else { - val geoJson = - buildString { - append("{\"type\": \"FeatureCollection\", \"features\": [") - listItems.forEachIndexed { index, farm -> - val regex = "\\(([^,]+), ([^)]+)\\)".toRegex() - val matches = regex.findAll(farm.coordinates.toString()) - val geoJsonCoordinates = - matches - .map { match -> - val (lat, lon) = match.destructured - "[$lon, $lat]" - }.joinToString(", ", prefix = "[", postfix = "]") - // Ensure latitude and longitude are not null - val latitude = - farm.latitude.toDoubleOrNull()?.takeIf { it != 0.0 } ?: 0.0 - val longitude = - farm.longitude.toDoubleOrNull()?.takeIf { it != 0.0 } ?: 0.0 - - val feature = - """ - { - "type": "Feature", - "properties": { - "remote_id": "${farm.remoteId}", - "farmer_name": "${ - farm.farmerName.split(" ").joinToString(" ") - }", - "member_id": "${farm.memberId}", - "collection_site": "${getSiteById?.name ?: ""}", - "agent_name": "${getSiteById?.agentName ?: ""}", - "farm_village": "${farm.village}", - "farm_district": "${farm.district}", - "farm_size": ${farm.size}, - "latitude": $latitude, - "longitude": $longitude, - "accuracyArray": "${farm.accuracyArray ?: ""}", - "created_at": "${farm.createdAt.let { Date(it) }}", - "updated_at": "${farm.updatedAt.let { Date(it) }}" - - }, - "geometry": { - "type": "${if ((farm.coordinates?.size ?: 0) > 1) "Polygon" else "Point"}", - "coordinates": ${if ((farm.coordinates?.size ?: 0) > 1) "[$geoJsonCoordinates]" else "[$latitude, $longitude]"} - } - } - """.trimIndent() - append(feature) - if (index < listItems.size - 1) append(",") - } - append("]}") - } - writer.write(geoJson) - } - } - } - return true - } catch (e: IOException) { - Toast.makeText(context, R.string.error_export_msg, Toast.LENGTH_SHORT).show() - return false - } - } - val createDocumentLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == Activity.RESULT_OK) { result.data?.data?.let { uri -> - if (createFile(context, uri)) { +// if (createFile(context, uri)) { + if (createFile( + context, uri,listItems, + exportFormat, + siteID , + cwsListItems + )){ Toast.makeText(context, R.string.success_export_msg, Toast.LENGTH_SHORT) .show() } @@ -662,14 +308,21 @@ fun FarmList( ) } if (showConfirmationDialog) { - ConfirmationDialog( + CustomizedConfirmationDialog( listItems, action = action!!, // Ensure action is not null onConfirm = { when (action) { Action.Export -> initiateFileCreation() Action.Share -> { - val file = createFileForSharing() + // file = createFileForSharing() + val file = createFileForSharing( + context, + listItems, + exportFormat, + siteID, + cwsListItems + ) if (file != null) { shareFile(file) } @@ -1149,1298 +802,6 @@ fun FarmList( } } - -@RequiresApi(Build.VERSION_CODES.N) -@Composable -fun ImportFileDialog( - siteId: Long, - onDismiss: () -> Unit, - navController: NavController, -) { - val context = LocalContext.current - val coroutineScope = rememberCoroutineScope() - - val farmViewModel: FarmViewModel = viewModel() - var selectedFileType by remember { mutableStateOf("") } - var isDropdownMenuExpanded by remember { mutableStateOf(false) } - // var importCompleted by remember { mutableStateOf(false) } - - // Create a launcher to handle the file picker result - val importLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent(), - ) { uri: Uri? -> - uri?.let { - coroutineScope.launch { - try { - val result = farmViewModel.importFile(context, it, siteId) - Toast.makeText(context, result.message, Toast.LENGTH_SHORT).show() - navController.navigate("farmList/$siteId") // Navigate to the refreshed farm list - onDismiss() // Dismiss the dialog after import is complete - } catch (e: Exception) { - Toast.makeText(context, R.string.import_failed, Toast.LENGTH_SHORT).show() - } - } - } - } - - // Create a launcher to handle the file creation result - val createDocumentLauncher = - rememberLauncherForActivityResult( - CreateDocument("todo/todo"), - ) { uri: Uri? -> - uri?.let { - // Get the template content based on the selected file type - val templateContent = farmViewModel.getTemplateContent(selectedFileType) - // Save the template content to the created document - coroutineScope.launch { - try { - farmViewModel.saveFileToUri(context, it, templateContent) - } catch (e: Exception) { - Toast.makeText( - context, - R.string.template_download_failed, - Toast.LENGTH_SHORT - ).show() - } - onDismiss() // Dismiss the dialog - } - } - } - - // Function to download the template file - fun downloadTemplate() { - coroutineScope.launch { - try { - // Prompt the user to select where to save the file - createDocumentLauncher.launch( - when (selectedFileType) { - "csv" -> "farm_template.csv" - "geojson" -> "farm_template.geojson" - else -> throw IllegalArgumentException("Unsupported file type: $selectedFileType") - }, - ) - } catch (e: Exception) { - Toast.makeText(context, R.string.template_download_failed, Toast.LENGTH_SHORT) - .show() - } - } - } - - AlertDialog( - onDismissRequest = { -// onDismiss() - }, - title = { Text(text = stringResource(R.string.import_file)) }, - text = { - Column( - modifier = - Modifier - .padding(8.dp) - .fillMaxWidth(), - ) { - Box( - modifier = - Modifier - .fillMaxWidth() - .border(1.dp, Color.Gray, RoundedCornerShape(4.dp)) - .clickable { isDropdownMenuExpanded = true } - .padding(16.dp), - ) { - Text( - text = if (selectedFileType.isNotEmpty()) selectedFileType else stringResource( - R.string.select_file_type - ), - color = if (selectedFileType.isNotEmpty()) Color.Black else Color.Gray, - ) - DropdownMenu( - expanded = isDropdownMenuExpanded, - onDismissRequest = { isDropdownMenuExpanded = false }, - ) { - DropdownMenuItem(onClick = { - selectedFileType = "csv" - isDropdownMenuExpanded = false - }, text = { Text("CSV") }) - DropdownMenuItem(onClick = { - selectedFileType = "geojson" - isDropdownMenuExpanded = false - }, text = { Text("GeoJSON") }) - } - } - Spacer(modifier = Modifier.height(16.dp)) - Button( - onClick = { downloadTemplate() }, - enabled = selectedFileType.isNotEmpty(), - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - ) { - Text(stringResource(R.string.download_template)) - } - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(R.string.select_file_to_import), - modifier = Modifier.padding(bottom = 8.dp), - ) - } - }, - confirmButton = { - Button(onClick = { - importLauncher.launch("*/*") - }) { - Text(stringResource(R.string.select_file)) - } - }, - dismissButton = { - Button(onClick = { onDismiss() }) { - Text(stringResource(R.string.cancel)) - } - }, - containerColor = MaterialTheme.colorScheme.background, // Background that adapts to light/dark - tonalElevation = 6.dp // Adds a subtle shadow for better UX - ) -} - - -@Composable -fun DeleteAllDialogPresenter( - showDeleteDialog: MutableState, - onProceedFn: () -> Unit, -) { - if (showDeleteDialog.value) { - AlertDialog( - modifier = Modifier.padding(horizontal = 32.dp), - onDismissRequest = { showDeleteDialog.value = false }, - title = { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Default.Warning, // Use a built-in warning icon - contentDescription = stringResource(id = R.string.warning), - tint = MaterialTheme.colorScheme.error, // Use error color for the icon - modifier = Modifier.size(24.dp) // Adjust the size of the icon - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(text = stringResource(id = R.string.delete_this_farm)) - } - }, - text = { - Column { - Text(stringResource(id = R.string.are_you_sure)) - Text(stringResource(id = R.string.farm_will_be_deleted)) - } - }, - confirmButton = { - TextButton(onClick = { onProceedFn() }) { - Text(text = stringResource(id = R.string.yes)) - } - }, - dismissButton = { - TextButton(onClick = { showDeleteDialog.value = false }) { - Text(text = stringResource(id = R.string.no)) - } - }, - containerColor = MaterialTheme.colorScheme.background, // Background that adapts to light/dark - tonalElevation = 6.dp // Adds a subtle shadow for better UX - ) - } -} - -//@Composable -//fun SiteDeleteAllDialogPresenter( -// showDeleteDialog: MutableState, -// onProceedFn: () -> Unit, -//) { -// if (showDeleteDialog.value) { -// AlertDialog( -// modifier = Modifier.padding(horizontal = 32.dp), -// onDismissRequest = { showDeleteDialog.value = false }, -// title = { -// Row(verticalAlignment = Alignment.CenterVertically) { -// Icon( -// imageVector = Icons.Default.Warning, // Use a built-in warning icon -// contentDescription = stringResource(id = R.string.warning), -// tint = MaterialTheme.colorScheme.error, // Use error color for the icon -// modifier = Modifier.size(24.dp) // Adjust the size of the icon -// ) -// Spacer(modifier = Modifier.width(8.dp)) -// Text(text = stringResource(id = R.string.delete_this_site)) -// } -// }, -// text = { -// Column { -// Text(stringResource(id = R.string.are_you_sure)) -// Text(stringResource(id = R.string.site_will_be_deleted)) -// } -// }, -// confirmButton = { -// TextButton(onClick = { onProceedFn() }) { -// Text(text = stringResource(id = R.string.yes)) -// } -// }, -// dismissButton = { -// TextButton(onClick = { showDeleteDialog.value = false }) { -// Text(text = stringResource(id = R.string.no)) -// } -// }, -// containerColor = MaterialTheme.colorScheme.background, // Background that adapts to light/dark -// tonalElevation = 6.dp // Adds a subtle shadow for better UX -// ) -// } -//} - -@Composable -fun SiteDeleteAllDialogPresenter( - showDeleteDialog: MutableState, - site: CollectionSite, - farmViewModel: FarmViewModel, - snackbarHostState: SnackbarHostState, - onProceedFn: () -> Unit, -) { - val scope = rememberCoroutineScope() - var deletedSite by remember { mutableStateOf(null) } - - if (showDeleteDialog.value) { - AlertDialog( - modifier = Modifier.padding(horizontal = 32.dp), - onDismissRequest = { showDeleteDialog.value = false }, - title = { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = stringResource(id = R.string.warning), - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(text = stringResource(id = R.string.delete_this_site)) - } - }, - text = { - Column { - Text(stringResource(id = R.string.are_you_sure)) - Text(stringResource(id = R.string.site_will_be_deleted)) - } - }, - confirmButton = { - TextButton( - onClick = { - // Store the site before deletion - deletedSite = site - - // Proceed with the deletion action - onProceedFn() - - // Show snackbar with undo option - scope.launch { - val result = snackbarHostState.showSnackbar( - message = "Site deleted", - actionLabel = "UNDO", - duration = SnackbarDuration.Long - ) - - when (result) { - SnackbarResult.ActionPerformed -> { - // Undo the deletion - deletedSite?.let { site -> - farmViewModel.restoreSite(site) - deletedSite = null - } - } - SnackbarResult.Dismissed -> { - // Clear the deleted site reference - deletedSite = null - } - } - } - - showDeleteDialog.value = false - } - ) { - Text(text = stringResource(id = R.string.yes)) - } - }, - dismissButton = { - TextButton(onClick = { showDeleteDialog.value = false }) { - Text(text = stringResource(id = R.string.no)) - } - }, - containerColor = MaterialTheme.colorScheme.background, - tonalElevation = 6.dp - ) - } -} - - - - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun FarmListHeader( - title: String, - onSearchQueryChanged: (String) -> Unit, - onBackClicked: () -> Unit, - showSearch: Boolean, - showRestore: Boolean, - onRestoreClicked: () -> Unit -) { - // State to hold the search query - var searchQuery by remember { mutableStateOf("") } - - // State to determine if the search mode is active - var isSearchVisible by remember { mutableStateOf(false) } - - TopAppBar( - modifier = Modifier - .background(MaterialTheme.colorScheme.primary) - .fillMaxWidth(), - navigationIcon = { - IconButton(onClick = { - if (isSearchVisible) { - // Exit search mode, clear search query - searchQuery = "" - onSearchQueryChanged("") - isSearchVisible = false - } else { - // Navigate back normally - onBackClicked() - } - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = MaterialTheme.colorScheme.onPrimary - ) - } - }, - title = { - Text( - text = title, - color = MaterialTheme.colorScheme.onPrimary, - fontSize = 22.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - actions = { - - if (showRestore) { - IconButton( - onClick = { onRestoreClicked() }, - modifier = Modifier.size(36.dp) - ) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = "Restore", - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onPrimary - ) - } - } - if (showSearch) { - IconButton(onClick = { - isSearchVisible = !isSearchVisible - }, modifier = Modifier.size(36.dp)) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = "Search", - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onPrimary - ) - } - } - }, - ) - - // Show search field when search mode is active - if (isSearchVisible) { - Box( - modifier = Modifier - .padding(top = 54.dp) - .fillMaxWidth(), - contentAlignment = Alignment.Center // Center the Row within the Box - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, // Center the contents within the Row - modifier = Modifier.fillMaxWidth() - ) { - OutlinedTextField( - value = searchQuery, - onValueChange = { - searchQuery = it - onSearchQueryChanged(it) - }, - modifier = Modifier - .fillMaxWidth() // Center with a smaller width - .padding(8.dp) - .clip(RoundedCornerShape(0.dp)), // Add rounded corners - placeholder = { - Text( - stringResource(R.string.search), - color = MaterialTheme.colorScheme.onBackground - ) - }, - leadingIcon = { - IconButton(onClick = { - // Exit search mode and clear search - searchQuery = "" - onSearchQueryChanged("") - isSearchVisible = false - }) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = MaterialTheme.colorScheme.onSurface - ) - } - }, - trailingIcon = { - if (searchQuery != "") { - IconButton(onClick = { - searchQuery = "" - onSearchQueryChanged("") - }) { - Icon( - Icons.Default.Clear, - contentDescription = "Clear", - tint = MaterialTheme.colorScheme.onSurface - ) - } - } - }, - singleLine = true, -// colors = TextFieldDefaults.outlinedTextFieldColors( -// cursorColor = MaterialTheme.colorScheme.onSurface, -// focusedBorderColor = MaterialTheme.colorScheme.primary, -// unfocusedBorderColor = MaterialTheme.colorScheme.onSurfaceVariant, -// focusedTextColor = MaterialTheme.colorScheme.onSurface -// ), - colors = TextFieldDefaults.colors( - cursorColor = MaterialTheme.colorScheme.onSurface, - focusedTextColor = MaterialTheme.colorScheme.onSurface, - errorCursorColor = Color.Red, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = MaterialTheme.colorScheme.onSurfaceVariant, - errorIndicatorColor = Color.Red - ), - shape = RoundedCornerShape(0.dp) - ) - - } - } - } -} - - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun FarmListHeaderPlots( - title: String, - onBackClicked: () -> Unit, - onExportClicked: () -> Unit, - onShareClicked: () -> Unit, - onImportClicked: () -> Unit, - onSearchQueryChanged: (String) -> Unit, - showExport: Boolean, - showShare: Boolean, - showSearch: Boolean, - onRestoreClicked: () -> Unit -) { - - var searchQuery by remember { mutableStateOf("") } - var isSearchVisible by remember { mutableStateOf(false) } - var isImportDisabled by remember { mutableStateOf(false) } - - TopAppBar( - title = { - Text( - text = title, - fontSize = 22.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - navigationIcon = { - IconButton(onClick = { - if (isSearchVisible) { - searchQuery = "" - onSearchQueryChanged("") - isSearchVisible = false - } else { - onBackClicked() - } - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = MaterialTheme.colorScheme.onPrimary - ) - } - }, - actions = { - Row( - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.horizontalScroll(rememberScrollState()) - ) { - IconButton( - onClick = { onRestoreClicked() }, - modifier = Modifier.size(36.dp) - ) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = "Restore", - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onPrimary - ) - } - if (showExport) { - IconButton(onClick = onExportClicked, modifier = Modifier.size(36.dp)) { - Icon( - painter = painterResource(id = R.drawable.save), - contentDescription = "Export", - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onPrimary, - ) - } - } - if (showShare) { - IconButton(onClick = onShareClicked, modifier = Modifier.size(36.dp)) { - Icon( - imageVector = Icons.Default.Share, - contentDescription = "Share", - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onPrimary - ) - } - } - IconButton( - onClick = { - if (!isImportDisabled) { - onImportClicked() - } - }, - modifier = Modifier.size(36.dp) - ) { - Icon( - painter = painterResource(id = R.drawable.icons8_import_file_48), - contentDescription = "Import", - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onPrimary, - ) - } - if (showSearch) { - IconButton(onClick = { - isSearchVisible = !isSearchVisible - }, modifier = Modifier.size(36.dp)) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = "Search", - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onPrimary - ) - } - } - } - }, - ) - if (isSearchVisible) { - Box( - modifier = Modifier - .padding(top = 54.dp) - .fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { - OutlinedTextField( - value = searchQuery, - onValueChange = { - searchQuery = it - onSearchQueryChanged(it) - }, - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - .clip(RoundedCornerShape(0.dp)), - placeholder = { Text(stringResource(R.string.search)) }, - leadingIcon = { - IconButton(onClick = { - searchQuery = "" - onSearchQueryChanged("") - isSearchVisible = false - }) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = MaterialTheme.colorScheme.onSurface - ) - } - }, - trailingIcon = { - if (searchQuery != "") { - IconButton(onClick = { - searchQuery = "" - onSearchQueryChanged("") - }) { - Icon( - Icons.Default.Clear, - contentDescription = "Clear", - tint = MaterialTheme.colorScheme.onSurface - ) - } - } - }, - singleLine = true, -// colors = TextFieldDefaults.outlinedTextFieldColors( -// cursorColor = MaterialTheme.colorScheme.onSurface, -// focusedBorderColor = MaterialTheme.colorScheme.primary, -// unfocusedBorderColor = MaterialTheme.colorScheme.onSurfaceVariant, -// focusedTextColor = MaterialTheme.colorScheme.onSurface -// ), - colors = TextFieldDefaults.colors( - cursorColor = MaterialTheme.colorScheme.onSurface, - focusedTextColor = MaterialTheme.colorScheme.onSurface, - errorCursorColor = Color.Red, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = MaterialTheme.colorScheme.onSurfaceVariant, - errorIndicatorColor = Color.Red - ), - shape = RoundedCornerShape(0.dp) - ) - - } - } - } -} - - - -@SuppressLint("MissingPermission") -@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) -@Composable -fun UpdateFarmForm( - navController: NavController, - farmId: Long?, - listItems: List, -) { - val floatValue = 123.45f - val item = - listItems.find { it.id == farmId } ?: Farm( - siteId = 0L, - farmerName = "Default Farmer", - memberId = "", - farmerPhoto = "Default photo", - village = "Default Village", - district = "Default District", - latitude = "Default Village", - longitude = "Default Village", - coordinates = null, - accuracyArray = null, - size = floatValue, - purchases = floatValue, - createdAt = 1L, - updatedAt = 1L, - ) - val context = LocalContext.current as Activity - var farmerName by remember { mutableStateOf(item.farmerName) } - var memberId by remember { mutableStateOf(item.memberId) } - var village by remember { mutableStateOf(item.village) } - var district by remember { mutableStateOf(item.district) } - val sharedPref = context.getSharedPreferences("FarmCollector", Context.MODE_PRIVATE) - var isValidSize by remember { mutableStateOf(true) } - var size by remember { - mutableStateOf( - sharedPref.getString("plot_size", item.size.toString()) ?: item.size.toString() - ) - } - var latitude by remember { mutableStateOf(item.latitude) } - var longitude by remember { mutableStateOf(item.longitude) } - var coordinates by remember { mutableStateOf(item.coordinates) } - var showKeepPolygonDialog by remember { mutableStateOf(false) } - val farmViewModel: FarmViewModel = - viewModel( - factory = FarmViewModelFactory(context.applicationContext as Application), - ) - - val showDialog = remember { mutableStateOf(false) } - val showLocationDialog = remember { mutableStateOf(false) } - val showLocationDialogNew = remember { mutableStateOf(false) } - val showPermissionRequest = remember { mutableStateOf(false) } - var expanded by remember { mutableStateOf(false) } - val items = listOf("Ha", "Acres", "Sqm", "Timad", "Fichesa", "Manzana", "Tarea") - var selectedUnit by remember { mutableStateOf(items[0]) } - val scientificNotationPattern = Pattern.compile("([+-]?\\d*\\.?\\d+)[eE][+-]?\\d+") - - LaunchedEffect(Unit) { - if (!isLocationEnabled(context)) { - showLocationDialog.value = true - } - } - - // Define string constants - val titleText = stringResource(id = R.string.enable_location_services) - val messageText = stringResource(id = R.string.location_services_required_message) - val enableButtonText = stringResource(id = R.string.enable) - - // Dialog to prompt user to enable location services - if (showLocationDialog.value) { - AlertDialog( - onDismissRequest = { showLocationDialog.value = false }, - title = { Text(titleText) }, - text = { Text(messageText) }, - confirmButton = { - Button(onClick = { - showLocationDialog.value = false - promptEnableLocation(context) - }) { - Text(enableButtonText) - } - }, - dismissButton = { - Button(onClick = { - showLocationDialog.value = false - Toast.makeText( - context, - R.string.location_permission_denied_message, - Toast.LENGTH_SHORT - ).show() - }) { - Text(stringResource(id = R.string.cancel)) - } - }, - ) - } - if (navController.currentBackStackEntry!!.savedStateHandle.contains("coordinates")) { - val parcelableCoordinates = navController.currentBackStackEntry!! - .savedStateHandle - .get>("coordinates") - - coordinates = parcelableCoordinates?.map { Pair(it.first, it.second) } - } - - - val fillForm = stringResource(id = R.string.fill_form) - - fun validateForm(): Boolean { - var isValid = true - val textWithNumbersRegex = Regex(".*[a-zA-Z]+.*") // Ensures there is at least one letter - if (farmerName.isBlank() || !farmerName.matches(textWithNumbersRegex)) { - isValid = false - } - - if (village.isBlank() || !village.matches(textWithNumbersRegex)) { - isValid = false - } - - if (district.isBlank() || !district.matches(textWithNumbersRegex)) { - isValid = false - } - - if (size.toFloatOrNull()?.let { it > 0 } != true) { - isValid = false - } - - if (latitude.isBlank() || longitude.isBlank()) { - isValid = false - } - - return isValid - } - - /** - * Updating Farm details - * Before sending to the database - */ - - fun updateFarmInstance() { - - val isValid = validateForm() - if (isValid) { - item.farmerPhoto = "" - item.farmerName = farmerName - item.memberId = memberId - item.latitude = latitude - item.village = village - item.district = district - item.longitude = longitude - - // Updated condition handling - if ((size.toDoubleOrNull()?.let { convertSize(it, selectedUnit).toFloat() } - ?: 0f) >= 4) { - // Check if coordinates are valid for a polygon - if ((coordinates?.size ?: 0) < 3) { - Toast.makeText( - context, - R.string.error_polygon_points, - Toast.LENGTH_SHORT, - ).show() - return - } - showKeepPolygonDialog = true - - } else { - if ((coordinates?.size ?: 0) >= 3) { - // Size is less than 4 but valid polygon coordinates are present - // Show the dialog to ask whether to keep or capture new coordinates - showKeepPolygonDialog = true - } else { - // Handle the case where size is less than the threshold and only one coordinate is present - item.coordinates = listOf( - Pair( - item.longitude.toDoubleOrNull() ?: 0.0, - item.latitude.toDoubleOrNull() ?: 0.0 - ) - ) - } - } - item.size = convertSize(size.toDouble(), selectedUnit).toFloat() - item.purchases = 0.toFloat() - item.updatedAt = Instant.now().millis - updateFarm(farmViewModel, item) - item.needsUpdate = false - val returnIntent = Intent() - context.setResult(Activity.RESULT_OK, returnIntent) - navController.navigate("farmList/$siteID") - } else { - Toast.makeText(context, fillForm, Toast.LENGTH_SHORT).show() - } - } - - // If changes are detected, show dialog to confirm - if (showKeepPolygonDialog) { - KeepPolygonDialog( - onDismiss = { showKeepPolygonDialog = false }, - onKeepExisting = { - item.coordinates = - coordinates?.plus(coordinates?.first()) as List> - updateFarmInstance() - showKeepPolygonDialog = false - }, - onCaptureNew = { - coordinates = - listOf() - navController.navigate("SetPolygon") - - with(sharedPref.edit()) { - putBoolean(KEY_HAS_NEW_POLYGON, true) - apply() - } - showKeepPolygonDialog = false - } - ) - } - - /** - * Confirm farm update and ask if they wish to capture new polygon - */ - if (showDialog.value) { - AlertDialog( - modifier = Modifier.padding(horizontal = 32.dp), - onDismissRequest = { showDialog.value = false }, - title = { Text(text = stringResource(id = R.string.update_farm)) }, - text = { - Column { - Text(text = stringResource(id = R.string.confirm_update_farm)) - } - }, - confirmButton = { - TextButton(onClick = { - if ((coordinates?.size ?: 0) >= 3) { - showKeepPolygonDialog = true - } else { - updateFarmInstance() - } - }) { - Text(text = stringResource(id = R.string.update_farm)) - } - }, - dismissButton = { - TextButton( - onClick = - { - showDialog.value = false - navController.navigate("setPolygon") - }, - ) { - Text(text = stringResource(id = R.string.set_polygon)) - } - }, - containerColor = MaterialTheme.colorScheme.background, - tonalElevation = 6.dp - ) - } - val scrollState = rememberScrollState() - val (focusRequester1) = FocusRequester.createRefs() - val (focusRequester2) = FocusRequester.createRefs() - val (focusRequester3) = FocusRequester.createRefs() - val isDarkTheme = isSystemInDarkTheme() - val inputLabelColor = if (isDarkTheme) Color.LightGray else Color.DarkGray - val inputTextColor = if (isDarkTheme) Color.White else Color.Black - val inputBorder = if (isDarkTheme) Color.LightGray else Color.DarkGray - - if (showPermissionRequest.value) { - LocationPermissionRequest( - onLocationEnabled = { - showLocationDialog.value = true - }, - onPermissionsGranted = { - showPermissionRequest.value = false - }, - showLocationDialogNew = showLocationDialogNew, - hasToShowDialog = showLocationDialogNew.value, - ) - } - - val locationHelper = LocationHelper(context) - val mapViewModel: MapViewModel = viewModel() - - var accuracyArray by rememberSaveable { mutableStateOf(listOf()) } - - Column( - modifier = - Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.background) - .verticalScroll(state = scrollState), - ) { - FarmListHeader( - title = stringResource(id = R.string.update_farm), - onSearchQueryChanged = {}, - onBackClicked = { navController.popBackStack() }, - showSearch = false, - showRestore = false, - onRestoreClicked = {} - ) - Spacer(modifier = Modifier.height(16.dp)) - TextField( - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = - KeyboardActions( - onDone = { focusRequester1.requestFocus() }, - ), - value = farmerName, - onValueChange = { farmerName = it }, - label = { Text(stringResource(id = R.string.farm_name), color = inputLabelColor) }, - isError = farmerName.isBlank(), - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - .onKeyEvent { - if (it.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_ENTER) { - focusRequester1.requestFocus() - true - } - false - }, - ) - TextField( - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = - KeyboardActions( - onDone = { focusRequester1.requestFocus() }, - ), - value = memberId, - onValueChange = { memberId = it }, - label = { Text(stringResource(id = R.string.member_id), color = inputLabelColor) }, - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - .onKeyEvent { - if (it.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_ENTER) { - focusRequester1.requestFocus() - } - false - }, - ) - TextField( - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = - KeyboardActions( - onDone = { focusRequester2.requestFocus() }, - ), - value = village, - onValueChange = { village = it }, - label = { Text(stringResource(id = R.string.village), color = inputLabelColor) }, - modifier = - Modifier - .focusRequester(focusRequester1) - .fillMaxWidth() - .padding(bottom = 16.dp), - ) - TextField( - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = - KeyboardActions( - onDone = { focusRequester3.requestFocus() }, - ), - value = district, - onValueChange = { district = it }, - label = { Text(stringResource(id = R.string.district), color = inputLabelColor) }, - modifier = - Modifier - .focusRequester(focusRequester2) - .fillMaxWidth() - .padding(bottom = 16.dp), - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - TextField( - singleLine = true, - value = truncateToDecimalPlaces(size, 9), - onValueChange = { it -> - val formattedValue = when { - validateSize(it) -> it - scientificNotationPattern.matcher(it).matches() -> { - truncateToDecimalPlaces(formatInput(it), 9) - } - - else -> it - } - size = formattedValue - isValidSize = validateSize(formattedValue) - with(sharedPref.edit()) { - putString("plot_size", formattedValue) - apply() - } - }, - keyboardOptions = - KeyboardOptions.Default.copy( - keyboardType = KeyboardType.Number, - ), - label = { - Text( - stringResource(id = R.string.size_in_hectares) + " (*)", - color = inputLabelColor - ) - }, - isError = size.toFloatOrNull() == null || size.toFloat() <= 0, // Validate size - colors = - TextFieldDefaults.colors( - errorLeadingIconColor = Color.Red, - cursorColor = inputTextColor, - errorCursorColor = Color.Red, - focusedIndicatorColor = inputBorder, - unfocusedIndicatorColor = inputBorder, - errorIndicatorColor = Color.Red, - ), - modifier = - Modifier - .focusRequester(focusRequester3) - .weight(1f) - .padding(bottom = 16.dp), - ) - - Spacer(modifier = Modifier.width(16.dp)) - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { - expanded = !expanded - }, - modifier = Modifier.weight(1f), - ) { - TextField( - readOnly = true, - value = selectedUnit, - onValueChange = { }, - label = { Text(stringResource(R.string.unit)) }, - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon( - expanded = expanded, - ) - }, - colors = ExposedDropdownMenuDefaults.textFieldColors(), - modifier = Modifier.menuAnchor(), - ) - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { - expanded = false - }, - ) { - items.forEach { selectionOption -> - DropdownMenuItem( - { Text(text = selectionOption) }, - onClick = { - selectedUnit = selectionOption - expanded = false - }, - ) - } - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - if ((size.toDoubleOrNull()?.let { convertSize(it, selectedUnit).toFloat() } ?: 0f) < 4f) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - TextField( - readOnly = true, - value = latitude, - onValueChange = { it -> - val formattedValue = when { - validateNumber(it) -> { - truncateToDecimalPlaces( - it, - 9 - ) - } - scientificNotationPattern.matcher(it).matches() -> { - truncateToDecimalPlaces( - formatInput(it), - 9 - ) - } - - else -> { - // Show a Toast message if the input does not meet the requirements - Toast.makeText( - context, - context.getString(R.string.error_latitude_decimal_places), - Toast.LENGTH_SHORT - ).show() - null - } - } - formattedValue?.let { - latitude = it - } - }, - label = { - Text( - stringResource(id = R.string.latitude), - color = inputLabelColor - ) - }, - modifier = - Modifier - .weight(1f) - .padding(bottom = 16.dp), - ) - Spacer(modifier = Modifier.width(16.dp)) - TextField( - readOnly = true, - value = longitude, - onValueChange = { it -> - val formattedValue = when { - validateNumber(it) -> { - truncateToDecimalPlaces( - it, - 9 - ) - } - scientificNotationPattern.matcher(it).matches() -> { - truncateToDecimalPlaces( - formatInput(it), - 9 - ) - } - else -> { - // Show a Toast message if the input does not meet the requirements - Toast.makeText( - context, - context.getString(R.string.error_longitude_decimal_places), - Toast.LENGTH_SHORT - ).show() - null - } - } - formattedValue?.let { - longitude = it - } - }, - label = { - Text( - stringResource(id = R.string.longitude), - color = inputLabelColor - ) - }, - modifier = - Modifier - .weight(1f) - .padding(bottom = 16.dp), - ) - } - } - Button( - onClick = { - showPermissionRequest.value = true - if (!isLocationEnabled(context)) { - showLocationDialog.value = true - } else { - if (isLocationEnabled(context) && context.hasLocationPermission()) { - val enteredSize = - size.toDoubleOrNull()?.let { convertSize(it, selectedUnit).toFloat() } ?: 0f - locationHelper.requestLocationPermissionAndUpdateCoordinates( - enteredSize = enteredSize, - navController = navController, - mapViewModel = mapViewModel, - onLocationResult = { newLatitude, newLongitude, accuracy -> - latitude = newLatitude - longitude = newLongitude - accuracyArray = accuracyArray + accuracy.toFloat() - } - ) - } else { - showPermissionRequest.value = true - showLocationDialog.value = true - } - } - }, - modifier = - Modifier - .align(Alignment.CenterHorizontally) - .fillMaxWidth(0.7f) - .padding(bottom = 5.dp) - .height(50.dp), - enabled = size.toFloatOrNull() != null, - ) { - Text( - text = - if (size.toDoubleOrNull()?.let { convertSize(it, selectedUnit).toFloat() } - ?.let { it < 4f } == - true - ) { - stringResource(id = R.string.get_coordinates) - } else { - stringResource( - id = R.string.set_new_polygon, - ) - }, - ) - } - Button( - onClick = { - if (validateForm()) { - showDialog.value = true - } else { - Toast.makeText(context, fillForm, Toast.LENGTH_SHORT).show() - } - }, - modifier = - Modifier - .fillMaxWidth() - .height(50.dp), - ) { - Text(text = stringResource(id = R.string.update_farm)) - } - } -} - fun updateFarm( farmViewModel: FarmViewModel, item: Farm, diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/SetPolygon.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/SetPolygon.kt index 732d8b1..d9fd1d6 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/SetPolygon.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/SetPolygon.kt @@ -52,13 +52,17 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import org.technoserve.farmcollector.R +import org.technoserve.farmcollector.database.models.ParcelableFarmData +import org.technoserve.farmcollector.database.models.ParcelablePair import org.technoserve.farmcollector.hasLocationPermission import org.technoserve.farmcollector.map.LocationHelper import org.technoserve.farmcollector.map.MapScreen import org.technoserve.farmcollector.map.MapViewModel +import org.technoserve.farmcollector.ui.components.InvalidPolygonDialog import org.technoserve.farmcollector.ui.composes.AreaDialog import org.technoserve.farmcollector.ui.composes.ConfirmDialog import org.technoserve.farmcollector.utils.convertSize +import org.technoserve.farmcollector.utils.isSystemInDarkTheme /** * This screen helps you to capture and visualize farm polygon. @@ -707,31 +711,5 @@ fun SetPolygon( } } -/** - * This function is used to display the message when the user captures the invalid polygon - */ - -@Composable -fun InvalidPolygonDialog( - showDialog: MutableState, - onDismiss: () -> Unit -) { - if (showDialog.value) { - AlertDialog( - onDismissRequest = { showDialog.value = false }, - title = { Text(text = stringResource(id = R.string.invalid_polygon_title)) }, - text = { Text(text = stringResource(id = R.string.invalid_polygon_message)) }, - confirmButton = { - TextButton(onClick = { - onDismiss() - }) { - Text(text = stringResource(id = R.string.ok)) - } - }, - containerColor = MaterialTheme.colorScheme.background, - tonalElevation = 6.dp - ) - } -} diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/UpdateFarmForm.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/UpdateFarmForm.kt new file mode 100644 index 0000000..13e1c1b --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/UpdateFarmForm.kt @@ -0,0 +1,693 @@ +package org.technoserve.farmcollector.ui.screens.farms + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.Intent +import android.view.KeyEvent +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import org.joda.time.Instant +import org.technoserve.farmcollector.R +import org.technoserve.farmcollector.database.models.Farm +import org.technoserve.farmcollector.database.models.ParcelablePair +import org.technoserve.farmcollector.hasLocationPermission +import org.technoserve.farmcollector.map.LocationHelper +import org.technoserve.farmcollector.map.MapViewModel +import org.technoserve.farmcollector.ui.components.FarmListHeader +import org.technoserve.farmcollector.ui.components.KeepPolygonDialog +import org.technoserve.farmcollector.utils.convertSize +import org.technoserve.farmcollector.utils.isSystemInDarkTheme +import org.technoserve.farmcollector.viewmodels.FarmViewModel +import org.technoserve.farmcollector.viewmodels.FarmViewModelFactory +import java.util.regex.Pattern + + +private const val KEY_HAS_NEW_POLYGON = "has_new_polygon" + +@SuppressLint("MissingPermission") +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +fun UpdateFarmForm( + navController: NavController, + farmId: Long?, + listItems: List, +) { + val floatValue = 123.45f + val item = + listItems.find { it.id == farmId } ?: Farm( + siteId = 0L, + farmerName = "Default Farmer", + memberId = "", + farmerPhoto = "Default photo", + village = "Default Village", + district = "Default District", + latitude = "Default Village", + longitude = "Default Village", + coordinates = null, + accuracyArray = null, + size = floatValue, + purchases = floatValue, + createdAt = 1L, + updatedAt = 1L, + ) + val context = LocalContext.current as Activity + var farmerName by remember { mutableStateOf(item.farmerName) } + var memberId by remember { mutableStateOf(item.memberId) } + var village by remember { mutableStateOf(item.village) } + var district by remember { mutableStateOf(item.district) } + val sharedPref = context.getSharedPreferences("FarmCollector", Context.MODE_PRIVATE) + var isValidSize by remember { mutableStateOf(true) } + var size by remember { + mutableStateOf( + sharedPref.getString("plot_size", item.size.toString()) ?: item.size.toString() + ) + } + var latitude by remember { mutableStateOf(item.latitude) } + var longitude by remember { mutableStateOf(item.longitude) } + var coordinates by remember { mutableStateOf(item.coordinates) } + var showKeepPolygonDialog by remember { mutableStateOf(false) } + val farmViewModel: FarmViewModel = + viewModel( + factory = FarmViewModelFactory(context.applicationContext as Application), + ) + + val showDialog = remember { mutableStateOf(false) } + val showLocationDialog = remember { mutableStateOf(false) } + val showLocationDialogNew = remember { mutableStateOf(false) } + val showPermissionRequest = remember { mutableStateOf(false) } + var expanded by remember { mutableStateOf(false) } + val items = listOf("Ha", "Acres", "Sqm", "Timad", "Fichesa", "Manzana", "Tarea") + var selectedUnit by remember { mutableStateOf(items[0]) } + val scientificNotationPattern = Pattern.compile("([+-]?\\d*\\.?\\d+)[eE][+-]?\\d+") + + LaunchedEffect(Unit) { + if (!isLocationEnabled(context)) { + showLocationDialog.value = true + } + } + + // Define string constants + val titleText = stringResource(id = R.string.enable_location_services) + val messageText = stringResource(id = R.string.location_services_required_message) + val enableButtonText = stringResource(id = R.string.enable) + + // Dialog to prompt user to enable location services + if (showLocationDialog.value) { + AlertDialog( + onDismissRequest = { showLocationDialog.value = false }, + title = { Text(titleText) }, + text = { Text(messageText) }, + confirmButton = { + Button(onClick = { + showLocationDialog.value = false + promptEnableLocation(context) + }) { + Text(enableButtonText) + } + }, + dismissButton = { + Button(onClick = { + showLocationDialog.value = false + Toast.makeText( + context, + R.string.location_permission_denied_message, + Toast.LENGTH_SHORT + ).show() + }) { + Text(stringResource(id = R.string.cancel)) + } + }, + ) + } + if (navController.currentBackStackEntry!!.savedStateHandle.contains("coordinates")) { + val parcelableCoordinates = navController.currentBackStackEntry!! + .savedStateHandle + .get>("coordinates") + + coordinates = parcelableCoordinates?.map { Pair(it.first, it.second) } + } + + + val fillForm = stringResource(id = R.string.fill_form) + + fun validateForm(): Boolean { + var isValid = true + val textWithNumbersRegex = Regex(".*[a-zA-Z]+.*") // Ensures there is at least one letter + if (farmerName.isBlank() || !farmerName.matches(textWithNumbersRegex)) { + isValid = false + } + + if (village.isBlank() || !village.matches(textWithNumbersRegex)) { + isValid = false + } + + if (district.isBlank() || !district.matches(textWithNumbersRegex)) { + isValid = false + } + + if (size.toFloatOrNull()?.let { it > 0 } != true) { + isValid = false + } + + if (latitude.isBlank() || longitude.isBlank()) { + isValid = false + } + + return isValid + } + + /** + * Updating Farm details + * Before sending to the database + */ + + fun updateFarmInstance() { + + val isValid = validateForm() + if (isValid) { + item.farmerPhoto = "" + item.farmerName = farmerName + item.memberId = memberId + item.latitude = latitude + item.village = village + item.district = district + item.longitude = longitude + + // Updated condition handling + if ((size.toDoubleOrNull()?.let { convertSize(it, selectedUnit).toFloat() } + ?: 0f) >= 4) { + // Check if coordinates are valid for a polygon + if ((coordinates?.size ?: 0) < 3) { + Toast.makeText( + context, + R.string.error_polygon_points, + Toast.LENGTH_SHORT, + ).show() + return + } + showKeepPolygonDialog = true + + } else { + if ((coordinates?.size ?: 0) >= 3) { + // Size is less than 4 but valid polygon coordinates are present + // Show the dialog to ask whether to keep or capture new coordinates + showKeepPolygonDialog = true + } else { + // Handle the case where size is less than the threshold and only one coordinate is present + item.coordinates = listOf( + Pair( + item.longitude.toDoubleOrNull() ?: 0.0, + item.latitude.toDoubleOrNull() ?: 0.0 + ) + ) + } + } + item.size = convertSize(size.toDouble(), selectedUnit).toFloat() + item.purchases = 0.toFloat() + item.updatedAt = Instant.now().millis + updateFarm(farmViewModel, item) + item.needsUpdate = false + val returnIntent = Intent() + context.setResult(Activity.RESULT_OK, returnIntent) + navController.navigate("farmList/$siteID") + } else { + Toast.makeText(context, fillForm, Toast.LENGTH_SHORT).show() + } + } + + // If changes are detected, show dialog to confirm + if (showKeepPolygonDialog) { + KeepPolygonDialog( + onDismiss = { showKeepPolygonDialog = false }, + onKeepExisting = { + item.coordinates = + coordinates?.plus(coordinates?.first()) as List> + updateFarmInstance() + showKeepPolygonDialog = false + }, + onCaptureNew = { + coordinates = + listOf() + navController.navigate("SetPolygon") + + with(sharedPref.edit()) { + putBoolean(KEY_HAS_NEW_POLYGON, true) + apply() + } + showKeepPolygonDialog = false + } + ) + } + + /** + * Confirm farm update and ask if they wish to capture new polygon + */ + if (showDialog.value) { + AlertDialog( + modifier = Modifier.padding(horizontal = 32.dp), + onDismissRequest = { showDialog.value = false }, + title = { Text(text = stringResource(id = R.string.update_farm)) }, + text = { + Column { + Text(text = stringResource(id = R.string.confirm_update_farm)) + } + }, + confirmButton = { + TextButton(onClick = { + if ((coordinates?.size ?: 0) >= 3) { + showKeepPolygonDialog = true + } else { + updateFarmInstance() + } + }) { + Text(text = stringResource(id = R.string.update_farm)) + } + }, + dismissButton = { + TextButton( + onClick = + { + showDialog.value = false + navController.navigate("setPolygon") + }, + ) { + Text(text = stringResource(id = R.string.set_polygon)) + } + }, + containerColor = MaterialTheme.colorScheme.background, + tonalElevation = 6.dp + ) + } + val scrollState = rememberScrollState() + val (focusRequester1) = FocusRequester.createRefs() + val (focusRequester2) = FocusRequester.createRefs() + val (focusRequester3) = FocusRequester.createRefs() + val isDarkTheme = isSystemInDarkTheme() + val inputLabelColor = if (isDarkTheme) Color.LightGray else Color.DarkGray + val inputTextColor = if (isDarkTheme) Color.White else Color.Black + val inputBorder = if (isDarkTheme) Color.LightGray else Color.DarkGray + + if (showPermissionRequest.value) { + LocationPermissionRequest( + onLocationEnabled = { + showLocationDialog.value = true + }, + onPermissionsGranted = { + showPermissionRequest.value = false + }, + showLocationDialogNew = showLocationDialogNew, + hasToShowDialog = showLocationDialogNew.value, + ) + } + + val locationHelper = LocationHelper(context) + val mapViewModel: MapViewModel = viewModel() + + var accuracyArray by rememberSaveable { mutableStateOf(listOf()) } + + Column( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .verticalScroll(state = scrollState), + ) { + FarmListHeader( + title = stringResource(id = R.string.update_farm), + onSearchQueryChanged = {}, + onBackClicked = { navController.popBackStack() }, + showSearch = false, + showRestore = false, + onRestoreClicked = {} + ) + Spacer(modifier = Modifier.height(16.dp)) + TextField( + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = + KeyboardActions( + onDone = { focusRequester1.requestFocus() }, + ), + value = farmerName, + onValueChange = { farmerName = it }, + label = { Text(stringResource(id = R.string.farm_name), color = inputLabelColor) }, + isError = farmerName.isBlank(), + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .onKeyEvent { + if (it.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_ENTER) { + focusRequester1.requestFocus() + true + } + false + }, + ) + TextField( + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = + KeyboardActions( + onDone = { focusRequester1.requestFocus() }, + ), + value = memberId, + onValueChange = { memberId = it }, + label = { Text(stringResource(id = R.string.member_id), color = inputLabelColor) }, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .onKeyEvent { + if (it.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_ENTER) { + focusRequester1.requestFocus() + } + false + }, + ) + TextField( + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = + KeyboardActions( + onDone = { focusRequester2.requestFocus() }, + ), + value = village, + onValueChange = { village = it }, + label = { Text(stringResource(id = R.string.village), color = inputLabelColor) }, + modifier = + Modifier + .focusRequester(focusRequester1) + .fillMaxWidth() + .padding(bottom = 16.dp), + ) + TextField( + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = + KeyboardActions( + onDone = { focusRequester3.requestFocus() }, + ), + value = district, + onValueChange = { district = it }, + label = { Text(stringResource(id = R.string.district), color = inputLabelColor) }, + modifier = + Modifier + .focusRequester(focusRequester2) + .fillMaxWidth() + .padding(bottom = 16.dp), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TextField( + singleLine = true, + value = truncateToDecimalPlaces(size, 9), + onValueChange = { it -> + val formattedValue = when { + validateSize(it) -> it + scientificNotationPattern.matcher(it).matches() -> { + truncateToDecimalPlaces(formatInput(it), 9) + } + + else -> it + } + size = formattedValue + isValidSize = validateSize(formattedValue) + with(sharedPref.edit()) { + putString("plot_size", formattedValue) + apply() + } + }, + keyboardOptions = + KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Number, + ), + label = { + Text( + stringResource(id = R.string.size_in_hectares) + " (*)", + color = inputLabelColor + ) + }, + isError = size.toFloatOrNull() == null || size.toFloat() <= 0, // Validate size + colors = + TextFieldDefaults.colors( + errorLeadingIconColor = Color.Red, + cursorColor = inputTextColor, + errorCursorColor = Color.Red, + focusedIndicatorColor = inputBorder, + unfocusedIndicatorColor = inputBorder, + errorIndicatorColor = Color.Red, + ), + modifier = + Modifier + .focusRequester(focusRequester3) + .weight(1f) + .padding(bottom = 16.dp), + ) + + Spacer(modifier = Modifier.width(16.dp)) + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { + expanded = !expanded + }, + modifier = Modifier.weight(1f), + ) { + TextField( + readOnly = true, + value = selectedUnit, + onValueChange = { }, + label = { Text(stringResource(R.string.unit)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = expanded, + ) + }, + colors = ExposedDropdownMenuDefaults.textFieldColors(), + modifier = Modifier.menuAnchor(), + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { + expanded = false + }, + ) { + items.forEach { selectionOption -> + DropdownMenuItem( + { Text(text = selectionOption) }, + onClick = { + selectedUnit = selectionOption + expanded = false + }, + ) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + if ((size.toDoubleOrNull()?.let { convertSize(it, selectedUnit).toFloat() } ?: 0f) < 4f) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TextField( + readOnly = true, + value = latitude, + onValueChange = { it -> + val formattedValue = when { + validateNumber(it) -> { + truncateToDecimalPlaces( + it, + 9 + ) + } + scientificNotationPattern.matcher(it).matches() -> { + truncateToDecimalPlaces( + formatInput(it), + 9 + ) + } + + else -> { + // Show a Toast message if the input does not meet the requirements + Toast.makeText( + context, + context.getString(R.string.error_latitude_decimal_places), + Toast.LENGTH_SHORT + ).show() + null + } + } + formattedValue?.let { + latitude = it + } + }, + label = { + Text( + stringResource(id = R.string.latitude), + color = inputLabelColor + ) + }, + modifier = + Modifier + .weight(1f) + .padding(bottom = 16.dp), + ) + Spacer(modifier = Modifier.width(16.dp)) + TextField( + readOnly = true, + value = longitude, + onValueChange = { it -> + val formattedValue = when { + validateNumber(it) -> { + truncateToDecimalPlaces( + it, + 9 + ) + } + scientificNotationPattern.matcher(it).matches() -> { + truncateToDecimalPlaces( + formatInput(it), + 9 + ) + } + else -> { + // Show a Toast message if the input does not meet the requirements + Toast.makeText( + context, + context.getString(R.string.error_longitude_decimal_places), + Toast.LENGTH_SHORT + ).show() + null + } + } + formattedValue?.let { + longitude = it + } + }, + label = { + Text( + stringResource(id = R.string.longitude), + color = inputLabelColor + ) + }, + modifier = + Modifier + .weight(1f) + .padding(bottom = 16.dp), + ) + } + } + Button( + onClick = { + showPermissionRequest.value = true + if (!isLocationEnabled(context)) { + showLocationDialog.value = true + } else { + if (isLocationEnabled(context) && context.hasLocationPermission()) { + val enteredSize = + size.toDoubleOrNull()?.let { convertSize(it, selectedUnit).toFloat() } ?: 0f + locationHelper.requestLocationPermissionAndUpdateCoordinates( + enteredSize = enteredSize, + navController = navController, + mapViewModel = mapViewModel, + onLocationResult = { newLatitude, newLongitude, accuracy -> + latitude = newLatitude + longitude = newLongitude + accuracyArray = accuracyArray + accuracy.toFloat() + } + ) + } else { + showPermissionRequest.value = true + showLocationDialog.value = true + } + } + }, + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .fillMaxWidth(0.7f) + .padding(bottom = 5.dp) + .height(50.dp), + enabled = size.toFloatOrNull() != null, + ) { + Text( + text = + if (size.toDoubleOrNull()?.let { convertSize(it, selectedUnit).toFloat() } + ?.let { it < 4f } == + true + ) { + stringResource(id = R.string.get_coordinates) + } else { + stringResource( + id = R.string.set_new_polygon, + ) + }, + ) + } + Button( + onClick = { + if (validateForm()) { + showDialog.value = true + } else { + Toast.makeText(context, fillForm, Toast.LENGTH_SHORT).show() + } + }, + modifier = + Modifier + .fillMaxWidth() + .height(50.dp), + ) { + Text(text = stringResource(id = R.string.update_farm)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/technoserve/farmcollector/utils/FileCreator.kt b/app/src/main/java/org/technoserve/farmcollector/utils/FileCreator.kt new file mode 100644 index 0000000..d217914 --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/utils/FileCreator.kt @@ -0,0 +1,251 @@ +package org.technoserve.farmcollector.utils + +import android.app.Application +import android.content.Context +import android.net.Uri +import android.os.Environment +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.lifecycle.viewmodel.compose.viewModel +import org.technoserve.farmcollector.R +import org.technoserve.farmcollector.database.models.CollectionSite +import org.technoserve.farmcollector.database.models.Farm +import org.technoserve.farmcollector.ui.screens.farms.siteID + +import org.technoserve.farmcollector.viewmodels.FarmViewModel +import org.technoserve.farmcollector.viewmodels.FarmViewModelFactory +import java.io.BufferedWriter +import java.io.File +import java.io.IOException +import java.io.OutputStreamWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + + +fun createFile( + context: Context, + uri: Uri, + listItems: List, + exportFormat: String, + siteID : Long, + cwsListItems: List + +): Boolean { + + val getSiteById = cwsListItems.find { it.siteId == siteID } + try { + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + BufferedWriter(OutputStreamWriter(outputStream)).use { writer -> + if (exportFormat == "CSV") { + writer.write( + "remote_id,farmer_name,member_id,collection_site,agent_name,farm_village,farm_district,farm_size,latitude,longitude,polygon,accuracyArray,created_at,updated_at\n", + ) + listItems.forEach { farm -> + val regex = "\\(([^,]+), ([^)]+)\\)".toRegex() + val matches = regex.findAll(farm.coordinates.toString()) + val reversedCoordinates = + matches + .map { match -> + val (lat, lon) = match.destructured + "[$lon, $lat]" + }.toList() + .let { coordinates -> + if (coordinates.isNotEmpty()) { + // Always include brackets, even for a single point + coordinates.joinToString( + ", ", + prefix = "[", + postfix = "]" + ) + } else { + "" + } + } + + val line = + "${farm.remoteId},\"${ + farm.farmerName.split(" ").joinToString(" ") + }\",${farm.memberId},${getSiteById?.name},\"${getSiteById?.agentName}\",\"${farm.village}\",\"${farm.district}\",${farm.size},${farm.latitude},${farm.longitude},\"${reversedCoordinates}\",\"${farm.accuracyArray}\",${ + Date(farm.createdAt) + },${Date(farm.updatedAt)}\n" + writer.write(line) + } + } else { + val geoJson = + buildString { + append("{\"type\": \"FeatureCollection\", \"features\": [") + listItems.forEachIndexed { index, farm -> + val regex = "\\(([^,]+), ([^)]+)\\)".toRegex() + val matches = regex.findAll(farm.coordinates.toString()) + val geoJsonCoordinates = + matches + .map { match -> + val (lat, lon) = match.destructured + "[$lon, $lat]" + }.joinToString(", ", prefix = "[", postfix = "]") + // Ensure latitude and longitude are not null + val latitude = + farm.latitude.toDoubleOrNull()?.takeIf { it != 0.0 } ?: 0.0 + val longitude = + farm.longitude.toDoubleOrNull()?.takeIf { it != 0.0 } ?: 0.0 + + val feature = + """ + { + "type": "Feature", + "properties": { + "remote_id": "${farm.remoteId}", + "farmer_name": "${ + farm.farmerName.split(" ").joinToString(" ") + }", + "member_id": "${farm.memberId}", + "collection_site": "${getSiteById?.name ?: ""}", + "agent_name": "${getSiteById?.agentName ?: ""}", + "farm_village": "${farm.village}", + "farm_district": "${farm.district}", + "farm_size": ${farm.size}, + "latitude": $latitude, + "longitude": $longitude, + "accuracyArray": "${farm.accuracyArray ?: ""}", + "created_at": "${farm.createdAt.let { Date(it) }}", + "updated_at": "${farm.updatedAt.let { Date(it) }}" + + }, + "geometry": { + "type": "${if ((farm.coordinates?.size ?: 0) > 1) "Polygon" else "Point"}", + "coordinates": ${if ((farm.coordinates?.size ?: 0) > 1) "[$geoJsonCoordinates]" else "[$latitude, $longitude]"} + } + } + """.trimIndent() + append(feature) + if (index < listItems.size - 1) append(",") + } + append("]}") + } + writer.write(geoJson) + } + } + } + return true + } catch (e: IOException) { + Toast.makeText(context, R.string.error_export_msg, Toast.LENGTH_SHORT).show() + return false + } +} + + +fun createFileForSharing( + context: Context, + listItems: List, + exportFormat: String, + siteID : Long, + cwsListItems: List +): File? { + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val getSiteById = cwsListItems.find { it.siteId == siteID } + val siteName = getSiteById?.name ?: "SiteName" + val filename = + if (exportFormat == "CSV") "farms_${siteName}_$timestamp.csv" else "farms_${siteName}_$timestamp.geojson" + val downloadsDir = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + val file = File(downloadsDir, filename) + + try { + file.bufferedWriter().use { writer -> + if (exportFormat == "CSV") { + writer.write( + "remote_id,farmer_name,member_id,collection_site,agent_name,farm_village,farm_district,farm_size,latitude,longitude,polygon,accuracyArray,created_at,updated_at\n", + ) + listItems.forEach { farm -> + val regex = "\\(([^,]+), ([^)]+)\\)".toRegex() + val matches = regex.findAll(farm.coordinates.toString()) + val reversedCoordinates = + matches + .map { match -> + val (lat, lon) = match.destructured + "[$lon, $lat]" + }.toList() + .let { coordinates -> + if (coordinates.isNotEmpty()) { + // Always include brackets, even for a single point + coordinates.joinToString(", ", prefix = "[", postfix = "]") + } else { + "" + } + } + + val line = + "${farm.remoteId},\"${ + farm.farmerName.split(" ").joinToString(" ") + }\",${farm.memberId},\"${getSiteById?.name}\",\"${getSiteById?.agentName}\",\"${farm.village}\",\"${farm.district}\",${farm.size},${farm.latitude},${farm.longitude},\"${reversedCoordinates}\",\"${farm.accuracyArray}\",${ + Date( + farm.createdAt, + ) + },${Date(farm.updatedAt)}\n" + writer.write(line) + } + } else { + val geoJson = + buildString { + append("{\"type\": \"FeatureCollection\", \"features\": [") + listItems.forEachIndexed { index, farm -> + val regex = "\\(([^,]+), ([^)]+)\\)".toRegex() + val matches = regex.findAll(farm.coordinates.toString()) + val geoJsonCoordinates = + matches + .map { match -> + val (lat, lon) = match.destructured + "[$lon, $lat]" + }.joinToString(", ", prefix = "[", postfix = "]") + val latitude = + farm.latitude.toDoubleOrNull()?.takeIf { it != 0.0 } ?: 0.0 + val longitude = + farm.longitude.toDoubleOrNull()?.takeIf { it != 0.0 } ?: 0.0 + + val feature = + """ + { + "type": "Feature", + "properties": { + "remote_id": "${farm.remoteId}", + "farmer_name":"${ + farm.farmerName.split(" ").joinToString(" ") + }", + "member_id": "${farm.memberId}", + "collection_site": "${getSiteById?.name ?: ""}", + "agent_name": "${getSiteById?.agentName ?: ""}", + "farm_village": "${farm.village}", + "farm_district": "${farm.district}", + "farm_size": ${farm.size}, + "latitude": $latitude, + "longitude": $longitude, + "accuracyArray": "${farm.accuracyArray ?: ""}", + "created_at": "${Date(farm.createdAt)}", + "updated_at": "${Date(farm.updatedAt)} + }, + "geometry": { + "type": "${if ((farm.coordinates?.size ?: 0) > 1) "Polygon" else "Point"}", + "coordinates": ${if ((farm.coordinates?.size ?: 0) > 1) "[$geoJsonCoordinates]" else "[$latitude, $longitude]"} + } + } + """.trimIndent() + append(feature) + if (index < listItems.size - 1) append(",") + } + append("]}") + } + writer.write(geoJson) + } + } + return file + } catch (e: IOException) { + Toast.makeText(context, R.string.error_export_msg, Toast.LENGTH_SHORT).show() + return null + } +} \ No newline at end of file diff --git a/app/src/main/java/org/technoserve/farmcollector/utils/isSystemInDarkTheme.kt b/app/src/main/java/org/technoserve/farmcollector/utils/isSystemInDarkTheme.kt new file mode 100644 index 0000000..fe530a3 --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/utils/isSystemInDarkTheme.kt @@ -0,0 +1,12 @@ +package org.technoserve.farmcollector.utils + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +@Composable +fun isSystemInDarkTheme(): Boolean { + val context = LocalContext.current + val sharedPreferences = context.getSharedPreferences("theme_mode", Context.MODE_PRIVATE) + return sharedPreferences.getBoolean("dark_mode", false) +} diff --git a/app/src/main/java/org/technoserve/farmcollector/viewmodels/AppUpdateViewModel.kt b/app/src/main/java/org/technoserve/farmcollector/viewmodels/AppUpdateViewModel.kt index fd96b39..186a347 100644 --- a/app/src/main/java/org/technoserve/farmcollector/viewmodels/AppUpdateViewModel.kt +++ b/app/src/main/java/org/technoserve/farmcollector/viewmodels/AppUpdateViewModel.kt @@ -68,55 +68,7 @@ fun UpdateAlert( } } -@Composable -fun RestoreDataAlert( - showDialog: Boolean, - onDismiss: () -> Unit, - deviceId: String, - farmViewModel: FarmViewModel, -) { - val context = LocalContext.current - var finalMessage by remember { mutableStateOf("") } - var showFinalMessage by remember { mutableStateOf(false) } - var showRestorePrompt by remember { mutableStateOf(false) } - if (showDialog) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Data Restoration") }, - text = { - Text("During restoration, you will recover some of the previously deleted records. Do you want to continue?") - }, - confirmButton = { - Button( - onClick = { - farmViewModel.restoreData( - deviceId = deviceId, - phoneNumber = "", - email = "", - farmViewModel = farmViewModel - ) { success -> - if (success) { - finalMessage = context.getString(R.string.data_restored_successfully) - } else { - showFinalMessage = true - showRestorePrompt = true - } - onDismiss() - } - } - ) { - Text("Continue") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - } - ) - } -} @Composable fun ExitConfirmationDialog( diff --git a/app/src/main/java/org/technoserve/farmcollector/viewmodels/FarmViewModel.kt b/app/src/main/java/org/technoserve/farmcollector/viewmodels/FarmViewModel.kt index da3b65d..a46fc01 100644 --- a/app/src/main/java/org/technoserve/farmcollector/viewmodels/FarmViewModel.kt +++ b/app/src/main/java/org/technoserve/farmcollector/viewmodels/FarmViewModel.kt @@ -29,10 +29,16 @@ import org.technoserve.farmcollector.BuildConfig import org.technoserve.farmcollector.R import org.technoserve.farmcollector.database.AppDatabase import org.technoserve.farmcollector.repositories.FarmRepository -import org.technoserve.farmcollector.database.MyPagingSource -import org.technoserve.farmcollector.database.RefreshableLiveData +import org.technoserve.farmcollector.database.helpers.MyPagingSource +import org.technoserve.farmcollector.database.helpers.RefreshableLiveData import org.technoserve.farmcollector.database.models.CollectionSite +import org.technoserve.farmcollector.database.models.CollectionSiteRestore import org.technoserve.farmcollector.database.models.Farm +import org.technoserve.farmcollector.database.models.FarmAddResult +import org.technoserve.farmcollector.database.models.FarmRestore +import org.technoserve.farmcollector.database.models.ImportResult +import org.technoserve.farmcollector.database.models.ParsedFarms +import org.technoserve.farmcollector.database.models.ServerFarmResponse import org.technoserve.farmcollector.database.sync.remote.ApiService import org.technoserve.farmcollector.database.sync.remote.FarmRequest import org.technoserve.farmcollector.ui.screens.farms.truncateToDecimalPlaces @@ -43,26 +49,6 @@ import java.io.InputStreamReader import java.util.UUID import java.util.regex.Pattern -data class ImportResult( - val success: Boolean, - val message: String, - val importedFarms: List, - val duplicateFarms: List = emptyList(), - val farmsNeedingUpdate: List = emptyList(), - val invalidFarms: List = emptyList() -) - -data class FarmAddResult( - val success: Boolean, - val message: String, - val farm: Farm, -) - -data class ParsedFarms( - val validFarms: List, - val invalidFarms: List -) - // Define a sealed class for restore status sealed class RestoreStatus { data object InProgress : RestoreStatus() @@ -72,45 +58,6 @@ sealed class RestoreStatus { data class Error(val message: String) : RestoreStatus() } - -data class CollectionSiteRestore( - val id: Long, - val local_cs_id: Long, - val name: String, - val device_id: String, - val agent_name: String, - val email: String, - val phone_number: String, - val village: String, - val district: String, - val created_at: String, - val updated_at: String -) - -data class FarmRestore( - val id: Long, - val remote_id: String, - val farmer_name: String, - val member_id: String?, - val size: Double, - val agent_name: String?, - val village: String, - val district: String, - val latitude: Double, - val longitude: Double, - val coordinates: List>, - val accuracyArray: List?, - val created_at: String, - val updated_at: String, - val site_id: Long -) - -data class ServerFarmResponse( - val device_id: String, - val collection_site: CollectionSiteRestore, - val farms: List -) - /** * This class represents farmviewmodel */ diff --git a/app/src/test/java/org/technoserve/farmcollector/database/FarmRepositoryTest.kt b/app/src/test/java/org/technoserve/farmcollector/database/FarmRepositoryTest.kt index f4fef35..f0a7ecc 100644 --- a/app/src/test/java/org/technoserve/farmcollector/database/FarmRepositoryTest.kt +++ b/app/src/test/java/org/technoserve/farmcollector/database/FarmRepositoryTest.kt @@ -12,6 +12,8 @@ import org.mockito.Mock import org.mockito.Mockito.* import org.mockito.MockitoAnnotations import org.technoserve.farmcollector.database.dao.FarmDAO +import org.technoserve.farmcollector.database.models.CollectionSite +import org.technoserve.farmcollector.database.models.Farm import org.technoserve.farmcollector.repositories.FarmRepository import java.util.UUID diff --git a/app/src/test/java/org/technoserve/farmcollector/database/FarmViewModelTest.kt b/app/src/test/java/org/technoserve/farmcollector/database/FarmViewModelTest.kt index d257439..2a578bd 100644 --- a/app/src/test/java/org/technoserve/farmcollector/database/FarmViewModelTest.kt +++ b/app/src/test/java/org/technoserve/farmcollector/database/FarmViewModelTest.kt @@ -11,6 +11,7 @@ import org.junit.Test import org.mockito.Mock import org.mockito.Mockito.* import org.mockito.MockitoAnnotations +import org.technoserve.farmcollector.database.models.Farm import org.technoserve.farmcollector.repositories.FarmRepository import org.technoserve.farmcollector.viewmodels.FarmViewModel diff --git a/app/src/test/java/org/technoserve/farmcollector/ui/components/KeepPolygonDialogKtTest.kt b/app/src/test/java/org/technoserve/farmcollector/ui/components/KeepPolygonDialogKtTest.kt new file mode 100644 index 0000000..00e7aac --- /dev/null +++ b/app/src/test/java/org/technoserve/farmcollector/ui/components/KeepPolygonDialogKtTest.kt @@ -0,0 +1,63 @@ +package org.technoserve.farmcollector.ui.components + +import org.junit.Assert.* + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +//@RunWith(AndroidJUnit4::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29]) +class KeepPolygonDialogTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testKeepPolygonDialogDisplaysCorrectly() { + composeTestRule.setContent { + KeepPolygonDialog( + onDismiss = {}, + onKeepExisting = {}, + onCaptureNew = {} + ) + } + + // Verify the dialog title and text are displayed + composeTestRule.onNodeWithText("Update Polygon").assertExists() + composeTestRule.onNodeWithText("Keep existing polygon or capture new").assertExists() + + // Verify the buttons are displayed + composeTestRule.onNodeWithText("Keep Existing").assertExists() + composeTestRule.onNodeWithText("Capture New").assertExists() + } + + @Test + fun testKeepPolygonDialogButtonActions() { + var keepExistingClicked = false + var captureNewClicked = false + + composeTestRule.setContent { + KeepPolygonDialog( + onDismiss = {}, + onKeepExisting = { keepExistingClicked = true }, + onCaptureNew = { captureNewClicked = true } + ) + } + + // Click the "Keep Existing" button + composeTestRule.onNodeWithText("Keep Existing").performClick() + assert(keepExistingClicked) + + // Click the "Capture New" button + composeTestRule.onNodeWithText("Capture New").performClick() + assert(captureNewClicked) + } +} diff --git a/build.gradle b/build.gradle index 1d226ad..d571a73 100644 --- a/build.gradle +++ b/build.gradle @@ -7,6 +7,7 @@ buildscript { classpath "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1" classpath 'com.google.dagger:hilt-android-gradle-plugin:2.51.1' classpath "com.google.devtools.ksp:symbol-processing-gradle-plugin:2.0.20-RC-1.0.24" + classpath 'com.android.tools.build:gradle:8.1.4' } ext { compose_version = "1.5.14" From c7fd9f60e57200a51422ba5a197ee99369da3ef3 Mon Sep 17 00:00:00 2001 From: lucienshema Date: Fri, 22 Nov 2024 17:35:26 +0200 Subject: [PATCH 5/7] added unit tests for components --- .idea/androidTestResultsUserPreferences.xml | 14 + .idea/deploymentTargetSelector.xml | 8 +- app/build.gradle | 12 +- .../farmcollector/GreetingTestKtTest.kt | 34 --- .../farmcollector/FarmCollectorApp.kt | 2 +- .../farmcollector/ui/components/FarmForm.kt | 3 - .../ui/components/FarmListHeader.kt | 6 - .../ui/components/FarmListHeaderPlots.kt | 6 - .../org/technoserve/farmcollector/HomeTest.kt | 7 +- .../farmcollector/MainActivityTest.kt | 9 +- .../farmcollector/MapViewModelUnitTest.kt | 2 +- .../converters/AccuracyListConvertTest.kt | 4 +- .../converters/CoordinateListConvertTest.kt | 18 +- .../CustomPaginationControlsKtTest.kt | 123 +++++++++ .../CustomizedConfirmationDialogKtTest.kt | 171 ++++++++++++ .../ui/components/FarmCardKtTest.kt | 167 ++++++++++++ .../ui/components/FarmListHeaderKtTest.kt | 175 ++++++++++++ .../components/FarmListHeaderPlotsKtTest.kt | 248 ++++++++++++++++++ .../components/FormatSelectionDialogKtTest.kt | 133 ++++++++++ .../components/InvalidPolygonDialogKtTest.kt | 115 ++++++++ .../ui/components/KeepPolygonDialogKtTest.kt | 7 +- .../ui/components/RestoreDataAlertKtTest.kt | 175 ++++++++++++ .../ui/components/SiteCardKtTest.kt | 178 +++++++++++++ 23 files changed, 1525 insertions(+), 92 deletions(-) delete mode 100644 app/src/androidTest/java/org/technoserve/farmcollector/GreetingTestKtTest.kt create mode 100644 app/src/test/java/org/technoserve/farmcollector/ui/components/CustomPaginationControlsKtTest.kt create mode 100644 app/src/test/java/org/technoserve/farmcollector/ui/components/CustomizedConfirmationDialogKtTest.kt create mode 100644 app/src/test/java/org/technoserve/farmcollector/ui/components/FarmCardKtTest.kt create mode 100644 app/src/test/java/org/technoserve/farmcollector/ui/components/FarmListHeaderKtTest.kt create mode 100644 app/src/test/java/org/technoserve/farmcollector/ui/components/FarmListHeaderPlotsKtTest.kt create mode 100644 app/src/test/java/org/technoserve/farmcollector/ui/components/FormatSelectionDialogKtTest.kt create mode 100644 app/src/test/java/org/technoserve/farmcollector/ui/components/InvalidPolygonDialogKtTest.kt create mode 100644 app/src/test/java/org/technoserve/farmcollector/ui/components/RestoreDataAlertKtTest.kt create mode 100644 app/src/test/java/org/technoserve/farmcollector/ui/components/SiteCardKtTest.kt diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml index 537f8f3..b9aec02 100644 --- a/.idea/androidTestResultsUserPreferences.xml +++ b/.idea/androidTestResultsUserPreferences.xml @@ -13,6 +13,7 @@ + @@ -113,6 +114,19 @@ + + + + + + + diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 51980c9..9c13317 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -13,15 +13,9 @@ - - - - + + \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index ce0dc83..2354aad 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -235,7 +235,7 @@ dependencies { androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.7.5" debugImplementation("androidx.compose.ui:ui-test-manifest:1.7.5") - +// // implementation("group:artifact") { // exclude group: "org.junit.jupiter", module: "junit-jupiter-engine" // } diff --git a/app/src/androidTest/java/org/technoserve/farmcollector/utils/LanguageUIKtTest.kt b/app/src/androidTest/java/org/technoserve/farmcollector/utils/LanguageSelectorKtTest.kt similarity index 73% rename from app/src/androidTest/java/org/technoserve/farmcollector/utils/LanguageUIKtTest.kt rename to app/src/androidTest/java/org/technoserve/farmcollector/utils/LanguageSelectorKtTest.kt index 4fe1b36..097ab7c 100644 --- a/app/src/androidTest/java/org/technoserve/farmcollector/utils/LanguageUIKtTest.kt +++ b/app/src/androidTest/java/org/technoserve/farmcollector/utils/LanguageSelectorKtTest.kt @@ -12,10 +12,11 @@ import io.mockk.just import io.mockk.mockk import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import org.junit.Assert.* import org.junit.Rule import org.junit.Test import org.mockito.Mockito.verify +import org.technoserve.farmcollector.ui.screens.settings.LanguageSelector +import org.technoserve.farmcollector.viewmodels.LanguageViewModel // Mock Language class @@ -23,7 +24,11 @@ data class Language(val displayName: String) // Mock ViewModel class LanguageViewModel : ViewModel() { - private val _currentLanguage = MutableStateFlow(Language("English")) + private val _currentLanguage = MutableStateFlow( + org.technoserve.farmcollector.database.models.Language( + "English" + ) + ) val currentLanguage: StateFlow get() = _currentLanguage fun selectLanguage(language: Language, context: Context) { @@ -32,7 +37,7 @@ class LanguageViewModel : ViewModel() { } -class LanguageUIKtTest{ +class LanguageSelectorKtTest{ @get:Rule val composeTestRule = createComposeRule() @@ -41,7 +46,10 @@ class LanguageUIKtTest{ @Test fun languageSelector_displaysCurrentLanguage() { val mockViewModel = mockk(relaxed = true) - val languages = listOf(Language("English"), Language("French")) + val languages = listOf( + org.technoserve.farmcollector.database.models.Language("English"), + org.technoserve.farmcollector.database.models.Language("French") + ) val currentLanguage = MutableStateFlow(languages[0]) // English every { mockViewModel.currentLanguage } returns currentLanguage @@ -57,7 +65,10 @@ class LanguageUIKtTest{ @Test fun languageSelector_opensDropdownOnClick() { val mockViewModel = mockk(relaxed = true) - val languages = listOf(Language("English"), Language("French")) + val languages = listOf( + org.technoserve.farmcollector.database.models.Language("English"), + org.technoserve.farmcollector.database.models.Language("French") + ) val currentLanguage = MutableStateFlow(languages[0]) // English every { mockViewModel.currentLanguage } returns currentLanguage @@ -77,7 +88,10 @@ class LanguageUIKtTest{ @Test fun languageSelector_selectsLanguageOnClick() { val mockViewModel = mockk(relaxed = true) - val languages = listOf(Language("English"), Language("French")) + val languages = listOf( + org.technoserve.farmcollector.database.models.Language("English"), + org.technoserve.farmcollector.database.models.Language("French") + ) val currentLanguage = MutableStateFlow(languages[0]) // English every { mockViewModel.currentLanguage } returns currentLanguage @@ -94,6 +108,9 @@ class LanguageUIKtTest{ composeTestRule.onNodeWithText("French").performClick() // Verify that selectLanguage was called with the correct language - verify { mockViewModel.selectLanguage(Language("French"), mockContext) } + verify { mockViewModel.selectLanguage( + org.technoserve.farmcollector.database.models.Language( + "French" + ), mockContext) } } } \ No newline at end of file diff --git a/app/src/main/java/org/technoserve/farmcollector/FarmCollectorApp.kt b/app/src/main/java/org/technoserve/farmcollector/FarmCollectorApp.kt index 153e8e1..7277710 100644 --- a/app/src/main/java/org/technoserve/farmcollector/FarmCollectorApp.kt +++ b/app/src/main/java/org/technoserve/farmcollector/FarmCollectorApp.kt @@ -15,13 +15,8 @@ import java.util.concurrent.TimeUnit class FarmCollectorApp : Application() { override fun onCreate() { super.onCreate() -// val config = Configuration.Builder() -// .setMinimumLoggingLevel(android.util.Log.DEBUG) -// .build() -// WorkManager.initialize(this, config) -// android.util.Log.d("WorkManager", "WorkManager initialized successfully") ContextProvider.initialize(this) - // initializeWorkManager() + initializeWorkManager() } // private diff --git a/app/src/main/java/org/technoserve/farmcollector/MainActivity.kt b/app/src/main/java/org/technoserve/farmcollector/MainActivity.kt index 73f2c3e..e5d1001 100644 --- a/app/src/main/java/org/technoserve/farmcollector/MainActivity.kt +++ b/app/src/main/java/org/technoserve/farmcollector/MainActivity.kt @@ -50,10 +50,8 @@ import org.technoserve.farmcollector.ui.screens.farms.SetPolygon import org.technoserve.farmcollector.ui.screens.settings.SettingsScreen import org.technoserve.farmcollector.ui.screens.farms.UpdateFarmForm import org.technoserve.farmcollector.ui.theme.FarmCollectorTheme -import org.technoserve.farmcollector.utils.LanguageViewModel -import org.technoserve.farmcollector.utils.LanguageViewModelFactory -import org.technoserve.farmcollector.utils.getLocalizedLanguages -import org.technoserve.farmcollector.utils.updateLocale +import org.technoserve.farmcollector.viewmodels.LanguageViewModel +import org.technoserve.farmcollector.viewmodels.LanguageViewModelFactory import java.util.Locale @@ -109,9 +107,12 @@ class MainActivity : ComponentActivity() { sharedPref.edit().remove("selectedUnit").apply() } + // Apply language preference when the activity starts + applyLanguagePreference() + setContent { val navController = rememberNavController() - var context = LocalContext.current + val context = LocalContext.current var canExitApp by remember { mutableStateOf(false) } val currentLanguage by languageViewModel.currentLanguage.collectAsState() @@ -154,7 +155,7 @@ class MainActivity : ComponentActivity() { LaunchedEffect(currentLanguage) { - updateLocale(context = applicationContext, Locale(currentLanguage.code)) + languageViewModel.updateLocale(context = applicationContext, Locale(currentLanguage.code)) } FarmCollectorTheme(darkTheme = darkMode.value) { @@ -175,7 +176,8 @@ class MainActivity : ComponentActivity() { modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background, ) { - val languages = getLocalizedLanguages(applicationContext) +// val languages = getLocalizedLanguages(applicationContext) + val languages = languageViewModel.languages val farmViewModel: FarmViewModel = viewModel( factory = FarmViewModelFactory(applicationContext as Application), @@ -274,6 +276,18 @@ class MainActivity : ComponentActivity() { } } + private fun applyLanguagePreference() { + // Get the preferred language from shared preferences + val savedLanguageCode = getSharedPreferences("settings", MODE_PRIVATE) + .getString("preferred_language", Locale.getDefault().language) + + val preferredLanguage = languageViewModel.languages .find { it.code == savedLanguageCode } + ?: languageViewModel.languages.first() + + // Update the locale using the LanguageViewModel + languageViewModel.selectLanguage(preferredLanguage, this) + } + override fun onDestroy() { super.onDestroy() locationHelper.cleanup() diff --git a/app/src/main/java/org/technoserve/farmcollector/database/models/languages.kt b/app/src/main/java/org/technoserve/farmcollector/database/models/languages.kt new file mode 100644 index 0000000..220c13c --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/database/models/languages.kt @@ -0,0 +1,7 @@ +package org.technoserve.farmcollector.database.models + +/** + * This class defines the language with language code and display name + */ + +data class Language(val code: String, val displayName: String) diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/home/Home.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/home/Home.kt index 78ffb77..bc3e944 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/home/Home.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/home/Home.kt @@ -18,8 +18,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -32,9 +36,9 @@ import org.technoserve.farmcollector.R import org.technoserve.farmcollector.ui.theme.Teal import org.technoserve.farmcollector.ui.theme.Turquoise import org.technoserve.farmcollector.ui.theme.White -import org.technoserve.farmcollector.utils.Language -import org.technoserve.farmcollector.utils.LanguageSelector -import org.technoserve.farmcollector.utils.LanguageViewModel +import org.technoserve.farmcollector.ui.screens.settings.LanguageSelector +import org.technoserve.farmcollector.viewmodels.LanguageViewModel +import java.util.Locale /** @@ -48,6 +52,15 @@ fun Home( languageViewModel: LanguageViewModel, languages: List ) { + + val currentLanguage by languageViewModel.currentLanguage.collectAsState() + val context = LocalContext.current + + + LaunchedEffect(currentLanguage) { + languageViewModel.updateLocale(context = context, Locale(currentLanguage.code)) + } + Column( Modifier .padding(top = 20.dp) @@ -152,4 +165,5 @@ fun Home( Spacer(modifier = Modifier.height(5.dp)) } -} \ No newline at end of file +} + diff --git a/app/src/main/java/org/technoserve/farmcollector/utils/languageUI.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/LanguageSelector.kt similarity index 80% rename from app/src/main/java/org/technoserve/farmcollector/utils/languageUI.kt rename to app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/LanguageSelector.kt index cb359b4..88dcce2 100644 --- a/app/src/main/java/org/technoserve/farmcollector/utils/languageUI.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/LanguageSelector.kt @@ -1,9 +1,11 @@ -package org.technoserve.farmcollector.utils +package org.technoserve.farmcollector.ui.screens.settings import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon @@ -15,12 +17,14 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import org.technoserve.farmcollector.R +import org.technoserve.farmcollector.viewmodels.LanguageViewModel /** * This function is used to select the language to use @@ -28,23 +32,25 @@ import org.technoserve.farmcollector.R @Composable fun LanguageSelector(viewModel: LanguageViewModel, languages: List) { + // Observe the current language state val currentLanguage by viewModel.currentLanguage.collectAsState() var expanded by remember { mutableStateOf(false) } val context = LocalContext.current - //do on click to set the language when activity loads - viewModel.selectLanguage(currentLanguage, context) - + // UI for the language selector Row( modifier = Modifier .padding(16.dp) - .clickable { expanded = !expanded } + .clickable { expanded = !expanded }, + verticalAlignment = Alignment.CenterVertically ) { Icon( imageVector = ImageVector.vectorResource(id = R.drawable.ic_global), contentDescription = "Global Icon" ) + Spacer(modifier = Modifier.width(8.dp)) Text(text = currentLanguage.displayName) + DropdownMenu( expanded = expanded, modifier = Modifier.background(MaterialTheme.colorScheme.background), @@ -53,6 +59,7 @@ fun LanguageSelector(viewModel: LanguageViewModel, languages: List) { languages.forEach { language -> DropdownMenuItem( onClick = { + // Change the language when clicked viewModel.selectLanguage(language, context) expanded = false }, diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/Settings.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/Settings.kt index 9915197..f0e6adc 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/Settings.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/Settings.kt @@ -31,8 +31,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavController import org.technoserve.farmcollector.R -import org.technoserve.farmcollector.utils.Language -import org.technoserve.farmcollector.utils.LanguageViewModel +import org.technoserve.farmcollector.viewmodels.LanguageViewModel /** * This function is used to display the dark mode toggle and language selection diff --git a/app/src/main/java/org/technoserve/farmcollector/utils/GetLocalizedLangs.kt b/app/src/main/java/org/technoserve/farmcollector/utils/GetLocalizedLangs.kt deleted file mode 100644 index a624904..0000000 --- a/app/src/main/java/org/technoserve/farmcollector/utils/GetLocalizedLangs.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.technoserve.farmcollector.utils - -import android.content.Context -import org.technoserve.farmcollector.R - -/** - * This function is used to retrieve the languages that are available in the app - */ -fun getLocalizedLanguages(context: Context): List { - val languages = listOf( - Language("en", context.getString(R.string.english)), - Language("fr", context.getString(R.string.french)), - Language("es", context.getString(R.string.spanish)), - Language("am", context.getString(R.string.amharic)), - Language("om", context.getString(R.string.oromo)), - Language("sw", context.getString(R.string.swahili)) - ) - - return languages.map { language -> - Language(language.code, language.displayName) - } -} diff --git a/app/src/main/java/org/technoserve/farmcollector/utils/LanguageViewModal.kt b/app/src/main/java/org/technoserve/farmcollector/utils/LanguageViewModal.kt deleted file mode 100644 index 925880c..0000000 --- a/app/src/main/java/org/technoserve/farmcollector/utils/LanguageViewModal.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.technoserve.farmcollector.utils - -import android.app.Application -import android.content.Context -import androidx.lifecycle.AndroidViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import java.util.Locale - -open class LanguageViewModel(application: Application) : AndroidViewModel(application) { - private val sharedPreferences = - application.getSharedPreferences("settings", Context.MODE_PRIVATE) - private val _currentLanguage = MutableStateFlow(getDefaultLanguage()) - val currentLanguage: StateFlow = _currentLanguage - - fun selectLanguage(language: Language, context: Context) { - _currentLanguage.value = language - savePreferredLanguage(language) - updateLocale(context, Locale(language.code)) - } - - private fun getDefaultLanguage(): Language { - val savedLanguageCode = - sharedPreferences.getString("preferred_language", Locale.getDefault().language) - return languages.find { it.code == savedLanguageCode } ?: languages.first() - } - - private fun savePreferredLanguage(language: Language) { - sharedPreferences.edit().putString("preferred_language", language.code).apply() - } -} diff --git a/app/src/main/java/org/technoserve/farmcollector/utils/UpdateLocale.kt b/app/src/main/java/org/technoserve/farmcollector/utils/UpdateLocale.kt deleted file mode 100644 index cdfac93..0000000 --- a/app/src/main/java/org/technoserve/farmcollector/utils/UpdateLocale.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.technoserve.farmcollector.utils - -import android.content.Context -import java.util.Locale - -/** - * This function is used to update the locale with the selected language - */ -fun updateLocale(context: Context, locale: Locale) { - Locale.setDefault(locale) - - val config = context.resources.configuration - - config.setLocale(locale) - - config.setLayoutDirection(locale) - - context.resources.updateConfiguration(config, context.resources.displayMetrics) - - val resources = context.resources - val dm = resources.displayMetrics - val conf = resources.configuration - conf.setLocale(locale) - resources.updateConfiguration(conf, dm) -} diff --git a/app/src/main/java/org/technoserve/farmcollector/utils/languages.kt b/app/src/main/java/org/technoserve/farmcollector/utils/languages.kt deleted file mode 100644 index c7fe514..0000000 --- a/app/src/main/java/org/technoserve/farmcollector/utils/languages.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.technoserve.farmcollector.utils - -/** - * This class defines the language with language code and display name - */ - -data class Language(val code: String, val displayName: String) - -val languages = listOf( - Language("en", "English"), - Language("fr", "French"), - Language("es", "Spanish"), - Language("am", "Amharic"), - Language("om", "Oromo"), - Language("sw", "Swahili") -) - diff --git a/app/src/main/java/org/technoserve/farmcollector/utils/LanguageFactoryViewModel.kt b/app/src/main/java/org/technoserve/farmcollector/viewmodels/LanguageFactoryViewModel.kt similarity index 91% rename from app/src/main/java/org/technoserve/farmcollector/utils/LanguageFactoryViewModel.kt rename to app/src/main/java/org/technoserve/farmcollector/viewmodels/LanguageFactoryViewModel.kt index 89ede5b..5b6bb3d 100644 --- a/app/src/main/java/org/technoserve/farmcollector/utils/LanguageFactoryViewModel.kt +++ b/app/src/main/java/org/technoserve/farmcollector/viewmodels/LanguageFactoryViewModel.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.utils +package org.technoserve.farmcollector.viewmodels import android.app.Application import androidx.lifecycle.ViewModel diff --git a/app/src/main/java/org/technoserve/farmcollector/viewmodels/LanguageViewModal.kt b/app/src/main/java/org/technoserve/farmcollector/viewmodels/LanguageViewModal.kt new file mode 100644 index 0000000..357567d --- /dev/null +++ b/app/src/main/java/org/technoserve/farmcollector/viewmodels/LanguageViewModal.kt @@ -0,0 +1,82 @@ +package org.technoserve.farmcollector.viewmodels + +import android.app.Application +import android.content.Context +import androidx.lifecycle.AndroidViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.technoserve.farmcollector.R +import org.technoserve.farmcollector.database.models.Language +import java.util.Locale + +class LanguageViewModel(application: Application) : AndroidViewModel(application) { + private val sharedPreferences = + application.getSharedPreferences("settings", Context.MODE_PRIVATE) + + // Dynamic list of localized languages + val languages: List = getLocalizedLanguages(application) + + private val _currentLanguage = MutableStateFlow(getDefaultLanguage()) + val currentLanguage: StateFlow = _currentLanguage + + + + fun selectLanguage(language: Language, context: Context) { + _currentLanguage.value = language + savePreferredLanguage(language) + updateLocale(context, Locale(language.code)) + // restartActivity(context) + } + + private fun getDefaultLanguage(): Language { + val savedLanguageCode = + sharedPreferences.getString("preferred_language", Locale.getDefault().language) + return languages.find { it.code == savedLanguageCode } ?: languages.first() + } + + private fun savePreferredLanguage(language: Language) { + sharedPreferences.edit().putString("preferred_language", language.code).apply() + } + + /** + * This function is used to update the locale with the selected language + */ + + fun updateLocale(context: Context, locale: Locale) { + Locale.setDefault(locale) + + val config = context.resources.configuration + + config.setLocale(locale) + config.setLayoutDirection(locale) + + context.resources.updateConfiguration(config, context.resources.displayMetrics) + + val resources = context.resources + val dm = resources.displayMetrics + val conf = resources.configuration + conf.setLocale(locale) + resources.updateConfiguration(conf, dm) + } + + + + /** + * This function is used to retrieve the languages that are available in the app + */ + + fun getLocalizedLanguages(context: Context): List { + val languages = listOf( + Language("en", context.getString(R.string.english)), + Language("fr", context.getString(R.string.french)), + Language("es", context.getString(R.string.spanish)), + Language("am", context.getString(R.string.amharic)), + Language("om", context.getString(R.string.oromo)), + Language("sw", context.getString(R.string.swahili)) + ) + + return languages.map { language -> + Language(language.code, language.displayName) + } + } +} diff --git a/app/src/test/java/org/technoserve/farmcollector/HomeTest.kt b/app/src/test/java/org/technoserve/farmcollector/HomeTest.kt index bda4fd3..be9751a 100644 --- a/app/src/test/java/org/technoserve/farmcollector/HomeTest.kt +++ b/app/src/test/java/org/technoserve/farmcollector/HomeTest.kt @@ -187,8 +187,7 @@ import org.mockito.Mockito.mock import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.technoserve.farmcollector.ui.screens.home.Home -import org.technoserve.farmcollector.utils.Language -import org.technoserve.farmcollector.utils.LanguageViewModel +import org.technoserve.farmcollector.viewmodels.LanguageViewModel @RunWith(RobolectricTestRunner::class) @Config(sdk = [33]) From 41653f8cd2358902d615b557deedfbd35350c2d4 Mon Sep 17 00:00:00 2001 From: lucienshema Date: Mon, 25 Nov 2024 16:53:30 +0200 Subject: [PATCH 7/7] separated concern on map and added new unit tests --- .idea/deploymentTargetSelector.xml | 14 - app/build.gradle | 2 + .../farmcollector/FarmCollectorApp.kt | 2 +- .../technoserve/farmcollector/MainActivity.kt | 6 +- .../farmcollector/{map => }/MapApplication.kt | 2 +- .../converters/AccuracyListConvert.kt | 3 + .../database/dao/CollectionSiteDAO.kt | 9 + .../helpers}/map/LocationHelper.kt | 4 +- .../helpers}/map/ZoneClusterManager.kt | 3 +- .../models/{languages.kt => Language.kt} | 0 .../models}/map/LocationState.kt | 2 +- .../{ => database/models}/map/MapState.kt | 2 +- .../models}/map/ZoneClusterItem.kt | 3 +- .../farmcollector/ui/components/FarmForm.kt | 8 +- .../ui/components/KeepPolygonDialog.kt | 2 +- .../farmcollector/ui/screens/farms/AddFarm.kt | 47 ---- .../ui/screens/farms/FarmList.kt | 61 ---- .../ui/screens/farms/UpdateFarmForm.kt | 6 +- .../farmcollector/ui/screens/home/Home.kt | 92 ++++++ .../{ => ui/screens}/map/MapScreen.kt | 4 +- .../ui/screens/{farms => map}/SetPolygon.kt | 15 +- .../ui/screens/settings/LanguageSelector.kt | 1 + .../ui/screens/settings/Settings.kt | 1 + .../farmcollector/{ => utils}/ContextExt.kt | 2 +- .../{ => utils}/map/GoogleMapsUtil.kt | 2 +- .../viewmodels/LanguageViewModal.kt | 4 +- .../{map => viewmodels}/MapViewModel.kt | 7 +- .../org/technoserve/farmcollector/HomeTest.kt | 264 ------------------ .../farmcollector/MapViewModelUnitTest.kt | 2 +- .../farmcollector/database/TestDatabase.kt | 11 + .../converters/AccuracyListConvertTest.kt | 1 + .../converters/CoordinateListConvertTest.kt | 11 +- .../database/mappers/FarmMappersKtTest.kt | 214 ++++++++++++++ .../database/models/CollectionSiteTest.kt | 149 ++++++++++ .../FarmRepositoryTest.kt | 135 ++++++++- .../ui/screens/home/HomeKtTest.kt | 156 +++++++++++ .../farmcollector/utils/UpdateLocaleKtTest.kt | 54 ---- .../FarmViewModelTest.kt | 9 +- .../viewmodels/LanguageViewModelTest.kt | 117 ++++++++ 39 files changed, 938 insertions(+), 489 deletions(-) rename app/src/main/java/org/technoserve/farmcollector/{map => }/MapApplication.kt (75%) rename app/src/main/java/org/technoserve/farmcollector/{ => database/helpers}/map/LocationHelper.kt (98%) rename app/src/main/java/org/technoserve/farmcollector/{ => database/helpers}/map/ZoneClusterManager.kt (72%) rename app/src/main/java/org/technoserve/farmcollector/database/models/{languages.kt => Language.kt} (100%) rename app/src/main/java/org/technoserve/farmcollector/{ => database/models}/map/LocationState.kt (79%) rename app/src/main/java/org/technoserve/farmcollector/{ => database/models}/map/MapState.kt (85%) rename app/src/main/java/org/technoserve/farmcollector/{ => database/models}/map/ZoneClusterItem.kt (78%) rename app/src/main/java/org/technoserve/farmcollector/{ => ui/screens}/map/MapScreen.kt (98%) rename app/src/main/java/org/technoserve/farmcollector/ui/screens/{farms => map}/SetPolygon.kt (98%) rename app/src/main/java/org/technoserve/farmcollector/{ => utils}/ContextExt.kt (91%) rename app/src/main/java/org/technoserve/farmcollector/{ => utils}/map/GoogleMapsUtil.kt (98%) rename app/src/main/java/org/technoserve/farmcollector/{map => viewmodels}/MapViewModel.kt (94%) delete mode 100644 app/src/test/java/org/technoserve/farmcollector/HomeTest.kt create mode 100644 app/src/test/java/org/technoserve/farmcollector/database/TestDatabase.kt create mode 100644 app/src/test/java/org/technoserve/farmcollector/database/mappers/FarmMappersKtTest.kt create mode 100644 app/src/test/java/org/technoserve/farmcollector/database/models/CollectionSiteTest.kt rename app/src/test/java/org/technoserve/farmcollector/{database => repositories}/FarmRepositoryTest.kt (57%) create mode 100644 app/src/test/java/org/technoserve/farmcollector/ui/screens/home/HomeKtTest.kt delete mode 100644 app/src/test/java/org/technoserve/farmcollector/utils/UpdateLocaleKtTest.kt rename app/src/test/java/org/technoserve/farmcollector/{database => viewmodels}/FarmViewModelTest.kt (95%) create mode 100644 app/src/test/java/org/technoserve/farmcollector/viewmodels/LanguageViewModelTest.kt diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 52b48c0..f464cd5 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -13,20 +13,6 @@ - - - - \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 2354aad..f28fb05 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -139,7 +139,9 @@ dependencies { implementation 'androidx.test:monitor:1.7.2' implementation 'androidx.test.ext:junit-ktx:1.2.1' implementation 'androidx.compose.ui:ui-test-junit4-android:1.7.5' + implementation 'com.google.android.gms:play-services-phenotype:17.0.0' testImplementation 'junit:junit:4.13.2' + testImplementation 'org.testng:testng:6.9.6' // Paging def paging_version = "3.3.4" diff --git a/app/src/main/java/org/technoserve/farmcollector/FarmCollectorApp.kt b/app/src/main/java/org/technoserve/farmcollector/FarmCollectorApp.kt index 7277710..57c41ab 100644 --- a/app/src/main/java/org/technoserve/farmcollector/FarmCollectorApp.kt +++ b/app/src/main/java/org/technoserve/farmcollector/FarmCollectorApp.kt @@ -16,7 +16,7 @@ class FarmCollectorApp : Application() { override fun onCreate() { super.onCreate() ContextProvider.initialize(this) - initializeWorkManager() + // initializeWorkManager() } // private diff --git a/app/src/main/java/org/technoserve/farmcollector/MainActivity.kt b/app/src/main/java/org/technoserve/farmcollector/MainActivity.kt index e5d1001..178c67e 100644 --- a/app/src/main/java/org/technoserve/farmcollector/MainActivity.kt +++ b/app/src/main/java/org/technoserve/farmcollector/MainActivity.kt @@ -38,15 +38,15 @@ import org.technoserve.farmcollector.viewmodels.ExitConfirmationDialog import org.technoserve.farmcollector.viewmodels.FarmViewModel import org.technoserve.farmcollector.viewmodels.FarmViewModelFactory import org.technoserve.farmcollector.viewmodels.UpdateAlert -import org.technoserve.farmcollector.map.LocationHelper -import org.technoserve.farmcollector.map.MapViewModel +import org.technoserve.farmcollector.database.helpers.map.LocationHelper +import org.technoserve.farmcollector.viewmodels.MapViewModel import org.technoserve.farmcollector.ui.screens.farms.AddFarm import org.technoserve.farmcollector.ui.screens.collectionsites.AddSite import org.technoserve.farmcollector.ui.screens.collectionsites.CollectionSiteList import org.technoserve.farmcollector.ui.screens.farms.FarmList import org.technoserve.farmcollector.ui.screens.home.Home import org.technoserve.farmcollector.ui.screens.settings.ScreenWithSidebar -import org.technoserve.farmcollector.ui.screens.farms.SetPolygon +import org.technoserve.farmcollector.ui.screens.map.SetPolygon import org.technoserve.farmcollector.ui.screens.settings.SettingsScreen import org.technoserve.farmcollector.ui.screens.farms.UpdateFarmForm import org.technoserve.farmcollector.ui.theme.FarmCollectorTheme diff --git a/app/src/main/java/org/technoserve/farmcollector/map/MapApplication.kt b/app/src/main/java/org/technoserve/farmcollector/MapApplication.kt similarity index 75% rename from app/src/main/java/org/technoserve/farmcollector/map/MapApplication.kt rename to app/src/main/java/org/technoserve/farmcollector/MapApplication.kt index 3c0eefc..806f52b 100644 --- a/app/src/main/java/org/technoserve/farmcollector/map/MapApplication.kt +++ b/app/src/main/java/org/technoserve/farmcollector/MapApplication.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.map +package org.technoserve.farmcollector import android.app.Application import dagger.hilt.android.HiltAndroidApp diff --git a/app/src/main/java/org/technoserve/farmcollector/database/converters/AccuracyListConvert.kt b/app/src/main/java/org/technoserve/farmcollector/database/converters/AccuracyListConvert.kt index 411cbd4..e0517d4 100644 --- a/app/src/main/java/org/technoserve/farmcollector/database/converters/AccuracyListConvert.kt +++ b/app/src/main/java/org/technoserve/farmcollector/database/converters/AccuracyListConvert.kt @@ -16,6 +16,9 @@ class AccuracyListConvert { } @TypeConverter fun toAccuracyList(value: String?): List? { + if (value == "[]") { + return emptyList() + } return value?.removePrefix("[")?.removeSuffix("]")?.split(",")?.map { it.trim().toFloatOrNull() } diff --git a/app/src/main/java/org/technoserve/farmcollector/database/dao/CollectionSiteDAO.kt b/app/src/main/java/org/technoserve/farmcollector/database/dao/CollectionSiteDAO.kt index d698a94..4f681ee 100644 --- a/app/src/main/java/org/technoserve/farmcollector/database/dao/CollectionSiteDAO.kt +++ b/app/src/main/java/org/technoserve/farmcollector/database/dao/CollectionSiteDAO.kt @@ -1,6 +1,7 @@ package org.technoserve.farmcollector.database.dao import androidx.lifecycle.LiveData +import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query @@ -9,6 +10,8 @@ import androidx.room.Update import org.technoserve.farmcollector.database.models.CollectionSite interface CollectionSiteDAO { + + @Transaction @Query("SELECT * FROM CollectionSites ORDER BY createdAt DESC") fun getAllSites(): List @@ -23,6 +26,12 @@ interface CollectionSiteDAO { @Query("SELECT * FROM CollectionSites WHERE siteId = :siteId") suspend fun getSiteById(siteId: Long): CollectionSite? + @Update + fun update(collectionSite: CollectionSite) + + @Delete + fun delete(collectionSite: CollectionSite) + @Update fun updateSite(site: CollectionSite) diff --git a/app/src/main/java/org/technoserve/farmcollector/map/LocationHelper.kt b/app/src/main/java/org/technoserve/farmcollector/database/helpers/map/LocationHelper.kt similarity index 98% rename from app/src/main/java/org/technoserve/farmcollector/map/LocationHelper.kt rename to app/src/main/java/org/technoserve/farmcollector/database/helpers/map/LocationHelper.kt index 4c0ec4a..131b44d 100644 --- a/app/src/main/java/org/technoserve/farmcollector/map/LocationHelper.kt +++ b/app/src/main/java/org/technoserve/farmcollector/database/helpers/map/LocationHelper.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.map +package org.technoserve.farmcollector.database.helpers.map import android.Manifest import android.annotation.SuppressLint @@ -32,6 +32,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.suspendCancellableCoroutine import org.technoserve.farmcollector.R +import org.technoserve.farmcollector.database.models.map.LocationState +import org.technoserve.farmcollector.viewmodels.MapViewModel import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException diff --git a/app/src/main/java/org/technoserve/farmcollector/map/ZoneClusterManager.kt b/app/src/main/java/org/technoserve/farmcollector/database/helpers/map/ZoneClusterManager.kt similarity index 72% rename from app/src/main/java/org/technoserve/farmcollector/map/ZoneClusterManager.kt rename to app/src/main/java/org/technoserve/farmcollector/database/helpers/map/ZoneClusterManager.kt index eedcd89..a287763 100644 --- a/app/src/main/java/org/technoserve/farmcollector/map/ZoneClusterManager.kt +++ b/app/src/main/java/org/technoserve/farmcollector/database/helpers/map/ZoneClusterManager.kt @@ -1,9 +1,10 @@ -package org.technoserve.farmcollector.map +package org.technoserve.farmcollector.database.helpers.map import android.content.Context import com.google.android.gms.maps.GoogleMap import com.google.maps.android.clustering.ClusterManager import com.google.maps.android.collections.MarkerManager +import org.technoserve.farmcollector.database.models.map.ZoneClusterItem class ZoneClusterManager( context: Context, diff --git a/app/src/main/java/org/technoserve/farmcollector/database/models/languages.kt b/app/src/main/java/org/technoserve/farmcollector/database/models/Language.kt similarity index 100% rename from app/src/main/java/org/technoserve/farmcollector/database/models/languages.kt rename to app/src/main/java/org/technoserve/farmcollector/database/models/Language.kt diff --git a/app/src/main/java/org/technoserve/farmcollector/map/LocationState.kt b/app/src/main/java/org/technoserve/farmcollector/database/models/map/LocationState.kt similarity index 79% rename from app/src/main/java/org/technoserve/farmcollector/map/LocationState.kt rename to app/src/main/java/org/technoserve/farmcollector/database/models/map/LocationState.kt index 0be6237..7e50da9 100644 --- a/app/src/main/java/org/technoserve/farmcollector/map/LocationState.kt +++ b/app/src/main/java/org/technoserve/farmcollector/database/models/map/LocationState.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.map +package org.technoserve.farmcollector.database.models.map data class LocationState( val latitude: Double = 0.0, diff --git a/app/src/main/java/org/technoserve/farmcollector/map/MapState.kt b/app/src/main/java/org/technoserve/farmcollector/database/models/map/MapState.kt similarity index 85% rename from app/src/main/java/org/technoserve/farmcollector/map/MapState.kt rename to app/src/main/java/org/technoserve/farmcollector/database/models/map/MapState.kt index 9a8d3d7..f0aec92 100644 --- a/app/src/main/java/org/technoserve/farmcollector/map/MapState.kt +++ b/app/src/main/java/org/technoserve/farmcollector/database/models/map/MapState.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.map +package org.technoserve.farmcollector.database.models.map import android.location.Location import com.google.maps.android.compose.MapType diff --git a/app/src/main/java/org/technoserve/farmcollector/map/ZoneClusterItem.kt b/app/src/main/java/org/technoserve/farmcollector/database/models/map/ZoneClusterItem.kt similarity index 78% rename from app/src/main/java/org/technoserve/farmcollector/map/ZoneClusterItem.kt rename to app/src/main/java/org/technoserve/farmcollector/database/models/map/ZoneClusterItem.kt index 3d4897f..2ebc516 100644 --- a/app/src/main/java/org/technoserve/farmcollector/map/ZoneClusterItem.kt +++ b/app/src/main/java/org/technoserve/farmcollector/database/models/map/ZoneClusterItem.kt @@ -1,7 +1,8 @@ -package org.technoserve.farmcollector.map +package org.technoserve.farmcollector.database.models.map import com.google.android.gms.maps.model.PolygonOptions import com.google.maps.android.clustering.ClusterItem +import org.technoserve.farmcollector.utils.map.getCenterOfPolygon data class ZoneClusterItem( val id: String, diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/components/FarmForm.kt b/app/src/main/java/org/technoserve/farmcollector/ui/components/FarmForm.kt index ad066ae..3b69b6b 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/components/FarmForm.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/components/FarmForm.kt @@ -58,10 +58,10 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import com.google.android.gms.maps.model.LatLngBounds import org.technoserve.farmcollector.R -import org.technoserve.farmcollector.map.LocationHelper -import org.technoserve.farmcollector.map.LocationState -import org.technoserve.farmcollector.map.MapViewModel -import org.technoserve.farmcollector.map.getCenterOfPolygon +import org.technoserve.farmcollector.database.helpers.map.LocationHelper +import org.technoserve.farmcollector.database.models.map.LocationState +import org.technoserve.farmcollector.viewmodels.MapViewModel +import org.technoserve.farmcollector.utils.map.getCenterOfPolygon import org.technoserve.farmcollector.ui.screens.farms.LocationPermissionRequest import org.technoserve.farmcollector.ui.screens.farms.addFarm import org.technoserve.farmcollector.ui.screens.farms.formatInput diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/components/KeepPolygonDialog.kt b/app/src/main/java/org/technoserve/farmcollector/ui/components/KeepPolygonDialog.kt index 310c68a..3b38dcd 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/components/KeepPolygonDialog.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/components/KeepPolygonDialog.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import org.technoserve.farmcollector.R -import org.technoserve.farmcollector.map.MapViewModel +import org.technoserve.farmcollector.viewmodels.MapViewModel /** * This function is used to allow the user to either keep the existing polygon or capture a new polygon diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/AddFarm.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/AddFarm.kt index d531ac6..c3082f1 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/AddFarm.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/AddFarm.kt @@ -1,90 +1,43 @@ package org.technoserve.farmcollector.ui.screens.farms import android.Manifest -import android.annotation.SuppressLint -import android.app.Activity -import android.app.Application import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.location.LocationManager import android.provider.Settings -import android.view.KeyEvent import android.widget.Toast -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.android.gms.maps.model.LatLng -import com.google.android.gms.maps.model.LatLngBounds import org.joda.time.Instant import org.technoserve.farmcollector.R import org.technoserve.farmcollector.database.models.Farm import org.technoserve.farmcollector.database.models.ParcelablePair import org.technoserve.farmcollector.viewmodels.FarmViewModel -import org.technoserve.farmcollector.viewmodels.FarmViewModelFactory -import org.technoserve.farmcollector.map.LocationHelper -import org.technoserve.farmcollector.map.LocationState -import org.technoserve.farmcollector.map.MapViewModel -import org.technoserve.farmcollector.map.getCenterOfPolygon import org.technoserve.farmcollector.ui.components.FarmForm import org.technoserve.farmcollector.ui.components.FarmListHeader -import org.technoserve.farmcollector.utils.convertSize -import org.technoserve.farmcollector.utils.isSystemInDarkTheme import java.math.BigDecimal import java.math.RoundingMode import java.util.UUID -import java.util.regex.Pattern /** * This function is used to add a capture farm details and location and ddd them to the database diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/FarmList.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/FarmList.kt index 87787fe..277a740 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/FarmList.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/FarmList.kt @@ -7,21 +7,13 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build -import android.os.Environment -import android.os.Parcel -import android.os.Parcelable -import android.view.KeyEvent import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.result.contract.ActivityResultContracts.CreateDocument import androidx.annotation.RequiresApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,94 +23,50 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Share -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.TabRowDefaults.SecondaryIndicator import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.BottomEnd -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.core.content.FileProvider import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.joda.time.Instant import org.technoserve.farmcollector.R -import org.technoserve.farmcollector.database.models.CollectionSite import org.technoserve.farmcollector.database.models.Farm import org.technoserve.farmcollector.database.models.ParcelableFarmData import org.technoserve.farmcollector.database.models.ParcelablePair @@ -127,9 +75,6 @@ import org.technoserve.farmcollector.viewmodels.FarmViewModel import org.technoserve.farmcollector.viewmodels.FarmViewModelFactory import org.technoserve.farmcollector.viewmodels.RestoreStatus import org.technoserve.farmcollector.utils.DeviceIdUtil -import org.technoserve.farmcollector.hasLocationPermission -import org.technoserve.farmcollector.map.LocationHelper -import org.technoserve.farmcollector.map.MapViewModel import org.technoserve.farmcollector.ui.components.CustomPaginationControls import org.technoserve.farmcollector.ui.components.CustomizedConfirmationDialog import org.technoserve.farmcollector.ui.components.DeleteAllDialogPresenter @@ -137,21 +82,15 @@ import org.technoserve.farmcollector.ui.components.FarmCard import org.technoserve.farmcollector.ui.components.FarmListHeaderPlots import org.technoserve.farmcollector.ui.components.FormatSelectionDialog import org.technoserve.farmcollector.ui.components.ImportFileDialog -import org.technoserve.farmcollector.ui.components.KeepPolygonDialog import org.technoserve.farmcollector.ui.composes.isValidPhoneNumber -import org.technoserve.farmcollector.utils.convertSize import org.technoserve.farmcollector.utils.createFile import org.technoserve.farmcollector.utils.createFileForSharing import org.technoserve.farmcollector.utils.isSystemInDarkTheme -import java.io.BufferedWriter import java.io.File -import java.io.IOException -import java.io.OutputStreamWriter import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -import java.util.regex.Pattern var siteID = 0L diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/UpdateFarmForm.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/UpdateFarmForm.kt index 13e1c1b..cfa50ae 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/UpdateFarmForm.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/UpdateFarmForm.kt @@ -56,9 +56,9 @@ import org.joda.time.Instant import org.technoserve.farmcollector.R import org.technoserve.farmcollector.database.models.Farm import org.technoserve.farmcollector.database.models.ParcelablePair -import org.technoserve.farmcollector.hasLocationPermission -import org.technoserve.farmcollector.map.LocationHelper -import org.technoserve.farmcollector.map.MapViewModel +import org.technoserve.farmcollector.utils.hasLocationPermission +import org.technoserve.farmcollector.database.helpers.map.LocationHelper +import org.technoserve.farmcollector.viewmodels.MapViewModel import org.technoserve.farmcollector.ui.components.FarmListHeader import org.technoserve.farmcollector.ui.components.KeepPolygonDialog import org.technoserve.farmcollector.utils.convertSize diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/home/Home.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/home/Home.kt index bc3e944..b04dc17 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/home/Home.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/home/Home.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController import org.technoserve.farmcollector.R +import org.technoserve.farmcollector.database.models.Language import org.technoserve.farmcollector.ui.theme.Teal import org.technoserve.farmcollector.ui.theme.Turquoise import org.technoserve.farmcollector.ui.theme.White @@ -167,3 +168,94 @@ fun Home( } } + +//import androidx.compose.ui.test.* +//import androidx.compose.ui.test.junit4.createComposeRule +//import androidx.navigation.testing.TestNavHostController +//import androidx.test.core.app.ApplicationProvider +//import org.junit.Assert.assertEquals +//import org.junit.Before +//import org.junit.Rule +//import org.junit.Test +//import org.junit.runner.RunWith +//import org.mockito.Mockito.mock +//import org.robolectric.RobolectricTestRunner +//import org.robolectric.annotation.Config +//import org.technoserve.farmcollector.database.models.Language +//import org.technoserve.farmcollector.ui.screens.home.Home +//import org.technoserve.farmcollector.viewmodels.LanguageViewModel +// +//@RunWith(RobolectricTestRunner::class) +//@Config(sdk = [33]) +//class HomeKtTest { +// +// @get:Rule +// val composeTestRule = createComposeRule() +// +// private lateinit var navController: TestNavHostController +// private lateinit var languageViewModel: LanguageViewModel +// private val testLanguages = listOf( +// Language("en", "English"), +// Language("es", "Spanish") +// ) +// +// @Before +// fun setup() { +// // Use ApplicationProvider for a context in unit tests +// val context = ApplicationProvider.getApplicationContext() +// navController = TestNavHostController(context) +// languageViewModel = mock(LanguageViewModel::class.java) +// } +// +// @Test +// fun homeScreen_displaysAppName() { +// composeTestRule.setContent { +// Home( +// navController = navController, +// languageViewModel = languageViewModel, +// languages = testLanguages +// ) +// } +// +// composeTestRule +// .onNodeWithText("TerraTrac") // Replace with the actual string if not resource-based +// .assertExists() +// .assertIsDisplayed() +// } +// +// @Test +// fun homeScreen_displaysGetStartedButton() { +// composeTestRule.setContent { +// Home( +// navController = navController, +// languageViewModel = languageViewModel, +// languages = testLanguages +// ) +// } +// +// composeTestRule +// .onNodeWithText("Get Started") +// .assertExists() +// .assertIsDisplayed() +// .assertHasClickAction() +// } +// +// @Test +// fun homeScreen_clickGetStartedNavigatesToSiteList() { +// composeTestRule.setContent { +// Home( +// navController = navController, +// languageViewModel = languageViewModel, +// languages = testLanguages +// ) +// } +// +// composeTestRule +// .onNodeWithText("Get Started") // Replace with actual string if not resource-based +// .performClick() +// +// // Verify navigation occurred +// assertEquals("siteList", navController.currentDestination?.route) +// } +//} + diff --git a/app/src/main/java/org/technoserve/farmcollector/map/MapScreen.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/map/MapScreen.kt similarity index 98% rename from app/src/main/java/org/technoserve/farmcollector/map/MapScreen.kt rename to app/src/main/java/org/technoserve/farmcollector/ui/screens/map/MapScreen.kt index 4ce7679..1d6a2e0 100644 --- a/app/src/main/java/org/technoserve/farmcollector/map/MapScreen.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/map/MapScreen.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.map +package org.technoserve.farmcollector.ui.screens.map import android.annotation.SuppressLint import android.content.Context @@ -49,6 +49,8 @@ import com.google.maps.android.compose.MapsComposeExperimentalApi import com.google.maps.android.compose.rememberCameraPositionState import kotlinx.coroutines.launch import org.technoserve.farmcollector.R +import org.technoserve.farmcollector.database.models.map.MapState +import org.technoserve.farmcollector.database.helpers.map.ZoneClusterManager @OptIn(MapsComposeExperimentalApi::class) @SuppressLint("PotentialBehaviorOverride") diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/SetPolygon.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/map/SetPolygon.kt similarity index 98% rename from app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/SetPolygon.kt rename to app/src/main/java/org/technoserve/farmcollector/ui/screens/map/SetPolygon.kt index d9fd1d6..df26c87 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/farms/SetPolygon.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/map/SetPolygon.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.ui.screens.farms +package org.technoserve.farmcollector.ui.screens.map import android.annotation.SuppressLint import android.app.Activity @@ -31,10 +31,8 @@ import androidx.compose.material3.ElevatedButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -54,13 +52,16 @@ import androidx.navigation.NavController import org.technoserve.farmcollector.R import org.technoserve.farmcollector.database.models.ParcelableFarmData import org.technoserve.farmcollector.database.models.ParcelablePair -import org.technoserve.farmcollector.hasLocationPermission -import org.technoserve.farmcollector.map.LocationHelper -import org.technoserve.farmcollector.map.MapScreen -import org.technoserve.farmcollector.map.MapViewModel +import org.technoserve.farmcollector.utils.hasLocationPermission +import org.technoserve.farmcollector.database.helpers.map.LocationHelper +import org.technoserve.farmcollector.viewmodels.MapViewModel import org.technoserve.farmcollector.ui.components.InvalidPolygonDialog import org.technoserve.farmcollector.ui.composes.AreaDialog import org.technoserve.farmcollector.ui.composes.ConfirmDialog +import org.technoserve.farmcollector.ui.screens.farms.formatInput +import org.technoserve.farmcollector.ui.screens.farms.isLocationEnabled +import org.technoserve.farmcollector.ui.screens.farms.promptEnableLocation +import org.technoserve.farmcollector.ui.screens.farms.truncateToDecimalPlaces import org.technoserve.farmcollector.utils.convertSize import org.technoserve.farmcollector.utils.isSystemInDarkTheme diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/LanguageSelector.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/LanguageSelector.kt index 88dcce2..320b714 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/LanguageSelector.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/LanguageSelector.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import org.technoserve.farmcollector.R +import org.technoserve.farmcollector.database.models.Language import org.technoserve.farmcollector.viewmodels.LanguageViewModel /** diff --git a/app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/Settings.kt b/app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/Settings.kt index f0e6adc..d46980c 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/Settings.kt +++ b/app/src/main/java/org/technoserve/farmcollector/ui/screens/settings/Settings.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavController import org.technoserve.farmcollector.R +import org.technoserve.farmcollector.database.models.Language import org.technoserve.farmcollector.viewmodels.LanguageViewModel /** diff --git a/app/src/main/java/org/technoserve/farmcollector/ContextExt.kt b/app/src/main/java/org/technoserve/farmcollector/utils/ContextExt.kt similarity index 91% rename from app/src/main/java/org/technoserve/farmcollector/ContextExt.kt rename to app/src/main/java/org/technoserve/farmcollector/utils/ContextExt.kt index 3d44020..1243136 100644 --- a/app/src/main/java/org/technoserve/farmcollector/ContextExt.kt +++ b/app/src/main/java/org/technoserve/farmcollector/utils/ContextExt.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector +package org.technoserve.farmcollector.utils import android.Manifest import android.content.Context diff --git a/app/src/main/java/org/technoserve/farmcollector/map/GoogleMapsUtil.kt b/app/src/main/java/org/technoserve/farmcollector/utils/map/GoogleMapsUtil.kt similarity index 98% rename from app/src/main/java/org/technoserve/farmcollector/map/GoogleMapsUtil.kt rename to app/src/main/java/org/technoserve/farmcollector/utils/map/GoogleMapsUtil.kt index 07ad1d5..127acc3 100644 --- a/app/src/main/java/org/technoserve/farmcollector/map/GoogleMapsUtil.kt +++ b/app/src/main/java/org/technoserve/farmcollector/utils/map/GoogleMapsUtil.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.map +package org.technoserve.farmcollector.utils.map /** * A set of utility functions for centering the camera given some [LatLng] points. diff --git a/app/src/main/java/org/technoserve/farmcollector/viewmodels/LanguageViewModal.kt b/app/src/main/java/org/technoserve/farmcollector/viewmodels/LanguageViewModal.kt index 357567d..cc51c2a 100644 --- a/app/src/main/java/org/technoserve/farmcollector/viewmodels/LanguageViewModal.kt +++ b/app/src/main/java/org/technoserve/farmcollector/viewmodels/LanguageViewModal.kt @@ -28,13 +28,13 @@ class LanguageViewModel(application: Application) : AndroidViewModel(application // restartActivity(context) } - private fun getDefaultLanguage(): Language { + fun getDefaultLanguage(): Language { val savedLanguageCode = sharedPreferences.getString("preferred_language", Locale.getDefault().language) return languages.find { it.code == savedLanguageCode } ?: languages.first() } - private fun savePreferredLanguage(language: Language) { + fun savePreferredLanguage(language: Language) { sharedPreferences.edit().putString("preferred_language", language.code).apply() } diff --git a/app/src/main/java/org/technoserve/farmcollector/map/MapViewModel.kt b/app/src/main/java/org/technoserve/farmcollector/viewmodels/MapViewModel.kt similarity index 94% rename from app/src/main/java/org/technoserve/farmcollector/map/MapViewModel.kt rename to app/src/main/java/org/technoserve/farmcollector/viewmodels/MapViewModel.kt index abcd2ab..d29f1d4 100644 --- a/app/src/main/java/org/technoserve/farmcollector/map/MapViewModel.kt +++ b/app/src/main/java/org/technoserve/farmcollector/viewmodels/MapViewModel.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.map +package org.technoserve.farmcollector.viewmodels import android.content.Context @@ -17,7 +17,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import org.technoserve.farmcollector.database.models.map.MapState +import org.technoserve.farmcollector.database.models.map.ZoneClusterItem +import org.technoserve.farmcollector.database.helpers.map.ZoneClusterManager import org.technoserve.farmcollector.utils.GeoCalculator +import org.technoserve.farmcollector.utils.map.calculateCameraViewPoints +import org.technoserve.farmcollector.utils.map.getCenterOfPolygon import javax.inject.Inject diff --git a/app/src/test/java/org/technoserve/farmcollector/HomeTest.kt b/app/src/test/java/org/technoserve/farmcollector/HomeTest.kt deleted file mode 100644 index be9751a..0000000 --- a/app/src/test/java/org/technoserve/farmcollector/HomeTest.kt +++ /dev/null @@ -1,264 +0,0 @@ -package org.technoserve.farmcollector - -//import androidx.annotation.StringRes -//import androidx.compose.ui.test.* -//import androidx.compose.ui.test.junit4.createComposeRule -//import androidx.navigation.testing.TestNavHostController -//import androidx.test.ext.junit.runners.AndroidJUnit4 -//import androidx.test.platform.app.InstrumentationRegistry -//import junit.framework.TestCase.assertEquals -//import org.junit.Before -//import org.junit.Rule -//import org.junit.Test -//import org.junit.runner.RunWith -//import org.mockito.Mockito.mock -//import org.mockito.Mockito.verify -//import org.technoserve.farmcollector.ui.screens.Home -//import org.technoserve.farmcollector.utils.Language -//import org.technoserve.farmcollector.utils.LanguageViewModel -// -//@RunWith(AndroidJUnit4::class) -//class HomeTest { -// @get:Rule -// val composeTestRule = createComposeRule() -// -// private lateinit var navController: TestNavHostController -// private lateinit var languageViewModel: LanguageViewModel -// private val testLanguages = listOf( -// Language("en", "English"), -// Language("es", "Spanish") -// ) -// -// @Before -// fun setup() { -// navController = TestNavHostController(InstrumentationRegistry.getInstrumentation().targetContext) -// languageViewModel = mock(LanguageViewModel::class.java) -// } -// -// @Test -// fun homeScreen_displaysAppName() { -// composeTestRule.setContent { -// Home( -// navController = navController, -// languageViewModel = languageViewModel, -// languages = testLanguages -// ) -// } -// -// composeTestRule -// .onNodeWithText(getResourceString(R.string.app_name)) -// .assertExists() -// .assertIsDisplayed() -// } -// -// @Test -// fun homeScreen_displaysGetStartedButton() { -// composeTestRule.setContent { -// Home( -// navController = navController, -// languageViewModel = languageViewModel, -// languages = testLanguages -// ) -// } -// -// composeTestRule -// .onNodeWithText(getResourceString(R.string.get_started)) -// .assertExists() -// .assertIsDisplayed() -// .assertHasClickAction() -// } -// -// @Test -// fun homeScreen_clickGetStartedNavigatesToSiteList() { -// composeTestRule.setContent { -// Home( -// navController = navController, -// languageViewModel = languageViewModel, -// languages = testLanguages -// ) -// } -// -// composeTestRule -// .onNodeWithText(getResourceString(R.string.get_started)) -// .performClick() -// -// // Verify navigation occurred -// assertEquals("siteList", navController.currentDestination?.route) -// } -// -// @Test -// fun homeScreen_displaysAppIntro() { -// composeTestRule.setContent { -// Home( -// navController = navController, -// languageViewModel = languageViewModel, -// languages = testLanguages -// ) -// } -// -// composeTestRule -// .onNodeWithText(getResourceString(R.string.app_intro)) -// .assertExists() -// .assertIsDisplayed() -// } -// -// @Test -// fun homeScreen_displaysDeveloperInfo() { -// composeTestRule.setContent { -// Home( -// navController = navController, -// languageViewModel = languageViewModel, -// languages = testLanguages -// ) -// } -// -// composeTestRule -// .onNodeWithText(getResourceString(R.string.developed_by)) -// .assertExists() -// .assertIsDisplayed() -// } -// -// @Test -// fun homeScreen_displaysLanguageSelector() { -// composeTestRule.setContent { -// Home( -// navController = navController, -// languageViewModel = languageViewModel, -// languages = testLanguages -// ) -// } -// -// // Verify LanguageSelector is displayed -// // Note: This assumes LanguageSelector has a testTag. You might need to add one. -// composeTestRule -// .onNodeWithTag("language_selector") -// .assertExists() -// .assertIsDisplayed() -// } -// -// @Test -// fun homeScreen_displaysAppIcon() { -// composeTestRule.setContent { -// Home( -// navController = navController, -// languageViewModel = languageViewModel, -// languages = testLanguages -// ) -// } -// -// composeTestRule -// .onNodeWithContentDescription("app_icon") -// .assertExists() -// .assertIsDisplayed() -// } -// -// @Test -// fun homeScreen_displaysLabsLogo() { -// composeTestRule.setContent { -// Home( -// navController = navController, -// languageViewModel = languageViewModel, -// languages = testLanguages -// ) -// } -// -// composeTestRule -// .onNodeWithContentDescription("tns_labs") -// .assertExists() -// .assertIsDisplayed() -// } -// -// private fun getResourceString(@StringRes resourceId: Int): String { -// return InstrumentationRegistry.getInstrumentation() -// .targetContext.resources.getString(resourceId) -// } -//} - -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.navigation.testing.TestNavHostController -import androidx.test.core.app.ApplicationProvider -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mockito.mock -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.technoserve.farmcollector.ui.screens.home.Home -import org.technoserve.farmcollector.viewmodels.LanguageViewModel - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [33]) -class HomeKtTest { - - @get:Rule - val composeTestRule = createComposeRule() - - private lateinit var navController: TestNavHostController - private lateinit var languageViewModel: LanguageViewModel - private val testLanguages = listOf( - Language("en", "English"), - Language("es", "Spanish") - ) - - @Before - fun setup() { - // Use ApplicationProvider for a context in unit tests - val context = ApplicationProvider.getApplicationContext() - navController = TestNavHostController(context) - languageViewModel = mock(LanguageViewModel::class.java) - } - - @Test - fun homeScreen_displaysAppName() { - composeTestRule.setContent { - Home( - navController = navController, - languageViewModel = languageViewModel, - languages = testLanguages - ) - } - - composeTestRule - .onNodeWithText("TerraTrac") // Replace with the actual string if not resource-based - .assertExists() - .assertIsDisplayed() - } - - @Test - fun homeScreen_displaysGetStartedButton() { - composeTestRule.setContent { - Home( - navController = navController, - languageViewModel = languageViewModel, - languages = testLanguages - ) - } - - composeTestRule - .onNodeWithText("Get Started") - .assertExists() - .assertIsDisplayed() - .assertHasClickAction() - } - - @Test - fun homeScreen_clickGetStartedNavigatesToSiteList() { - composeTestRule.setContent { - Home( - navController = navController, - languageViewModel = languageViewModel, - languages = testLanguages - ) - } - - composeTestRule - .onNodeWithText("Get Started") // Replace with actual string if not resource-based - .performClick() - - // Verify navigation occurred - assertEquals("siteList", navController.currentDestination?.route) - } -} \ No newline at end of file diff --git a/app/src/test/java/org/technoserve/farmcollector/MapViewModelUnitTest.kt b/app/src/test/java/org/technoserve/farmcollector/MapViewModelUnitTest.kt index 648a07a..c5fa79b 100644 --- a/app/src/test/java/org/technoserve/farmcollector/MapViewModelUnitTest.kt +++ b/app/src/test/java/org/technoserve/farmcollector/MapViewModelUnitTest.kt @@ -7,7 +7,7 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -import org.technoserve.farmcollector.map.MapViewModel +import org.technoserve.farmcollector.viewmodels.MapViewModel @RunWith(RobolectricTestRunner::class) class MapViewModelKtTest { diff --git a/app/src/test/java/org/technoserve/farmcollector/database/TestDatabase.kt b/app/src/test/java/org/technoserve/farmcollector/database/TestDatabase.kt new file mode 100644 index 0000000..cd725c4 --- /dev/null +++ b/app/src/test/java/org/technoserve/farmcollector/database/TestDatabase.kt @@ -0,0 +1,11 @@ +package org.technoserve.farmcollector.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import org.technoserve.farmcollector.database.dao.CollectionSiteDAO +import org.technoserve.farmcollector.database.models.CollectionSite + +@Database(entities = [CollectionSite::class], version = 1, exportSchema = false) +abstract class TestDatabase : RoomDatabase() { + abstract fun collectionSiteDAO(): CollectionSiteDAO +} diff --git a/app/src/test/java/org/technoserve/farmcollector/database/converters/AccuracyListConvertTest.kt b/app/src/test/java/org/technoserve/farmcollector/database/converters/AccuracyListConvertTest.kt index 03fa445..a23c17d 100644 --- a/app/src/test/java/org/technoserve/farmcollector/database/converters/AccuracyListConvertTest.kt +++ b/app/src/test/java/org/technoserve/farmcollector/database/converters/AccuracyListConvertTest.kt @@ -51,6 +51,7 @@ class AccuracyListConvertTest { @Test fun `toAccuracyList with empty brackets returns empty list`() { val result = converter.toAccuracyList("[]") + println(result) // Debugging output assertEquals(emptyList(), result) } diff --git a/app/src/test/java/org/technoserve/farmcollector/database/converters/CoordinateListConvertTest.kt b/app/src/test/java/org/technoserve/farmcollector/database/converters/CoordinateListConvertTest.kt index 5478243..1862a97 100644 --- a/app/src/test/java/org/technoserve/farmcollector/database/converters/CoordinateListConvertTest.kt +++ b/app/src/test/java/org/technoserve/farmcollector/database/converters/CoordinateListConvertTest.kt @@ -16,7 +16,7 @@ class CoordinateListConvertTest { @Test fun `fromCoordinates with null input returns empty string`() { val result = converter.fromCoordinateList(null) - assertEquals("", result) + assertEquals(null, result) } @Test @@ -30,16 +30,21 @@ class CoordinateListConvertTest { fun `fromCoordinates with single coordinate pair`() { val coordinates = listOf(Pair(45.0, -122.0)) val result = converter.fromCoordinateList(coordinates) + // Using Gson to parse back and verify the structure val gson = Gson() val listType = object : TypeToken>>() {}.type val parsedResult: List> = gson.fromJson(result, listType) assertEquals(1, parsedResult.size) - assertEquals(45.0, parsedResult[0].first) - assertEquals(-122.0, parsedResult[0].second) + + // Use a delta to compare floating-point numbers + val delta = 0.0001 + assertEquals(45.0, parsedResult[0].first, delta) + assertEquals(-122.0, parsedResult[0].second, delta) } + @Test fun `fromCoordinates with multiple coordinate pairs`() { val coordinates = listOf( diff --git a/app/src/test/java/org/technoserve/farmcollector/database/mappers/FarmMappersKtTest.kt b/app/src/test/java/org/technoserve/farmcollector/database/mappers/FarmMappersKtTest.kt new file mode 100644 index 0000000..6a6b29c --- /dev/null +++ b/app/src/test/java/org/technoserve/farmcollector/database/mappers/FarmMappersKtTest.kt @@ -0,0 +1,214 @@ +package org.technoserve.farmcollector.database.mappers + +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.technoserve.farmcollector.database.dao.FarmDAO +import org.technoserve.farmcollector.database.models.CollectionSite +import org.technoserve.farmcollector.database.models.Farm + +class FarmMappersKtTest{ + private lateinit var mockFarmDAO: FarmDAO + + @Before + fun setUp() { + mockFarmDAO = mock(FarmDAO::class.java) + } + + @Test + fun `toDeviceFarmDtoList with valid input`() { + val deviceId = "device123" + val collectionSite = CollectionSite( + name = "Site Name", + agentName = "Agent Name", + phoneNumber = "1234567890", + email = "agent@example.com", + village = "Village A", + district = "District A", + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + val farms = listOf( + Farm( + siteId = 1L, + farmerPhoto = "photo.jpg", + farmerName = "Farmer A", + memberId = "M001", + village = "Village A", + district = "District X", + purchases = 10f, + size = 1.5f, + latitude = "12.34", + longitude = "56.78", + coordinates = listOf(Pair(12.34, 56.78)), + accuracyArray = listOf(5.0f, null, 3.5f), + synced = false, + scheduledForSync = false, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis(), + needsUpdate = true + ), + Farm( + siteId = 1L, + farmerPhoto = "photo.jpg", + farmerName = "Farmer B", + memberId = "M002", + village = "Village B", + district = "District B", + purchases = 10f, + size = 2.0f, + latitude = "23.45", + longitude = "67.89", + coordinates = listOf(Pair(23.45, 67.89)), + accuracyArray = null, + synced = false, + scheduledForSync = false, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis(), + needsUpdate = true + ) + ) + `when`(mockFarmDAO.getCollectionSiteById(1L)).thenReturn(collectionSite) + + val result = farms.toDeviceFarmDtoList(deviceId, mockFarmDAO) + + assertEquals(1, result.size) // Only one DeviceFarmDto because farms are grouped by siteId + val deviceFarmDto = result.first() + assertEquals(deviceId, deviceFarmDto.device_id) + assertEquals("Site Name", deviceFarmDto.collection_site.name) + assertEquals(2, deviceFarmDto.farms.size) + assertEquals("Farmer A", deviceFarmDto.farms[0].farmer_name) + assertEquals(listOf(12.34, 56.78), deviceFarmDto.farms[0].coordinates?.first() ?: "null") + } + + @Test + fun `toDeviceFarmDtoList with missing collection site`() { + val deviceId = "device123" + val farms = listOf( + Farm( + siteId = 1L, + farmerPhoto = "photo.jpg", + farmerName = "Farmer A", + memberId = "M001", + village = "Village A", + district = "District X", + purchases = 10f, + size = 1.5f, + latitude = "12.34", + longitude = "56.78", + coordinates = listOf(Pair(12.34, 56.78)), + accuracyArray = null, + synced = false, + scheduledForSync = false, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis(), + needsUpdate = true + ) + ) + `when`(mockFarmDAO.getCollectionSiteById(1L)).thenReturn(null) + + val result = farms.toDeviceFarmDtoList(deviceId, mockFarmDAO) + + assertTrue(result.isEmpty()) // No DeviceFarmDto should be created + } + + @Test + fun `toDeviceFarmDtoList with invalid latitude and longitude`() { + val deviceId = "device123" + val collectionSite = CollectionSite( + name = "Site Name", + agentName = "Agent Name", + phoneNumber = "1234567890", + email = "agent@example.com", + village = "Village A", + district = "District A", + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis(), + ) + val farms = listOf( + Farm( + siteId = 1L, + farmerPhoto = "photo.jpg", + farmerName = "Farmer A", + memberId = "M001", + village = "Village A", + district = "District X", + purchases = 10f, + size = 1.5f, + latitude = "invalid", + longitude = "", + coordinates = null, + accuracyArray = null, + synced = false, + scheduledForSync = false, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis(), + needsUpdate = true + ) + ) + `when`(mockFarmDAO.getCollectionSiteById(1L)).thenReturn(collectionSite) + + val result = farms.toDeviceFarmDtoList(deviceId, mockFarmDAO) + + assertEquals(1, result.size) + val farmDto = result.first().farms.first() + assertEquals(0.0, farmDto.latitude, 0.0) + assertEquals(0.0, farmDto.longitude, 0.0) + } + + @Test + fun `toDeviceFarmDtoList with empty input list`() { + val deviceId = "device123" + val farms = emptyList() + + val result = farms.toDeviceFarmDtoList(deviceId, mockFarmDAO) + + assertTrue(result.isEmpty()) + } + + @Test + fun `toDeviceFarmDtoList handles null or empty accuracyArray and coordinates`() { + val deviceId = "device123" + val collectionSite = CollectionSite( + name = "Site Name", + agentName = "Agent Name", + phoneNumber = "1234567890", + email = "agent@example.com", + village = "Village A", + district = "District A", + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis(), + ) + val farms = listOf( + Farm( + siteId = 1L, + farmerPhoto = "photo.jpg", + farmerName = "Farmer A", + memberId = "M001", + village = "Village A", + district = "District X", + purchases = 10f, + size = 1.5f, + latitude = "invalid", + longitude = "", + coordinates = null, + accuracyArray = null, + synced = false, + scheduledForSync = false, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis(), + needsUpdate = true + ) + ) + `when`(mockFarmDAO.getCollectionSiteById(1L)).thenReturn(collectionSite) + + val result = farms.toDeviceFarmDtoList(deviceId, mockFarmDAO) + + assertEquals(1, result.size) + val farmDto = result.first().farms.first() + farmDto.coordinates?.let { assertTrue(it.isEmpty()) } + assertNull(farmDto.accuracies) + } +} \ No newline at end of file diff --git a/app/src/test/java/org/technoserve/farmcollector/database/models/CollectionSiteTest.kt b/app/src/test/java/org/technoserve/farmcollector/database/models/CollectionSiteTest.kt new file mode 100644 index 0000000..f33c210 --- /dev/null +++ b/app/src/test/java/org/technoserve/farmcollector/database/models/CollectionSiteTest.kt @@ -0,0 +1,149 @@ +package org.technoserve.farmcollector.database.models + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.technoserve.farmcollector.database.TestDatabase +import org.technoserve.farmcollector.database.dao.CollectionSiteDAO +import java.util.concurrent.Executors + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class CollectionSiteTest{ + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() // For LiveData + + private lateinit var database: TestDatabase + private lateinit var collectionSiteDao: CollectionSiteDAO + + @Before + fun setUp() { + database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + TestDatabase::class.java + ) + .setTransactionExecutor(Executors.newSingleThreadExecutor()) + .build() + + collectionSiteDao = database.collectionSiteDAO() + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun `insert and retrieve CollectionSite`() { + val collectionSite = CollectionSite( + name = "Test Site", + agentName = "Agent Smith", + phoneNumber = "1234567890", + email = "agent@example.com", + village = "Test Village", + district = "Test District", + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + + // Insert the entity + val id = collectionSiteDao.insertSite(collectionSite) + assertTrue(id > 0) // Ensure the ID was auto-generated + + // Retrieve and verify the entity + val retrievedSite = collectionSiteDao.getCollectionSiteById(id) + assertNotNull(retrievedSite) + assertEquals(collectionSite.name, retrievedSite?.name) + assertEquals(collectionSite.agentName, retrievedSite?.agentName) + assertEquals(collectionSite.phoneNumber, retrievedSite?.phoneNumber) + assertEquals(collectionSite.email, retrievedSite?.email) + assertEquals(collectionSite.village, retrievedSite?.village) + assertEquals(collectionSite.district, retrievedSite?.district) + } + + @Test + fun `update CollectionSite`() { + val collectionSite = CollectionSite( + name = "Test Site", + agentName = "Agent Smith", + phoneNumber = "1234567890", + email = "agent@example.com", + village = "Test Village", + district = "Test District", + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + + // Insert and retrieve ID + val id = collectionSiteDao.insertSite(collectionSite) + assertTrue(id > 0) + + // Update entity + val updatedSite = collectionSite.copy(name = "Updated Site") + collectionSiteDao.updateSite(updatedSite) + + // Retrieve and verify update + val retrievedSite = collectionSiteDao.getCollectionSiteById(id) + assertNotNull(retrievedSite) + assertEquals("Updated Site", retrievedSite?.name) + } + + @Test + fun `delete CollectionSite`() { + val collectionSite = CollectionSite( + name = "Test Site", + agentName = "Agent Smith", + phoneNumber = "1234567890", + email = "agent@example.com", + village = "Test Village", + district = "Test District", + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + + // Insert and retrieve ID + val id = collectionSiteDao.insertSite(collectionSite) + assertTrue(id > 0) + + // Delete the entity + collectionSiteDao.delete(collectionSite) + + // Verify deletion + val retrievedSite = collectionSiteDao.getCollectionSiteById(id) + assertNull(retrievedSite) + } + + @Test + fun `verify DateConverter works with CollectionSite`() { + val createdAt = System.currentTimeMillis() + val updatedAt = System.currentTimeMillis() + + val collectionSite = CollectionSite( + name = "Test Site", + agentName = "Agent Smith", + phoneNumber = "1234567890", + email = "agent@example.com", + village = "Test Village", + district = "Test District", + createdAt = createdAt, + updatedAt = updatedAt + ) + + // Insert and retrieve ID + val id = collectionSiteDao.insertSite(collectionSite) + val retrievedSite = collectionSiteDao.getCollectionSiteById(id) + + assertNotNull(retrievedSite) + assertEquals(createdAt, retrievedSite?.createdAt) + assertEquals(updatedAt, retrievedSite?.updatedAt) + } +} \ No newline at end of file diff --git a/app/src/test/java/org/technoserve/farmcollector/database/FarmRepositoryTest.kt b/app/src/test/java/org/technoserve/farmcollector/repositories/FarmRepositoryTest.kt similarity index 57% rename from app/src/test/java/org/technoserve/farmcollector/database/FarmRepositoryTest.kt rename to app/src/test/java/org/technoserve/farmcollector/repositories/FarmRepositoryTest.kt index f0a7ecc..efcc301 100644 --- a/app/src/test/java/org/technoserve/farmcollector/database/FarmRepositoryTest.kt +++ b/app/src/test/java/org/technoserve/farmcollector/repositories/FarmRepositoryTest.kt @@ -1,20 +1,23 @@ -package org.technoserve.farmcollector.database - -import org.junit.Assert.* +package org.technoserve.farmcollector.repositories import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.Mock -import org.mockito.Mockito.* +import org.mockito.Mockito.anyLong +import org.mockito.Mockito.anyString +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations import org.technoserve.farmcollector.database.dao.FarmDAO import org.technoserve.farmcollector.database.models.CollectionSite import org.technoserve.farmcollector.database.models.Farm -import org.technoserve.farmcollector.repositories.FarmRepository import java.util.UUID class FarmRepositoryTest { @@ -32,22 +35,108 @@ class FarmRepositoryTest { farmRepository = FarmRepository(mockFarmDAO) } + @Test fun `get readAllSites returns all sites`() { + // Mock DAO to return expected LiveData val expectedSites = MutableLiveData>() + val testSites = listOf( + CollectionSite( + name = "Site A", + agentName = "Agent Name", + phoneNumber = "12345", + email = "test@example.com", + village = "Village Name", + district = "District Name", + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ), + CollectionSite( + name = "Site B", + agentName = "Agent Name", + phoneNumber = "12345", + email = "test@example.com", + village = "Village Name", + district = "District Name", + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + ) + expectedSites.value = testSites `when`(mockFarmDAO.getSites()).thenReturn(expectedSites) - val sites = farmRepository.readAllSites - assertEquals(expectedSites, sites) + // Call repository and ensure LiveData is not null + val result = farmRepository.readAllSites + assertNotNull(result) + + // Observe the LiveData and validate the content + result.observeForever { actualSites -> + assertEquals(testSites, actualSites) + } + + // Verify DAO method was called + verify(mockFarmDAO).getSites() } + @Test fun `get readData returns all farms`() { + // Mock DAO to return expected LiveData val expectedFarms = MutableLiveData>() + val testFarms = listOf( + Farm( + siteId = 1L, + farmerPhoto = "photo.jpg", + farmerName = "New Farmer A", + memberId = "12345", + village = "Village A", + district = "District X", + purchases = 10f, + size = 100f, + latitude = "12.34", + longitude = "56.78", + coordinates = listOf(Pair(12.34, 56.78)), + accuracyArray = listOf(5.0f), + synced = false, + scheduledForSync = false, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis(), + needsUpdate = true + ), + Farm( + siteId = 2L, + farmerPhoto = "photo.jpg", + farmerName = "New Farmer A", + memberId = "12345", + village = "Village A", + district = "District X", + purchases = 10f, + size = 100f, + latitude = "12.34", + longitude = "56.78", + coordinates = listOf(Pair(12.34, 56.78)), + accuracyArray = listOf(5.0f), + synced = false, + scheduledForSync = false, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis(), + needsUpdate = true + ) + ) + expectedFarms.value = testFarms `when`(mockFarmDAO.getData()).thenReturn(expectedFarms) - val data = farmRepository.readData - assertEquals(expectedFarms, data) + // Call repository and ensure LiveData is not null + val result = farmRepository.readData + assertNotNull(result) + + // Observe the LiveData and validate the content + result.observeForever { actualFarms -> + assertEquals(testFarms, actualFarms) + } + + // Verify DAO method was called + verify(mockFarmDAO).getData() } @Test @@ -81,7 +170,15 @@ class FarmRepositoryTest { updatedAt = System.currentTimeMillis(), needsUpdate = true ) - `when`(mockFarmDAO.getFarmByDetails(UUID.randomUUID(), anyString(), anyString(), anyString())).thenReturn(null) + + `when`( + mockFarmDAO.getFarmByDetails( + UUID.randomUUID(), + "Old Farmer", + "Village A", + "District X" + ) + ).thenReturn(null) farmRepository.addFarm(farm) verify(mockFarmDAO).insert(farm) @@ -113,7 +210,14 @@ class FarmRepositoryTest { val newFarm = existingFarm.copy(farmerName = "New Farmer") // Mock DAO method to return existing farm - `when`(mockFarmDAO.getFarmByDetails(existingFarm.remoteId, existingFarm.memberId, existingFarm.village, existingFarm.district)) + `when`( + mockFarmDAO.getFarmByDetails( + existingFarm.remoteId, + existingFarm.memberId, + existingFarm.village, + existingFarm.district + ) + ) .thenReturn(existingFarm) // Perform the add operation @@ -134,7 +238,14 @@ class FarmRepositoryTest { createdAt = 17000, updatedAt = 17000 ) - `when`(mockFarmDAO.getSiteByDetails(anyLong(), anyString(), anyString(), anyString())).thenReturn(null) + `when`( + mockFarmDAO.getSiteByDetails( + anyLong(), + anyString(), + anyString(), + anyString() + ) + ).thenReturn(null) val result = farmRepository.addSite(site) assertTrue(result) diff --git a/app/src/test/java/org/technoserve/farmcollector/ui/screens/home/HomeKtTest.kt b/app/src/test/java/org/technoserve/farmcollector/ui/screens/home/HomeKtTest.kt new file mode 100644 index 0000000..548cf60 --- /dev/null +++ b/app/src/test/java/org/technoserve/farmcollector/ui/screens/home/HomeKtTest.kt @@ -0,0 +1,156 @@ +package org.technoserve.farmcollector.ui.screens.home + +import android.annotation.SuppressLint +import android.content.Context +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.navigation.NavController +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.mockk.mockk +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.technoserve.farmcollector.database.models.Language +import org.technoserve.farmcollector.viewmodels.LanguageViewModel + + +//@RunWith(RobolectricTestRunner::class) +//@Config(sdk = [33]) +@RunWith(AndroidJUnit4::class) +class HomeKtTest{ + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var languageViewModel: LanguageViewModel + private lateinit var navController: NavController + private lateinit var mockContext: Context + + @Before + fun setUp() { + // Mock dependencies + languageViewModel = mockk(relaxed = true) + navController = mockk(relaxed = true) + mockContext = mockk(relaxed = true) + } + + @Test + fun testLanguageChangeUpdatesUI() { + // Arrange: Create a list of languages to test + val languages = listOf( + Language("en", "English"), + Language("fr", "French") + ) + + // Start the Composable + composeTestRule.setContent { + Home( + navController = navController, + languageViewModel = languageViewModel, + languages = languages + ) + } + + // Act: Change the language to French + val frenchLanguage = languages.find { it.code == "fr" }!! + languageViewModel.selectLanguage(frenchLanguage, mockContext) + + // Assert: Verify that the currentLanguage state is updated to "fr" + assert(languageViewModel.currentLanguage.value.code == "fr") + } + + @SuppressLint("CheckResult") + @Test + fun testNavigationWhenGetStartedClicked() { + // Arrange: Create a list of languages to test + val languages = listOf( + Language("en", "English"), + Language("fr", "French") + ) + + // Start the Composable + composeTestRule.setContent { + Home( + navController = navController, + languageViewModel = languageViewModel, + languages = languages + ) + } + + // Act: Perform click on the "Get Started" button + composeTestRule.onNodeWithText("Get Started").performClick() + + // Assert: Verify that the navController.navigate method was called with the expected route + verify { navController.navigate("siteList") } + } + + @Test + fun testLanguageSelectorDisplayed() { + // Arrange: Create a list of languages to test + val languages = listOf( + Language("en", "English"), + Language("fr", "French") + ) + + // Start the Composable + composeTestRule.setContent { + Home( + navController = navController, + languageViewModel = languageViewModel, + languages = languages + ) + } + + // Act: Check if the language selector is displayed + composeTestRule.onNodeWithText("English").assertIsDisplayed() + composeTestRule.onNodeWithText("French").assertIsDisplayed() + } + + @Test + fun testAppNameDisplayed() { + // Arrange: Create a list of languages to test + val languages = listOf( + Language("en", "English"), + Language("fr", "French") + ) + + // Start the Composable + composeTestRule.setContent { + Home( + navController = navController, + languageViewModel = languageViewModel, + languages = languages + ) + } + + // Assert: Verify that the app name text is displayed + composeTestRule.onNodeWithText("App Name").assertIsDisplayed() + } + + @Test + fun testDeveloperLabelDisplayed() { + // Arrange: Create a list of languages to test + val languages = listOf( + Language("en", "English"), + Language("fr", "French") + ) + + // Start the Composable + composeTestRule.setContent { + Home( + navController = navController, + languageViewModel = languageViewModel, + languages = languages + ) + } + + // Assert: Verify that the "Developed by" text is displayed + composeTestRule.onNodeWithText("Developed by").assertIsDisplayed() + } +} \ No newline at end of file diff --git a/app/src/test/java/org/technoserve/farmcollector/utils/UpdateLocaleKtTest.kt b/app/src/test/java/org/technoserve/farmcollector/utils/UpdateLocaleKtTest.kt deleted file mode 100644 index b4665d0..0000000 --- a/app/src/test/java/org/technoserve/farmcollector/utils/UpdateLocaleKtTest.kt +++ /dev/null @@ -1,54 +0,0 @@ -package org.technoserve.farmcollector.utils - -import android.content.Context -import android.content.res.Configuration -import android.content.res.Resources -import androidx.core.text.layoutDirection -import junit.framework.TestCase.assertEquals -import junit.framework.TestCase.assertNotNull -import org.junit.Test -import org.mockito.Mockito.* -import java.util.Locale - - -class UpdateLocaleKtTest{ - @Test - fun `updateLocale updates the locale and layout direction`() { - // Mock Context and Resources - val mockContext = mock(Context::class.java) - val mockResources = mock(Resources::class.java) - val mockConfiguration = Configuration() - - `when`(mockContext.resources).thenReturn(mockResources) - `when`(mockResources.configuration).thenReturn(mockConfiguration) - `when`(mockResources.displayMetrics).thenReturn(mock(Resources::class.java).displayMetrics) - - // Call the function with a specific locale - val locale = Locale("fr", "FR") // French locale - updateLocale(mockContext, locale) - - // Verify that the locale and layout direction were set correctly - assertEquals(locale, mockConfiguration.locales[0]) - assertEquals(locale, Locale.getDefault()) - assertEquals(locale.layoutDirection, mockConfiguration.layoutDirection) - } - - @Test - fun `updateLocale does not crash with null locale`() { - // Mock Context and Resources - val mockContext = mock(Context::class.java) - val mockResources = mock(Resources::class.java) - val mockConfiguration = Configuration() - - `when`(mockContext.resources).thenReturn(mockResources) - `when`(mockResources.configuration).thenReturn(mockConfiguration) - `when`(mockResources.displayMetrics).thenReturn(mock(Resources::class.java).displayMetrics) - - // Call the function with a null locale - updateLocale(mockContext, Locale.getDefault()) - - // Verify the configuration and default locale remain unchanged - assertNotNull(Locale.getDefault()) - assertNotNull(mockConfiguration) - } -} \ No newline at end of file diff --git a/app/src/test/java/org/technoserve/farmcollector/database/FarmViewModelTest.kt b/app/src/test/java/org/technoserve/farmcollector/viewmodels/FarmViewModelTest.kt similarity index 95% rename from app/src/test/java/org/technoserve/farmcollector/database/FarmViewModelTest.kt rename to app/src/test/java/org/technoserve/farmcollector/viewmodels/FarmViewModelTest.kt index 2a578bd..cbd7a47 100644 --- a/app/src/test/java/org/technoserve/farmcollector/database/FarmViewModelTest.kt +++ b/app/src/test/java/org/technoserve/farmcollector/viewmodels/FarmViewModelTest.kt @@ -1,4 +1,4 @@ -package org.technoserve.farmcollector.database +package org.technoserve.farmcollector.viewmodels import android.app.Application import androidx.arch.core.executor.testing.InstantTaskExecutorRule @@ -8,13 +8,18 @@ import org.junit.Assert.* import org.junit.Before import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith import org.mockito.Mock import org.mockito.Mockito.* import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import org.technoserve.farmcollector.database.models.Farm import org.technoserve.farmcollector.repositories.FarmRepository -import org.technoserve.farmcollector.viewmodels.FarmViewModel + +//@RunWith(RobolectricTestRunner::class) +//@Config(sdk = [33]) class FarmViewModelTest { @get:Rule diff --git a/app/src/test/java/org/technoserve/farmcollector/viewmodels/LanguageViewModelTest.kt b/app/src/test/java/org/technoserve/farmcollector/viewmodels/LanguageViewModelTest.kt new file mode 100644 index 0000000..dff849d --- /dev/null +++ b/app/src/test/java/org/technoserve/farmcollector/viewmodels/LanguageViewModelTest.kt @@ -0,0 +1,117 @@ +package org.technoserve.farmcollector.viewmodels + +import android.content.Context +import android.content.SharedPreferences +import android.content.res.Resources +import android.content.res.Configuration +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.Mockito.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.technoserve.farmcollector.database.models.Language +import java.util.Locale + + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class LanguageViewModelTest{ + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private lateinit var languageViewModel: LanguageViewModel + private lateinit var context: Context + private lateinit var sharedPreferences: SharedPreferences + private lateinit var resources: Resources + + @Before + fun setUp() { + // Mocking Context and SharedPreferences + context = mock() + sharedPreferences = mock() + resources = mock() + + // Mocking shared preferences return value + whenever(sharedPreferences.getString("preferred_language", Locale.getDefault().language)).thenReturn("en") + whenever(context.getSharedPreferences("settings", Context.MODE_PRIVATE)).thenReturn(sharedPreferences) + whenever(context.resources).thenReturn(resources) + + // Initializing the ViewModel + languageViewModel = LanguageViewModel(ApplicationProvider.getApplicationContext()) + } + + @Test + fun testGetDefaultLanguage() { + // Given that the preferred language is "en" + val defaultLanguage = languageViewModel.getDefaultLanguage() + + // Then it should return the "en" language + assertEquals("en", defaultLanguage.code) + } + + @Test + fun testSavePreferredLanguage() { + // Given a new language to save + val newLanguage = Language("fr", "French") + + // When saving the new language + languageViewModel.savePreferredLanguage(newLanguage) + + // Then SharedPreferences should store the new language + Mockito.verify(sharedPreferences).edit().putString("preferred_language", "fr") + } + + @Test + fun testSelectLanguage() { + // Given a language "fr" and a mock context + val newLanguage = Language("fr", "French") + val mockContext = mock() + + // When selecting the language + languageViewModel.selectLanguage(newLanguage, mockContext) + + // Then currentLanguage should be updated + assertEquals("fr", languageViewModel.currentLanguage.value.code) + + // And SharedPreferences should be updated with the new language + Mockito.verify(sharedPreferences).edit().putString("preferred_language", "fr") + } + + @Test + fun testUpdateLocale() { + // Given a mock context and locale + val locale = Locale("fr") + val mockContext = mock() + val config = mock() + + // Mock the resources and configuration + whenever(mockContext.resources).thenReturn(resources) + whenever(resources.configuration).thenReturn(config) + + // When updating the locale + languageViewModel.updateLocale(mockContext, locale) + + // Then it should set the locale on resources configuration + Mockito.verify(resources.configuration).setLocale(locale) + Mockito.verify(resources.configuration).setLayoutDirection(locale) + } + + @Test + fun testGetLocalizedLanguages() { + // Given a mock context with language strings + val context = ApplicationProvider.getApplicationContext() + val languages = languageViewModel.getLocalizedLanguages(context) + + // Then the languages list should contain the predefined languages + assertEquals(6, languages.size) + assertTrue(languages.any { it.code == "en" }) + assertTrue(languages.any { it.code == "fr" }) + } +} \ No newline at end of file