diff --git a/starter/.idea/.name b/starter/.idea/.name new file mode 100644 index 000000000..38403ec62 --- /dev/null +++ b/starter/.idea/.name @@ -0,0 +1 @@ +Project4 \ No newline at end of file diff --git a/starter/.idea/androidTestResultsUserPreferences.xml b/starter/.idea/androidTestResultsUserPreferences.xml new file mode 100644 index 000000000..8327fb590 --- /dev/null +++ b/starter/.idea/androidTestResultsUserPreferences.xml @@ -0,0 +1,126 @@ + + + + + + \ No newline at end of file diff --git a/starter/.idea/compiler.xml b/starter/.idea/compiler.xml new file mode 100644 index 000000000..b589d56e9 --- /dev/null +++ b/starter/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/starter/.idea/gradle.xml b/starter/.idea/gradle.xml new file mode 100644 index 000000000..a0de2a152 --- /dev/null +++ b/starter/.idea/gradle.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/starter/.idea/kotlinc.xml b/starter/.idea/kotlinc.xml new file mode 100644 index 000000000..2b8a50fc2 --- /dev/null +++ b/starter/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/starter/.idea/misc.xml b/starter/.idea/misc.xml new file mode 100644 index 000000000..8d25e2666 --- /dev/null +++ b/starter/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/starter/.idea/vcs.xml b/starter/.idea/vcs.xml new file mode 100644 index 000000000..6c0b86358 --- /dev/null +++ b/starter/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/starter/app/build.gradle b/starter/app/build.gradle index 73b7fbf4e..fa7b9f581 100644 --- a/starter/app/build.gradle +++ b/starter/app/build.gradle @@ -1,20 +1,23 @@ -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-kapt' -apply plugin: "androidx.navigation.safeargs.kotlin" -apply plugin: 'kotlin-android-extensions' -apply plugin: 'com.google.gms.google-services' +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'kotlin-kapt' + id 'androidx.navigation.safeargs.kotlin' + id 'com.google.gms.google-services' + id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin' +} android { - compileSdkVersion rootProject.compileSdkVersion - buildToolsVersion "29.0.2" + namespace 'com.udacity.project4' + compileSdk 33 defaultConfig { applicationId "com.udacity.project4" - minSdkVersion rootProject.minSdkVersion - targetSdkVersion rootProject.targetSdkVersion + minSdk rootProject.minSdkVersion + targetSdk rootProject.targetSdkVersion versionCode 1 versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -26,15 +29,18 @@ android { kotlinOptions { jvmTarget = "1.8" } - testOptions.unitTests { - includeAndroidResources = true - returnDefaultValues = true + testOptions { + unitTests { + includeAndroidResources = true + returnDefaultValues = true + } + animationsDisabled = true } - //dataBinding { - // enabled = true - // enabledForTests = true - //} +// dataBinding { +// enabled = true +// enabledForTests = true +// } buildFeatures { dataBinding true } @@ -42,47 +48,44 @@ android { dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) + // App dependencies implementation "androidx.appcompat:appcompat:$appCompatVersion" - implementation "androidx.legacy:legacy-support-v4:$androidXLegacySupport" - implementation "androidx.annotation:annotation:$androidXAnnotations" - - implementation "androidx.cardview:cardview:$cardVersion" +// implementation "androidx.legacy:legacy-support-v4:$androidXLegacySupport" +// implementation "androidx.annotation:annotation:$androidXAnnotations" + implementation "androidx.swiperefreshlayout:swiperefreshlayout:$swipeRefreshVersion" +// implementation "androidx.cardview:cardview:$cardVersion" implementation "com.google.android.material:material:$materialVersion" - implementation "androidx.recyclerview:recyclerview:$recyclerViewVersion" +// implementation "androidx.recyclerview:recyclerview:$recyclerViewVersion" implementation "androidx.constraintlayout:constraintlayout:$constraintVersion" - implementation 'com.google.code.gson:gson:2.8.5' + implementation "com.google.code.gson:gson:$gsonVersion" - // Architecture Components - //Navigation dependencies - implementation 'androidx.appcompat:appcompat:1.2.0' - implementation 'androidx.constraintlayout:constraintlayout:2.0.0-rc1' - kapt "androidx.lifecycle:lifecycle-compiler:$archLifecycleVersion" - implementation "androidx.lifecycle:lifecycle-extensions:$archLifecycleVersion" - implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$archLifecycleVersion" + // Architecture Components & Navigation dependencies +// kapt "androidx.lifecycle:lifecycle-compiler:$archLifecycleVersion" implementation "androidx.lifecycle:lifecycle-extensions:$archLifecycleVersion" - implementation "androidx.lifecycle:lifecycle-livedata-ktx:$archLifecycleVersion" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$archLifecycleKtxVersion" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:$archLifecycleKtxVersion" implementation "androidx.navigation:navigation-fragment-ktx:$navigationVersion" implementation "androidx.navigation:navigation-ui-ktx:$navigationVersion" implementation "androidx.test.espresso:espresso-idling-resource:$espressoVersion" + implementation "androidx.work:work-runtime-ktx:$workManagerVersion" - //Room dependencies - implementation "androidx.room:room-ktx:$roomVersion" + // Room dependencies implementation "androidx.room:room-runtime:$roomVersion" kapt "androidx.room:room-compiler:$roomVersion" + // Kotlin Extensions and Coroutines support for Room + implementation "androidx.room:room-ktx:$roomVersion" - //Coroutines Dependencies - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" - - //Koin - implementation "org.koin:koin-android:$koinVersion" - implementation "org.koin:koin-androidx-viewmodel:$koinVersion" + // Coroutines Dependencies +// implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" + // Koin + implementation "io.insert-koin:koin-android:$koinVersion" + implementation "io.insert-koin:koin-core:$koinVersion" // Dependencies for local unit tests - testImplementation "junit:junit:$junitVersion" - testImplementation "org.hamcrest:hamcrest-all:$hamcrestVersion" +// testImplementation "org.hamcrest:hamcrest-all:$hamcrestVersion" testImplementation "androidx.arch.core:core-testing:$archTestingVersion" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" @@ -91,9 +94,11 @@ dependencies { testImplementation "org.mockito:mockito-core:$mockitoVersion" // AndroidX Test - JVM testing + testImplementation "junit:junit:$junitVersion" testImplementation "androidx.test:core-ktx:$androidXTestCoreVersion" testImplementation "androidx.test.ext:junit-ktx:$androidXTestExtKotlinRunnerVersion" testImplementation "androidx.test:rules:$androidXTestRulesVersion" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" // AndroidX Test - Instrumented testing androidTestImplementation "androidx.test:core-ktx:$androidXTestCoreVersion" @@ -104,23 +109,27 @@ dependencies { androidTestImplementation "androidx.arch.core:core-testing:$archTestingVersion" androidTestImplementation "org.robolectric:annotations:$robolectricVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion" + // Testing code for more advanced views such as the DatePicker and RecyclerView. + // It also contains accessibility checks and a class called CountingIdlingResource. androidTestImplementation "androidx.test.espresso:espresso-contrib:$espressoVersion" androidTestImplementation "androidx.test.espresso:espresso-intents:$espressoVersion" androidTestImplementation "androidx.test.espresso.idling:idling-concurrent:$espressoVersion" androidTestImplementation "junit:junit:$junitVersion" - // Once https://issuetracker.google.com/127986458 is fixed this can be testImplementation - implementation "androidx.fragment:fragment-testing:$fragmentVersion" + // Testing code should not be included in the main code. + // Once https://issuetracker.google.com/128612536 is fixed this can be fixed. + debugImplementation "androidx.fragment:fragment-testing:$fragmentTestingVersion" implementation "androidx.test:core:$androidXTestCoreVersion" - implementation "androidx.fragment:fragment:$fragmentVersion" +// implementation "androidx.fragment:fragment:$fragmentVersion" androidTestImplementation "org.mockito:mockito-core:$mockitoVersion" androidTestImplementation "com.linkedin.dexmaker:dexmaker-mockito:$dexMakerVersion" - androidTestImplementation('org.koin:koin-test:2.0.1') { exclude group: 'org.mockito' } +// androidTestImplementation('org.koin:koin-test:2.0.1') { exclude group: 'org.mockito' } + androidTestImplementation "io.insert-koin:koin-test:$koinVersion" + // Maps & Geofencing + implementation "com.google.android.gms:play-services-location:$playServicesLocationVersion" + implementation "com.google.android.gms:play-services-maps:$playServicesMapsVersion" - - //Maps & Geofencing - implementation "com.google.android.gms:play-services-location:$playServicesVersion" - implementation "com.google.android.gms:play-services-maps:$playServicesVersion" - - -} + // Firebase + implementation "com.firebaseui:firebase-ui-auth:$firebaseUiAuthVersion" + implementation "com.google.firebase:firebase-auth-ktx:$firebaseAuthKtxVersion" +} \ No newline at end of file diff --git a/starter/app/google-services.json b/starter/app/google-services.json new file mode 100644 index 000000000..7d893818a --- /dev/null +++ b/starter/app/google-services.json @@ -0,0 +1,47 @@ +{ + "project_info": { + "project_number": "423316326245", + "project_id": "locationreminder-80efc", + "storage_bucket": "locationreminder-80efc.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:423316326245:android:a33b5cc75ea0375d7a838c", + "android_client_info": { + "package_name": "com.udacity.project4" + } + }, + "oauth_client": [ + { + "client_id": "423316326245-vg0vfgi6s4avapcmptbj3l314s7l4o66.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "com.udacity.project4", + "certificate_hash": "8daf07abce85f500329c4babccc42573b5ffeed4" + } + }, + { + "client_id": "423316326245-8fjtl5af57sanqo7cvf9850cigltbkb5.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyB9Y3uogCpRJbOC-bliKYlzjhL-__57OZY" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "423316326245-0gidq5ki1qov5ms3cnsoa50obfd3v3p3.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/starter/app/src/androidTest/java/com/udacity/project4/MainCoroutineRule.kt b/starter/app/src/androidTest/java/com/udacity/project4/MainCoroutineRule.kt new file mode 100644 index 000000000..273229e9e --- /dev/null +++ b/starter/app/src/androidTest/java/com/udacity/project4/MainCoroutineRule.kt @@ -0,0 +1,27 @@ +package com.udacity.project4 + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.* +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +/** + * Source: + * https://stackoverflow.com/a/73300292 + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MainCoroutineRule( + val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() +) : TestWatcher() { + + override fun starting(description: Description) { + super.starting(description) + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + super.finished(description) + Dispatchers.resetMain() + } +} \ No newline at end of file diff --git a/starter/app/src/androidTest/java/com/udacity/project4/RemindersActivityTest.kt b/starter/app/src/androidTest/java/com/udacity/project4/RemindersActivityTest.kt index 44f979063..fffa5b960 100644 --- a/starter/app/src/androidTest/java/com/udacity/project4/RemindersActivityTest.kt +++ b/starter/app/src/androidTest/java/com/udacity/project4/RemindersActivityTest.kt @@ -1,71 +1,203 @@ package com.udacity.project4 import android.app.Application +import android.os.Build +import android.view.View +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.espresso.Espresso.closeSoftKeyboard +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.IdlingRegistry +import androidx.test.espresso.ViewAction +import androidx.test.espresso.action.GeneralClickAction +import androidx.test.espresso.action.Press +import androidx.test.espresso.action.Tap +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.RootMatchers.withDecorView +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest +import com.udacity.project4.locationreminders.RemindersActivity import com.udacity.project4.locationreminders.data.ReminderDataSource import com.udacity.project4.locationreminders.data.local.LocalDB import com.udacity.project4.locationreminders.data.local.RemindersLocalRepository import com.udacity.project4.locationreminders.reminderslist.RemindersListViewModel import com.udacity.project4.locationreminders.savereminder.SaveReminderViewModel +import com.udacity.project4.util.DataBindingIdlingResource +import com.udacity.project4.util.monitorActivity +import com.udacity.project4.utils.EspressoIdlingResource +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking +import org.hamcrest.CoreMatchers +import org.junit.After import org.junit.Before +import org.junit.Rule +import org.junit.Test import org.junit.runner.RunWith import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.core.context.startKoin import org.koin.core.context.stopKoin import org.koin.dsl.module -import org.koin.test.AutoCloseKoinTest +import org.koin.test.KoinTest import org.koin.test.get +/** + * END TO END test to black box test the app. + * + * Source: + * https://medium.com/koin-developers/unboxing-koin-2-1-7f1133ebb790 + */ +@ExperimentalCoroutinesApi @RunWith(AndroidJUnit4::class) @LargeTest -//END TO END test to black box test the app -class RemindersActivityTest : - AutoCloseKoinTest() {// Extended Koin Test - embed autoclose @after method to close Koin after every test - - private lateinit var repository: ReminderDataSource - private lateinit var appContext: Application - - /** - * As we use Koin as a Service Locator Library to develop our code, we'll also use Koin to test our code. - * at this step we will initialize Koin related code to be able to use it in out testing. - */ - @Before - fun init() { - stopKoin()//stop the original app koin - appContext = getApplicationContext() - val myModule = module { - viewModel { - RemindersListViewModel( - appContext, - get() as ReminderDataSource - ) - } - single { - SaveReminderViewModel( - appContext, - get() as ReminderDataSource - ) - } - single { RemindersLocalRepository(get()) as ReminderDataSource } - single { LocalDB.createRemindersDao(appContext) } - } - //declare a new koin module - startKoin { - modules(listOf(myModule)) - } - //Get our real repository - repository = get() - - //clear the data to start fresh - runBlocking { - repository.deleteAllReminders() - } - } - - -// TODO: add End to End testing to the app - -} +class RemindersActivityTest : KoinTest { + + // Executes each task synchronously using Architecture Components. + @get:Rule + var instantExecutorRule = InstantTaskExecutorRule() + + @get:Rule + var activityScenarioRule = ActivityScenarioRule(RemindersActivity::class.java) + + // An Idling Resource that waits for Data Binding to have no pending bindings. + private val dataBindingIdlingResource = DataBindingIdlingResource() + + private lateinit var decorView: View + private lateinit var repository: ReminderDataSource + private lateinit var appContext: Application + + /** + * As we use Koin as a Service Locator Library to develop our code, we'll also use Koin to test our code. + * At this step we will initialize Koin related code to be able to use it in our testing. + */ + @Before + fun init() { + // Stop the original app koin. + stopKoin() + appContext = getApplicationContext() + + val myModule = module { + viewModel { RemindersListViewModel(appContext, get() as ReminderDataSource) } + single { SaveReminderViewModel(appContext, get() as ReminderDataSource) } + single { RemindersLocalRepository(get()) as ReminderDataSource } + single { LocalDB.createRemindersDao(appContext) } + } + + // Declare a new koin module. + startKoin { modules(listOf(myModule)) } + + // Get our real repository. + repository = get() + + // Clear the data to start fresh. + runBlocking { + repository.deleteAllReminders() + } + + activityScenarioRule.scenario.onActivity { activity -> + decorView = activity.window.decorView + } + } + + /** + * Idling resources tell Espresso that the app is idle or busy. This is needed when operations + * are not scheduled in the main Looper (for example when executed on a different thread). + */ + @Before + fun registerIdlingResource() { + IdlingRegistry.getInstance().register(EspressoIdlingResource.countingIdlingResource) + IdlingRegistry.getInstance().register(dataBindingIdlingResource) + } + + /** + * Unregister your Idling Resource so it can be garbage collected and does not leak any memory. + */ + @After + fun unregisterIdlingResource() { + IdlingRegistry.getInstance().unregister(EspressoIdlingResource.countingIdlingResource) + IdlingRegistry.getInstance().unregister(dataBindingIdlingResource) + } + + @Test + fun clickingOnFloatingActionButton_checkIfFieldsAreDisplayed() = runBlocking { + val activityScenario = ActivityScenario.launch(RemindersActivity::class.java) + dataBindingIdlingResource.monitorActivity(activityScenario) + + onView(withId(R.id.addReminderFAB)).perform(click()) + onView(withId(R.id.reminderTitle)).check(matches(isDisplayed())) + onView(withId(R.id.reminderDescription)).check(matches(isDisplayed())) + onView(withId(R.id.selectLocation)).check(matches(isDisplayed())) + + activityScenario.close() + } + + @Test + fun addingReminderWithoutTitle_checkIfPreventMessageIsShowed() = runBlocking { + val activityActivityScenario = ActivityScenario.launch(RemindersActivity::class.java) + dataBindingIdlingResource.monitorActivity(activityActivityScenario) + + onView(withId(R.id.addReminderFAB)).perform(click()) + onView(withId(R.id.saveReminder)).perform(click()) + onView(withText("Please enter title")).check(matches(isDisplayed())) + + activityActivityScenario.close() + } + + /** + * Toast Assertions doesn't working in versions below 30. + * https://github.com/android/android-test/issues/803 + */ + @Test + fun addingReminder_verifyIfToastIsCalled() { + // Verifying Exception because Toast Assertions doesn't work in versions above 30. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + throw Exception("Toast Assertions doesn't work in versions above 30.") + } + + // Setup for this test. + val typingTitle = "Title" + val typingDescription = "Description" + val activityScenario = ActivityScenario.launch(RemindersActivity::class.java) + dataBindingIdlingResource.monitorActivity(activityScenario) + + // GIVEN - Clicking to add a new task on Save Reminder screen. + onView(withId(R.id.addReminderFAB)).perform(click()) + onView(withId(R.id.reminderTitle)).perform(ViewActions.typeText(typingTitle)) + onView(withId(R.id.reminderDescription)).perform(ViewActions.typeText(typingDescription)) + closeSoftKeyboard() + + // WHEN - Clicking in any position in the map, and saving the location. + (appContext as MyApp).testingMode = true + onView(withId(R.id.selectLocation)).perform(click()) + onView(withId(R.id.map_fragment)).perform(simulateClickOnMap(500, 500)) + // Wait for map to load. + Thread.sleep(2000) + + // THEN - Save Reminder and check if Toast is displayed. + onView(withId(R.id.saveReminder)).perform(click()) + onView(withText(R.string.reminder_saved)).inRoot( + withDecorView(CoreMatchers.not(decorView)) + ).check(matches(isDisplayed())) + + activityScenario.close() + } + + private fun simulateClickOnMap(x: Int, y: Int): ViewAction { + return GeneralClickAction( + Tap.SINGLE, { view -> + val screenPos = IntArray(2) + view.getLocationOnScreen(screenPos) + val screenX = (screenPos[0] + x).toFloat() + val screenY = (screenPos[1] + y).toFloat() + floatArrayOf(screenX, screenY) + }, + Press.FINGER, 0, 0 + ) + } +} \ No newline at end of file diff --git a/starter/app/src/androidTest/java/com/udacity/project4/locationreminders/data/local/RemindersDaoTest.kt b/starter/app/src/androidTest/java/com/udacity/project4/locationreminders/data/local/RemindersDaoTest.kt index 6e674abfb..7c02d2024 100644 --- a/starter/app/src/androidTest/java/com/udacity/project4/locationreminders/data/local/RemindersDaoTest.kt +++ b/starter/app/src/androidTest/java/com/udacity/project4/locationreminders/data/local/RemindersDaoTest.kt @@ -2,29 +2,90 @@ package com.udacity.project4.locationreminders.data.local import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.room.Room -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.filters.SmallTest; +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest import com.udacity.project4.locationreminders.data.dto.ReminderDTO - -import org.junit.Before; -import org.junit.Rule; -import org.junit.runner.RunWith; - -import kotlinx.coroutines.ExperimentalCoroutinesApi; -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking import org.hamcrest.CoreMatchers.`is` import org.hamcrest.CoreMatchers.notNullValue +import org.hamcrest.CoreMatchers.nullValue import org.hamcrest.MatcherAssert.assertThat import org.junit.After +import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +/** + * Unit test the DAO. + */ @ExperimentalCoroutinesApi @RunWith(AndroidJUnit4::class) -//Unit test the DAO @SmallTest class RemindersDaoTest { -// TODO: Add testing implementation to the RemindersDao.kt + @get:Rule + var instantTaskExecutorRule = InstantTaskExecutorRule() + + private lateinit var database: RemindersDatabase + + private val item1 = ReminderDTO("1", "first", "loc", 122.6, 122.7) + private val item2 = ReminderDTO("2", "second", "loc2", 0.0, 0.0) + + @Before + fun initDb() { + database = Room.inMemoryDatabaseBuilder( + getApplicationContext(), RemindersDatabase::class.java + ).allowMainThreadQueries().build() + } + + @After + fun closeDb() { + database.close() + } + + @Test + fun insertAReminder_thenGettingItById_returnsThisSameReminder() = runBlocking { + // GIVEN - Insert a Reminder. + database.reminderDao().saveReminder(item1) + + // WHEN - Getting a Reminder by ID. + val check = database.reminderDao().getReminderById(item1.id) + + // THEN - The loaded data contains the expected values. + assertThat(check as ReminderDTO, notNullValue()) + assertThat(check.latitude, `is`(item1.latitude)) + assertThat(check.location, `is`(item1.location)) + assertThat(check.id, `is`(item1.id)) + assertThat(check.longitude, `is`(item1.longitude)) + assertThat(check.description, `is`(item1.description)) + assertThat(check.title, `is`(item1.title)) + } + + @Test + fun insertAReminder_thenDeletingAllReminders_returnsNullForThisInsertedReminder() = runBlocking { + // GIVEN - Insert a Reminder. + database.reminderDao().saveReminder(item1) + + // WHEN - Deleting all Reminders. + database.reminderDao().deleteAllReminders() + + // THEN - Getting the inserted Reminder returns null. + assertThat(database.reminderDao().getReminderById(item1.id), `is`(nullValue())) + } + + @Test + fun insertTwoReminders_thenGetAllReminders_returnsANonEmptyListOfReminders() = runBlocking { + // GIVEN - Inserting two Reminders. + database.reminderDao().saveReminder(item1) + database.reminderDao().saveReminder(item2) + + // WHEN - Getting all Reminders. + val list = database.reminderDao().getReminders() + // THEN - Check if list of Reminders is not null. + assertThat(list.isNotEmpty(), `is`(true)) + } } \ No newline at end of file diff --git a/starter/app/src/androidTest/java/com/udacity/project4/locationreminders/data/local/RemindersLocalRepositoryTest.kt b/starter/app/src/androidTest/java/com/udacity/project4/locationreminders/data/local/RemindersLocalRepositoryTest.kt index 97d6b48e0..ea99a762f 100644 --- a/starter/app/src/androidTest/java/com/udacity/project4/locationreminders/data/local/RemindersLocalRepositoryTest.kt +++ b/starter/app/src/androidTest/java/com/udacity/project4/locationreminders/data/local/RemindersLocalRepositoryTest.kt @@ -1,30 +1,86 @@ package com.udacity.project4.locationreminders.data.local -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.room.Room import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.udacity.project4.locationreminders.data.dto.ReminderDTO import com.udacity.project4.locationreminders.data.dto.Result -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.hamcrest.CoreMatchers import org.hamcrest.CoreMatchers.`is` -import org.hamcrest.CoreMatchers.instanceOf import org.hamcrest.MatcherAssert.assertThat import org.junit.After import org.junit.Before -import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +/** + * Medium Test to test the repository. + */ @ExperimentalCoroutinesApi @RunWith(AndroidJUnit4::class) -//Medium Test to test the repository @MediumTest class RemindersLocalRepositoryTest { -// TODO: Add testing implementation to the RemindersLocalRepository.kt + private lateinit var repository: RemindersLocalRepository + private lateinit var database: RemindersDatabase + private val item1 = ReminderDTO("1", "2", "3", 100.0, 120.0) + private val item2 = ReminderDTO("2", "second", "loc2", 0.0, 0.0) + + @Before + fun setup() { + database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + RemindersDatabase::class.java + ).allowMainThreadQueries().build() + repository = RemindersLocalRepository(database.reminderDao(), UnconfinedTestDispatcher()) + } + + @After + fun cleanUp() = database.close() + + @Test + fun gettingANewReminderWithoutSavingItOnDatabase_returnsNoReminderFound() = runBlocking { + // GIVEN - Trying to get a new Reminder without saving it on database. + val result = repository.getReminder(item1.id) + + // THEN - No Reminder was found. + assertThat(result is Result.Error, `is`(true)) + result as Result.Error + assertThat(result.message, `is`("Reminder not found!")) + } + + @Test + fun gettingANewReminderAfterSavingItOnDatabase_returnsThisSameReminder() = runBlocking { + // GIVEN - A new Reminder saved in the database. + repository.saveReminder(item1) + + // WHEN - Reminder retrieved by ID. + val result = repository.getReminder(item1.id) as Result.Success + val loaded = result.data + + // THEN - Same Reminder is returned. + assertThat(loaded.longitude, `is`(item1.longitude)) + assertThat(loaded.latitude, `is`(item1.latitude)) + assertThat(loaded, CoreMatchers.notNullValue()) + assertThat(loaded.id, `is`(item1.id)) + assertThat(loaded.description, `is`(item1.description)) + assertThat(loaded.location, `is`(item1.location)) + assertThat(loaded.title, `is`(item1.title)) + } + + @Test + fun deletingAllReminders_returnsARepoWithNoReminders() = runBlocking { + // GIVEN - Deleting all Reminders. + repository.deleteAllReminders() + + // THEN - There are no Reminders in our repo. + val result = repository.getReminders() as Result.Success + val data = result.data + assertThat(data, `is`(emptyList())) + } } \ No newline at end of file diff --git a/starter/app/src/androidTest/java/com/udacity/project4/locationreminders/reminderslist/ReminderListFragmentTest.kt b/starter/app/src/androidTest/java/com/udacity/project4/locationreminders/reminderslist/ReminderListFragmentTest.kt index 6ba9a278a..60e398098 100644 --- a/starter/app/src/androidTest/java/com/udacity/project4/locationreminders/reminderslist/ReminderListFragmentTest.kt +++ b/starter/app/src/androidTest/java/com/udacity/project4/locationreminders/reminderslist/ReminderListFragmentTest.kt @@ -1,31 +1,120 @@ package com.udacity.project4.locationreminders.reminderslist -import android.content.Context import android.os.Bundle import androidx.fragment.app.testing.launchFragmentInContainer import androidx.navigation.NavController import androidx.navigation.Navigation -import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.action.ViewActions.* +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.* import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.udacity.project4.R +import com.udacity.project4.locationreminders.data.ReminderDataSource +import com.udacity.project4.locationreminders.data.dto.ReminderDTO +import com.udacity.project4.locationreminders.data.local.LocalDB +import com.udacity.project4.locationreminders.data.local.RemindersLocalRepository +import com.udacity.project4.locationreminders.savereminder.SaveReminderViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking import org.junit.Assert.* +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import org.koin.test.KoinTest +import org.koin.test.get import org.mockito.Mockito.mock import org.mockito.Mockito.verify +/** + * UI Testing. + */ @RunWith(AndroidJUnit4::class) @ExperimentalCoroutinesApi -//UI Testing @MediumTest -class ReminderListFragmentTest { +class ReminderListFragmentTest : KoinTest { -// TODO: test the navigation of the fragments. -// TODO: test the displayed data on the UI. -// TODO: add testing for the error messages. + private val mockNavController = mock(NavController::class.java) + private lateinit var remindersListViewModel: RemindersListViewModel + private lateinit var repository: ReminderDataSource + + @Before + fun init() { + stopKoin() + val module = module { + single { + SaveReminderViewModel( + ApplicationProvider.getApplicationContext(), + get() as ReminderDataSource + ) + } + viewModel { + RemindersListViewModel( + ApplicationProvider.getApplicationContext(), + get() as ReminderDataSource + ) + } + single { + LocalDB.createRemindersDao(ApplicationProvider.getApplicationContext()) + } + single { RemindersLocalRepository(get()) as ReminderDataSource } + + } + startKoin { + modules(listOf(module)) + } + repository = get() + runBlocking { + repository.deleteAllReminders() + } + remindersListViewModel = + RemindersListViewModel(ApplicationProvider.getApplicationContext(), repository) + } + + @Test + fun homeScreen_afterOpeningApp_checkNoDataMessageIsDisplayed() { + // GIVEN - On the home screen. + launchFragmentInContainer(Bundle(), R.style.AppTheme) + + // THEN - Verify that we have No Data in our RecyclerView. + onView(withText("No Data")).check(matches(isDisplayed())) + } + + @Test + fun homeScreen_onClick_navigateToSaveReminderScreen() { + // GIVEN - On the home screen. + val sec = launchFragmentInContainer(Bundle(), R.style.AppTheme) + sec.onFragment { Navigation.setViewNavController(it.view!!, mockNavController) } + + // WHEN - Click on the FAB. + onView(withId(R.id.addReminderFAB)).perform(click()) + + // THEN - Verify that we navigate to the first Save Reminder screen. + verify(mockNavController).navigate( + ReminderListFragmentDirections.actionReminderListFragmentToSaveReminderFragment() + ) + } + + @Test + fun homeScreen_savingAReminderon_reminderInfoIsDisplayed() { + // GIVEN - Saving a Reminder. + val remind = ReminderDTO("1", "2", "3", 0.0, 0.9) + runBlocking { + repository.saveReminder(remind) + } + + // WHEN - On the home screen. + launchFragmentInContainer(Bundle(), R.style.AppTheme) + + // THEN - Verify that the title, description and location are displayed. + onView(withText(remind.title)).check(matches(isDisplayed())) + onView(withText(remind.description)).check(matches(isDisplayed())) + onView(withText(remind.location)).check(matches(isDisplayed())) + } } \ No newline at end of file diff --git a/starter/app/src/androidTest/java/com/udacity/project4/util/DataBindingIdlingResource.kt b/starter/app/src/androidTest/java/com/udacity/project4/util/DataBindingIdlingResource.kt index 1f1711e06..9a88bdf51 100644 --- a/starter/app/src/androidTest/java/com/udacity/project4/util/DataBindingIdlingResource.kt +++ b/starter/app/src/androidTest/java/com/udacity/project4/util/DataBindingIdlingResource.kt @@ -18,12 +18,11 @@ package com.udacity.project4.util import android.view.View import androidx.databinding.DataBindingUtil import androidx.databinding.ViewDataBinding -import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.fragment.app.testing.FragmentScenario import androidx.test.core.app.ActivityScenario import androidx.test.espresso.IdlingResource -import java.util.UUID +import java.util.* /** * An espresso idling resource implementation that reports idle status for all data binding @@ -104,8 +103,10 @@ fun DataBindingIdlingResource.monitorActivity( /** * Sets the fragment from a [FragmentScenario] to be used from [DataBindingIdlingResource]. */ +/* fun DataBindingIdlingResource.monitorFragment(fragmentScenario: FragmentScenario) { fragmentScenario.onFragment { this.activity = it.requireActivity() } } + */ diff --git a/starter/app/src/debug/res/values/google_maps_api.xml b/starter/app/src/debug/res/values/google_maps_api.xml index 4f597c14c..141af24c0 100644 --- a/starter/app/src/debug/res/values/google_maps_api.xml +++ b/starter/app/src/debug/res/values/google_maps_api.xml @@ -1,4 +1,7 @@ - - - + + AIzaSyC6hBgGRJHLlKoosvbR-nPUg0w5Lg3cbWs + \ No newline at end of file diff --git a/starter/app/src/main/AndroidManifest.xml b/starter/app/src/main/AndroidManifest.xml index db5d2ec2a..ed9b4778e 100644 --- a/starter/app/src/main/AndroidManifest.xml +++ b/starter/app/src/main/AndroidManifest.xml @@ -1,19 +1,28 @@ - + + + - - + - + + + + + - + + + + + + + + + + + + + - - - - - - - - - - - - \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/MyApp.kt b/starter/app/src/main/java/com/udacity/project4/MyApp.kt index 15c18be55..0cfcec1a1 100644 --- a/starter/app/src/main/java/com/udacity/project4/MyApp.kt +++ b/starter/app/src/main/java/com/udacity/project4/MyApp.kt @@ -1,47 +1,52 @@ package com.udacity.project4 import android.app.Application +import android.content.Intent import com.udacity.project4.locationreminders.data.ReminderDataSource import com.udacity.project4.locationreminders.data.local.LocalDB import com.udacity.project4.locationreminders.data.local.RemindersLocalRepository import com.udacity.project4.locationreminders.reminderslist.RemindersListViewModel import com.udacity.project4.locationreminders.savereminder.SaveReminderViewModel +import com.udacity.project4.locationreminders.savereminder.selectreminderlocation.SelectLocationViewModel import org.koin.android.ext.koin.androidContext import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.core.context.startKoin import org.koin.dsl.module class MyApp : Application() { + lateinit var broadcastIntent: Intent + var hasNotificationPermission = true + var testingMode = false - override fun onCreate() { - super.onCreate() + override fun onCreate() { + super.onCreate() - /** - * use Koin Library as a service locator - */ - val myModule = module { - //Declare a ViewModel - be later inject into Fragment with dedicated injector using by viewModel() - viewModel { - RemindersListViewModel( - get(), - get() as ReminderDataSource - ) - } - //Declare singleton definitions to be later injected using by inject() - single { - //This view model is declared singleton to be used across multiple fragments - SaveReminderViewModel( - get(), - get() as ReminderDataSource - ) - } - single { RemindersLocalRepository(get()) as ReminderDataSource } - single { LocalDB.createRemindersDao(this@MyApp) } - } + /** + * use Koin Library as a service locator + */ + val myModule = module { + // Declare a ViewModel - be later inject into Fragment with dedicated injector using by viewModel() + viewModel { + RemindersListViewModel( + get(), + get() as ReminderDataSource + ) + } + // Declare singleton definitions to be later injected using by inject() + single { + //This view model is declared singleton to be used across multiple fragments + SaveReminderViewModel( + get(), + get() as ReminderDataSource + ) + } + single { RemindersLocalRepository(get()) as ReminderDataSource } + single { LocalDB.createRemindersDao(this@MyApp) } + } - startKoin { - androidContext(this@MyApp) - modules(listOf(myModule)) - } - } + startKoin { + androidContext(this@MyApp) + modules(listOf(myModule)) + } + } } \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/TrackingActivity.kt b/starter/app/src/main/java/com/udacity/project4/TrackingActivity.kt new file mode 100644 index 000000000..29d84ab6c --- /dev/null +++ b/starter/app/src/main/java/com/udacity/project4/TrackingActivity.kt @@ -0,0 +1,234 @@ +package com.udacity.project4 + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Looper +import android.provider.Settings +import android.util.Log +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.Granularity +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import com.udacity.project4.utils.permissionGranted +import com.udacity.project4.utils.requestTrackingPermissions +import com.udacity.project4.utils.showRequestPermissionRationale +import com.udacity.project4.utils.toast + +/** + * This Activity shows how to track the user Location + * + * Sources: + * https://stackoverflow.com/questions/60384554/access-background-location-not-working-on-lower-than-q-29-android-versions + * https://stackoverflow.com/questions/40142331/how-to-request-location-permission-at-runtime/40142454#40142454 + * https://github.com/zoontek/react-native-permissions/issues/620 + * -> https://github.com/zoontek/react-native-permissions/issues/620#issuecomment-866690012 + */ +class TrackingActivity : AppCompatActivity() { + + companion object { + private const val MY_PERMISSIONS_REQUEST_LOCATION = 99 + private const val MY_PERMISSIONS_REQUEST_BACKGROUND_LOCATION = 66 + private const val UPDATE_INTERVAL = (10 * 1000).toLong() // 10 secs + private const val FASTEST_INTERVAL: Long = 2000 // 2 secs + } + + private var fusedLocationProvider: FusedLocationProviderClient? = null + private val locationRequest: LocationRequest = LocationRequest.Builder( + Priority.PRIORITY_BALANCED_POWER_ACCURACY, UPDATE_INTERVAL + ).apply { + setMinUpdateDistanceMeters(5F) + setGranularity(Granularity.GRANULARITY_PERMISSION_LEVEL) + setWaitForAccurateLocation(true) + setMinUpdateIntervalMillis(FASTEST_INTERVAL) + }.build() + + /* + private val locationRequest: LocationRequest = LocationRequest.create().apply { + interval = 30 + fastestInterval = 10 + priority = LocationRequest.PRIORITY_BALANCED_POWER_ACCURACY + maxWaitTime = 60 + } + */ + + private var locationCallback: LocationCallback = object : LocationCallback() { + override fun onLocationResult(locationResult: LocationResult) { + val locationList = locationResult.locations + if (locationList.isNotEmpty()) { + // The last location in the list is the newest. + val location = locationList.last() + toast("Got Location: $location") + } + } + } + + //-------------------------------------------------- + // Lifecycle Methods + //-------------------------------------------------- + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_tracking) + + fusedLocationProvider = LocationServices.getFusedLocationProviderClient(this) + checkLocationPermission() + } + + @SuppressLint("MissingPermission") + override fun onResume() { + super.onResume() + if (permissionGranted(Manifest.permission.ACCESS_FINE_LOCATION)) { + fusedLocationProvider?.requestLocationUpdates( + locationRequest, + locationCallback, + Looper.getMainLooper() + ) + } + } + + override fun onPause() { + super.onPause() + if (permissionGranted(Manifest.permission.ACCESS_FINE_LOCATION)) { + fusedLocationProvider?.removeLocationUpdates(locationCallback) + } + } + + //-------------------------------------------------- + // Permission Methods + //-------------------------------------------------- + + private fun checkLocationPermission() { + if (!permissionGranted(Manifest.permission.ACCESS_FINE_LOCATION)) { + // Should we show an explanation? + if (showRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)) { + // Show an explanation to the user *asynchronously* -- don't block this thread + // waiting for the user's response! After the user sees the explanation, try again + // to request the permission. + val title = this.getString(R.string.location_permission_needed) + val message = this.getString(R.string.location_permission_explanation) + AlertDialog.Builder(parent) + .setTitle(title) + .setMessage(message) + .setPositiveButton("OK") { _, _ -> + // Prompt the user once explanation has been shown. + requestLocationPermission() + } + .create() + .show() + } else { + // No explanation needed, we can request the permission. + requestLocationPermission() + } + } else { + checkBackgroundLocationPermission() + } + } + + private fun checkBackgroundLocationPermission() { + if (!permissionGranted(Manifest.permission.ACCESS_BACKGROUND_LOCATION)) { + requestBackgroundLocationPermission() + } + } + + private fun requestLocationPermission() { + requestTrackingPermissions( + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), + MY_PERMISSIONS_REQUEST_LOCATION + ) + } + + private fun requestBackgroundLocationPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + requestTrackingPermissions( + arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION), + MY_PERMISSIONS_REQUEST_BACKGROUND_LOCATION + ) + } else { + requestTrackingPermissions( + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), + MY_PERMISSIONS_REQUEST_LOCATION + ) + } + } + + @SuppressLint("MissingSuperCall", "MissingPermission") + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + Log.d("aaa", "bbb") + when (requestCode) { + MY_PERMISSIONS_REQUEST_LOCATION -> { + onRequestLocationPermissionsResult(grantResults) + return + } + MY_PERMISSIONS_REQUEST_BACKGROUND_LOCATION -> { + onRequestBackgroundLocationPermissionsResult(grantResults) + return + } + } + } + + @SuppressLint("MissingPermission") + private fun onRequestLocationPermissionsResult(grantResults: IntArray) { + // If request is cancelled, the result arrays are empty. + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // Permission was granted, yay! Do the location-related task you need to do. + if (permissionGranted(Manifest.permission.ACCESS_FINE_LOCATION)) { + fusedLocationProvider?.requestLocationUpdates( + locationRequest, + locationCallback, + Looper.getMainLooper() + ) + + // Now check background location. + checkBackgroundLocationPermission() + } + + } else { + // Permission denied, boo! Disable the functionality that depends on this permission. + toast("Permission denied") + + // Check if we are in a state where the user has denied the permission and + // selected Don't ask again. + if (!showRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)) { + startActivity( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", this.packageName, null), + ), + ) + } + } + } + + @SuppressLint("MissingPermission") + private fun onRequestBackgroundLocationPermissionsResult(grantResults: IntArray) { + // If request is cancelled, the result arrays are empty. + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // Permission was granted, yay! Do the location-related task you need to do. + if (permissionGranted(Manifest.permission.ACCESS_FINE_LOCATION)) { + fusedLocationProvider?.requestLocationUpdates( + locationRequest, + locationCallback, + Looper.getMainLooper() + ) + toast("Granted Background Location Permission") + } + } else { + // Permission denied, boo! Disable the functionality that depends on this permission. + toast("Permission denied") + } + } +} \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/authentication/AuthenticationActivity.kt b/starter/app/src/main/java/com/udacity/project4/authentication/AuthenticationActivity.kt index cdf405c6b..64c49400c 100644 --- a/starter/app/src/main/java/com/udacity/project4/authentication/AuthenticationActivity.kt +++ b/starter/app/src/main/java/com/udacity/project4/authentication/AuthenticationActivity.kt @@ -1,8 +1,19 @@ package com.udacity.project4.authentication +import android.app.Activity +import android.content.Intent import android.os.Bundle +import android.util.Log +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import com.firebase.ui.auth.AuthUI +import com.firebase.ui.auth.IdpResponse +import com.google.firebase.auth.FirebaseAuth import com.udacity.project4.R +import com.udacity.project4.databinding.ActivityAuthenticationBinding +import com.udacity.project4.locationreminders.RemindersActivity /** * This class should be the starting point of the app, It asks the users to sign in / register, and redirects the @@ -10,15 +21,121 @@ import com.udacity.project4.R */ class AuthenticationActivity : AppCompatActivity() { + companion object { + const val TAG = "MainFragment" + } + + // Get a reference to the ViewModel scoped to this Fragment + private val viewModel by viewModels() + private lateinit var binding: ActivityAuthenticationBinding + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_authentication) -// TODO: Implement the create account and sign in using FirebaseUI, use sign in using email and sign in using Google + val layoutId = R.layout.activity_authentication + binding = DataBindingUtil.setContentView(this, layoutId) + binding.authButton.text = getString(R.string.login) + + checkIfUserIsLogged() + } -// TODO: If the user was authenticated, send him to RemindersActivity + override fun onResume() { + super.onResume() + observeAuthenticationState() -// TODO: a bonus is to customize the sign in flow to look nice using : - //https://github.com/firebase/FirebaseUI-Android/blob/master/auth/README.md#custom-layout + binding.authButton.setOnClickListener { + launchSignInFlow() + } + } + + /** + * Listen to the result of the sign in process by filter for when + * SIGN_IN_REQUEST_CODE is passed back. Start by having log statements to know + * whether the user has signed in successfully + */ + private val resultLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + // There are no request codes + val data: Intent? = result.data + val response = IdpResponse.fromResultIntent(data) + if (result.resultCode == Activity.RESULT_OK) { + // User successfully signed in + Log.i(TAG, "Successfully signed in user " + + "${FirebaseAuth.getInstance().currentUser?.displayName}!" + ) + // If the user was authenticated, send him to RemindersActivity. + startActivity(Intent(this, RemindersActivity::class.java)) + } else { + // Sign in failed. If response is null the user canceled the + // sign-in flow using the back button. Otherwise check + // response.getError().getErrorCode() and handle the error. + Log.i(TAG, "Sign in unsuccessful ${response?.error?.errorCode}") + } + } + } + + /** + * Observes the authentication state and changes the UI accordingly. + * If there is a logged in user: (1) show a logout button and (2) display their name. + * If there is no logged in user: show a login button + */ + private fun observeAuthenticationState() { + viewModel.authenticationState.observe(this) { authenticationState -> + // in LoginViewModel and change the UI accordingly. + when (authenticationState) { + LoginViewModel.AuthenticationState.AUTHENTICATED -> { logoutFromFirebase() } + else -> { loginOnFirebase() } + } + } + } + + private fun loginOnFirebase() { + binding.authButton.text = getString(R.string.login) + binding.authButton.setOnClickListener { + launchSignInFlow() + } + } + + private fun logoutFromFirebase() { + binding.authButton.text = getString(R.string.logout) + binding.authButton.setOnClickListener { + AuthUI.getInstance().signOut(this) + } + } + + /** + * Implement the create account and sign in using FirebaseUI, + * use sign in using email and sign in using Google. + */ + private fun launchSignInFlow() { + // Give users the option to sign in / register with their email or Google account. + // If users choose to register with their email, + // they will need to create a password as well. + val providers = arrayListOf( + AuthUI.IdpConfig.EmailBuilder().build(), AuthUI.IdpConfig.GoogleBuilder().build() + // This is where you can provide more ways for users to register and + // sign in. + ) + + // Create and launch sign-in intent. + val intent = AuthUI.getInstance() + .createSignInIntentBuilder() + .setAvailableProviders(providers) + .build() + resultLauncher.launch(intent) + } + + private fun checkIfUserIsLogged() { + if (!userIsLoggedIn()) { + AuthUI.getInstance().signOut(this) + } else { + startActivity(Intent(this, RemindersActivity::class.java)) + } + } + private fun userIsLoggedIn(): Boolean { + val user = FirebaseAuth.getInstance().currentUser + return user != null } -} +} \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/authentication/FirebaseUserLiveData.kt b/starter/app/src/main/java/com/udacity/project4/authentication/FirebaseUserLiveData.kt new file mode 100644 index 000000000..a866eefd8 --- /dev/null +++ b/starter/app/src/main/java/com/udacity/project4/authentication/FirebaseUserLiveData.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2019, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.udacity.project4.authentication + +import androidx.lifecycle.LiveData +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser + +/** + * This class observes the current FirebaseUser. If there is no logged in user, FirebaseUser will + * be null. + * + * Note that onActive() and onInactive() will get triggered when the configuration changes (for + * example when the device is rotated). This may be undesirable or expensive depending on the + * nature of your LiveData object, but is okay for this purpose since we are only adding and + * removing the authStateListener. + */ +class FirebaseUserLiveData : LiveData() { + private val firebaseAuth = FirebaseAuth.getInstance() + + // Set the value of this FireUserLiveData object by hooking it up to equal the value of the + // current FirebaseUser. You can utilize the FirebaseAuth.AuthStateListener callback to get + // updates on the current Firebase user logged into the app. + private val authStateListener = FirebaseAuth.AuthStateListener { firebaseAuth -> + // Use the FirebaseAuth instance instantiated at the beginning of the class to get an + // entry point into the Firebase Authentication SDK the app is using. + // With an instance of the FirebaseAuth class, you can now query for the current user. + value = firebaseAuth.currentUser + } + + // When this object has an active observer, start observing the FirebaseAuth state to see if + // there is currently a logged in user. + override fun onActive() { + firebaseAuth.addAuthStateListener(authStateListener) + } + + // When this object no longer has an active observer, stop observing the FirebaseAuth state to + // prevent memory leaks. + override fun onInactive() { + firebaseAuth.removeAuthStateListener(authStateListener) + } +} \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/authentication/LoginViewModel.kt b/starter/app/src/main/java/com/udacity/project4/authentication/LoginViewModel.kt new file mode 100644 index 000000000..b8a6a20ed --- /dev/null +++ b/starter/app/src/main/java/com/udacity/project4/authentication/LoginViewModel.kt @@ -0,0 +1,22 @@ +package com.udacity.project4.authentication + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.map + +class LoginViewModel : ViewModel() { + + enum class AuthenticationState { + AUTHENTICATED, UNAUTHENTICATED, INVALID_AUTHENTICATION + } + + // Create an authenticationState variable based off the FirebaseUserLiveData object. By + // creating this variable, other classes will be able to query for whether the user is logged + // in or not + val authenticationState = FirebaseUserLiveData().map { user -> + if (user != null) { + AuthenticationState.AUTHENTICATED + } else { + AuthenticationState.UNAUTHENTICATED + } + } +} \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/base/BaseFragment.kt b/starter/app/src/main/java/com/udacity/project4/base/BaseFragment.kt index 805ac3622..6b92451e3 100644 --- a/starter/app/src/main/java/com/udacity/project4/base/BaseFragment.kt +++ b/starter/app/src/main/java/com/udacity/project4/base/BaseFragment.kt @@ -10,6 +10,7 @@ import com.google.android.material.snackbar.Snackbar * Base Fragment to observe on the common LiveData objects */ abstract class BaseFragment : Fragment() { + /** * Every fragment has to have an instance of a view model that extends from the BaseViewModel */ @@ -24,10 +25,10 @@ abstract class BaseFragment : Fragment() { Toast.makeText(activity, it, Toast.LENGTH_LONG).show() }) _viewModel.showSnackBar.observe(this, Observer { - Snackbar.make(this.view!!, it, Snackbar.LENGTH_LONG).show() + Snackbar.make(this.requireView(), it, Snackbar.LENGTH_LONG).show() }) _viewModel.showSnackBarInt.observe(this, Observer { - Snackbar.make(this.view!!, getString(it), Snackbar.LENGTH_LONG).show() + Snackbar.make(this.requireView(), getString(it), Snackbar.LENGTH_LONG).show() }) _viewModel.navigationCommand.observe(this, Observer { command -> diff --git a/starter/app/src/main/java/com/udacity/project4/base/BaseRecyclerViewAdapter.kt b/starter/app/src/main/java/com/udacity/project4/base/BaseRecyclerViewAdapter.kt index ee2cbd3a8..0abc392fb 100644 --- a/starter/app/src/main/java/com/udacity/project4/base/BaseRecyclerViewAdapter.kt +++ b/starter/app/src/main/java/com/udacity/project4/base/BaseRecyclerViewAdapter.kt @@ -23,12 +23,9 @@ abstract class BaseRecyclerViewAdapter(private val callback: ((item: T) -> Un override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DataBindingViewHolder { val layoutInflater = LayoutInflater.from(parent.context) - val binding = DataBindingUtil .inflate(layoutInflater, getLayoutRes(viewType), parent, false) - binding.lifecycleOwner = getLifecycleOwner() - return DataBindingViewHolder(binding) } @@ -66,5 +63,4 @@ abstract class BaseRecyclerViewAdapter(private val callback: ((item: T) -> Un open fun getLifecycleOwner(): LifecycleOwner? { return null } -} - +} \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/base/BaseViewModel.kt b/starter/app/src/main/java/com/udacity/project4/base/BaseViewModel.kt index 5e109543a..97602b499 100644 --- a/starter/app/src/main/java/com/udacity/project4/base/BaseViewModel.kt +++ b/starter/app/src/main/java/com/udacity/project4/base/BaseViewModel.kt @@ -9,7 +9,6 @@ import com.udacity.project4.utils.SingleLiveEvent * Base class for View Models to declare the common LiveData objects in one place */ abstract class BaseViewModel(app: Application) : AndroidViewModel(app) { - val navigationCommand: SingleLiveEvent = SingleLiveEvent() val showErrorMessage: SingleLiveEvent = SingleLiveEvent() val showSnackBar: SingleLiveEvent = SingleLiveEvent() @@ -17,5 +16,4 @@ abstract class BaseViewModel(app: Application) : AndroidViewModel(app) { val showToast: SingleLiveEvent = SingleLiveEvent() val showLoading: SingleLiveEvent = SingleLiveEvent() val showNoData: MutableLiveData = MutableLiveData() - } \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/base/DataBindingViewHolder.kt b/starter/app/src/main/java/com/udacity/project4/base/DataBindingViewHolder.kt index ab63e4eb3..9b7681578 100644 --- a/starter/app/src/main/java/com/udacity/project4/base/DataBindingViewHolder.kt +++ b/starter/app/src/main/java/com/udacity/project4/base/DataBindingViewHolder.kt @@ -11,7 +11,7 @@ class DataBindingViewHolder(private val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(item: T) { - binding.setVariable(BR.item , item) + binding.setVariable(BR.item, item) binding.executePendingBindings() } } \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/locationreminders/ReminderDescriptionActivity.kt b/starter/app/src/main/java/com/udacity/project4/locationreminders/ReminderDescriptionActivity.kt index 6cf227bcf..bc6166443 100644 --- a/starter/app/src/main/java/com/udacity/project4/locationreminders/ReminderDescriptionActivity.kt +++ b/starter/app/src/main/java/com/udacity/project4/locationreminders/ReminderDescriptionActivity.kt @@ -3,35 +3,60 @@ package com.udacity.project4.locationreminders import android.content.Context import android.content.Intent import android.os.Bundle +import android.util.Log import androidx.appcompat.app.AppCompatActivity import androidx.databinding.DataBindingUtil import com.udacity.project4.R import com.udacity.project4.databinding.ActivityReminderDescriptionBinding import com.udacity.project4.locationreminders.reminderslist.ReminderDataItem +import com.udacity.project4.utils.TAG /** * Activity that displays the reminder details after the user clicks on the notification */ class ReminderDescriptionActivity : AppCompatActivity() { + private lateinit var binding: ActivityReminderDescriptionBinding + companion object { private const val EXTRA_ReminderDataItem = "EXTRA_ReminderDataItem" - // receive the reminder object after the user clicks on the notification + private var reminderTitle = "" + private var description = "" + private var location = "" + private var latitude = 0.0 + private var longitude = 0.0 + + // Receive the reminder object after the user clicks on the notification fun newIntent(context: Context, reminderDataItem: ReminderDataItem): Intent { + Log.d(TAG, "ReminderDescriptionActivity.newIntent() -> reminderDataItem: $reminderDataItem") val intent = Intent(context, ReminderDescriptionActivity::class.java) intent.putExtra(EXTRA_ReminderDataItem, reminderDataItem) + + reminderTitle = reminderDataItem.title.toString() + description = reminderDataItem.description.toString() + location = reminderDataItem.location.toString() + reminderDataItem.latitude?.let { + latitude = it + } + reminderDataItem.longitude?.let { + longitude = it + } return intent } } - private lateinit var binding: ActivityReminderDescriptionBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding = DataBindingUtil.setContentView( - this, - R.layout.activity_reminder_description - ) -// TODO: Add the implementation of the reminder details + Log.d(TAG, "ReminderDescriptionActivity.onCreate().") + + val layoutId = R.layout.activity_reminder_description + binding = DataBindingUtil.setContentView(this, layoutId) + + binding.title.text = reminderTitle + binding.description.text = description + binding.location.text = location + binding.latitude.text = latitude.toString() + binding.longitude.text = longitude.toString() } -} +} \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/locationreminders/RemindersActivity.kt b/starter/app/src/main/java/com/udacity/project4/locationreminders/RemindersActivity.kt index 271fed105..787f84b26 100644 --- a/starter/app/src/main/java/com/udacity/project4/locationreminders/RemindersActivity.kt +++ b/starter/app/src/main/java/com/udacity/project4/locationreminders/RemindersActivity.kt @@ -1,34 +1,31 @@ package com.udacity.project4.locationreminders -import android.Manifest -import android.content.pm.PackageManager import android.os.Bundle -import android.view.MenuItem import androidx.appcompat.app.AppCompatActivity -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat -import androidx.navigation.fragment.NavHostFragment -import com.udacity.project4.R -import kotlinx.android.synthetic.main.activity_reminders.* +import com.udacity.project4.databinding.ActivityRemindersBinding /** * The RemindersActivity that holds the reminders fragments */ class RemindersActivity : AppCompatActivity() { + private lateinit var binding: ActivityRemindersBinding + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_reminders) - + binding = ActivityRemindersBinding.inflate(layoutInflater) + setContentView(binding.root) } + /* override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { android.R.id.home -> { - (nav_host_fragment as NavHostFragment).navController.popBackStack() + (binding.navHostFragment as NavHostFragment).navController.popBackStack() return true } } return super.onOptionsItemSelected(item) } -} + */ +} \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/locationreminders/data/dto/Result.kt b/starter/app/src/main/java/com/udacity/project4/locationreminders/data/dto/Result.kt index 69099ec11..6c63d0094 100644 --- a/starter/app/src/main/java/com/udacity/project4/locationreminders/data/dto/Result.kt +++ b/starter/app/src/main/java/com/udacity/project4/locationreminders/data/dto/Result.kt @@ -1,6 +1,5 @@ package com.udacity.project4.locationreminders.data.dto - /** * A sealed class that encapsulates successful outcome with a value of type [T] * or a failure with message and statusCode diff --git a/starter/app/src/main/java/com/udacity/project4/locationreminders/data/local/LocalDB.kt b/starter/app/src/main/java/com/udacity/project4/locationreminders/data/local/LocalDB.kt index 82d2b3cb3..4a482c119 100644 --- a/starter/app/src/main/java/com/udacity/project4/locationreminders/data/local/LocalDB.kt +++ b/starter/app/src/main/java/com/udacity/project4/locationreminders/data/local/LocalDB.kt @@ -3,14 +3,13 @@ package com.udacity.project4.locationreminders.data.local import android.content.Context import androidx.room.Room - /** * Singleton class that is used to create a reminder db */ object LocalDB { /** - * static method that creates a reminder class and returns the DAO of the reminder + * Static method that creates a reminder class and returns the DAO of the reminder */ fun createRemindersDao(context: Context): RemindersDao { return Room.databaseBuilder( @@ -18,5 +17,4 @@ object LocalDB { RemindersDatabase::class.java, "locationReminders.db" ).build().reminderDao() } - } \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/locationreminders/data/local/RemindersDao.kt b/starter/app/src/main/java/com/udacity/project4/locationreminders/data/local/RemindersDao.kt index 837efecb0..f58990e0d 100644 --- a/starter/app/src/main/java/com/udacity/project4/locationreminders/data/local/RemindersDao.kt +++ b/starter/app/src/main/java/com/udacity/project4/locationreminders/data/local/RemindersDao.kt @@ -4,7 +4,6 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import androidx.room.Update import com.udacity.project4.locationreminders.data.dto.ReminderDTO /** @@ -12,6 +11,7 @@ import com.udacity.project4.locationreminders.data.dto.ReminderDTO */ @Dao interface RemindersDao { + /** * @return all reminders. */ @@ -27,7 +27,6 @@ interface RemindersDao { /** * Insert a reminder in the database. If the reminder already exists, replace it. - * * @param reminder the reminder to be inserted. */ @Insert(onConflict = OnConflictStrategy.REPLACE) @@ -38,5 +37,4 @@ interface RemindersDao { */ @Query("DELETE FROM reminders") suspend fun deleteAllReminders() - } \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/locationreminders/data/local/RemindersDatabase.kt b/starter/app/src/main/java/com/udacity/project4/locationreminders/data/local/RemindersDatabase.kt index ac77d06eb..99e360444 100644 --- a/starter/app/src/main/java/com/udacity/project4/locationreminders/data/local/RemindersDatabase.kt +++ b/starter/app/src/main/java/com/udacity/project4/locationreminders/data/local/RemindersDatabase.kt @@ -9,6 +9,5 @@ import com.udacity.project4.locationreminders.data.dto.ReminderDTO */ @Database(entities = [ReminderDTO::class], version = 1, exportSchema = false) abstract class RemindersDatabase : RoomDatabase() { - abstract fun reminderDao(): RemindersDao } \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/locationreminders/data/local/RemindersLocalRepository.kt b/starter/app/src/main/java/com/udacity/project4/locationreminders/data/local/RemindersLocalRepository.kt index 4ff8d984f..a2d16ec6b 100644 --- a/starter/app/src/main/java/com/udacity/project4/locationreminders/data/local/RemindersLocalRepository.kt +++ b/starter/app/src/main/java/com/udacity/project4/locationreminders/data/local/RemindersLocalRepository.kt @@ -3,11 +3,13 @@ package com.udacity.project4.locationreminders.data.local import com.udacity.project4.locationreminders.data.ReminderDataSource import com.udacity.project4.locationreminders.data.dto.ReminderDTO import com.udacity.project4.locationreminders.data.dto.Result -import kotlinx.coroutines.* +import com.udacity.project4.utils.wrapEspressoIdlingResource +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** * Concrete implementation of a data source as a db. - * * The repository is implemented so that you can focus on only testing it. * * @param remindersDao the dao that does the Room db operations @@ -22,11 +24,13 @@ class RemindersLocalRepository( * Get the reminders list from the local db * @return Result the holds a Success with all the reminders or an Error object with the error message */ - override suspend fun getReminders(): Result> = withContext(ioDispatcher) { - return@withContext try { - Result.Success(remindersDao.getReminders()) - } catch (ex: Exception) { - Result.Error(ex.localizedMessage) + override suspend fun getReminders(): Result> = wrapEspressoIdlingResource { + withContext(ioDispatcher) { + return@withContext try { + Result.Success(remindersDao.getReminders()) + } catch (ex: Exception) { + Result.Error(ex.localizedMessage) + } } } @@ -34,26 +38,29 @@ class RemindersLocalRepository( * Insert a reminder in the db. * @param reminder the reminder to be inserted */ - override suspend fun saveReminder(reminder: ReminderDTO) = + override suspend fun saveReminder(reminder: ReminderDTO) = wrapEspressoIdlingResource { withContext(ioDispatcher) { remindersDao.saveReminder(reminder) } + } /** * Get a reminder by its id * @param id to be used to get the reminder * @return Result the holds a Success object with the Reminder or an Error object with the error message */ - override suspend fun getReminder(id: String): Result = withContext(ioDispatcher) { - try { - val reminder = remindersDao.getReminderById(id) - if (reminder != null) { - return@withContext Result.Success(reminder) - } else { - return@withContext Result.Error("Reminder not found!") + override suspend fun getReminder(id: String): Result = wrapEspressoIdlingResource { + withContext(ioDispatcher) { + try { + val reminder = remindersDao.getReminderById(id) + if (reminder != null) { + return@withContext Result.Success(reminder) + } else { + return@withContext Result.Error("Reminder not found!") + } + } catch (e: Exception) { + return@withContext Result.Error(e.localizedMessage) } - } catch (e: Exception) { - return@withContext Result.Error(e.localizedMessage) } } @@ -61,8 +68,10 @@ class RemindersLocalRepository( * Deletes all the reminders in the db */ override suspend fun deleteAllReminders() { - withContext(ioDispatcher) { - remindersDao.deleteAllReminders() + wrapEspressoIdlingResource { + withContext(ioDispatcher) { + remindersDao.deleteAllReminders() + } } } } diff --git a/starter/app/src/main/java/com/udacity/project4/locationreminders/geofence/GeofenceBroadcastReceiver.kt b/starter/app/src/main/java/com/udacity/project4/locationreminders/geofence/GeofenceBroadcastReceiver.kt index 83eb044b4..bcc133995 100644 --- a/starter/app/src/main/java/com/udacity/project4/locationreminders/geofence/GeofenceBroadcastReceiver.kt +++ b/starter/app/src/main/java/com/udacity/project4/locationreminders/geofence/GeofenceBroadcastReceiver.kt @@ -3,21 +3,33 @@ package com.udacity.project4.locationreminders.geofence import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.util.Log +import androidx.work.ExistingWorkPolicy +import androidx.work.WorkManager +import com.google.gson.Gson +import com.udacity.project4.MyApp +import com.udacity.project4.utils.TAG /** - * Triggered by the Geofence. Since we can have many Geofences at once, we pull the request - * ID from the first Geofence, and locate it within the cached data in our Room DB + * Triggered by the Geofence. Since we can have many GeoFences at once, we pull the request + * ID from the first Geofence, and locate it within the cached data in our Room DB. * - * Or users can add the reminders and then close the app, So our app has to run in the background + * Or users can add the reminders and then close the app, so our app has to run in the background * and handle the geofencing in the background. - * To do that you can use https://developer.android.com/reference/android/support/v4/app/JobIntentService to do that. - * + * To do that you can use: + * https://developer.android.com/reference/android/support/v4/app/JobIntentService to do that. */ - class GeofenceBroadcastReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - -//TODO: implement the onReceive method to receive the geofencing events at the background - - } + override fun onReceive(context: Context, intent: Intent) { + Log.d(TAG, "GeofenceBroadcastReceiver.onReceive().") + (context.applicationContext as MyApp).broadcastIntent = intent + val workManager = WorkManager.getInstance(context) + val intentToBundle = Gson().toJson(intent) + val workRequest = GeofenceTransitionsWorkManager.buildWorkRequest(intentToBundle) + workManager.enqueueUniqueWork( + UNIQUE_WORK_NAME, + ExistingWorkPolicy.KEEP, + workRequest + ) + } } \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/locationreminders/geofence/GeofenceTransitionsJobIntentService.kt b/starter/app/src/main/java/com/udacity/project4/locationreminders/geofence/GeofenceTransitionsJobIntentService.kt deleted file mode 100644 index 5932cd3ca..000000000 --- a/starter/app/src/main/java/com/udacity/project4/locationreminders/geofence/GeofenceTransitionsJobIntentService.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.udacity.project4.locationreminders.geofence - -import android.content.Context -import android.content.Intent -import androidx.core.app.JobIntentService -import com.google.android.gms.location.Geofence -import com.udacity.project4.locationreminders.data.dto.ReminderDTO -import com.udacity.project4.locationreminders.data.dto.Result -import com.udacity.project4.locationreminders.data.local.RemindersLocalRepository -import com.udacity.project4.locationreminders.reminderslist.ReminderDataItem -import com.udacity.project4.utils.sendNotification -import kotlinx.coroutines.* -import org.koin.android.ext.android.inject -import kotlin.coroutines.CoroutineContext - -class GeofenceTransitionsJobIntentService : JobIntentService(), CoroutineScope { - - private var coroutineJob: Job = Job() - override val coroutineContext: CoroutineContext - get() = Dispatchers.IO + coroutineJob - - companion object { - private const val JOB_ID = 573 - - // TODO: call this to start the JobIntentService to handle the geofencing transition events - fun enqueueWork(context: Context, intent: Intent) { - enqueueWork( - context, - GeofenceTransitionsJobIntentService::class.java, JOB_ID, - intent - ) - } - } - - override fun onHandleWork(intent: Intent) { - //TODO: handle the geofencing transition events and - // send a notification to the user when he enters the geofence area - //TODO call @sendNotification - } - - //TODO: get the request id of the current geofence - private fun sendNotification(triggeringGeofences: List) { - val requestId = "" - - //Get the local repository instance - val remindersLocalRepository: ReminderDataSource by inject() -// Interaction to the repository has to be through a coroutine scope - CoroutineScope(coroutineContext).launch(SupervisorJob()) { - //get the reminder with the request id - val result = remindersLocalRepository.getReminder(requestId) - if (result is Result.Success) { - val reminderDTO = result.data - //send a notification to the user with the reminder details - sendNotification( - this@GeofenceTransitionsJobIntentService, ReminderDataItem( - reminderDTO.title, - reminderDTO.description, - reminderDTO.location, - reminderDTO.latitude, - reminderDTO.longitude, - reminderDTO.id - ) - ) - } - } - } - -} diff --git a/starter/app/src/main/java/com/udacity/project4/locationreminders/geofence/GeofenceTransitionsWorkManager.kt b/starter/app/src/main/java/com/udacity/project4/locationreminders/geofence/GeofenceTransitionsWorkManager.kt new file mode 100644 index 000000000..10be19a09 --- /dev/null +++ b/starter/app/src/main/java/com/udacity/project4/locationreminders/geofence/GeofenceTransitionsWorkManager.kt @@ -0,0 +1,119 @@ +package com.udacity.project4.locationreminders.geofence + +import android.Manifest +import android.content.Context +import android.os.Build +import android.util.Log +import android.widget.Toast +import androidx.work.OneTimeWorkRequest +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.google.android.gms.location.Geofence +import com.google.android.gms.location.GeofencingEvent +import com.udacity.project4.MyApp +import com.udacity.project4.R +import com.udacity.project4.locationreminders.reminderslist.ReminderDataItem +import com.udacity.project4.utils.TAG +import com.udacity.project4.utils.errorMessage +import com.udacity.project4.utils.permissionGranted +import com.udacity.project4.utils.sendNotification + +/** + * - Source: + * https://www.kodeco.com/7372-geofencing-api-tutorial-for-android + * - JobIntentService is deprecated: + * https://medium.com/tech-takeaways/how-to-migrate-the-deprecated-jobintentservice-a0071a7957ed + */ +const val UNIQUE_WORK_NAME = "GeofenceTransitionsWorkManager" + +class GeofenceTransitionsWorkManager( + val context: Context, workerParameters: WorkerParameters +) : Worker(context, workerParameters) { + + companion object { + private const val INTENT_PARAM = "INTENT_PARAM" + + fun buildWorkRequest(parameter: String): OneTimeWorkRequest { +// val data = Data.Builder().putString(INTENT_PARAM, parameter).build() + return OneTimeWorkRequestBuilder().apply { +// setInputData(data) + }.build() + } + } + + override fun doWork(): Result { +// val parameter: String? = inputData.getString(INTENT_PARAM) + // Handle your work. + val intent = (context.applicationContext as MyApp).broadcastIntent + Log.d(TAG, "GeofenceTransitionsWorkManager.doWork() -> [1]") + val geofencingEvent = GeofencingEvent.fromIntent(intent) + if (geofencingEvent != null) { + Log.d(TAG, "GeofenceTransitionsWorkManager.doWork() -> [2]") + if (geofencingEvent.hasError()) { + Log.d(TAG, "GeofenceTransitionsWorkManager.doWork() -> [3]") + val errorMessage = errorMessage(context, geofencingEvent.errorCode) + Log.e(TAG, "GeofenceTransitionsWorkManager.doWork() -> $errorMessage") + return Result.failure() + } else { + Log.d(TAG, "GeofenceTransitionsWorkManager.doWork() -> [4]") + } + handleEvent(geofencingEvent) + } else { + Log.d(TAG, "GeofenceTransitionsWorkManager.doWork() -> [5]") + } + return Result.success() + } + + /** + * Source: + * https://developer.android.com/training/location/geofencing#HandleGeofenceTransitions + */ + private fun handleEvent(event: GeofencingEvent) { + Log.d(TAG, "GeofenceTransitionsWorkManager.handleEvent().") + val geofenceTransition = event.geofenceTransition +// if (geofenceTransition == Geofence.GEOFENCE_TRANSITION_ENTER) { + if (geofenceTransition == Geofence.GEOFENCE_TRANSITION_ENTER || + geofenceTransition == Geofence.GEOFENCE_TRANSITION_EXIT) { + Log.d(TAG, "GeofenceTransitionsWorkManager.handleEvent() -> " + + "event.geofenceTransition == Geofence.GEOFENCE_TRANSITION_ENTER") + event.triggeringGeofences?.let { list -> + for (item in list) { + val geofenceTransitionDetails = getGeofenceTransitionDetails(item) + checkNotification(context, geofenceTransitionDetails) + } + } + } else { + Log.d(TAG, "GeofenceTransitionsWorkManager.handleEvent() -> [2]") + } + } + + private fun getGeofenceTransitionDetails(currentGeoFence: Geofence): ReminderDataItem { + val id = currentGeoFence.requestId + val lat = currentGeoFence.latitude + val lng = currentGeoFence.longitude + return ReminderDataItem( + title = id, + description = "", + location = id, + latitude = lat, + longitude = lng + ) + } + + private fun checkNotification(context: Context, reminderDataItem: ReminderDataItem) { + val notificationPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.permissionGranted(Manifest.permission.POST_NOTIFICATIONS) + } else { + true + } + Log.d(TAG, "GeofenceTransitionsWorkManager.callNotification() -> " + + "notificationPermission: $notificationPermission") + if (notificationPermission) { + sendNotification(context, reminderDataItem) + } else { + val message = context.getString(R.string.no_notification_permission) + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } + } +} \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/locationreminders/reminderslist/ReminderListFragment.kt b/starter/app/src/main/java/com/udacity/project4/locationreminders/reminderslist/ReminderListFragment.kt index befe344ce..5d0729c7d 100644 --- a/starter/app/src/main/java/com/udacity/project4/locationreminders/reminderslist/ReminderListFragment.kt +++ b/starter/app/src/main/java/com/udacity/project4/locationreminders/reminderslist/ReminderListFragment.kt @@ -1,43 +1,81 @@ package com.udacity.project4.locationreminders.reminderslist +import android.Manifest +import android.os.Build import android.os.Bundle -import android.view.* +import android.util.Log +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.MenuHost +import androidx.core.view.MenuProvider import androidx.databinding.DataBindingUtil +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import com.firebase.ui.auth.AuthUI +import com.udacity.project4.MyApp import com.udacity.project4.R import com.udacity.project4.base.BaseFragment import com.udacity.project4.base.NavigationCommand import com.udacity.project4.databinding.FragmentRemindersBinding +import com.udacity.project4.utils.TAG +import com.udacity.project4.utils.permissionGranted import com.udacity.project4.utils.setDisplayHomeAsUpEnabled import com.udacity.project4.utils.setTitle import com.udacity.project4.utils.setup +import com.udacity.project4.utils.toast import org.koin.androidx.viewmodel.ext.android.viewModel class ReminderListFragment : BaseFragment() { - //use Koin to retrieve the ViewModel instance + + companion object { + private const val POST = Manifest.permission.POST_NOTIFICATIONS + } + + // Use Koin to retrieve the ViewModel instance. override val _viewModel: RemindersListViewModel by viewModel() private lateinit var binding: FragmentRemindersBinding + private var logoutClicked = false + private lateinit var parent: FragmentActivity + + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted: Boolean -> + Log.d(TAG, "ReminderListFragment.requestPermissionLauncher -> isGranted: $isGranted") + (parent.applicationContext as MyApp).hasNotificationPermission = isGranted + if (isGranted) { + Log.d(TAG, "ReminderListFragment.requestPermissionLauncher -> Notification permission is granted.") + parent.toast(R.string.notification_permission_granted) + } else { + Log.d(TAG, "ReminderListFragment.requestPermissionLauncher -> Notification permission is NOT granted.") + parent.toast(R.string.no_notification_permission) + } + } + + //-------------------------------------------------- + // Lifecycle Methods + //-------------------------------------------------- + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - binding = - DataBindingUtil.inflate( - inflater, - R.layout.fragment_reminders, container, false - ) + ): View { + val layoutId = R.layout.fragment_reminders + binding = DataBindingUtil.inflate(inflater, layoutId, container, false) binding.viewModel = _viewModel - - setHasOptionsMenu(true) - setDisplayHomeAsUpEnabled(false) - setTitle(getString(R.string.app_name)) - - binding.refreshLayout.setOnRefreshListener { _viewModel.loadReminders() } + init() return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + Log.d(TAG, "ReminderListFragment.onViewCreated().") binding.lifecycleOwner = this setupRecyclerView() binding.addReminderFAB.setOnClickListener { @@ -47,41 +85,105 @@ class ReminderListFragment : BaseFragment() { override fun onResume() { super.onResume() - //load the reminders list on the ui + Log.d(TAG, "ReminderListFragment.onResume().") + + logoutClicked = false + // Load the reminders list on the ui _viewModel.loadReminders() } + //-------------------------------------------------- + // Main Methods + //-------------------------------------------------- + + private fun init() { + Log.d(TAG, "ReminderListFragment.init().") + parent = requireActivity() + parent.onBackPressedDispatcher.addCallback(viewLifecycleOwner, backPressHandler) + + val menuHost: MenuHost = parent + addMenu(menuHost) + + setDisplayHomeAsUpEnabled(false) + setTitle(getString(R.string.app_name)) + + binding.refreshLayout.setOnRefreshListener { _viewModel.loadReminders() } + + requestPermissionListener() + } + private fun navigateToAddReminder() { - //use the navigationCommand live data to navigate between the fragments + Log.d(TAG, "ReminderListFragment.navigateToAddReminder().") + // Use the navigationCommand live data to navigate between the fragments _viewModel.navigationCommand.postValue( NavigationCommand.To( - ReminderListFragmentDirections.toSaveReminder() + ReminderListFragmentDirections.actionReminderListFragmentToSaveReminderFragment() ) ) } private fun setupRecyclerView() { - val adapter = RemindersListAdapter { - } - -// setup the recycler view using the extension function + Log.d(TAG, "ReminderListFragment.setupRecyclerView().") + val adapter = RemindersListAdapter {} + // Setup the recycler view using the extension function binding.reminderssRecyclerView.setup(adapter) } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.logout -> { -// TODO: add the logout implementation + private fun requestPermissionListener() { + Log.d(TAG, "ReminderListFragment.requestPermissionListener().") + when { + parent.permissionGranted(POST) -> { + // You can use the API that requires the permission. + Log.d(TAG, "ReminderListFragment.requestPermissionListener() -> Permission granted!") + } + else -> { + // The registered ActivityResultCallback gets the result of this request. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requestPermissionLauncher.launch(POST) + } } } - return super.onOptionsItemSelected(item) - } - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - super.onCreateOptionsMenu(menu, inflater) -// display logout as menu item - inflater.inflate(R.menu.main_menu, menu) + //-------------------------------------------------- + // Menu Methods + //-------------------------------------------------- + + /** + * Source: + * https://java73.medium.com/back-press-handling-with-androidx-navigation-component-60bbc0fd169 + */ + private val backPressHandler = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (logoutClicked) { + AuthUI.getInstance().signOut(requireContext()) + } + activity?.finishAndRemoveTask() + } } -} + private fun addMenu(menuHost: MenuHost) { + Log.d(TAG, "ReminderListFragment.addMenu().") + // Add menu items without using the Fragment Menu APIs + // Note how we can tie the MenuProvider to the viewLifecycleOwner + // and an optional Lifecycle.State (here, RESUMED) to indicate when + // the menu should be visible + menuHost.addMenuProvider(object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.main_menu, menu) + } + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + when (menuItem.itemId) { + R.id.logout -> { + logoutClicked = true + parent.onBackPressedDispatcher.onBackPressed() + } + android.R.id.home -> { + parent.onBackPressedDispatcher.onBackPressed() + } + } + return true + } + }, viewLifecycleOwner, Lifecycle.State.RESUMED) + } +} \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/locationreminders/reminderslist/RemindersListAdapter.kt b/starter/app/src/main/java/com/udacity/project4/locationreminders/reminderslist/RemindersListAdapter.kt index 4cb48f178..846fa82e0 100644 --- a/starter/app/src/main/java/com/udacity/project4/locationreminders/reminderslist/RemindersListAdapter.kt +++ b/starter/app/src/main/java/com/udacity/project4/locationreminders/reminderslist/RemindersListAdapter.kt @@ -3,8 +3,7 @@ package com.udacity.project4.locationreminders.reminderslist import com.udacity.project4.R import com.udacity.project4.base.BaseRecyclerViewAdapter - -//Use data binding to show the reminder on the item +// Use data binding to show the reminder on the item class RemindersListAdapter(callBack: (selectedReminder: ReminderDataItem) -> Unit) : BaseRecyclerViewAdapter(callBack) { override fun getLayoutRes(viewType: Int) = R.layout.it_reminder diff --git a/starter/app/src/main/java/com/udacity/project4/locationreminders/reminderslist/RemindersListViewModel.kt b/starter/app/src/main/java/com/udacity/project4/locationreminders/reminderslist/RemindersListViewModel.kt index f76bbd7b7..1c1f538ed 100644 --- a/starter/app/src/main/java/com/udacity/project4/locationreminders/reminderslist/RemindersListViewModel.kt +++ b/starter/app/src/main/java/com/udacity/project4/locationreminders/reminderslist/RemindersListViewModel.kt @@ -1,19 +1,21 @@ package com.udacity.project4.locationreminders.reminderslist import android.app.Application +import android.util.Log import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.udacity.project4.base.BaseViewModel import com.udacity.project4.locationreminders.data.ReminderDataSource import com.udacity.project4.locationreminders.data.dto.ReminderDTO import com.udacity.project4.locationreminders.data.dto.Result +import com.udacity.project4.utils.TAG import kotlinx.coroutines.launch class RemindersListViewModel( app: Application, private val dataSource: ReminderDataSource ) : BaseViewModel(app) { - // list that holds the reminder data to be displayed on the UI + // List that holds the reminder data to be displayed on the UI. val remindersList = MutableLiveData>() /** @@ -21,16 +23,20 @@ class RemindersListViewModel( * or show error if any */ fun loadReminders() { + Log.d(TAG, "RemindersListViewModel.loadReminders().") showLoading.value = true viewModelScope.launch { - //interacting with the dataSource has to be through a coroutine + // Interacting with the dataSource has to be through a coroutine. val result = dataSource.getReminders() showLoading.postValue(false) when (result) { is Result.Success<*> -> { + Log.d(TAG, "RemindersListViewModel.loadReminders() -> Result.Success") val dataList = ArrayList() dataList.addAll((result.data as List).map { reminder -> - //map the reminder data from the DB to the be ready to be displayed on the UI + // Map the reminder data from the DB to the be ready to be displayed on the UI. + Log.d(TAG, "RemindersListViewModel.loadReminders() -> reminder: $reminder") + ReminderDataItem( reminder.title, reminder.description, @@ -42,19 +48,22 @@ class RemindersListViewModel( }) remindersList.value = dataList } - is Result.Error -> + is Result.Error -> { + Log.d(TAG, "RemindersListViewModel.loadReminders() -> Result.Error") showSnackBar.value = result.message + } } - //check if no data has to be shown + // Check if no data has to be shown. invalidateShowNoData() } } /** - * Inform the user that there's not any data if the remindersList is empty + * Inform the user that there's not any data if the remindersList is empty. */ private fun invalidateShowNoData() { + Log.d(TAG, "RemindersListViewModel.invalidateShowNoData().") showNoData.value = remindersList.value == null || remindersList.value!!.isEmpty() } } \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/locationreminders/savereminder/SaveReminderFragment.kt b/starter/app/src/main/java/com/udacity/project4/locationreminders/savereminder/SaveReminderFragment.kt index 04e7f182d..3bebd887d 100644 --- a/starter/app/src/main/java/com/udacity/project4/locationreminders/savereminder/SaveReminderFragment.kt +++ b/starter/app/src/main/java/com/udacity/project4/locationreminders/savereminder/SaveReminderFragment.kt @@ -1,61 +1,449 @@ package com.udacity.project4.locationreminders.savereminder +import android.Manifest +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Intent +import android.os.Build import android.os.Bundle +import android.provider.Settings +import android.util.Log import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import androidx.core.view.MenuHost +import androidx.core.view.MenuProvider import androidx.databinding.DataBindingUtil +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import com.google.android.gms.location.Geofence +import com.google.android.gms.location.Geofence.NEVER_EXPIRE +import com.google.android.gms.location.GeofencingClient +import com.google.android.gms.location.GeofencingRequest +import com.google.android.gms.location.LocationServices +import com.google.android.gms.maps.model.LatLng +import com.udacity.project4.MyApp import com.udacity.project4.R import com.udacity.project4.base.BaseFragment import com.udacity.project4.base.NavigationCommand import com.udacity.project4.databinding.FragmentSaveReminderBinding +import com.udacity.project4.locationreminders.geofence.GeofenceBroadcastReceiver +import com.udacity.project4.locationreminders.reminderslist.ReminderDataItem +import com.udacity.project4.locationreminders.savereminder.selectreminderlocation.SelectLocationFragment.Companion.ARGUMENTS +import com.udacity.project4.utils.GeofencingConstants.GEOFENCE_RADIUS_IN_METERS +import com.udacity.project4.utils.LandmarkDataObject +import com.udacity.project4.utils.TAG +import com.udacity.project4.utils.errorMessage +import com.udacity.project4.utils.getNavigationResult +import com.udacity.project4.utils.isGpsEnabled +import com.udacity.project4.utils.permissionDeniedFeedback +import com.udacity.project4.utils.permissionGranted import com.udacity.project4.utils.setDisplayHomeAsUpEnabled +import com.udacity.project4.utils.toast import org.koin.android.ext.android.inject class SaveReminderFragment : BaseFragment() { - //Get the view model this time as a single to be shared with the another fragment + + //-------------------------------------------------- + // Attributes + //-------------------------------------------------- + + companion object { + internal const val ACTION_GEOFENCE_EVENT = "SaveReminderFragment.ACTION_GEOFENCE_EVENT" + const val GEO_FENCE_ID = "id" + const val GEO_FENCE_LAT = "lat" + const val GEO_FENCE_LNG = "lng" + } + + // Get the view model this time as a single to be shared with the another fragment override val _viewModel: SaveReminderViewModel by inject() + private lateinit var binding: FragmentSaveReminderBinding + private lateinit var parent: FragmentActivity + private var alertShouldEnableGps: AlertDialog? = null - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - binding = - DataBindingUtil.inflate(inflater, R.layout.fragment_save_reminder, container, false) + private lateinit var geofencingClient: GeofencingClient + private lateinit var geofenceData: LandmarkDataObject - setDisplayHomeAsUpEnabled(true) + private var currentGeoFenceLocation = "" + private var currentGeoFenceLatitude = 0.0 + private var currentGeoFenceLongitude = 0.0 + private lateinit var dataItem: ReminderDataItem + + // A PendingIntent for the Broadcast Receiver that handles geofence transitions. + private val geofencePendingIntent: PendingIntent by lazy { + val intent = Intent(requireContext(), GeofenceBroadcastReceiver::class.java) + intent.action = ACTION_GEOFENCE_EVENT + intent.putExtra(GEO_FENCE_ID, geofenceData.id) + intent.putExtra(GEO_FENCE_LAT, geofenceData.latLong.latitude) + intent.putExtra(GEO_FENCE_LNG, geofenceData.latLong.longitude) + // Use FLAG_UPDATE_CURRENT so that you get the same pending intent back when calling + // addGeoFences() and removeGeoFences(). + var intentFlagTypeUpdateCurrent = PendingIntent.FLAG_UPDATE_CURRENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // Source: https://stackoverflow.com/a/74174664/1354788 + intentFlagTypeUpdateCurrent = PendingIntent.FLAG_MUTABLE + } + PendingIntent.getBroadcast(requireContext(), 0, intent, intentFlagTypeUpdateCurrent) + } + + //-------------------------------------------------- + // Activity Result Callbacks + //-------------------------------------------------- + + // + private val finePermissionRequest = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isEnabled -> + Log.d(TAG, "SaveReminderFragment.finePermissionRequest -> isEnabled: $isEnabled") + if (isEnabled) { + checkGpsEnabled() + } + } + // + + /* + private val finePermissionRequest = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + Log.d(TAG, "SaveReminderFragment.locationPermissionRequest -> permissions: $permissions") + val finePermission = permissions.getOrDefault(Manifest.permission.ACCESS_FINE_LOCATION, false) + val coarsePermission = permissions.getOrDefault(Manifest.permission.ACCESS_COARSE_LOCATION, false) + if (finePermission && coarsePermission) { + Log.d(TAG, "SaveReminderFragment.finePermissionRequest -> Permissions granted.") + checkGpsEnabled() + } else { + // No location access granted. + Log.d(TAG, "SaveReminderFragment.finePermissionRequest -> No location access granted.") + } + } + */ + private val locationPermissionRequest = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + Log.d(TAG, "SaveReminderFragment.locationPermissionRequest -> permissions: $permissions") + val finePermission = permissions.getOrDefault(Manifest.permission.ACCESS_FINE_LOCATION, false) + val backgroundPermission = hasBackgroundPermission() + if (finePermission && backgroundPermission) { + Log.d(TAG, "SaveReminderFragment.locationPermissionRequest -> Permissions granted.") + checkGpsEnabled() + } else { + // No location access granted. + Log.d(TAG, "SaveReminderFragment.locationPermissionRequest -> No location access granted.") + } + } + + /** + * Source: + * https://www.tothenew.com/blog/android-katha-onactivityresult-is-deprecated-now-what/ + */ + private var activityResultLauncher = registerForActivityResult(ActivityResultContracts + .StartActivityForResult()) { + Log.d(TAG, "SaveReminderFragment.activityResultLauncher.") + if (parent.isGpsEnabled()) { + Log.d(TAG, "SaveReminderFragment.activityResultLauncher -> GPS enabled.") + addGeoFence() + } else { + Log.d(TAG, "SaveReminderFragment.activityResultLauncher -> GPS NOT enabled.") + parent.permissionDeniedFeedback() + } + } + + //-------------------------------------------------- + // Lifecycle Methods + //-------------------------------------------------- + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + Log.d(TAG, "SaveReminderFragment.onCreateView().") + + val layoutId = R.layout.fragment_save_reminder + binding = DataBindingUtil.inflate(inflater, layoutId, container, false) binding.viewModel = _viewModel + init() return binding.root } + private fun init() { + Log.d(TAG, "SaveReminderFragment.init().") + setDisplayHomeAsUpEnabled(true) + parent = requireActivity() + parent.onBackPressedDispatcher.addCallback(viewLifecycleOwner, backPressHandler) + + val menuHost: MenuHost = parent + addMenu(menuHost) + + geofencingClient = LocationServices.getGeofencingClient(parent) + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + Log.d(TAG, "SaveReminderFragment.onViewCreated().") + binding.lifecycleOwner = this binding.selectLocation.setOnClickListener { - // Navigate to another fragment to get the user location - _viewModel.navigationCommand.value = - NavigationCommand.To(SaveReminderFragmentDirections.actionSaveReminderFragmentToSelectLocationFragment()) + Log.d(TAG, "SaveReminderFragment.onViewCreated() -> binding.selectLocation.setOnClickListener") + // Navigate to another fragment to get the user location + val directions = SaveReminderFragmentDirections + .actionSaveReminderFragmentToSelectLocationFragment() + _viewModel.navigationCommand.value = NavigationCommand.To(directions) } + // Use the user entered reminder details to: + // 1) Check GeoFence permissions. + // 2) Add a geofencing request. + // 3) Save the reminder to the local db. binding.saveReminder.setOnClickListener { - val title = _viewModel.reminderTitle.value - val description = _viewModel.reminderDescription - val location = _viewModel.reminderSelectedLocationStr.value - val latitude = _viewModel.latitude - val longitude = _viewModel.longitude.value + Log.d(TAG, "SaveReminderFragment.onViewCreated() -> binding.saveReminder.setOnClickListener.") + val selectedPoi = _viewModel.selectedPOI.value + val location = selectedPoi?.name + val lat = selectedPoi?.latLng?.latitude + val lng = selectedPoi?.latLng?.longitude -// TODO: use the user entered reminder details to: -// 1) add a geofencing request -// 2) save the reminder to the local db + if (location != null) { + currentGeoFenceLocation = location + } + if (lat != null) { + currentGeoFenceLatitude = lat + } + if (lng != null) { + currentGeoFenceLongitude = lng + } + saveReminderOnDatabase() } } override fun onDestroy() { super.onDestroy() - //make sure to clear the view model after destroy, as it's a single view model. + Log.d(TAG, "SaveReminderFragment.onDestroy().") + // Make sure to clear the view model after destroy, as it's a single view model. _viewModel.onClear() + disableDialogs() + } + + private fun disableDialogs() { + Log.d(TAG, "SaveReminderFragment.disableDialogs().") + alertShouldEnableGps?.let { + if (it.isShowing) { + it.cancel() + } + } + } + + //-------------------------------------------------- + // Database Methods + //-------------------------------------------------- + + private fun saveReminderOnDatabase() { + val app = requireActivity().application as MyApp + Log.d(TAG, "SaveReminderFragment.saveReminderOnDatabase().") + val title = _viewModel.reminderTitle.value + val description = _viewModel.reminderDescription.value + dataItem = ReminderDataItem( + title = title, + description = description, + location = currentGeoFenceLocation, + latitude = currentGeoFenceLatitude, + longitude = currentGeoFenceLongitude + ) + + if (app.testingMode) { + _viewModel.validateAndSaveReminder(dataItem) + } else { + val paramsValid = _viewModel.validateEnteredData(dataItem) +// val paramsValid = titleValid && +// currentGeoFenceLocation.isNotEmpty() && +// currentGeoFenceLatitude != 0.0 && +// currentGeoFenceLongitude != 0.0 + if (paramsValid) { + checkGeoFencePermissions() + } else { + Log.d(TAG, "SaveReminderFragment.saveReminderOnDatabase() -> Couldn't create a Geofence.") + } + } + } + + //-------------------------------------------------- + // GeoFence Methods + //-------------------------------------------------- + + /** + * When we want to add a Geofence, the flow should be as follows: + * + * 1.First check if both the required permissions (foreground and background) have been granted. + * If there is any ungranted permission, request it properly. + * + * 2.If all the required permissions have been granted, then we should proceed to check if the + * device location is on. If the device location is not on, show the location settings dialog + * and ask the user to enable it. + * + * 3. We should automatically attempt to add a Geofence ONLY IF we are certain that the required + * permissions have been granted and the device location is on. + */ + private fun checkGeoFencePermissions() { + Log.d(TAG, "SaveReminderFragment.checkGeoFencePermissions().") + val backgroundPermission = hasBackgroundPermission() + val finePermission = parent.permissionGranted(Manifest.permission.ACCESS_FINE_LOCATION) + // Source: https://developer.android.com/training/location/geofencing#RequestGeofences + val geofencePermissions = backgroundPermission && finePermission + if (geofencePermissions) { + Log.d(TAG, "SaveReminderFragment.checkGeoFencePermissions() -> GeoFence permissions enabled.") + checkGpsEnabled() + } else { + Log.d(TAG, "SaveReminderFragment.checkGeoFencePermissions() -> GeoFence permissions NOT enabled.") + parent.toast(R.string.location_permission_needed) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + Log.d(TAG, "SaveReminderFragment.checkGeoFencePermissions() -> " + + "Asking for 'Manifest.permission.ACCESS_BACKGROUND_LOCATION' and " + + "'Manifest.permission.ACCESS_FINE_LOCATION' permissions.") + locationPermissionRequest.launch(arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_BACKGROUND_LOCATION) + ) + } else { + Log.d(TAG, "SaveReminderFragment.checkGeoFencePermissions() -> " + + "Asking for 'Manifest.permission.ACCESS_FINE_LOCATION' permission.") + finePermissionRequest.launch(Manifest.permission.ACCESS_FINE_LOCATION) + } + } + } + + private fun hasBackgroundPermission(): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + parent.permissionGranted(Manifest.permission.ACCESS_BACKGROUND_LOCATION) + } else { + true + } + + private fun checkGpsEnabled() { + Log.d(TAG, "SaveReminderFragment.checkGpsEnabled().") + if (!parent.isGpsEnabled()) { + Log.d(TAG, "SaveReminderFragment.checkGpsEnabled() -> GPS is NOT enabled.") + buildAlertMessageNoGps() + } else { + Log.d(TAG, "SaveReminderFragment.checkGpsEnabled() -> GPS is enabled.") + addGeoFence() + } + } + + /** + * Source: + * https://stackoverflow.com/a/25175756/1354788 + */ + private fun buildAlertMessageNoGps() { + Log.d(TAG, "SaveReminderFragment.buildAlertMessageNoGps().") + val builder = AlertDialog.Builder(parent) + builder.setMessage(R.string.should_enable_gps) + .setCancelable(false) + .setPositiveButton("Yes") { _, _ -> + activityResultLauncher.launch(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) + } + .setNegativeButton("No") { dialog, _ -> + dialog.cancel() + parent.permissionDeniedFeedback() + } + alertShouldEnableGps = builder.create() + alertShouldEnableGps?.show() + } + + private fun addGeoFence() { + Log.d(TAG, "SaveReminderFragment.addGeoFence().") + // Build the Geofence Object. + val currentGeofenceData = LandmarkDataObject(currentGeoFenceLocation, + LatLng(currentGeoFenceLatitude, currentGeoFenceLongitude)) + geofenceData = currentGeofenceData + val geofence = buildGeoFence(currentGeofenceData) + + // Build the geofence request. + val geofencingRequest = GeofencingRequest.Builder() + // The INITIAL_TRIGGER_ENTER flag indicates that geofencing service should trigger a + // GEOFENCE_TRANSITION_ENTER notification when the geofence is added and if the device + // is already inside that geofence. + .setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER) + // Add the GeoFences to be monitored by geofencing service. + .addGeofence(geofence) + .build() + + addGeoFenceRequest(geofencingRequest, geofence) + } + + private fun buildGeoFence(currentGeofenceData: LandmarkDataObject) : Geofence { + Log.d(TAG, "SaveReminderFragment.buildGeoFence().") + return Geofence.Builder() + // Set the request ID, string to identify the geofence. + .setRequestId(currentGeofenceData.id) + // Set the circular region of this geofence. + .setCircularRegion( + currentGeofenceData.latLong.latitude, + currentGeofenceData.latLong.longitude, + GEOFENCE_RADIUS_IN_METERS + ) + // Set the expiration duration of the geofence. + .setExpirationDuration(NEVER_EXPIRE) + // Set the transition types of interest. Alerts are only generated for these + // transitions. We track entry and exit transitions in this sample. + .setTransitionTypes(Geofence.GEOFENCE_TRANSITION_ENTER) + .build() + } + + @SuppressLint("MissingPermission") + private fun addGeoFenceRequest(geofencingRequest: GeofencingRequest, geofence: Geofence) { + Log.d(TAG, "SaveReminderFragment.addGeoFenceRequest().") + geofencingClient.addGeofences(geofencingRequest, geofencePendingIntent).run { + addOnSuccessListener { + // GeoFences added. + parent.toast(R.string.geofences_added) + Log.d(TAG, "SaveReminderFragment.addGeoFenceRequest() -> SUCCESS!! Adding GeoFence: ${geofence.requestId}") + _viewModel.validateAndSaveReminder(dataItem) + } + addOnFailureListener { + // Failed to add GeoFences. + parent.toast(R.string.geofences_not_added) + val message = it.message + if (message != null) { + val errorMessage = errorMessage(parent, message.toInt()) + Log.d(TAG, "SaveReminderFragment.addGeoFenceRequest() -> ERROR!! Error message: $errorMessage") + } + } + } + } + + //-------------------------------------------------- + // Menu Methods + //-------------------------------------------------- + + private fun addMenu(menuHost: MenuHost) { + Log.d(TAG, "SaveReminderFragment.addMenu().") + // Add menu items without using the Fragment Menu APIs. Note how we can tie the MenuProvider + // to the viewLifecycleOwner and an optional Lifecycle.State (here, RESUMED) to indicate + // when the menu should be visible. + menuHost.addMenuProvider(object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {} + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + when (menuItem.itemId) { + android.R.id.home -> { + parent.onBackPressedDispatcher.onBackPressed() + } + } + return true + } + }, viewLifecycleOwner, Lifecycle.State.RESUMED) + } + + private val backPressHandler = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + _viewModel.navigationCommand.postValue( + NavigationCommand.Back + ) + } } -} +} \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/locationreminders/savereminder/SaveReminderViewModel.kt b/starter/app/src/main/java/com/udacity/project4/locationreminders/savereminder/SaveReminderViewModel.kt index 6e78845b1..f7bb3e526 100644 --- a/starter/app/src/main/java/com/udacity/project4/locationreminders/savereminder/SaveReminderViewModel.kt +++ b/starter/app/src/main/java/com/udacity/project4/locationreminders/savereminder/SaveReminderViewModel.kt @@ -1,6 +1,7 @@ package com.udacity.project4.locationreminders.savereminder import android.app.Application +import android.util.Log import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.google.android.gms.maps.model.PointOfInterest @@ -10,42 +11,49 @@ import com.udacity.project4.base.NavigationCommand import com.udacity.project4.locationreminders.data.ReminderDataSource import com.udacity.project4.locationreminders.data.dto.ReminderDTO import com.udacity.project4.locationreminders.reminderslist.ReminderDataItem +import com.udacity.project4.utils.TAG import kotlinx.coroutines.launch -class SaveReminderViewModel(val app: Application, val dataSource: ReminderDataSource) : - BaseViewModel(app) { - val reminderTitle = MutableLiveData() - val reminderDescription = MutableLiveData() - val reminderSelectedLocationStr = MutableLiveData() - val selectedPOI = MutableLiveData() - val latitude = MutableLiveData() - val longitude = MutableLiveData() +class SaveReminderViewModel( + val app: Application, val dataSource: ReminderDataSource +): BaseViewModel(app) { + val reminderTitle = MutableLiveData() + val reminderDescription = MutableLiveData() + val selectedPOI = MutableLiveData() + + val reminderSelectedLocationStr = MutableLiveData() + + init { + reminderDescription.postValue("") + } /** - * Clear the live data objects to start fresh next time the view model gets called + * Clear the live data objects to start fresh next time the view model gets called. */ fun onClear() { reminderTitle.value = null reminderDescription.value = null reminderSelectedLocationStr.value = null selectedPOI.value = null - latitude.value = null - longitude.value = null } /** - * Validate the entered data then saves the reminder data to the DataSource + * Validate the entered data then saves the reminder data to the DataSource. */ - fun validateAndSaveReminder(reminderData: ReminderDataItem) { + fun validateAndSaveReminder(reminderData: ReminderDataItem) : Boolean { + Log.d(TAG, "SaveReminderViewModel.validateAndSaveReminder().") if (validateEnteredData(reminderData)) { saveReminder(reminderData) + return true } + return false } /** - * Save the reminder to the data source + * Save the reminder to the data source. */ fun saveReminder(reminderData: ReminderDataItem) { + Log.d(TAG, "SaveReminderViewModel.saveReminder().") showLoading.value = true viewModelScope.launch { dataSource.saveReminder( @@ -65,9 +73,10 @@ class SaveReminderViewModel(val app: Application, val dataSource: ReminderDataSo } /** - * Validate the entered data and show error to the user if there's any invalid data + * Validate the entered data and show error to the user if there's any invalid data. */ fun validateEnteredData(reminderData: ReminderDataItem): Boolean { + Log.d(TAG, "SaveReminderViewModel.validateEnteredData().") if (reminderData.title.isNullOrEmpty()) { showSnackBarInt.value = R.string.err_enter_title return false @@ -77,6 +86,21 @@ class SaveReminderViewModel(val app: Application, val dataSource: ReminderDataSo showSnackBarInt.value = R.string.err_select_location return false } + + if ( + (reminderData.latitude.toString().isEmpty()) || + (reminderData.longitude.toString().isEmpty()) + ) { + showSnackBarInt.value = R.string.err_select_latitude_longitude + return false + } + return true } + + fun onSaveLocation(poi: PointOfInterest?) { + Log.d(TAG, "SaveReminderViewModel.onSaveLocation().") + selectedPOI.value = poi + navigationCommand.value = NavigationCommand.Back + } } \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/locationreminders/savereminder/selectreminderlocation/SelectLocationFragment.kt b/starter/app/src/main/java/com/udacity/project4/locationreminders/savereminder/selectreminderlocation/SelectLocationFragment.kt index 3c27e92fa..16311dcf7 100644 --- a/starter/app/src/main/java/com/udacity/project4/locationreminders/savereminder/selectreminderlocation/SelectLocationFragment.kt +++ b/starter/app/src/main/java/com/udacity/project4/locationreminders/savereminder/selectreminderlocation/SelectLocationFragment.kt @@ -1,91 +1,476 @@ package com.udacity.project4.locationreminders.savereminder.selectreminderlocation - import android.Manifest -import android.app.Activity +import android.annotation.SuppressLint import android.content.Intent -import android.content.IntentSender import android.content.pm.PackageManager import android.content.res.Resources +import android.location.Geocoder +import android.os.Build import android.os.Bundle +import android.provider.Settings import android.util.Log import android.view.* +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat +import androidx.core.view.MenuHost +import androidx.core.view.MenuProvider import androidx.databinding.DataBindingUtil -import com.google.android.gms.common.api.ApiException -import com.google.android.gms.common.api.ResolvableApiException -import com.google.android.gms.location.* +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.OnMapReadyCallback import com.google.android.gms.maps.SupportMapFragment import com.google.android.gms.maps.model.* -import com.google.android.material.snackbar.Snackbar +import com.udacity.project4.MyApp import com.udacity.project4.R import com.udacity.project4.base.BaseFragment import com.udacity.project4.base.NavigationCommand import com.udacity.project4.databinding.FragmentSelectLocationBinding import com.udacity.project4.locationreminders.savereminder.SaveReminderViewModel +import com.udacity.project4.utils.GeofencingConstants.GEOFENCE_RADIUS_IN_METERS +import com.udacity.project4.utils.TAG +import com.udacity.project4.utils.isGpsEnabled +import com.udacity.project4.utils.permissionDeniedFeedback import com.udacity.project4.utils.setDisplayHomeAsUpEnabled +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import org.koin.android.ext.android.inject +import java.io.IOException +import java.util.* + +class SelectLocationFragment : BaseFragment(), OnMapReadyCallback { -class SelectLocationFragment : BaseFragment() { + //-------------------------------------------------- + // Attributes + //-------------------------------------------------- - //Use Koin to get the view model of the SaveReminder + companion object { + const val ARGUMENTS = "args" + } + + // Use Koin to get the view model of the SaveReminder override val _viewModel: SaveReminderViewModel by inject() + // Use Koin to get the view model of the SelectLocation + + private var map: GoogleMap? = null + + private var currentMarker: Marker? = null + private var currentCircleMarker: Circle? = null + private var currentPOI: PointOfInterest? = null + private var poiLatLng = LatLng(-34.0, 151.0) + private var poiLocation = "Sydney" + + private var alertShouldEnableGps: AlertDialog? = null + private lateinit var binding: FragmentSelectLocationBinding + private lateinit var parent: FragmentActivity + + //-------------------------------------------------- + // Activity Result Callbacks + //-------------------------------------------------- + + /** + * Source: + * https://developer.android.com/training/location/permissions#user-choice-affects-permission-grants + */ + + private val locationPermissionRequest = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + Log.d(TAG, "SelectLocationFragment.locationPermissionRequest() -> permissions: $permissions") + when { + permissions.getOrDefault(Manifest.permission.ACCESS_FINE_LOCATION, false) -> { + // Precise location access granted. + Log.d(TAG, "SelectLocationFragment.locationPermissionRequest() -> Precise location access granted.") + enableMyLocation() + } + permissions.getOrDefault(Manifest.permission.ACCESS_COARSE_LOCATION, false) -> { + // Only approximate location access granted. + Log.d(TAG, "SelectLocationFragment.locationPermissionRequest() -> Only approximate location access granted.") + } else -> { + // No location access granted. + Log.d(TAG, "SelectLocationFragment.locationPermissionRequest() -> No location access granted.") + } + } + } + + /** + * Source: + * https://www.tothenew.com/blog/android-katha-onactivityresult-is-deprecated-now-what/ + */ + private var activityResultLauncher = registerForActivityResult(ActivityResultContracts + .StartActivityForResult()) { + Log.d(TAG, "SelectLocationFragment.activityResultLauncher.") + if (parent.isGpsEnabled()) { + enableMyLocation() + } else { + parent.permissionDeniedFeedback() + } + } + + //-------------------------------------------------- + // Lifecycle Methods + //-------------------------------------------------- override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - binding = - DataBindingUtil.inflate(inflater, R.layout.fragment_select_location, container, false) + ): View { + val layoutId = R.layout.fragment_select_location + binding = DataBindingUtil.inflate(inflater, layoutId, container, false) binding.viewModel = _viewModel binding.lifecycleOwner = this + init() + + return binding.root + } + + private fun init() { + parent = requireActivity() + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backPressHandler) - setHasOptionsMenu(true) setDisplayHomeAsUpEnabled(true) -// TODO: add the map setup implementation -// TODO: zoom to the user location after taking his permission -// TODO: add style to the map -// TODO: put a marker to location that the user selected + startMapFeature() + initMenus() + binding.saveButton.setOnClickListener { + onLocationSelected() + } + } -// TODO: call this function after the user confirms on the selected location - onLocationSelected() + override fun onDestroy() { + super.onDestroy() + Log.d(TAG, "SelectLocationFragment.onDestroy().") + disableDialogs() + } - return binding.root + private fun disableDialogs() { + alertShouldEnableGps?.let { + if (it.isShowing) { + it.cancel() + } + } } - private fun onLocationSelected() { - // TODO: When the user confirms on the selected location, - // send back the selected location details to the view model - // and navigate back to the previous fragment to save the reminder and add the geofence + //-------------------------------------------------- + // Menu Methods + //-------------------------------------------------- + + private fun initMenus() { + val menuHost: MenuHost = requireActivity() + addMenu(menuHost) + } + + @Suppress("UNUSED_EXPRESSION") + private fun addMenu(menuHost: MenuHost) { + // Add menu items without using the Fragment Menu APIs. Note how we can tie the + // MenuProvider to the viewLifecycleOwner and an optional Lifecycle.State (here, RESUMED) + // to indicate when the menu should be visible. + menuHost.addMenuProvider(object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.map_options, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + when (menuItem.itemId) { + R.id.normal_map -> { + map?.mapType = GoogleMap.MAP_TYPE_NORMAL + true + } + R.id.hybrid_map -> { + map?.mapType = GoogleMap.MAP_TYPE_HYBRID + true + } + R.id.satellite_map -> { + map?.mapType = GoogleMap.MAP_TYPE_SATELLITE + true + } + R.id.terrain_map -> { + map?.mapType = GoogleMap.MAP_TYPE_TERRAIN + true + } + android.R.id.home -> { + requireActivity().onBackPressedDispatcher.onBackPressed() + true + } + } + return true + } + }, viewLifecycleOwner, Lifecycle.State.RESUMED) + } + + private val backPressHandler = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + _viewModel.navigationCommand.postValue( + NavigationCommand.Back + ) + } + } + + //-------------------------------------------------- + // Maps Methods + //-------------------------------------------------- + + private fun startMapFeature() { + Log.d(TAG, "SelectLocationFragment.startMapFeature().") + val mapFragment = childFragmentManager + .findFragmentById(R.id.map_fragment) as SupportMapFragment + mapFragment.getMapAsync(this) } + /** + * Manipulates the map once available. This callback is triggered when the map is ready to + * be used. This is where we can add markers or lines, add listeners or move the camera. + * In this case, we just add a marker near Sydney, Australia. + * If Google Play services is not installed on the device, the user will be prompted to install + * it inside the SupportMapFragment. This method will only be triggered once the user has + * installed Google Play services and returned to the app. + */ + override fun onMapReady(googleMap: GoogleMap) { + val app = requireActivity().application as MyApp + Log.d(TAG, "----- SelectLocationFragment.onMapReady() -> testingMode = ${app.testingMode}") + map = googleMap + + // Add a marker in Sydney and move the camera + val sydney = LatLng(-34.0, 151.0) + map?.let { + it.addMarker(MarkerOptions().position(sydney).title("Marker in Sydney")) + it.moveCamera(CameraUpdateFactory.newLatLng(sydney)) + setMapClick(it) + + if (!app.testingMode) { +// if (_viewModel.testing.value == false) { + // Put a marker to location that the user selected. + setPoiClick(it) + // Add style to the map. + setMapStyle(it) + enableMyLocation() + } + } + } + + private fun setMapClick(map: GoogleMap?) { + val app = requireActivity().application as MyApp + Log.d(TAG, "----- SelectLocationFragment.setMapClick() -> testingMode = ${app.testingMode}") + map?.setOnMapClickListener { latLng -> +// lifecycleScope.launch(Dispatchers.IO) { + if (app.testingMode) { + Log.d(TAG, "SelectLocationFragment.setMapClick() -> _viewModel.testing.value == true") + currentPOI = PointOfInterest(poiLatLng, "", poiLocation) + onLocationSelected() + } else { + Log.d(TAG, "SelectLocationFragment.setMapClick() -> _viewModel.testing.value == false") + val geocoder = Geocoder(requireContext(), Locale.getDefault()) + fetchGeocode(geocoder, latLng) + } +// } + } + } - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.map_options, menu) + @Suppress("DEPRECATION") + private fun fetchGeocode(geocoder: Geocoder, latLng: LatLng) { + val app = requireActivity().application as MyApp + Log.d(TAG, "----- SelectLocationFragment.fetchGeocode() -> testingMode = ${app.testingMode}") + lifecycleScope.launch(Dispatchers.IO) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Log.d(TAG, "SelectLocationFragment.fetchGeocode() -> Version >= Android 13.") + geocoder.getFromLocation(latLng.latitude, latLng.longitude, 1) { addresses -> + val poi = PointOfInterest(latLng, "", addresses[0].featureName) + updateCurrentPoi(poi) + } + } else { + Log.d(TAG, "SelectLocationFragment.fetchGeocode() -> Version < Android 13.") + val addresses = geocoder.getFromLocation(latLng.latitude, latLng.longitude, 1) + addresses?.let { + val poi = PointOfInterest(latLng, "", it[0].featureName) + updateCurrentPoi(poi) + } + } + } catch (e: IOException) { + Log.d(TAG, "SelectLocationFragment.fetchGeocode() -> Failed to fetch address list.") + Log.e(TAG, "Failed to fetch address list: ${e.message}") + } + } } - override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { - // TODO: Change the map type based on the user's selection. - R.id.normal_map -> { - true + private fun putMarkerOnMap(poi: PointOfInterest) { + Log.d(TAG, "SelectLocationFragment.putMarkerOnMap().") + try { + poiLatLng = poi.latLng + poiLocation = poi.name + currentMarker = map?.addMarker(MarkerOptions().position(poi.latLng).title(poi.name)) + currentMarker?.showInfoWindow() + } catch (e: Exception) { + Log.d(TAG, "SelectLocationFragment.putMarkerOnMap() -> Failed to put Marker on map.") + Log.e(TAG, "Failed to put Marker on map: ${e.message}") } - R.id.hybrid_map -> { - true + } + + private fun putCircleOnMap(poi: PointOfInterest) { + Log.d(TAG, "SelectLocationFragment.putCircleOnMap().") + try { + // Add circle range. + currentCircleMarker = map?.addCircle(CircleOptions() + .center(poi.latLng) + .radius(GEOFENCE_RADIUS_IN_METERS.toDouble()) + .fillColor( + ContextCompat.getColor( + requireContext(), + R.color.geofencing_circle_fill_color + ) + ) + .strokeColor( + ContextCompat.getColor( + requireContext(), + R.color.geofencing_circle_stroke_color + ) + ) + ) + } catch (e: Exception) { + Log.d(TAG, "SelectLocationFragment.putCircleOnMap() -> Failed to put Circle on map.") + Log.e(TAG, "Failed to put Circle on map: ${e.message}") } - R.id.satellite_map -> { - true + } + + private fun removeMarkers() { + Log.d(TAG, "SelectLocationFragment.removeMarkers().") + try { + currentMarker?.remove() + currentCircleMarker?.remove() + } catch (e: Exception) { + Log.d(TAG, "SelectLocationFragment.removeMarkers() -> Failed to remove Marker and Circle Marker on map.") + Log.e(TAG, "Failed to remove Marker and Circle Marker on map: ${e.message}") } - R.id.terrain_map -> { - true + } + + private fun updateCurrentPoi(poi: PointOfInterest) { + Log.d(TAG, "SelectLocationFragment.updateCurrentPoi().") + lifecycleScope.launch(Dispatchers.Main) { + binding.saveButton.visibility = View.VISIBLE + removeMarkers() + currentPOI = poi + putMarkerOnMap(poi) + putCircleOnMap(poi) } - else -> super.onOptionsItemSelected(item) } + private fun setPoiClick(map: GoogleMap?) { + Log.d(TAG, "SelectLocationFragment.setPoiClick().") + map?.setOnPoiClickListener { poi -> + updateCurrentPoi(poi) + } + } -} + private fun setMapStyle(map: GoogleMap) { + Log.d(TAG, "SelectLocationFragment.setMapStyle().") + try { + // Customize the styling of the base map using a JSON object defined in a raw res file. + val success = map.setMapStyle( + MapStyleOptions.loadRawResourceStyle( + requireActivity(), + R.raw.map_style + ) + ) + if (!success) { + Log.e(TAG, "Style parsing failed.") + } + } catch (e: Resources.NotFoundException) { + Log.e(TAG, "Can't find style. Error: ", e) + } + } + + //-------------------------------------------------- + // Location Methods + //-------------------------------------------------- + + @SuppressLint("MissingPermission") + private fun enableMyLocation() { + Log.d(TAG, "SelectLocationFragment.enableMyLocation().") + if (isPermissionGranted()) { + Log.d(TAG, "SelectLocationFragment.enableMyLocation() -> isPermissionGranted? true") + map?.isMyLocationEnabled = true + checkGpsEnabled() + } else { + Log.d(TAG, "SelectLocationFragment.enableMyLocation() -> isPermissionGranted? false") + locationPermissionRequest.launch(arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION) + ) + } + } + private fun isPermissionGranted(): Boolean { + return ContextCompat.checkSelfPermission( + requireActivity(), + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + } + + private fun checkGpsEnabled() { + Log.d(TAG, "SelectLocationFragment.checkGpsEnabled().") + if (!parent.isGpsEnabled()) { + Log.d(TAG, "SelectLocationFragment.checkGpsEnabled() -> [1]") + buildAlertMessageNoGps() + } + } + + /** + * Source: + * https://stackoverflow.com/a/25175756/1354788 + */ + private fun buildAlertMessageNoGps() { + Log.d(TAG, "SelectLocationFragment.buildAlertMessageNoGps().") + val builder = AlertDialog.Builder(parent) + builder.setMessage(R.string.should_enable_gps) + .setCancelable(false) + .setPositiveButton("Yes") { _, _ -> + activityResultLauncher.launch(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) + } + .setNegativeButton("No") { dialog, _ -> + dialog.cancel() + parent.permissionDeniedFeedback() + } + alertShouldEnableGps = builder.create() + alertShouldEnableGps?.show() + } + + /** + * When the user confirms on the selected location, send back the selected location details + * to the view model and navigate back to the previous fragment to save the reminder and add + * the geofence. + */ + private fun onLocationSelected() { + Log.d(TAG, "SelectLocationFragment.onLocationSelected().") + currentPOI?.let { + Log.d(TAG, "SelectLocationFragment.onLocationSelected() -> " + + "location: ${it.name}, latitude: $${it.latLng.latitude}, longitude: ${it.latLng.longitude}") + } + _viewModel.onSaveLocation(currentPOI) + +// parent.toast(R.string.poi_selected) +// lifecycleScope.launch { +// delay(1000) + /* + val triple = Triple(location, latitude, longitude) + setNavigationResult(triple, ARGUMENTS) + findNavController().popBackStack() + */ +// } + + /* + // Use the navigationCommand live data to navigate between the fragments + _viewModel.navigationCommand.postValue( + NavigationCommand.To( + SelectLocationFragmentDirections.actionSelectLocationFragmentToSaveReminderFragment( + ) + ) + ) + */ + } +} \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/locationreminders/savereminder/selectreminderlocation/SelectLocationViewModel.kt b/starter/app/src/main/java/com/udacity/project4/locationreminders/savereminder/selectreminderlocation/SelectLocationViewModel.kt new file mode 100644 index 000000000..bbf4a003f --- /dev/null +++ b/starter/app/src/main/java/com/udacity/project4/locationreminders/savereminder/selectreminderlocation/SelectLocationViewModel.kt @@ -0,0 +1,20 @@ +package com.udacity.project4.locationreminders.savereminder.selectreminderlocation + +import android.app.Application +import androidx.lifecycle.MutableLiveData +import com.udacity.project4.base.BaseViewModel + +class SelectLocationViewModel( + val app: Application +): BaseViewModel(app) { + + val testing = MutableLiveData() + + init { + testing.postValue(false) + } + + fun enableTestingMode() { + testing.postValue(true) + } +} \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/utils/BindingAdapters.kt b/starter/app/src/main/java/com/udacity/project4/utils/BindingAdapters.kt index e6cc157dc..7fbf97511 100644 --- a/starter/app/src/main/java/com/udacity/project4/utils/BindingAdapters.kt +++ b/starter/app/src/main/java/com/udacity/project4/utils/BindingAdapters.kt @@ -6,7 +6,6 @@ import androidx.lifecycle.LiveData import androidx.recyclerview.widget.RecyclerView import com.udacity.project4.base.BaseRecyclerViewAdapter - object BindingAdapters { /** diff --git a/starter/app/src/main/java/com/udacity/project4/utils/EspressoIdlingResource.kt b/starter/app/src/main/java/com/udacity/project4/utils/EspressoIdlingResource.kt new file mode 100644 index 000000000..3262f00d2 --- /dev/null +++ b/starter/app/src/main/java/com/udacity/project4/utils/EspressoIdlingResource.kt @@ -0,0 +1,32 @@ +package com.udacity.project4.utils + +import androidx.test.espresso.idling.CountingIdlingResource + +object EspressoIdlingResource { + + private const val RESOURCE = "GLOBAL" + + @JvmField + val countingIdlingResource = CountingIdlingResource(RESOURCE) + + fun increment() { + countingIdlingResource.increment() + } + + fun decrement() { + if (!countingIdlingResource.isIdleNow) { + countingIdlingResource.decrement() + } + } +} + +inline fun wrapEspressoIdlingResource(function: () -> T): T { + // Espresso does not work well with coroutines yet. + // See // https://github.com/Kotlin/kotlinx.coroutines/issues/982 + EspressoIdlingResource.increment() // Set app as busy. + return try { + function() + } finally { + EspressoIdlingResource.decrement() // Set app as idle. + } +} \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/utils/Extensions.kt b/starter/app/src/main/java/com/udacity/project4/utils/Extensions.kt index 6e03d1f0a..546762558 100644 --- a/starter/app/src/main/java/com/udacity/project4/utils/Extensions.kt +++ b/starter/app/src/main/java/com/udacity/project4/utils/Extensions.kt @@ -3,25 +3,28 @@ package com.udacity.project4.utils import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.content.Context -import android.net.ConnectivityManager +import android.content.pm.PackageManager +import android.location.LocationManager import android.util.Log import android.view.View +import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment -import androidx.recyclerview.widget.GridLayoutManager +import androidx.fragment.app.FragmentActivity +import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.google.android.gms.maps.model.LatLng +import com.udacity.project4.R import com.udacity.project4.base.BaseRecyclerViewAdapter -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import java.io.IOException -import java.net.HttpURLConnection -import java.net.URL +import com.udacity.project4.locationreminders.reminderslist.ReminderDataItem +const val TAG = "Project4" /** - * Extension function to setup the RecyclerView + * Extension function to setup the RecyclerView. */ fun RecyclerView.setup( adapter: BaseRecyclerViewAdapter @@ -46,7 +49,9 @@ fun Fragment.setDisplayHomeAsUpEnabled(bool: Boolean) { } } -//animate changing the view visibility +/** + * Animate changing the view visibility. + */ fun View.fadeIn() { this.visibility = View.VISIBLE this.alpha = 0f @@ -57,7 +62,9 @@ fun View.fadeIn() { }) } -//animate changing the view visibility +/** + * Animate changing the view visibility. + */ fun View.fadeOut() { this.animate().alpha(0f).setListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { @@ -66,3 +73,65 @@ fun View.fadeOut() { } }) } + +fun Context.toast(textId: Int) { + Toast.makeText(this, textId, Toast.LENGTH_LONG).show() +} + +fun Context.toast(text: String) { + Toast.makeText(this, text, Toast.LENGTH_SHORT).show() +} + +fun Context.permissionGranted(permission: String): Boolean { + return ContextCompat.checkSelfPermission( + this, permission + ) == PackageManager.PERMISSION_GRANTED +} + +fun FragmentActivity.requestTrackingPermissions(array: Array, requestCode: Int) { + ActivityCompat.requestPermissions( + this, + array, + requestCode + ) +} + +fun FragmentActivity.showRequestPermissionRationale(permission: String): Boolean = + ActivityCompat.shouldShowRequestPermissionRationale( + this, + permission + ) + +/** + * Source: + * https://stackoverflow.com/a/60757744/1354788 + */ +fun Fragment.setNavigationResult(result: Any, key: String = "key") { + this.findNavController().previousBackStackEntry?.savedStateHandle?.set(key, result) +} + +fun Fragment.getNavigationResult(key: String = "key") = + findNavController().currentBackStackEntry?.savedStateHandle?.getLiveData(key) + +fun getFakeReminderItem() : ReminderDataItem { + return ReminderDataItem( + title = "some title", + description = "some description", + location = "some location", + latitude = -34.0, // Sydney, Australia + longitude = 151.0 // Sydney, Australia + ) +} + +fun FragmentActivity.isGpsEnabled(): Boolean { + Log.d(TAG, "Extensions.isGpsEnabled().") + val manager = this.getSystemService(Context.LOCATION_SERVICE) as LocationManager + return manager.isProviderEnabled(LocationManager.GPS_PROVIDER) +} + +fun FragmentActivity.permissionDeniedFeedback() { + Log.d(TAG, "Extensions.permissionDeniedFeedback().") + this.toast(R.string.allow_all_time_did_not_accepted) +} + +data class LandmarkDataObject(val id: String, val latLong: LatLng) \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/utils/GeofenceUtils.kt b/starter/app/src/main/java/com/udacity/project4/utils/GeofenceUtils.kt new file mode 100644 index 000000000..fc58e9a0e --- /dev/null +++ b/starter/app/src/main/java/com/udacity/project4/utils/GeofenceUtils.kt @@ -0,0 +1,35 @@ +package com.udacity.project4.utils + +import android.content.Context +import com.google.android.gms.location.GeofenceStatusCodes +import com.udacity.project4.R +import java.util.concurrent.TimeUnit + +/** + * Returns the error string for a geofencing error code. + */ +fun errorMessage(context: Context, errorCode: Int): String { + val resources = context.resources + return when (errorCode) { + GeofenceStatusCodes.GEOFENCE_NOT_AVAILABLE -> resources.getString( + R.string.geofence_not_available + ) + GeofenceStatusCodes.GEOFENCE_TOO_MANY_GEOFENCES -> resources.getString( + R.string.geofence_too_many_geofences + ) + GeofenceStatusCodes.GEOFENCE_TOO_MANY_PENDING_INTENTS -> resources.getString( + R.string.geofence_too_many_pending_intents + ) + else -> resources.getString(R.string.unknown_geofence_error) + } +} + +internal object GeofencingConstants { + /** + * Used to set an expiration time for a geofence. After this amount of time, Location services + * stops tracking the geofence. For this sample, geofences expire after one hour. + */ + val GEOFENCE_EXPIRATION_IN_MILLISECONDS: Long = TimeUnit.HOURS.toMillis(1) + const val GEOFENCE_RADIUS_IN_METERS = 100f + const val EXTRA_GEOFENCE_INDEX = "GEOFENCE_INDEX" +} \ No newline at end of file diff --git a/starter/app/src/main/java/com/udacity/project4/utils/NotificationUtils.kt b/starter/app/src/main/java/com/udacity/project4/utils/NotificationUtils.kt index 5cfc0102c..c5551ca9f 100644 --- a/starter/app/src/main/java/com/udacity/project4/utils/NotificationUtils.kt +++ b/starter/app/src/main/java/com/udacity/project4/utils/NotificationUtils.kt @@ -1,10 +1,14 @@ package com.udacity.project4.utils +import android.annotation.SuppressLint import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context +import android.graphics.BitmapFactory +import android.graphics.Color import android.os.Build +import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.TaskStackBuilder import com.udacity.project4.BuildConfig @@ -12,40 +16,67 @@ import com.udacity.project4.R import com.udacity.project4.locationreminders.ReminderDescriptionActivity import com.udacity.project4.locationreminders.reminderslist.ReminderDataItem -private const val NOTIFICATION_CHANNEL_ID = BuildConfig.APPLICATION_ID + ".channel" +private const val CHANNEL_ID = BuildConfig.APPLICATION_ID + ".channel" -fun sendNotification(context: Context, reminderDataItem: ReminderDataItem) { - val notificationManager = context - .getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - // We need to create a NotificationChannel associated with our CHANNEL_ID before sending a notification. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O - && notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) == null - ) { - val name = context.getString(R.string.app_name) - val channel = NotificationChannel( - NOTIFICATION_CHANNEL_ID, - name, - NotificationManager.IMPORTANCE_DEFAULT - ) - notificationManager.createNotificationChannel(channel) +/** + * We need to create a NotificationChannel associated with our CHANNEL_ID before sending a + * notification. + */ +@SuppressLint("NewApi") +fun createChannel(context: Context, notificationManager: NotificationManager) { + Log.d(TAG, "NotificationUtils.createChannel().") + val sdkAboveOreo = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + val channelNull = notificationManager.getNotificationChannel(CHANNEL_ID) == null + if (sdkAboveOreo && channelNull) { + val notificationChannel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.channel_name), + NotificationManager.IMPORTANCE_HIGH + ).apply { + setShowBadge(false) + } + notificationChannel.enableLights(true) + notificationChannel.lightColor = Color.RED + notificationChannel.enableVibration(true) + notificationChannel.description = context.getString(R.string.notification_channel_description) + notificationManager.createNotificationChannel(notificationChannel) } +} +fun sendNotification(context: Context, reminderDataItem: ReminderDataItem) { + Log.d(TAG, "NotificationUtils.sendNotification().") + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + createChannel(context, notificationManager) val intent = ReminderDescriptionActivity.newIntent(context.applicationContext, reminderDataItem) - //create a pending intent that opens ReminderDescriptionActivity when the user clicks on the notification + // Create a pending intent that opens ReminderDescriptionActivity when the user clicks on the + // notification. + var intentFlagTypeUpdateCurrent = PendingIntent.FLAG_UPDATE_CURRENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + intentFlagTypeUpdateCurrent = PendingIntent.FLAG_IMMUTABLE + } val stackBuilder = TaskStackBuilder.create(context) .addParentStack(ReminderDescriptionActivity::class.java) .addNextIntent(intent) - val notificationPendingIntent = stackBuilder - .getPendingIntent(getUniqueId(), PendingIntent.FLAG_UPDATE_CURRENT) + val notificationPendingIntent = stackBuilder.getPendingIntent( + getUniqueId(), intentFlagTypeUpdateCurrent + ) + + val mapIcon = R.drawable.map + val mapImage = BitmapFactory.decodeResource(context.resources, mapIcon) + val bigPicStyle = NotificationCompat.BigPictureStyle() + .bigPicture(mapImage) + .bigLargeIcon(null) -// build the notification object with the data to be shown - val notification = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) - .setSmallIcon(R.mipmap.ic_launcher) + // Build the notification object with the data to be shown. + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.map_small) .setContentTitle(reminderDataItem.title) .setContentText(reminderDataItem.location) + .setPriority(NotificationCompat.PRIORITY_HIGH) .setContentIntent(notificationPendingIntent) + .setStyle(bigPicStyle) + .setLargeIcon(mapImage) .setAutoCancel(true) .build() diff --git a/starter/app/src/main/java/com/udacity/project4/utils/SingleLiveEvent.kt b/starter/app/src/main/java/com/udacity/project4/utils/SingleLiveEvent.kt index c561da259..08dce7fe2 100644 --- a/starter/app/src/main/java/com/udacity/project4/utils/SingleLiveEvent.kt +++ b/starter/app/src/main/java/com/udacity/project4/utils/SingleLiveEvent.kt @@ -42,7 +42,6 @@ class SingleLiveEvent : MutableLiveData() { @MainThread override fun observe(owner: LifecycleOwner, observer: Observer) { - if (hasActiveObservers()) { Log.w(TAG, "Multiple observers registered but only one will be notified of changes.") } @@ -70,7 +69,6 @@ class SingleLiveEvent : MutableLiveData() { } companion object { - private val TAG = "SingleLiveEvent" } } \ No newline at end of file diff --git a/starter/app/src/main/res/drawable/android.png b/starter/app/src/main/res/drawable/android.png new file mode 100644 index 000000000..8d73f092c Binary files /dev/null and b/starter/app/src/main/res/drawable/android.png differ diff --git a/starter/app/src/main/res/drawable/ic_no_data.xml b/starter/app/src/main/res/drawable/ic_no_data.xml index c4f47af01..33fc6d7ab 100644 --- a/starter/app/src/main/res/drawable/ic_no_data.xml +++ b/starter/app/src/main/res/drawable/ic_no_data.xml @@ -3,7 +3,7 @@ android:height="80dp" android:viewportWidth="404.688" android:viewportHeight="404.688"> - + diff --git a/starter/app/src/main/res/drawable/map_small.xml b/starter/app/src/main/res/drawable/map_small.xml new file mode 100644 index 000000000..3c5dac8b3 --- /dev/null +++ b/starter/app/src/main/res/drawable/map_small.xml @@ -0,0 +1,10 @@ + + + diff --git a/starter/app/src/main/res/layout/activity_authentication.xml b/starter/app/src/main/res/layout/activity_authentication.xml index 898573e26..4a5973f4a 100644 --- a/starter/app/src/main/res/layout/activity_authentication.xml +++ b/starter/app/src/main/res/layout/activity_authentication.xml @@ -1,20 +1,35 @@ - + xmlns:tools="http://schemas.android.com/tools"> - + - \ No newline at end of file + + +