diff --git a/.github/workflows/canonical-gradle.yml b/.github/workflows/canonical-gradle.yml index 15288c27..8e23e2b4 100644 --- a/.github/workflows/canonical-gradle.yml +++ b/.github/workflows/canonical-gradle.yml @@ -9,6 +9,10 @@ on: pull_request: branches: [ summer-2020 ] +defaults: + run: + working-directory: android/canonical + jobs: build: @@ -20,8 +24,6 @@ jobs: uses: actions/setup-java@v1 with: java-version: 1.8 - - name: Switch to canonical project - run: cd android/canonical/ - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Build with Gradle diff --git a/android/canonical/.gitignore b/android/canonical/.gitignore new file mode 100644 index 00000000..603b1407 --- /dev/null +++ b/android/canonical/.gitignore @@ -0,0 +1,14 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx diff --git a/android/canonical/app/.gitignore b/android/canonical/app/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/android/canonical/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/canonical/app/build.gradle b/android/canonical/app/build.gradle new file mode 100644 index 00000000..21ec3087 --- /dev/null +++ b/android/canonical/app/build.gradle @@ -0,0 +1,107 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' +apply plugin: 'kotlin-kapt' +apply plugin: 'com.google.gms.google-services' + +android { + compileSdkVersion 29 + buildToolsVersion "29.0.3" + + buildFeatures { + dataBinding true + } + + defaultConfig { + applicationId "com.google.samples.quickstart.canonical" + minSdkVersion 16 + targetSdkVersion 29 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + multiDexEnabled true + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + + staging { + initWith(buildTypes.debug) // keep versionName and PIN from 'debug' + defaultConfig.minSdkVersion 18 + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8.toString() + } + + testOptions { + unitTests.returnDefaultValues = true + } +} + +dependencies { + implementation fileTree(dir: "libs", include: ["*.jar"]) + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation 'androidx.core:core-ktx:1.3.1' + implementation 'androidx.appcompat:appcompat:1.1.0' + implementation "androidx.fragment:fragment-ktx:1.2.5" + implementation 'androidx.constraintlayout:constraintlayout:1.1.3' + implementation 'com.google.android.gms:play-services-auth:18.1.0' + implementation 'com.google.android.material:material:1.1.0' + implementation 'com.google.android.gms:play-services-maps:17.0.0' + implementation 'com.google.android.gms:play-services-location:17.0.0' + implementation 'com.google.android.libraries.places:places:2.3.0' + implementation "androidx.lifecycle:lifecycle-extensions:2.2.0" + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0' + implementation 'com.google.firebase:firebase-analytics-ktx:17.4.4' + implementation 'com.google.firebase:firebase-auth-ktx:19.3.2' + implementation 'com.google.firebase:firebase-firestore-ktx:21.4.3' + + implementation 'com.google.android.material:material:1.1.0' + implementation 'androidx.percentlayout:percentlayout:1.0.0' + implementation 'androidx.cardview:cardview:1.0.0' + + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.3' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.3' + + debugImplementation "androidx.fragment:fragment-testing:1.2.5" + + kapt 'com.android.databinding:compiler:3.1.4' + + // Core library + androidTestImplementation 'androidx.test:core:1.0.0' + + // AndroidJUnitRunner and JUnit Rules + androidTestImplementation 'androidx.test:runner:1.1.0' + androidTestImplementation 'androidx.test:rules:1.1.0' + + + // Assertions + androidTestImplementation 'androidx.test.ext:junit:1.1.1' + androidTestImplementation 'androidx.test.ext:truth:1.0.0' + androidTestImplementation 'com.google.truth:truth:0.42' + + // Espresso dependencies + androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + androidTestImplementation 'com.android.support.test.uiautomator:uiautomator-v18:2.1.3' + + testImplementation 'junit:junit:4.12' + testImplementation "androidx.arch.core:core-testing:2.1.0" + testImplementation "androidx.test.ext:junit-ktx:1.1.1" + testImplementation "androidx.test:core-ktx:1.2.0" + testImplementation "org.robolectric:robolectric:4.1" + + + + +} diff --git a/android/canonical/app/google-services.json b/android/canonical/app/google-services.json new file mode 100644 index 00000000..fa0f128d --- /dev/null +++ b/android/canonical/app/google-services.json @@ -0,0 +1,48 @@ +{ + "project_info": { + "project_number": "558666181231", + "firebase_url": "https://pivotal-nebula-281103.firebaseio.com", + "project_id": "pivotal-nebula-281103", + "storage_bucket": "pivotal-nebula-281103.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:558666181231:android:ace62cb2b3bd45e4a3fb9a", + "android_client_info": { + "package_name": "com.google.samples.quickstart.canonical" + } + }, + "oauth_client": [ + { + "client_id": "558666181231-ts5q1e04oha2g1m7km4jonff5s6fe1qk.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "com.google.samples.quickstart.canonical", + "certificate_hash": "cf9e073a08e94150b32d4c6e5004a5e26a647287" + } + }, + { + "client_id": "558666181231-bt85k6hb4mfeuri817m7o2n1m96a3shh.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "THIS IS PRIVATE" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "558666181231-bt85k6hb4mfeuri817m7o2n1m96a3shh.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/canonical/app/proguard-rules.pro b/android/canonical/app/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/android/canonical/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/android/canonical/app/src/androidTest/java/com/google/samples/quickstart/canonical/DatabaseInteractionTest.kt b/android/canonical/app/src/androidTest/java/com/google/samples/quickstart/canonical/DatabaseInteractionTest.kt new file mode 100644 index 00000000..85dc8187 --- /dev/null +++ b/android/canonical/app/src/androidTest/java/com/google/samples/quickstart/canonical/DatabaseInteractionTest.kt @@ -0,0 +1,85 @@ +package com.google.samples.quickstart.canonical + +import androidx.test.espresso.Espresso.onData +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.ActivityTestRule +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector +import org.hamcrest.Matchers +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DatabaseInteractionTest { + @get:Rule + var activityRule: ActivityTestRule + = ActivityTestRule(MainActivity::class.java) + + lateinit var device : UiDevice + + @Before + fun setup() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + onView(ViewMatchers.withId(R.id.sign_in_button)) + .perform(click()) + val googleSignInDialog = device.findObject(UiSelector().text("example@gmail.com")) + googleSignInDialog.clickAndWaitForNewWindow() + // Make sure that: + // 1. Your google account must have signed out before test. + // 2. You should have at lest one google account for your device, + // which means, when you click sign in button, you have at least + // one account to choose + + // Construct your test database: + // Account: example@gmail.com + + } + + @Test + fun submitRecord() { + onView(ViewMatchers.withId(R.id.start_pause_btn)) + .perform(click()) + Thread.sleep(5000) + + onView(ViewMatchers.withId(R.id.submit_btn)) + .perform(click()) + Thread.sleep(2000) + onView(ViewMatchers.withText("Submission Confirm")) + .inRoot(isDialog()) + .check(matches(ViewMatchers.isDisplayed())) + onView(ViewMatchers.withText("Confirm")) + .inRoot(isDialog()) + .perform(click()) + Thread.sleep(1000) + + onView(ViewMatchers.withId(R.id.bottom_navigation_item_profile)) + .perform(click()) + + onView(ViewMatchers.withId(R.id.run_history_list_view)) + .check(matches(ViewMatchers.isDisplayed())) + onData(Matchers.anything()) + .inAdapterView(ViewMatchers.withId(R.id.run_history_list_view)) + .atPosition(0) + .onChildView(ViewMatchers.withId(R.id.single_run_time)) + .check(matches(ViewMatchers.withText("00:00:05"))) // In your test database, the latest + .perform(click()) + } + + @After + fun logoutUser() { + onView(ViewMatchers.withId(R.id.bottom_navigation_item_profile)) + .perform(click()) + onView(ViewMatchers.withId(R.id.logout_button)) + .perform(click()) + Thread.sleep(1000) + } +} \ No newline at end of file diff --git a/android/canonical/app/src/androidTest/java/com/google/samples/quickstart/canonical/ExampleInstrumentedTest.kt b/android/canonical/app/src/androidTest/java/com/google/samples/quickstart/canonical/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..54f9f01a --- /dev/null +++ b/android/canonical/app/src/androidTest/java/com/google/samples/quickstart/canonical/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.google.samples.quickstart.canonical + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.google.samples.quickstart.canonical", appContext.packageName) + } +} \ No newline at end of file diff --git a/android/canonical/app/src/androidTest/java/com/google/samples/quickstart/canonical/LoginFragmentTest.kt b/android/canonical/app/src/androidTest/java/com/google/samples/quickstart/canonical/LoginFragmentTest.kt new file mode 100644 index 00000000..611e8374 --- /dev/null +++ b/android/canonical/app/src/androidTest/java/com/google/samples/quickstart/canonical/LoginFragmentTest.kt @@ -0,0 +1,93 @@ +package com.google.samples.quickstart.canonical + +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.matcher.RootMatchers +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.ActivityTestRule +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector +import org.hamcrest.core.IsNot +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LoginFragmentTest { + @get:Rule + var activityRule: ActivityTestRule + = ActivityTestRule(MainActivity::class.java) + + lateinit var device : UiDevice + + @Before + fun setup() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + Espresso.onView(ViewMatchers.withId(R.id.sign_in_button)) + .perform(ViewActions.click()) + val googleSignInDialog = device.findObject(UiSelector().text("example@gmail.com")) + googleSignInDialog.clickAndWaitForNewWindow() + // Make sure that: + // 1. Your google account must have signed out before test. + // 2. You should have at lest one google account for your device, + // which means, when you click sign in button, you have at least + // one account to choose + } + + + @Test + fun logoutAndSignIn() { + // Logout test + Espresso.onView(ViewMatchers.withId(R.id.bottom_navigation_item_profile)) + .perform(ViewActions.click()) + Espresso.onView(ViewMatchers.withId(R.id.logout_button)) + .perform(ViewActions.click()) + Thread.sleep(1000) + + // Sign in test + Espresso.onView(ViewMatchers.withId(R.id.sign_in_button)) + .perform(ViewActions.click()) + val googleSignInDialog = device.findObject(UiSelector().text("example@gmail.com")) + googleSignInDialog.clickAndWaitForNewWindow() + } + + @Test + fun signInFailureHandle() { + // Logout + Espresso.onView(ViewMatchers.withId(R.id.bottom_navigation_item_profile)) + .perform(ViewActions.click()) + Espresso.onView(ViewMatchers.withId(R.id.logout_button)) + .perform(ViewActions.click()) + Thread.sleep(1000) + + // Sign in failure test + Espresso.onView(ViewMatchers.withId(R.id.sign_in_button)) + .perform(ViewActions.click()) + device.pressBack() + Espresso.onView(ViewMatchers.withText(R.string.login_failed)) + .inRoot(RootMatchers.withDecorView(IsNot.not(activityRule.activity.window.decorView))) + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) + + // Sign in + Espresso.onView(ViewMatchers.withId(R.id.sign_in_button)) + .perform(ViewActions.click()) + val googleSignInDialog = device.findObject(UiSelector().text("example@gmail.com")) + googleSignInDialog.clickAndWaitForNewWindow() + + } + + + @After + fun logoutUser() { + Espresso.onView(ViewMatchers.withId(R.id.bottom_navigation_item_profile)) + .perform(ViewActions.click()) + Espresso.onView(ViewMatchers.withId(R.id.logout_button)) + .perform(ViewActions.click()) + Thread.sleep(1000) + } +} \ No newline at end of file diff --git a/android/canonical/app/src/androidTest/java/com/google/samples/quickstart/canonical/MapsFragmentTest.kt b/android/canonical/app/src/androidTest/java/com/google/samples/quickstart/canonical/MapsFragmentTest.kt new file mode 100644 index 00000000..61b93c9c --- /dev/null +++ b/android/canonical/app/src/androidTest/java/com/google/samples/quickstart/canonical/MapsFragmentTest.kt @@ -0,0 +1,70 @@ +package com.google.samples.quickstart.canonical + +import androidx.test.espresso.Espresso +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.ActivityTestRule +import androidx.test.rule.GrantPermissionRule +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + + +@RunWith(AndroidJUnit4::class) +class MapsFragmentTest { + + @get:Rule + var activityRule: ActivityTestRule + = ActivityTestRule(MainActivity::class.java) + + @Rule @JvmField + val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(android.Manifest.permission.ACCESS_FINE_LOCATION) + + lateinit var device : UiDevice + + @Before + fun setup() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + onView(ViewMatchers.withId(R.id.sign_in_button)) + .perform(ViewActions.click()) + val googleSignInDialog = device.findObject(UiSelector().text("example@gmail.com")) + googleSignInDialog.clickAndWaitForNewWindow() + // Make sure that: + // 1. Your google account must have signed out before test. + // 2. You should have at lest one google account for your device, + // which means, when you click sign in button, you have at least + // one account to choose + } + + + @Test + fun searchDestination() { + Thread.sleep(1000) + onView(ViewMatchers.withId(R.id.bottom_navigation_item_map)) + .perform(ViewActions.click()) + Thread.sleep(3000) + onView(ViewMatchers.withId(R.id.autocomplete_fragment)) + .perform(ViewActions.click()) + val searchView = device.findObject(UiSelector().text("Search")) + searchView.text = "Central Park" + Thread.sleep(2000) + device.click(300,800) + Thread.sleep(2000) + } + + @After + fun logoutUser() { + onView(ViewMatchers.withId(R.id.bottom_navigation_item_profile)) + .perform(ViewActions.click()) + onView(ViewMatchers.withId(R.id.logout_button)) + .perform(ViewActions.click()) + Thread.sleep(1000) + } +} \ No newline at end of file diff --git a/android/canonical/app/src/androidTest/java/com/google/samples/quickstart/canonical/ProfileFragmentTest.kt b/android/canonical/app/src/androidTest/java/com/google/samples/quickstart/canonical/ProfileFragmentTest.kt new file mode 100644 index 00000000..1d69ebe4 --- /dev/null +++ b/android/canonical/app/src/androidTest/java/com/google/samples/quickstart/canonical/ProfileFragmentTest.kt @@ -0,0 +1,104 @@ +package com.google.samples.quickstart.canonical + +import androidx.test.espresso.Espresso.onData +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.* +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.ActivityTestRule +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector +import org.hamcrest.Matchers.anything +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ProfileFragmentTest { + @get:Rule + var activityRule: ActivityTestRule + = ActivityTestRule(MainActivity::class.java) + + lateinit var device : UiDevice + + @Before + fun setup() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + onView(withId(R.id.sign_in_button)) + .perform(click()) + val googleSignInDialog = device.findObject(UiSelector().text("exampleForProfileTest@gmail.com")) + googleSignInDialog.clickAndWaitForNewWindow() + Thread.sleep(1000) + // Make sure that: + // 1. Your google account must have signed out before test. + // 2. You should have at lest one google account for your device, + // which means, when you click sign in button, you have at least + // one account to choose + + // Account: exampleForProfileTest@gmail.com + // Display name: example user + + // Construct your test database: + // User: exampleForProfileTest@gmail.com + // Total running time: 00:05:39 + // Total energy consumed: 74 + // Latest running history: + // datetime : 2000-01-01 00:00:00 + // time : 00:00:02 + + } + + @Test + fun checkUserInfoTest() { + onView(withId(R.id.bottom_navigation_item_profile)) + .perform(click()) + onView(withId(R.id.usr_img)) + .check(matches(isDisplayed())) + onView(withId(R.id.usr_name)) + .check(matches(withText("example user"))) + onView(withId(R.id.usr_email)) + .check(matches(withText("exampleForProfileTest@gmail.com"))) + } + + @Test + fun checkUserStatisticTest() { + onView(withId(R.id.bottom_navigation_item_profile)) + .perform(click()) + onView(withId(R.id.usr_run_time)) + .check(matches(withText("00:05:39"))) + onView(withId(R.id.usr_run_energy)) + .check(matches(withText("74"))) + } + + @Test + fun checkUserRunHistoryTest() { + onView(withId(R.id.bottom_navigation_item_profile)) + .perform(click()) + onView(withId(R.id.run_history_list_view)) + .check(matches(isDisplayed())) + onData(anything()).inAdapterView(withId(R.id.run_history_list_view)) + .atPosition(0) + .onChildView(withId(R.id.single_run_datetime)) + .check(matches(withText("2000-01-01 00:00:00"))) + .perform(click()) + onData(anything()).inAdapterView(withId(R.id.run_history_list_view)) + .atPosition(0) + .onChildView(withId(R.id.single_run_time)) + .check(matches(withText("00:00:02"))) + .perform(click()) + } + + + @After + fun logoutUser() { + onView(withId(R.id.bottom_navigation_item_profile)) + .perform(click()) + onView(withId(R.id.logout_button)) + .perform(click()) + Thread.sleep(1000) + } +} \ No newline at end of file diff --git a/android/canonical/app/src/androidTest/java/com/google/samples/quickstart/canonical/RunFragmentTest.kt b/android/canonical/app/src/androidTest/java/com/google/samples/quickstart/canonical/RunFragmentTest.kt new file mode 100644 index 00000000..f272d780 --- /dev/null +++ b/android/canonical/app/src/androidTest/java/com/google/samples/quickstart/canonical/RunFragmentTest.kt @@ -0,0 +1,231 @@ +package com.google.samples.quickstart.canonical + +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.RootMatchers.withDecorView +import androidx.test.espresso.matcher.ViewMatchers.* +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.ActivityTestRule +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector +import org.hamcrest.core.IsNot.not +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + + +@RunWith(AndroidJUnit4::class) +class RunFragmentTest { + + @get:Rule + var activityRule: ActivityTestRule + = ActivityTestRule(MainActivity::class.java) + + private lateinit var device : UiDevice + + @Before + fun setup() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + onView(withId(R.id.sign_in_button)) + .perform(click()) + val googleSignInDialog = device.findObject(UiSelector().text("example@gmail.com")) + googleSignInDialog.clickAndWaitForNewWindow() + // Make sure that: + // 1. Your google account must have signed out before test. + // 2. You should have at lest one google account for your device, + // which means, when you click sign in button, you have at least + // one account to choose + } + + @Test + fun startClick() { +// launchFragmentInContainer() + onView(withId(R.id.start_pause_btn)) + .perform(click()) + Thread.sleep(2000) + onView(withId(R.id.running_chronometer)) + .check(matches(withText("00:02"))) + Thread.sleep(2000) + } + + @Test + fun pauseClick() { +// launchFragmentInContainer() + onView(withId(R.id.start_pause_btn)) + .perform(click()) + Thread.sleep(2000) + onView(withId(R.id.start_pause_btn)) + .perform(click()) + Thread.sleep(2000) + onView(withId(R.id.running_chronometer)) + .check(matches(withText("00:02"))) + } + + @Test + fun startAndPauseClick() { +// launchFragmentInContainer() + onView(withId(R.id.start_pause_btn)) + .perform(click()) + Thread.sleep(2000) + onView(withId(R.id.start_pause_btn)) + .perform(click()) + Thread.sleep(1000) + onView(withId(R.id.start_pause_btn)) + .perform(click()) + Thread.sleep(1000) + onView(withId(R.id.running_chronometer)) + .check(matches(withText("00:03"))) + } + + @Test + fun resetClickWhenWorking() { +// launchFragmentInContainer() + onView(withId(R.id.start_pause_btn)) + .perform(click()) + Thread.sleep(2000) + onView(withId(R.id.reset_btn)) + .perform(click()) + onView(withId(R.id.running_chronometer)) + .check(matches(withText("00:00"))) + } + + @Test + fun resetClickWhenPausing() { +// launchFragmentInContainer() + onView(withId(R.id.start_pause_btn)) + .perform(click()) + Thread.sleep(2000) + onView(withId(R.id.running_chronometer)) + .check(matches(withText("00:02"))) + + onView(withId(R.id.start_pause_btn)) + .perform(click()) + Thread.sleep(2000) + onView(withId(R.id.running_chronometer)) + .check(matches(withText("00:02"))) + + onView(withId(R.id.reset_btn)) + .perform(click()) + onView(withId(R.id.running_chronometer)) + .check(matches(withText("00:00"))) + } + + @Test + fun submitClickWhenWorking() { +// launchFragmentInContainer() + onView(withId(R.id.start_pause_btn)) + .perform(click()) + Thread.sleep(2000) + onView(withId(R.id.running_chronometer)) + .check(matches(withText("00:02"))) + + onView(withId(R.id.submit_btn)) + .perform(click()) + onView(withText("Submission Confirm")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + onView(withText("Cancel")) + .inRoot(isDialog()) + .perform(click()) + } + + @Test + fun submitClickWhenPausing() { +// launchFragmentInContainer() + onView(withId(R.id.start_pause_btn)) + .perform(click()) + Thread.sleep(2000) + onView(withId(R.id.start_pause_btn)) + .perform(click()) + Thread.sleep(1000) + + onView(withId(R.id.submit_btn)) + .perform(click()) + onView(withText("Submission Confirm")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + onView(withText("Cancel")) + .inRoot(isDialog()) + .perform(click()) + } + + @Test + fun submitClickBeforeWorking() { + onView(withId(R.id.submit_btn)) + .perform(click()) + onView(withText(R.string.submit_illegal)) + .inRoot(withDecorView(not(activityRule.activity.window.decorView))) + .check(matches(isDisplayed())) + } + + @Test + fun submitCancel() { + onView(withId(R.id.start_pause_btn)) + .perform(click()) + Thread.sleep(2000) + + onView(withId(R.id.submit_btn)) + .perform(click()) + onView(withText("Cancel")) + .inRoot(isDialog()) + .perform(click()) + + onView(withId(R.id.running_chronometer)) + .check(matches(withText("00:02"))) + } + + @Test + fun switchFragmentWhenWorking() { + onView(withId(R.id.start_pause_btn)) + .perform(click()) + Thread.sleep(2000) + + // Fragment transition needs time + onView(withId(R.id.bottom_navigation_item_profile)) + .perform(click()) + Thread.sleep(500) + onView(withId(R.id.bottom_navigation_item_run)) + .perform(click()) + Thread.sleep(1000) + + onView(withId(R.id.running_chronometer)) + .check(matches(withText("00:04"))) + } + + @Test + fun switchFragmentWhenPausing() { + onView(withId(R.id.start_pause_btn)) + .perform(click()) + Thread.sleep(2000) + onView(withId(R.id.start_pause_btn)) + .perform(click()) + + // Fragment transition needs time + onView(withId(R.id.bottom_navigation_item_profile)) + .perform(click()) + Thread.sleep(1000) + onView(withId(R.id.bottom_navigation_item_run)) + .perform(click()) + + onView(withId(R.id.running_chronometer)) + .check(matches(withText("00:02"))) + } + + + + @After + fun logoutUser() { + onView(withId(R.id.bottom_navigation_item_profile)) + .perform(click()) + onView(withId(R.id.logout_button)) + .perform(click()) + Thread.sleep(1000) + } + +} \ No newline at end of file diff --git a/android/canonical/app/src/main/AndroidManifest.xml b/android/canonical/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..add6e967 --- /dev/null +++ b/android/canonical/app/src/main/AndroidManifest.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/canonical/app/src/main/ic_werun_launcher-playstore.png b/android/canonical/app/src/main/ic_werun_launcher-playstore.png new file mode 100644 index 00000000..8261ec80 Binary files /dev/null and b/android/canonical/app/src/main/ic_werun_launcher-playstore.png differ diff --git a/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/LoginFragment.kt b/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/LoginFragment.kt new file mode 100644 index 00000000..7b1d3d40 --- /dev/null +++ b/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/LoginFragment.kt @@ -0,0 +1,85 @@ +package com.google.samples.quickstart.canonical + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Observer +import com.google.android.gms.common.SignInButton +import com.google.samples.quickstart.canonical.SignInViewModel.Companion.FIREBASE_AUTH_WITH_GOOGLE_FAIL +import com.google.samples.quickstart.canonical.SignInViewModel.Companion.FIREBASE_AUTH_WITH_GOOGLE_SUCCESSFUL +import com.google.samples.quickstart.canonical.SignInViewModel.Companion.GOOGLE_SIGN_IN_UNSUCCESSFUL + +class LoginFragment : Fragment() { + + private val signInVM: SignInViewModel by activityViewModels() + + private fun signIn() { + val signInIntent = signInVM.getSignInIntent() + startActivityForResult(signInIntent, RC_SIGN_IN) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + // Result returned from launching the Intent from GoogleSignInApi.getSignInIntent(...); + if (requestCode == RC_SIGN_IN) { + when (signInVM.firebaseAuth(data)) { + FIREBASE_AUTH_WITH_GOOGLE_SUCCESSFUL -> { + Log.i(LOGIN_FRAGMENT_TAG, "Google sign in successful") + } + + FIREBASE_AUTH_WITH_GOOGLE_FAIL -> { + Log.w(LOGIN_FRAGMENT_TAG, "Google sign in failed") + } + + GOOGLE_SIGN_IN_UNSUCCESSFUL -> { + Log.w(LOGIN_FRAGMENT_TAG, "Google sign in unsuccessful") + } + } + } + // No other requestCode, ignore it. + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Add login status change listener + signInVM.getFirebaseAuthLogStatusLiveData().observe(this, Observer { + when (it) { + true -> { + Log.d(LOGIN_FRAGMENT_TAG, "firebaseUser is not null") + // Start main activity + val intent = Intent(context, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + startActivity(intent) + } + + false -> { + Log.d(LOGIN_FRAGMENT_TAG, "firebaseUser is null") + } + } + }) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + // Inflate the layout for this fragment + return inflater.inflate(R.layout.fragment_login, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.findViewById(R.id.sign_in_button).setOnClickListener { + signIn() + } + } + + companion object { + const val RC_SIGN_IN = 0 + const val LOGIN_FRAGMENT_TAG = "Login fragment" + } +} \ No newline at end of file diff --git a/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/MainActivity.kt b/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/MainActivity.kt new file mode 100644 index 00000000..b1e4d451 --- /dev/null +++ b/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/MainActivity.kt @@ -0,0 +1,128 @@ +package com.google.samples.quickstart.canonical + +import android.content.Intent +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import android.util.Log +import androidx.activity.viewModels +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.fragment.app.FragmentTransaction +import androidx.lifecycle.Observer +import com.google.android.material.bottomnavigation.BottomNavigationView +import java.lang.Exception + +class MainActivity : AppCompatActivity() { + + private val signInVM : SignInViewModel by viewModels() + private val profileVM : ProfileViewModel by viewModels() + + private fun setupNavigationBar() { + supportFragmentManager + .beginTransaction() + .replace(R.id.fragment_container, RunFragment()) + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) + .commit() + + val bottomNavigation : BottomNavigationView = findViewById(R.id.bottom_navigation_view) + bottomNavigation.setOnNavigationItemSelectedListener { item -> + try { + when (item.itemId) { + + R.id.bottom_navigation_item_run -> { + if (!bottomNavigation.menu.findItem(R.id.bottom_navigation_item_run).isChecked) { + supportFragmentManager + .beginTransaction() + .replace(R.id.fragment_container, RunFragment()) + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) + .commit() + } + true + } + + R.id.bottom_navigation_item_map -> { + if (!bottomNavigation.menu.findItem(R.id.bottom_navigation_item_map).isChecked) { + supportFragmentManager + .beginTransaction() + .replace(R.id.fragment_container, MapsFragment()) + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) + .commit() + } + true + } + + R.id.bottom_navigation_item_profile -> { + if (!bottomNavigation.menu.findItem(R.id.bottom_navigation_item_profile).isChecked) { + supportFragmentManager + .beginTransaction() + .replace(R.id.fragment_container, ProfileFragment()) + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) + .commit() + } + true + } + + else -> false + } + } catch (e:Exception) { + Log.e(MAIN_ACTIVITY_TAG, "Navigation failed", e) + false + } + } + } + + private fun logoutUserObserver() { + val observer = Observer { + when (it) { + true -> { + Log.d(MAIN_ACTIVITY_TAG, "firebaseUser is not null") + } + + false -> { + Log.d(MAIN_ACTIVITY_TAG, "firebaseUser is null") + val intent = Intent(this, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + startActivity(intent) + } + } + } + // set LifeCycle owner with MainActivity. Observe will be destroyed when MainActivity is destroyed + signInVM.getFirebaseAuthLogStatusLiveData().observe(this, observer) + } + + private fun checkLogin() { + when (signInVM.isLogIn()) { + true -> { + Log.d(MAIN_ACTIVITY_TAG, "Already login") + setupNavigationBar() + // Init Profile + val firebaseUser = signInVM.getFirebaseAuthCurUser() + profileVM.initAppUser(firebaseUser?.displayName ?: "", firebaseUser?.email ?: "", + firebaseUser?.uid ?: "", firebaseUser?.photoUrl.toString()) + logoutUserObserver() + } + + false -> { + Log.d(MAIN_ACTIVITY_TAG, "No login. Update UI") + findViewById(R.id.main_activity_view).removeAllViews() + supportFragmentManager + .beginTransaction() + .replace(R.id.main_activity_view, LoginFragment()) + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) + .commit() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + Log.d(MAIN_ACTIVITY_TAG, "onCreate") + signInVM.signInVMInit(this, this) + checkLogin() + } + + companion object { + const val MAIN_ACTIVITY_TAG = "MainActivity" + } + +} \ No newline at end of file diff --git a/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/MapsFragment.kt b/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/MapsFragment.kt new file mode 100644 index 00000000..f195d67a --- /dev/null +++ b/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/MapsFragment.kt @@ -0,0 +1,196 @@ +package com.google.samples.quickstart.canonical + +import android.Manifest +import android.app.Activity +import android.content.pm.PackageManager +import android.location.Location +import androidx.fragment.app.Fragment + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.Toast +import androidx.core.content.ContextCompat.checkSelfPermission +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationServices +import com.google.android.gms.common.api.Status + +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.LatLng +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.MarkerOptions +import com.google.android.libraries.places.api.Places +import com.google.android.libraries.places.api.model.Place +import com.google.android.libraries.places.api.model.RectangularBounds +import com.google.android.libraries.places.api.net.PlacesClient +import com.google.android.libraries.places.widget.AutocompleteSupportFragment +import com.google.android.libraries.places.widget.listener.PlaceSelectionListener +import java.lang.Exception + +class MapsFragment : Fragment() { + + private lateinit var map: GoogleMap + private lateinit var fusedLocationClient: FusedLocationProviderClient + private lateinit var lastLocation: Location + private lateinit var placesClient : PlacesClient + private lateinit var autocompleteLayout : LinearLayout + private lateinit var targetLatLng : LatLng + private lateinit var targetName : String + private lateinit var autocompleteFragment : AutocompleteSupportFragment + private var currentLatLng : LatLng? = null + private var targetMarker : Marker? = null + + companion object { + private const val LOCATION_PERMISSION_REQUEST_CODE = 1 + private const val ZOOM_VALUE = 14f + private const val PADDING_RATIO = 1.5 + private const val FRAGMENT_TAG = "Mapfragment" + } + + override fun onRequestPermissionsResult(requestCode: Int, + permissions: Array, grantResults: IntArray) { + when (requestCode) { + LOCATION_PERMISSION_REQUEST_CODE -> { + // If request is cancelled, the result arrays are empty. + if ((grantResults.isNotEmpty() && + grantResults[0] == PackageManager.PERMISSION_GRANTED)) { + setUpMap() + } else { + // Explain to the user that the feature is unavailable because + // the features requires a permission that the user has denied. + } + return + } + else -> { + // Ignore all other requests. + } + } + } + + private fun initPlaces() { + context?.let { Places.initialize(it, getString(R.string.google_maps_key)) } + placesClient = context?.let { Places.createClient(it) }!! + } + + private fun setPlacesSearchBias() { + // Search nearby result + currentLatLng?.let { + autocompleteFragment.setLocationBias( + RectangularBounds.newInstance( + LatLng(currentLatLng!!.latitude - 1, currentLatLng!!.longitude - 1), + LatLng(currentLatLng!!.latitude + 1, currentLatLng!!.longitude + 1) + )) + } + } + + private fun setUpMap() { + if (checkSelfPermission(context as Activity, + Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), LOCATION_PERMISSION_REQUEST_CODE) + return + } + + // Add and adjust the position of MyLocation button. + map.isMyLocationEnabled = true + map.setPadding(0, (PADDING_RATIO * autocompleteLayout.height).toInt(),0,0) + + fusedLocationClient.lastLocation.addOnSuccessListener { location -> + // Got last known location. In some rare situations this can be null. + try { + location?.let { + Log.d(FRAGMENT_TAG, "Locating Success ${location.latitude}, ${location.longitude}") + lastLocation = location + currentLatLng = LatLng(location.latitude, location.longitude) + map.animateCamera(CameraUpdateFactory.newLatLngZoom( + currentLatLng, + ZOOM_VALUE + )) + map.addMarker(MarkerOptions() + .position(currentLatLng!!) + .title(getString(R.string.my_location_title))) + map.moveCamera(CameraUpdateFactory.newLatLngZoom( + currentLatLng, + ZOOM_VALUE + )) + setPlacesSearchBias() + } ?: run{ + Log.d(FRAGMENT_TAG, "Locating Failed") + Toast.makeText(this.context, getString(R.string.cannot_access_location), Toast.LENGTH_LONG).show() + } + } catch (e : Exception) { + Log.e(FRAGMENT_TAG, "Map generation failed", e) + } + + } + } + + private fun setUpAutocomplete(autocompleteFragment : AutocompleteSupportFragment, mapFragment : SupportMapFragment) { + autocompleteFragment.setPlaceFields(listOf(Place.Field.ID, Place.Field.NAME, Place.Field.LAT_LNG)) + autocompleteFragment.setOnPlaceSelectedListener(object : PlaceSelectionListener { + override fun onPlaceSelected(place: Place) { + targetLatLng = place.latLng!! + targetName = place.name.toString() + mapFragment.getMapAsync(searchPlacesCallback) + } + + override fun onError(status: Status) { + Log.e(FRAGMENT_TAG, "An error occurred: $status") + } + }) + } + + + private val mapReadyCallback = OnMapReadyCallback { googleMap -> + /** + * 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. + */ + map = googleMap + map.uiSettings.isZoomControlsEnabled = true + setUpMap() + } + + private val searchPlacesCallback = OnMapReadyCallback { map -> + targetMarker?.remove() + map.moveCamera(CameraUpdateFactory.newLatLngZoom( + targetLatLng, + ZOOM_VALUE + )) + targetMarker = map.addMarker(MarkerOptions() + .position(targetLatLng) + .title(targetName) + .draggable(true)) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + fusedLocationClient = LocationServices.getFusedLocationProviderClient(this.activity as Activity) + return inflater.inflate(R.layout.fragment_maps, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initPlaces() + + val mapFragment = childFragmentManager.findFragmentById(R.id.map_fragment) as SupportMapFragment + autocompleteFragment = childFragmentManager.findFragmentById(R.id.autocomplete_fragment) as AutocompleteSupportFragment + autocompleteLayout = view.findViewById(R.id.autocomplete_linearLayout) + mapFragment.getMapAsync(mapReadyCallback) + + setUpAutocomplete(autocompleteFragment, mapFragment) + } +} diff --git a/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/ProfileFragment.kt b/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/ProfileFragment.kt new file mode 100644 index 00000000..34e62d32 --- /dev/null +++ b/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/ProfileFragment.kt @@ -0,0 +1,78 @@ +package com.google.samples.quickstart.canonical + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.ListView +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.databinding.DataBindingUtil +import com.google.samples.quickstart.canonical.databinding.FragmentProfileBinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.android.synthetic.main.fragment_profile.* + + +class ProfileFragment : Fragment() { + + private val signInVM: SignInViewModel by activityViewModels() + private val profileVM : ProfileViewModel by activityViewModels() + private lateinit var binding : FragmentProfileBinding + + private fun downloadPhotoAndSetView(userImage: ImageView) { + val url = profileVM.getUserPhotoURL() + if (url != "") { + Log.d(PROFILE_TAG, url) + CoroutineScope(Dispatchers.Main).launch{ + val bmImage = profileVM.downloadImage(url) + userImage.setImageBitmap(bmImage) + } + } + } + + private fun setRunHistory() { + val runHistoryListForView : ArrayList + = profileVM.getRunHistoryListForView() + val runHistoryAdapter = RunHistoryAdapter(requireContext(), runHistoryListForView) + val runHistoryListView = view?.findViewById(R.id.run_history_list_view) + runHistoryListView?.adapter = runHistoryAdapter + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + // Inflate the layout for this fragment + binding = DataBindingUtil.inflate(inflater, R.layout.fragment_profile, container, false) + binding.lifecycleOwner = this + binding.profileViewModel = profileVM + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val logoutButton : ImageButton = view.findViewById(R.id.logout_button) + logoutButton.setOnClickListener { + // Sign out both Google account and Firebase + signInVM.signOut() + } + val refreshButton : ImageButton = view.findViewById(R.id.refresh_button) + refreshButton.setOnClickListener { + profileVM.refreshUser(view.findViewById(R.id.run_history_list_view).adapter + as RunHistoryAdapter) + downloadPhotoAndSetView(usr_img) + } + setRunHistory() + // update UI + downloadPhotoAndSetView(usr_img) + } + + companion object { + private const val PROFILE_TAG = "ProfileFragment" + } +} diff --git a/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/ProfileViewModel.kt b/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/ProfileViewModel.kt new file mode 100644 index 00000000..11d1b8b4 --- /dev/null +++ b/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/ProfileViewModel.kt @@ -0,0 +1,226 @@ +package com.google.samples.quickstart.canonical + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.os.AsyncTask +import android.util.Log +import android.widget.ImageView +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.firebase.firestore.DocumentReference +import com.google.firebase.firestore.FieldValue +import com.google.firebase.firestore.ktx.firestore +import com.google.firebase.ktx.Firebase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.InputStream +import java.net.URL +import java.util.concurrent.TimeUnit + + +class ProfileViewModel : ViewModel() { + data class AppUser ( + var userName : String, + var email : String, + var uid : String, + var googleAccountProfileUrl : String, + var totalDistanceMeters : MutableLiveData = MutableLiveData(0), + var totalEnergyCalories : MutableLiveData = MutableLiveData(0), + var totalTimeMillisecond : MutableLiveData = MutableLiveData(0), + var runHistoryList : ArrayList> =ArrayList() + ) + + data class SingleRun ( + var time : String, + var dateTime : String + ) + + private lateinit var runUserDocRef : DocumentReference + private lateinit var curAppUser: AppUser + private var runHistoryListForView: ArrayList = ArrayList() + private var totalEnergyCaloriesMutableLiveDataString : MutableLiveData = MutableLiveData("") + private var totalTimeMutableLiveDataString : MutableLiveData = MutableLiveData("") + + + private fun getUid() : String { + return curAppUser.uid + } + + private fun getTotalTimeMillisecond() : Long? { + return curAppUser.totalTimeMillisecond.value + } + + private fun getRunHistoryList() : ArrayList> { + val reversedRunHistory = curAppUser.runHistoryList + reversedRunHistory.reverse() + Log.d(PROFILE_VM_TAG, "size ${reversedRunHistory.size}") + return reversedRunHistory + } + + private fun setTotalDistanceMeters(totalDistanceMeters : Long) { + curAppUser.totalDistanceMeters.value = totalDistanceMeters + } + + private fun setTotalEnergyCalories(totalEnergyCalories : Long) { + curAppUser.totalEnergyCalories.value = totalEnergyCalories + } + + private fun setTotalTimeMillisecond(totalTimeMillisecond : Long) { + curAppUser.totalTimeMillisecond.value = totalTimeMillisecond + } + + private fun setTotalEnergyCaloriesString() { + totalEnergyCaloriesMutableLiveDataString.value = curAppUser.totalEnergyCalories.value.toString() + } + + private fun setTotalTimeString() { + totalTimeMutableLiveDataString.value = convertMStoStringHMS(curAppUser.totalTimeMillisecond.value ?: 0) + } + + private fun setRunHistoryList(runHistoryList : ArrayList>) { + curAppUser.runHistoryList = runHistoryList + } + + private fun setRunHistoryListForView() { + runHistoryListForView.clear() + val runHistoryListOfHashMap = getRunHistoryList() + for (singleRun in runHistoryListOfHashMap) { + val singleRunTime : String = convertMStoStringHMS(singleRun[KEY_SINGLE_RUN_TIME] as Long) + val singleRunTimestamp : String = singleRun[KEY_SINGLE_RUN_TIMESTAMP] as String + runHistoryListForView.add(SingleRun(singleRunTime, singleRunTimestamp)) + } + } + + private fun convertMStoStringHMS(millionSeconds : Long) : String { + return "%02d:%02d:%02d".format( + TimeUnit.MILLISECONDS.toHours(millionSeconds), + TimeUnit.MILLISECONDS.toMinutes(millionSeconds) % TimeUnit.HOURS.toMinutes(1), + TimeUnit.MILLISECONDS.toSeconds(millionSeconds) % TimeUnit.MINUTES.toSeconds(1) + ) + } + + private fun calculateCalories(millionSeconds : Long) : Long { + return millionSeconds.div(4500) + } + + private fun syncAppUserStatistic(adapter: RunHistoryAdapter? = null) { + runUserDocRef.get() + .addOnSuccessListener {document -> + if (document != null) { + Log.d(PROFILE_VM_TAG, "Get doc successfully") + setTotalDistanceMeters(document.data!![KEY_TOTAL_DIS_M] as Long) + setTotalEnergyCalories(document.data!![KEY_TOTAL_EN_CAL] as Long) + setTotalTimeMillisecond(document.data!![KEY_TOTAL_TIME_MS] as Long) + setRunHistoryList(document.data!![KEY_RUN_HISTORY] as ArrayList>) + setRunHistoryListForView() + setTotalEnergyCaloriesString() + setTotalTimeString() + adapter?.notifyDataSetChanged() + } else { + Log.d(PROFILE_VM_TAG, "No such user") + } + } + .addOnFailureListener { + Log.w(PROFILE_VM_TAG, "Get doc Failed") + } + } + + fun getUserName() : String { + return curAppUser.userName + } + + fun getUserEmail() : String { + return curAppUser.email + } + + fun getUserPhotoURL() : String { + return curAppUser.googleAccountProfileUrl + } + + fun getTimeHMSMutableLiveData() : MutableLiveData { + return totalTimeMutableLiveDataString + } + + fun getTotalEnergyCaloriesMutableLiveData() : MutableLiveData { + return totalEnergyCaloriesMutableLiveDataString + } + + fun getRunHistoryListForView(): ArrayList { + return runHistoryListForView + } + + fun initAppUser(userName: String, email: String, uid: String, photoURL: String, + userCollectionName : String = USER_COLLECTION_NAME) { + Log.d(PROFILE_VM_TAG, "initAppUser") + curAppUser = AppUser(userName, email, uid, photoURL) + runUserDocRef = Firebase.firestore.collection(userCollectionName).document(getUid()) + syncAppUserStatistic() + } + + fun refreshUser(adapter: RunHistoryAdapter) { + syncAppUserStatistic(adapter) + } + + fun uploadNewRecord(singleRunningTimeMillionSeconds : Long, timestamp : String) { + + val singleRunningCalories = calculateCalories(singleRunningTimeMillionSeconds ?: 0) + + Log.d(PROFILE_VM_TAG, "newTotalTimeMillisecond $singleRunningTimeMillionSeconds") + + val singleRunData = hashMapOf( + KEY_SINGLE_RUN_TIME to singleRunningTimeMillionSeconds, + KEY_SINGLE_RUN_TIMESTAMP to timestamp + ) + + val updateRunUserData = hashMapOf( + KEY_TOTAL_TIME_MS to FieldValue.increment(singleRunningTimeMillionSeconds), + KEY_TOTAL_EN_CAL to FieldValue.increment(singleRunningCalories), + KEY_RUN_HISTORY to FieldValue.arrayUnion(singleRunData) + ) + + + Firebase.firestore.runBatch { batch -> + batch.update(runUserDocRef, updateRunUserData as Map) + + } + .addOnSuccessListener { + Log.d(PROFILE_VM_TAG, "Upload record successfully") + syncAppUserStatistic() + } + } + + suspend fun downloadImage(url: String): Bitmap? { + var userPhotoBitmap: Bitmap? = null + try { + Log.d(PROFILE_VM_TAG, url) + withContext(Dispatchers.IO) { + val inStream: InputStream = URL(url).openStream() + userPhotoBitmap = BitmapFactory.decodeStream(inStream) + } + } catch (e: Exception) { + Log.e(PROFILE_VM_TAG, e.message!!) + } + return userPhotoBitmap + } + + + companion object { + const val PROFILE_VM_TAG = "ProfileVM" + + const val USER_COLLECTION_NAME = "RunUser" + const val KEY_USR_NAME = "UserName" + const val KEY_USR_EMAIL = "Email" + const val KEY_TOTAL_DIS_M = "TotalDistanceMeters" + const val KEY_TOTAL_EN_CAL = "TotalEnergyCalories" + const val KEY_TOTAL_TIME_MS = "TotalTimeMillisecond" + const val KEY_RUN_HISTORY = "RunHistory" + + const val KEY_SINGLE_RUN_TIME = "Time" + const val KEY_SINGLE_RUN_TIMESTAMP = "Timestamp" + + const val DEFAULT_TIME = "00:00:00" + } +} \ No newline at end of file diff --git a/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/RunFragment.kt b/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/RunFragment.kt new file mode 100644 index 00000000..1933cd9c --- /dev/null +++ b/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/RunFragment.kt @@ -0,0 +1,139 @@ +package com.google.samples.quickstart.canonical + +import android.app.AlertDialog +import android.os.Bundle +import android.os.SystemClock +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import com.google.samples.quickstart.canonical.databinding.FragmentRunBinding +import kotlinx.android.synthetic.main.fragment_run.* +import java.text.SimpleDateFormat +import java.util.* + + +class RunFragment : Fragment() { + + private val stopwatchVM: StopwatchViewModel by activityViewModels() + private val profileViewModel : ProfileViewModel by activityViewModels() + private lateinit var binding : FragmentRunBinding + + private fun pauseStopwatch() { + running_chronometer.stop() + stopwatchVM.pauseStopwatch(running_chronometer.base) + } + + private fun startStopwatch() { + running_chronometer.base = SystemClock.elapsedRealtime() - stopwatchVM.getPauseOffset() + running_chronometer.start() + stopwatchVM.startStopwatch() + } + + private fun resetStopwatch() { + running_chronometer.base = SystemClock.elapsedRealtime() + running_chronometer.stop() + stopwatchVM.resetStopwatch() + } + + private fun startOrPauseStopwatch() { + if (!stopwatchVM.getIsStopwatchWorking()) { + // pause/init status -> start status + startStopwatch() + } else { + // start status -> pause/init status + pauseStopwatch() + } + } + + private fun submitRecord() { + if (!stopwatchVM.getIsReadyForUpload()) { + Toast.makeText(context, getString(R.string.submit_illegal), Toast.LENGTH_SHORT).show() + return + } + + // pause stopwatch if stopwatch is working before user make a confirmation + if (stopwatchVM.getIsStopwatchWorking()) { + pauseStopwatch() + } + + // build alert dialog + val dialogBuilder = AlertDialog.Builder(context) + dialogBuilder.setMessage(getString(R.string.submit_confirm_dialog_message)) + .setCancelable(false) + // user confirm submission + .setPositiveButton(getString(R.string.dialog_confirm_button)) { dialog, id -> + Toast.makeText(context, "Submitted", Toast.LENGTH_SHORT).show() + profileViewModel.uploadNewRecord(stopwatchVM.getPauseOffset(), getCurDateAndTime()) + resetStopwatch() + } + // user cancel submission + .setNegativeButton(getString(R.string.dialog_cancel_button)) { dialog, id -> + dialog.cancel() + } + + // submit alert show + val alert = dialogBuilder.create() + alert.setTitle(getString(R.string.submit_confirm_dialog_title)) + alert.show() + } + + private fun getCurDateAndTime() : String { + val date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()) + Log.d(RUN_FRAGMENT_TAG,"date $date") + return date + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + binding = DataBindingUtil.inflate(inflater, R.layout.fragment_run, container, false) + binding.lifecycleOwner = this + binding.stopwatchViewModel = stopwatchVM + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.startPauseBtn.setOnClickListener { + startOrPauseStopwatch() + } + + binding.resetBtn.setOnClickListener { + resetStopwatch() + } + + binding.submitBtn.setOnClickListener { + submitRecord() + } + + } + + override fun onResume() { + super.onResume() + if (stopwatchVM.getIsStopwatchWorking()) { + running_chronometer.base = stopwatchVM.getActualStartTimeBeforeFragmentPause() + running_chronometer.start() + } else { + running_chronometer.base = SystemClock.elapsedRealtime() - stopwatchVM.getPauseOffset() + running_chronometer.stop() + } + } + + override fun onPause() { + super.onPause() + if (stopwatchVM.getIsStopwatchWorking()) { + stopwatchVM.saveStopwatchStatus(running_chronometer.base) + } + } + + companion object { + const val RUN_FRAGMENT_TAG = "RunFragment" + } +} \ No newline at end of file diff --git a/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/RunHistoryAdapter.kt b/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/RunHistoryAdapter.kt new file mode 100644 index 00000000..e637e17a --- /dev/null +++ b/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/RunHistoryAdapter.kt @@ -0,0 +1,56 @@ +package com.google.samples.quickstart.canonical + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import android.widget.TextView + + +class RunHistoryAdapter( + context: Context, + private val runHistoryArrayList: ArrayList) : BaseAdapter() { + + private val inflater: LayoutInflater + = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater + + override fun getCount(): Int { + return runHistoryArrayList.size + } + + override fun getItem(position: Int): Any { + return runHistoryArrayList[position] + } + + override fun getItemId(position: Int): Long { + return position.toLong() + } + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + // Get view for row item + val rowView : View + val holder : ViewHolder + if (convertView == null) { + rowView = inflater.inflate(R.layout.single_run_item, parent, false) + holder = ViewHolder(rowView) + rowView.tag = holder + } else { + rowView = convertView + holder = rowView.tag as ViewHolder + } + + val singleRun = getItem(position) as ProfileViewModel.SingleRun + holder.timeTextView.text = singleRun.time + holder.datetimeTextView.text = singleRun.dateTime + + return rowView + } + + private class ViewHolder(row: View) { + val timeTextView: TextView = row.findViewById(R.id.single_run_time) + val datetimeTextView: TextView = row.findViewById(R.id.single_run_datetime) + } + +} + diff --git a/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/SignInViewModel.kt b/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/SignInViewModel.kt new file mode 100644 index 00000000..7c2701d5 --- /dev/null +++ b/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/SignInViewModel.kt @@ -0,0 +1,203 @@ +package com.google.samples.quickstart.canonical + +import android.content.Context +import android.content.Intent +import android.util.Log +import android.widget.Toast +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInClient +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.Tasks +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.GoogleAuthProvider +import com.google.firebase.firestore.ktx.firestore +import com.google.firebase.ktx.Firebase + + +class SignInViewModel : ViewModel() { + data class CurFirebaseUser( + var firebaseUser: MutableLiveData = MutableLiveData(FirebaseAuth.getInstance().currentUser), + var isLogin: MutableLiveData = MutableLiveData(firebaseUser.value?.let { true } ?: run{ false }) + ) + + private lateinit var googleSignInClient: GoogleSignInClient + private lateinit var context: Context + private lateinit var activity: MainActivity + private var authStateListenerForSignOut : FirebaseAuth.AuthStateListener? = null + private lateinit var curFirebaseUser : MutableLiveData + + + private fun setResources(activityContext: Context, activityMain: MainActivity) { + context = activityContext + activity = activityMain + } + + private fun signInFailureHandle() { + Toast.makeText(context, context.getString(R.string.login_failed), Toast.LENGTH_SHORT).show() + signOut() + } + + private fun setCurFirebaseUser(firebaseUser: FirebaseUser) { + curFirebaseUser.value!!.firebaseUser.value = firebaseUser + curFirebaseUser.value!!.isLogin.value = true + } + + private fun googleSignInInit() { + val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestIdToken(context.getString(R.string.default_web_client_id)) + .requestEmail() + .requestProfile() + .build() + + googleSignInClient = GoogleSignIn.getClient(activity, gso) + Log.d(SIGN_IN_VM_TAG, "googleSignInClientInit") + } + + private fun firebaseSignOutInit() { + Log.d(SIGN_IN_VM_TAG, "firebaseSignOutInit") + authStateListenerForSignOut = FirebaseAuth.AuthStateListener { + it.currentUser ?: let { + Log.w(SIGN_IN_VM_TAG, "firebaseSignOut Succeed") + curFirebaseUser.value!!.firebaseUser.value = null + curFirebaseUser.value!!.isLogin.value = false + } + } + FirebaseAuth.getInstance().addAuthStateListener(authStateListenerForSignOut!!) + } + + private fun createUser(firebaseUser : FirebaseUser, userCollectionName : String = ProfileViewModel.USER_COLLECTION_NAME) { + val db = Firebase.firestore + val dbFirebaseUser = hashMapOf( + ProfileViewModel.KEY_USR_NAME to (firebaseUser.displayName ?: ""), + ProfileViewModel.KEY_USR_EMAIL to (firebaseUser.email ?: ""), + ProfileViewModel.KEY_TOTAL_DIS_M to 0L, + ProfileViewModel.KEY_TOTAL_EN_CAL to 0L, + ProfileViewModel.KEY_TOTAL_TIME_MS to 0L, + ProfileViewModel.KEY_RUN_HISTORY to arrayListOf>() + ) + val ref = db.collection(userCollectionName).document(firebaseUser.uid) + ref.get() + .onSuccessTask { document -> + when (document!!.exists()) { + true -> { + Log.d(SIGN_IN_VM_TAG, "User already exist with ID: ${document.id}") + Tasks.forResult(null) + } + false -> ref.set(dbFirebaseUser) // New user + + } as Task + } + .addOnSuccessListener { + setCurFirebaseUser(firebaseUser) + Log.d(SIGN_IN_VM_TAG, "Create user with ID: ${ref.id}") + } + .addOnFailureListener { + signInFailureHandle() + Log.w(SIGN_IN_VM_TAG, "Error adding new user") + } + } + + private fun googleSignOut() { + googleSignInInit() + googleSignInClient.signOut() + .addOnFailureListener { + Log.w(SIGN_IN_VM_TAG, "googleSignOut Failed") + Toast.makeText(context, context.getString(R.string.sign_out_failed), Toast.LENGTH_SHORT).show() + } + } + + private fun firebaseSignOut() { + firebaseSignOutInit() + FirebaseAuth.getInstance().signOut() + } + + private fun firebaseAuthWithGoogle(idToken: String) { + Log.d(SIGN_IN_VM_TAG, "firebaseAuthWithGoogle start") + val auth = FirebaseAuth.getInstance() + val credential = GoogleAuthProvider.getCredential(idToken, null) + auth.signInWithCredential(credential) + .addOnCompleteListener { task -> + Log.d(SIGN_IN_VM_TAG, "firebase firebaseAuthWithGoogle:task check") + if (task.isSuccessful) { + // Firebase Sign in success, update UI with the signed-in user's information + Log.d(SIGN_IN_VM_TAG, "firebase signInWithCredential:success") + Log.d(SIGN_IN_VM_TAG, "firebase signed-in user's Email:" + auth.currentUser!!.email) + createUser(auth.currentUser!!) + } else { + // If sign in fails, log a message to the user. + signInFailureHandle() + Log.w(SIGN_IN_VM_TAG, "signInWithCredential:failure", task.exception) + } + } + } + + fun firebaseAuth(data: Intent?) : Int { + val task = GoogleSignIn.getSignedInAccountFromIntent(data) + if (task.isSuccessful) { + return try { + // Google Sign In was successful, authenticate with Firebase + val account = task.getResult(ApiException::class.java)!! + Log.d(SIGN_IN_VM_TAG, "Google Sign In was successful:" + account.idToken) + firebaseAuthWithGoogle(account.idToken!!) + FIREBASE_AUTH_WITH_GOOGLE_SUCCESSFUL + } catch (e: ApiException) { + // Google Sign In failed + Log.w(SIGN_IN_VM_TAG, "Google sign in failed", e) + FIREBASE_AUTH_WITH_GOOGLE_FAIL + } + } else { + Toast.makeText(context, context.getString(R.string.login_failed), Toast.LENGTH_SHORT).show() + Log.w(SIGN_IN_VM_TAG, "Google sign in unsuccessful") + signInFailureHandle() + return GOOGLE_SIGN_IN_UNSUCCESSFUL + } + } + + fun getFirebaseAuthLogStatusLiveData(): MutableLiveData { + return curFirebaseUser.value!!.isLogin + } + + fun getFirebaseAuthCurUser(): FirebaseUser? { + return curFirebaseUser.value!!.firebaseUser.value + } + + fun getSignInIntent(): Intent { + googleSignInInit() + return googleSignInClient.signInIntent + } + + fun signInVMInit(activityContext: Context, activityMain: MainActivity) { + Log.d(SIGN_IN_VM_TAG, "signInInit") + setResources(activityContext, activityMain) + curFirebaseUser = MutableLiveData(CurFirebaseUser()) + } + + fun isLogIn(): Boolean { + Log.d(SIGN_IN_VM_TAG, "isLogIn():"+curFirebaseUser.value!!.isLogin.value.toString()) + return curFirebaseUser.value!!.isLogin.value!! + } + + fun signOut() { + firebaseSignOut() + googleSignOut() + } + + override fun onCleared() { + super.onCleared() + authStateListenerForSignOut?.let { + FirebaseAuth.getInstance().removeAuthStateListener(authStateListenerForSignOut!!) + } + } + + companion object { + const val SIGN_IN_VM_TAG = "signInVM" + const val GOOGLE_SIGN_IN_UNSUCCESSFUL = 1 + const val FIREBASE_AUTH_WITH_GOOGLE_SUCCESSFUL = 2 + const val FIREBASE_AUTH_WITH_GOOGLE_FAIL = 3 + } +} \ No newline at end of file diff --git a/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/StopwatchViewModel.kt b/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/StopwatchViewModel.kt new file mode 100644 index 00000000..dc22f578 --- /dev/null +++ b/android/canonical/app/src/main/java/com/google/samples/quickstart/canonical/StopwatchViewModel.kt @@ -0,0 +1,75 @@ +package com.google.samples.quickstart.canonical + +import android.os.SystemClock +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel + +class StopwatchViewModel : ViewModel() { + + private var pauseOffset = MutableLiveData(0L) + private var fragmentPauseStartTime = MutableLiveData(0L) + private var isStopwatchWorking = MutableLiveData(false) + private var isReadyForUpload = MutableLiveData(false) + + private fun setPauseOffset(pause_offset_value:Long){ + pauseOffset.value = pause_offset_value + } + + private fun setIsStopwatchWorkingStatus(working_status:Boolean){ + isStopwatchWorking.value = working_status + } + + private fun setIsReadyForUploadStatus(upload_status:Boolean){ + isReadyForUpload.value = upload_status + } + + private fun setFragmentPauseStartTime(fragment_pause_start_time:Long){ + fragmentPauseStartTime.value = fragment_pause_start_time + } + + fun getFragmentPauseStartTime() : Long { + return fragmentPauseStartTime.value!! + } + + fun getPauseOffset() : Long { + return pauseOffset.value!! + } + + fun getIsStopwatchWorkingMutableLiveData() : MutableLiveData { + return isStopwatchWorking + } + + fun getIsStopwatchWorking() : Boolean { + return isStopwatchWorking.value!! + } + + fun getIsReadyForUpload() : Boolean { + return isReadyForUpload.value!! + } + + fun getActualStartTimeBeforeFragmentPause(): Long { + return getFragmentPauseStartTime() - getPauseOffset() + } + + fun pauseStopwatch(curStopwatchBase : Long) { + setPauseOffset(SystemClock.elapsedRealtime() - curStopwatchBase) + setIsStopwatchWorkingStatus(false) + } + + fun startStopwatch() { + setIsStopwatchWorkingStatus(true) + setIsReadyForUploadStatus(true) + } + + fun resetStopwatch() { + setPauseOffset(0) + setIsStopwatchWorkingStatus(false) + setIsReadyForUploadStatus(false) + setFragmentPauseStartTime(0) + } + + fun saveStopwatchStatus(curStopwatchBase : Long) { + setPauseOffset(SystemClock.elapsedRealtime() - curStopwatchBase) + setFragmentPauseStartTime(SystemClock.elapsedRealtime()) + } +} \ No newline at end of file diff --git a/android/canonical/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/android/canonical/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 00000000..966abaff --- /dev/null +++ b/android/canonical/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/canonical/app/src/main/res/drawable/ic_baseline_access_alarm_24.xml b/android/canonical/app/src/main/res/drawable/ic_baseline_access_alarm_24.xml new file mode 100644 index 00000000..5c0154b7 --- /dev/null +++ b/android/canonical/app/src/main/res/drawable/ic_baseline_access_alarm_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/canonical/app/src/main/res/drawable/ic_baseline_directions_run_24.xml b/android/canonical/app/src/main/res/drawable/ic_baseline_directions_run_24.xml new file mode 100644 index 00000000..160dd540 --- /dev/null +++ b/android/canonical/app/src/main/res/drawable/ic_baseline_directions_run_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/canonical/app/src/main/res/drawable/ic_baseline_exit_to_app_24.xml b/android/canonical/app/src/main/res/drawable/ic_baseline_exit_to_app_24.xml new file mode 100644 index 00000000..83cdf05a --- /dev/null +++ b/android/canonical/app/src/main/res/drawable/ic_baseline_exit_to_app_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/canonical/app/src/main/res/drawable/ic_baseline_map_24.xml b/android/canonical/app/src/main/res/drawable/ic_baseline_map_24.xml new file mode 100644 index 00000000..d1274d89 --- /dev/null +++ b/android/canonical/app/src/main/res/drawable/ic_baseline_map_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/canonical/app/src/main/res/drawable/ic_baseline_pause_24.xml b/android/canonical/app/src/main/res/drawable/ic_baseline_pause_24.xml new file mode 100644 index 00000000..13d6d2ec --- /dev/null +++ b/android/canonical/app/src/main/res/drawable/ic_baseline_pause_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/canonical/app/src/main/res/drawable/ic_baseline_people_24.xml b/android/canonical/app/src/main/res/drawable/ic_baseline_people_24.xml new file mode 100644 index 00000000..2989eb66 --- /dev/null +++ b/android/canonical/app/src/main/res/drawable/ic_baseline_people_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/canonical/app/src/main/res/drawable/ic_baseline_person_24.xml b/android/canonical/app/src/main/res/drawable/ic_baseline_person_24.xml new file mode 100644 index 00000000..6bdced2d --- /dev/null +++ b/android/canonical/app/src/main/res/drawable/ic_baseline_person_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/canonical/app/src/main/res/drawable/ic_baseline_refresh_24.xml b/android/canonical/app/src/main/res/drawable/ic_baseline_refresh_24.xml new file mode 100644 index 00000000..f2be45ba --- /dev/null +++ b/android/canonical/app/src/main/res/drawable/ic_baseline_refresh_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/canonical/app/src/main/res/drawable/ic_baseline_send_24.xml b/android/canonical/app/src/main/res/drawable/ic_baseline_send_24.xml new file mode 100644 index 00000000..fe37f93f --- /dev/null +++ b/android/canonical/app/src/main/res/drawable/ic_baseline_send_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/canonical/app/src/main/res/drawable/ic_baseline_timer_24.xml b/android/canonical/app/src/main/res/drawable/ic_baseline_timer_24.xml new file mode 100644 index 00000000..18975721 --- /dev/null +++ b/android/canonical/app/src/main/res/drawable/ic_baseline_timer_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/canonical/app/src/main/res/drawable/ic_launcher.xml b/android/canonical/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 00000000..481518f0 --- /dev/null +++ b/android/canonical/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,15 @@ + + + + diff --git a/android/canonical/app/src/main/res/drawable/ic_launcher_background.xml b/android/canonical/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..61bb79ed --- /dev/null +++ b/android/canonical/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/canonical/app/src/main/res/drawable/ic_login_page.xml b/android/canonical/app/src/main/res/drawable/ic_login_page.xml new file mode 100644 index 00000000..604da397 --- /dev/null +++ b/android/canonical/app/src/main/res/drawable/ic_login_page.xml @@ -0,0 +1,14 @@ + + + + diff --git a/android/canonical/app/src/main/res/drawable/ic_werun_launcher_foreground.xml b/android/canonical/app/src/main/res/drawable/ic_werun_launcher_foreground.xml new file mode 100644 index 00000000..93e2de55 --- /dev/null +++ b/android/canonical/app/src/main/res/drawable/ic_werun_launcher_foreground.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/android/canonical/app/src/main/res/drawable/launcher_bg.xml b/android/canonical/app/src/main/res/drawable/launcher_bg.xml new file mode 100644 index 00000000..ed63c889 --- /dev/null +++ b/android/canonical/app/src/main/res/drawable/launcher_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/canonical/app/src/main/res/drawable/profile_bg.xml b/android/canonical/app/src/main/res/drawable/profile_bg.xml new file mode 100644 index 00000000..e7c326dd --- /dev/null +++ b/android/canonical/app/src/main/res/drawable/profile_bg.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/android/canonical/app/src/main/res/drawable/profile_statistic_bar_bg.xml b/android/canonical/app/src/main/res/drawable/profile_statistic_bar_bg.xml new file mode 100644 index 00000000..db032a2c --- /dev/null +++ b/android/canonical/app/src/main/res/drawable/profile_statistic_bar_bg.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/android/canonical/app/src/main/res/layout/activity_main.xml b/android/canonical/app/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..e032ad61 --- /dev/null +++ b/android/canonical/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,27 @@ + + + + + + + + \ No newline at end of file diff --git a/android/canonical/app/src/main/res/layout/fragment_login.xml b/android/canonical/app/src/main/res/layout/fragment_login.xml new file mode 100644 index 00000000..7f966171 --- /dev/null +++ b/android/canonical/app/src/main/res/layout/fragment_login.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/canonical/app/src/main/res/layout/fragment_maps.xml b/android/canonical/app/src/main/res/layout/fragment_maps.xml new file mode 100644 index 00000000..a023bad9 --- /dev/null +++ b/android/canonical/app/src/main/res/layout/fragment_maps.xml @@ -0,0 +1,26 @@ + + + + + + + + + + \ No newline at end of file diff --git a/android/canonical/app/src/main/res/layout/fragment_profile.xml b/android/canonical/app/src/main/res/layout/fragment_profile.xml new file mode 100644 index 00000000..bdb62211 --- /dev/null +++ b/android/canonical/app/src/main/res/layout/fragment_profile.xml @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/canonical/app/src/main/res/layout/fragment_run.xml b/android/canonical/app/src/main/res/layout/fragment_run.xml new file mode 100644 index 00000000..cf94c399 --- /dev/null +++ b/android/canonical/app/src/main/res/layout/fragment_run.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/canonical/app/src/main/res/layout/single_run_item.xml b/android/canonical/app/src/main/res/layout/single_run_item.xml new file mode 100644 index 00000000..043f90ca --- /dev/null +++ b/android/canonical/app/src/main/res/layout/single_run_item.xml @@ -0,0 +1,32 @@ + + + + + + + \ No newline at end of file diff --git a/android/canonical/app/src/main/res/menu/bottom_navigation_menu.xml b/android/canonical/app/src/main/res/menu/bottom_navigation_menu.xml new file mode 100644 index 00000000..b05fc766 --- /dev/null +++ b/android/canonical/app/src/main/res/menu/bottom_navigation_menu.xml @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/canonical/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/canonical/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..03eed253 --- /dev/null +++ b/android/canonical/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/canonical/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/canonical/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..03eed253 --- /dev/null +++ b/android/canonical/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/canonical/app/src/main/res/mipmap-anydpi-v26/ic_werun_launcher.xml b/android/canonical/app/src/main/res/mipmap-anydpi-v26/ic_werun_launcher.xml new file mode 100644 index 00000000..3142efa3 --- /dev/null +++ b/android/canonical/app/src/main/res/mipmap-anydpi-v26/ic_werun_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/canonical/app/src/main/res/mipmap-anydpi-v26/ic_werun_launcher_round.xml b/android/canonical/app/src/main/res/mipmap-anydpi-v26/ic_werun_launcher_round.xml new file mode 100644 index 00000000..3142efa3 --- /dev/null +++ b/android/canonical/app/src/main/res/mipmap-anydpi-v26/ic_werun_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/canonical/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/canonical/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..a571e600 Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/canonical/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/canonical/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..61da551c Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/canonical/app/src/main/res/mipmap-hdpi/ic_werun_launcher.png b/android/canonical/app/src/main/res/mipmap-hdpi/ic_werun_launcher.png new file mode 100644 index 00000000..a51f5a7f Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-hdpi/ic_werun_launcher.png differ diff --git a/android/canonical/app/src/main/res/mipmap-hdpi/ic_werun_launcher_background.png b/android/canonical/app/src/main/res/mipmap-hdpi/ic_werun_launcher_background.png new file mode 100644 index 00000000..872b2fb0 Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-hdpi/ic_werun_launcher_background.png differ diff --git a/android/canonical/app/src/main/res/mipmap-hdpi/ic_werun_launcher_round.png b/android/canonical/app/src/main/res/mipmap-hdpi/ic_werun_launcher_round.png new file mode 100644 index 00000000..5cc3f877 Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-hdpi/ic_werun_launcher_round.png differ diff --git a/android/canonical/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/canonical/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..c41dd285 Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/canonical/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/canonical/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..db5080a7 Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/canonical/app/src/main/res/mipmap-mdpi/ic_werun_launcher.png b/android/canonical/app/src/main/res/mipmap-mdpi/ic_werun_launcher.png new file mode 100644 index 00000000..dddd32f4 Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-mdpi/ic_werun_launcher.png differ diff --git a/android/canonical/app/src/main/res/mipmap-mdpi/ic_werun_launcher_background.png b/android/canonical/app/src/main/res/mipmap-mdpi/ic_werun_launcher_background.png new file mode 100644 index 00000000..17616077 Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-mdpi/ic_werun_launcher_background.png differ diff --git a/android/canonical/app/src/main/res/mipmap-mdpi/ic_werun_launcher_round.png b/android/canonical/app/src/main/res/mipmap-mdpi/ic_werun_launcher_round.png new file mode 100644 index 00000000..849c20e5 Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-mdpi/ic_werun_launcher_round.png differ diff --git a/android/canonical/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/canonical/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..6dba46da Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/canonical/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/canonical/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..da31a871 Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/canonical/app/src/main/res/mipmap-xhdpi/ic_werun_launcher.png b/android/canonical/app/src/main/res/mipmap-xhdpi/ic_werun_launcher.png new file mode 100644 index 00000000..f93eed21 Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-xhdpi/ic_werun_launcher.png differ diff --git a/android/canonical/app/src/main/res/mipmap-xhdpi/ic_werun_launcher_background.png b/android/canonical/app/src/main/res/mipmap-xhdpi/ic_werun_launcher_background.png new file mode 100644 index 00000000..d2208168 Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-xhdpi/ic_werun_launcher_background.png differ diff --git a/android/canonical/app/src/main/res/mipmap-xhdpi/ic_werun_launcher_round.png b/android/canonical/app/src/main/res/mipmap-xhdpi/ic_werun_launcher_round.png new file mode 100644 index 00000000..add1575c Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-xhdpi/ic_werun_launcher_round.png differ diff --git a/android/canonical/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/canonical/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..15ac6817 Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/canonical/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/canonical/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..b216f2d3 Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/canonical/app/src/main/res/mipmap-xxhdpi/ic_werun_launcher.png b/android/canonical/app/src/main/res/mipmap-xxhdpi/ic_werun_launcher.png new file mode 100644 index 00000000..0974d6b4 Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-xxhdpi/ic_werun_launcher.png differ diff --git a/android/canonical/app/src/main/res/mipmap-xxhdpi/ic_werun_launcher_background.png b/android/canonical/app/src/main/res/mipmap-xxhdpi/ic_werun_launcher_background.png new file mode 100644 index 00000000..04da7760 Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-xxhdpi/ic_werun_launcher_background.png differ diff --git a/android/canonical/app/src/main/res/mipmap-xxhdpi/ic_werun_launcher_round.png b/android/canonical/app/src/main/res/mipmap-xxhdpi/ic_werun_launcher_round.png new file mode 100644 index 00000000..3c42178b Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-xxhdpi/ic_werun_launcher_round.png differ diff --git a/android/canonical/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/canonical/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..f25a4197 Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/canonical/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/canonical/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..e96783cc Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/canonical/app/src/main/res/mipmap-xxxhdpi/ic_werun_launcher.png b/android/canonical/app/src/main/res/mipmap-xxxhdpi/ic_werun_launcher.png new file mode 100644 index 00000000..f0f5146f Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-xxxhdpi/ic_werun_launcher.png differ diff --git a/android/canonical/app/src/main/res/mipmap-xxxhdpi/ic_werun_launcher_background.png b/android/canonical/app/src/main/res/mipmap-xxxhdpi/ic_werun_launcher_background.png new file mode 100644 index 00000000..3a66c3c3 Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-xxxhdpi/ic_werun_launcher_background.png differ diff --git a/android/canonical/app/src/main/res/mipmap-xxxhdpi/ic_werun_launcher_round.png b/android/canonical/app/src/main/res/mipmap-xxxhdpi/ic_werun_launcher_round.png new file mode 100644 index 00000000..76981f57 Binary files /dev/null and b/android/canonical/app/src/main/res/mipmap-xxxhdpi/ic_werun_launcher_round.png differ diff --git a/android/canonical/app/src/main/res/values/colors.xml b/android/canonical/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..994ee1b4 --- /dev/null +++ b/android/canonical/app/src/main/res/values/colors.xml @@ -0,0 +1,9 @@ + + + #6200EE + #3700B3 + #03DAC5 + #4169E1 + #87CEEB + #5286D4 + \ No newline at end of file diff --git a/android/canonical/app/src/main/res/values/google_maps_api.xml b/android/canonical/app/src/main/res/values/google_maps_api.xml new file mode 100644 index 00000000..bae495a5 --- /dev/null +++ b/android/canonical/app/src/main/res/values/google_maps_api.xml @@ -0,0 +1,25 @@ + + + AIzaSyDz7brZFFw7tIg8bQLCALELeHp4JNkZmJo + + \ No newline at end of file diff --git a/android/canonical/app/src/main/res/values/strings.xml b/android/canonical/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..0dc22e51 --- /dev/null +++ b/android/canonical/app/src/main/res/values/strings.xml @@ -0,0 +1,31 @@ + + WeRun + Run + Map + Me + This is Run fragment + Stop + Start + Pause + Reset + Started + Stopped + My location + Cannot access location now. Please Try later + Logout + SignOut Failed. Please try again. + Login Failed. Please try it again. + TOTAL TIME + CALORIES + 240 + 00:00:00 + User Email + User name + Submit + Do you want to submit this record ? + Confirm + Cancel + Submission Confirm + Start your running before submission. + Welcome, Runner! + \ No newline at end of file diff --git a/android/canonical/app/src/main/res/values/styles.xml b/android/canonical/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..2537ffc5 --- /dev/null +++ b/android/canonical/app/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/android/canonical/app/src/release/res/values/google_maps_api.xml b/android/canonical/app/src/release/res/values/google_maps_api.xml new file mode 100644 index 00000000..d81bc19b --- /dev/null +++ b/android/canonical/app/src/release/res/values/google_maps_api.xml @@ -0,0 +1,20 @@ + + + YOUR_KEY_HERE + \ No newline at end of file diff --git a/android/canonical/app/src/test/java/com/google/samples/quickstart/canonical/ExampleUnitTest.kt b/android/canonical/app/src/test/java/com/google/samples/quickstart/canonical/ExampleUnitTest.kt new file mode 100644 index 00000000..d5e6c6ad --- /dev/null +++ b/android/canonical/app/src/test/java/com/google/samples/quickstart/canonical/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.google.samples.quickstart.canonical + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/android/canonical/app/src/test/java/com/google/samples/quickstart/canonical/StopwatchViewModelTest.kt b/android/canonical/app/src/test/java/com/google/samples/quickstart/canonical/StopwatchViewModelTest.kt new file mode 100644 index 00000000..f899fbbd --- /dev/null +++ b/android/canonical/app/src/test/java/com/google/samples/quickstart/canonical/StopwatchViewModelTest.kt @@ -0,0 +1,59 @@ +package com.google.samples.quickstart.canonical + +import android.os.SystemClock +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(manifest=Config.NONE) +class StopwatchViewModelTest { + + private val stopwatchViewModelInstance : StopwatchViewModel = StopwatchViewModel() + + @Test + fun pauseStopwatchTestSpecificOffset() { + stopwatchViewModelInstance.pauseStopwatch(SystemClock.elapsedRealtime() - 100) + assertEquals(stopwatchViewModelInstance.getIsStopwatchWorking(), false) + assertEquals(stopwatchViewModelInstance.getPauseOffset(), 100L) + } + + @Test + fun pauseStopwatchTestZeroOffset() { + stopwatchViewModelInstance.pauseStopwatch(SystemClock.elapsedRealtime()) + assertEquals(stopwatchViewModelInstance.getIsStopwatchWorking(), false) + assertEquals(stopwatchViewModelInstance.getPauseOffset(), 0L) + } + + @Test + fun startStopwatch() { + stopwatchViewModelInstance.startStopwatch() + assertEquals(stopwatchViewModelInstance.getIsStopwatchWorking(), true) + assertEquals(stopwatchViewModelInstance.getIsReadyForUpload(), true) + } + + @Test + fun resetStopwatch() { + stopwatchViewModelInstance.resetStopwatch() + assertEquals(stopwatchViewModelInstance.getIsStopwatchWorking(), false) + assertEquals(stopwatchViewModelInstance.getIsReadyForUpload(), false) + assertEquals(stopwatchViewModelInstance.getPauseOffset(), 0L) + assertEquals(stopwatchViewModelInstance.getFragmentPauseStartTime(), 0L) + } + + @Test + fun saveStopwatchStatusSpecificOffset() { + stopwatchViewModelInstance.saveStopwatchStatus(SystemClock.elapsedRealtime() - 100) + assertEquals(stopwatchViewModelInstance.getPauseOffset(), 100L) + assertEquals(stopwatchViewModelInstance.getFragmentPauseStartTime(), SystemClock.elapsedRealtime()) + } + + @Test + fun saveStopwatchStatusZeroOffset() { + stopwatchViewModelInstance.saveStopwatchStatus(SystemClock.elapsedRealtime()) + assertEquals(stopwatchViewModelInstance.getPauseOffset(), 0) + assertEquals(stopwatchViewModelInstance.getFragmentPauseStartTime(), SystemClock.elapsedRealtime()) + } +} \ No newline at end of file diff --git a/android/canonical/build.gradle b/android/canonical/build.gradle new file mode 100644 index 00000000..a26c23a1 --- /dev/null +++ b/android/canonical/build.gradle @@ -0,0 +1,26 @@ +buildscript { + ext.kotlin_version = "1.3.72" + repositories { + google() + jcenter() + } + dependencies { + classpath "com.android.tools.build:gradle:4.0.0" + classpath 'com.google.gms:google-services:4.3.3' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/android/canonical/gradle.properties b/android/canonical/gradle.properties new file mode 100644 index 00000000..4d15d015 --- /dev/null +++ b/android/canonical/gradle.properties @@ -0,0 +1,21 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official \ No newline at end of file diff --git a/android/canonical/gradle/wrapper/gradle-wrapper.jar b/android/canonical/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..f6b961fd Binary files /dev/null and b/android/canonical/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/canonical/gradle/wrapper/gradle-wrapper.properties b/android/canonical/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..a6564f83 --- /dev/null +++ b/android/canonical/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Jun 03 15:18:33 CDT 2020 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip diff --git a/android/canonical/gradlew b/android/canonical/gradlew new file mode 100755 index 00000000..cccdd3d5 --- /dev/null +++ b/android/canonical/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/android/canonical/gradlew.bat b/android/canonical/gradlew.bat new file mode 100644 index 00000000..e95643d6 --- /dev/null +++ b/android/canonical/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/canonical/readme.md b/android/canonical/readme.md new file mode 100644 index 00000000..71dfbcb3 --- /dev/null +++ b/android/canonical/readme.md @@ -0,0 +1,22 @@ +# WeRun -- An app for recording running time + + +## Setup + +1. Setup Google Maps API + + To get one, follow this link's instruction + https://developers.google.com/maps/documentation/android-sdk/get-api-key#get-the-api-key + + Then replace the value of google maps key in res/value/google_maps_key.xml with the key you obtained. + + Remember to restrict your API key usage before using it in production. + https://developers.google.com/maps/documentation/android-sdk/get-api-key#restrict_key + +2. Setup Firebase + + Follow this instruction: Option 1 from Step 1 to Step 3.1 + https://firebase.google.com/docs/android/setup#console + + **Replace the old google-services.json with the file you obtained from the guide.** + diff --git a/android/canonical/settings.gradle b/android/canonical/settings.gradle new file mode 100644 index 00000000..d3593029 --- /dev/null +++ b/android/canonical/settings.gradle @@ -0,0 +1,2 @@ +include ':app' +rootProject.name = "Summer 2020" \ No newline at end of file