diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..a574b92 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,28 @@ +(`[x]` becomes a filled in checkbox, `[ ]` is an empty one) + +- [ ] I have verified there are [no duplicate active or recent bugs, questions, or requests](https://github.com/afollestad/nock-nock/issues?q=is%3Aissue+is%3Aclosed) +- [ ] I have given my issue a non-generic title. + +--- + +If this is a improvement or feature request, you can remove everything below. +Also, please consider making a pull request if you are capable of contributing. + +###### Include the following: + + - Nock Nock version: `0.x.x` + - Affected device: Google Pixel 3 XL with Android 9.0 + +--- + +###### Reproduction Steps + +1. + +--- + +###### Expected Result + +--- + +###### Actual Result \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md deleted file mode 100644 index 2d7d09f..0000000 --- a/.github/ISSUE_TEMPLATE/Bug_report.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: Bug report -about: Something is crashing or not working as intended - ---- - -*Please consider making a Pull Request if you are capable of doing so.* - -**App Version:** - -x.x.x - -**Affected Device(s):** - -Google Pixel 3 XL with Android 9.0 - -**Describe the Bug:** - -A clear description of what is the bug is. - -**To Reproduce:** -1. -2. -3. - -**Expected Behavior:** - -A clear description of what you expected to happen. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/Feature_request.md b/.github/ISSUE_TEMPLATE/Feature_request.md deleted file mode 100644 index 77310ae..0000000 --- a/.github/ISSUE_TEMPLATE/Feature_request.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project - ---- - -*Please consider making a Pull Request if you are capable of doing so.* - -**Description what you'd like to happen:** - -A clear description if the feature or behavior you'd like implemented. - -**Describe alternatives you've considered:** - -A clear description of any alternative solutions you've considered. diff --git a/.github/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE.md similarity index 78% rename from .github/pull_request_template.md rename to .github/PULL_REQUEST_TEMPLATE.md index 6307e10..b4035a9 100644 --- a/.github/pull_request_template.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,8 +1,9 @@ + ### Guidelines -1. You must run the `spotlessApply` task before committing, either through Android Studio or with `./gradlew spotlessApply`. +1. You must run the `spotlessApply` task before commiting, either through Android Studio or with `./gradlew spotlessApply`. 2. A PR should be focused and contained. If you are changing multiple unrelated things, they should be in separate PRs. 3. A PR should fix a bug or solve a problem - something that only you would use is not necessarily something that should be published. 4. Give your PR a detailed title and description - look over your code one last time before actually creating the PR. Give it a self-review. -**If you do not follow the guidelines, your PR will be rejected.** +**If you do not follow the guidelines, your PR will be rejected.** \ No newline at end of file diff --git a/.gitignore b/.gitignore index 454e51a..161128f 100644 --- a/.gitignore +++ b/.gitignore @@ -180,6 +180,4 @@ gradle-app.setting .gradletasknamecache # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 -# gradle/wrapper/gradle-wrapper.properties - -app/google-services.json \ No newline at end of file +# gradle/wrapper/gradle-wrapper.properties \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 50f0406..06ee295 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,16 +1,11 @@ - - - - - - + diff --git a/.idea/modules.xml b/.idea/modules.xml index 23f5d23..d5f1277 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -3,11 +3,11 @@ - + diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..c1eda38 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,22 @@ +language: android +jdk: oraclejdk8 +android: + components: + - tools + - platform-tools + - build-tools-28.0.3 + - android-28 + - extra-android-support + - extra-android-m2repository + - extra-google-m2repository + + # Additional components + #- extra-google-google_play_services + #- addon-google_apis-google-19 + + # Specify at least one system image, if you need to run emulator(s) during your tests + #- sys-img-armeabi-v7a-android-19 + #- sys-img-x86-android-17 + + licenses: + - '.+' diff --git a/README.md b/README.md index 84ee16c..ea087de 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ ## Nock Nock +[![Build Status](https://travis-ci.org/afollestad/nock-nock.svg)](https://travis-ci.org/afollestad/nock-nock) [![License](https://img.shields.io/badge/license-Apache%202-4EB1BA.svg?style=flat-square)](https://www.apache.org/licenses/LICENSE-2.0.html) -![Showcase](https://raw.githubusercontent.com/afollestad/nock-nock/master/art/showcase5.png) +![Showcase](https://raw.githubusercontent.com/afollestad/nock-nock/master/art/showcasemain.png) Nock Nock is a simple app which allows you to monitor your websites for maximum uptime. @@ -10,4 +11,4 @@ The app will automatically knock on the door of your websites (or web servers) o to make sure they are up and responding successfully. If something is wrong, you get a notification telling you so.
-Get it on Google Play +Get it on Google Play \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index d6b315e..d9184e1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,65 +15,30 @@ android { versionCode versions.publishVersionCode versionName versions.publishVersion } - - compileOptions { - sourceCompatibility 1.8 - targetCompatibility 1.8 - } - - packagingOptions { - exclude 'META-INF/atomicfu.kotlin_module' - } } dependencies { - implementation project(':common') - implementation project(':engine') implementation project(':data') + implementation project(':utilities') + implementation project(':engine') implementation project(':notifications') implementation project(':viewcomponents') - // Google/AppCompat - implementation 'androidx.appcompat:appcompat:' + versions.androidxCore - implementation 'androidx.recyclerview:recyclerview:' + versions.androidxRecyclerView - implementation 'com.google.android.material:material:' + versions.googleMaterial - implementation 'androidx.browser:browser:' + versions.androidxBrowser - implementation 'com.google.firebase:firebase-core:' + versions.firebaseCore + implementation 'androidx.appcompat:appcompat:' + versions.androidx + implementation 'androidx.recyclerview:recyclerview:' + versions.androidx + implementation 'com.google.android.material:material:' + versions.androidx - // Lifecycle - kapt 'androidx.lifecycle:lifecycle-compiler:' + versions.lifecycle - - // Kotlin implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + versions.kotlin - // JOIN - implementation 'org.koin:koin-android:' + versions.koin - implementation 'org.koin:koin-androidx-scope:' + versions.koin - implementation 'org.koin:koin-androidx-viewmodel:' + versions.koin + implementation 'com.google.dagger:dagger:' + versions.dagger + kapt 'com.google.dagger:dagger-compiler:' + versions.dagger - // afollestad implementation 'com.afollestad.material-dialogs:core:' + versions.materialDialogs - // Debugging - implementation 'com.jakewharton.timber:timber:' + versions.timber - implementation("com.crashlytics.sdk.android:crashlytics:${versions.fabric}") { - transitive = true - } - - // Testing testImplementation 'junit:junit:' + versions.junit testImplementation 'org.mockito:mockito-core:' + versions.mockito testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:' + versions.mockitoKotlin testImplementation 'com.google.truth:truth:' + versions.truth - testImplementation 'androidx.arch.core:core-testing:' + versions.archTesting - - // UI testing - androidTestImplementation 'androidx.test:runner:' + versions.androidxTestRunner - androidTestImplementation 'androidx.test:rules:' + versions.androidxTestRunner } -apply from: '../spotless.gradle' -apply from: '../mock/mock.gradle' - -apply plugin: "io.fabric" -apply plugin: 'com.google.gms.google-services' \ No newline at end of file +apply from: '../spotless.gradle' \ No newline at end of file diff --git a/app/release/NockNock.apk b/app/release/NockNock.apk new file mode 100644 index 0000000..21235a5 Binary files /dev/null and b/app/release/NockNock.apk differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 332578e..87a00d8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -31,20 +31,22 @@ android:name="com.afollestad.nocknock.ui.addsite.AddSiteActivity" android:label="@string/add_site" android:launchMode="singleTop" + android:theme="@style/AppTheme.Transparent" android:windowSoftInputMode="stateHidden"/> - + diff --git a/app/src/main/java/com/afollestad/nocknock/AppExt.kt b/app/src/main/java/com/afollestad/nocknock/AppExt.kt index 828e01f..efd36d4 100644 --- a/app/src/main/java/com/afollestad/nocknock/AppExt.kt +++ b/app/src/main/java/com/afollestad/nocknock/AppExt.kt @@ -1,32 +1,16 @@ -/** +/* + * Licensed under Apache-2.0 + * * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package com.afollestad.nocknock import android.app.Activity import android.app.Application import android.app.Application.ActivityLifecycleCallbacks -import android.content.ActivityNotFoundException -import android.content.Intent import android.os.Bundle -import androidx.browser.customtabs.CustomTabsIntent import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY import androidx.core.text.HtmlCompat.fromHtml -import com.afollestad.materialdialogs.utils.MDUtil.resolveColor -import com.afollestad.nocknock.utilities.ext.toUri -import com.afollestad.nocknock.utilities.ui.toast typealias ActivityLifeChange = (activity: Activity, resumed: Boolean) -> Unit @@ -56,37 +40,3 @@ fun Application.onActivityLifeChange(cb: ActivityLifeChange) { } fun String.toHtml() = fromHtml(this, FROM_HTML_MODE_LEGACY) - -fun Activity.viewUrl(url: String) { - val customTabsIntent = CustomTabsIntent.Builder() - .apply { - setToolbarColor(resolveColor(this@viewUrl, attr = R.attr.colorPrimary)) - } - .build() - try { - customTabsIntent.launchUrl(this, url.toUri()) - } catch (_: ActivityNotFoundException) { - toast(R.string.install_web_browser) - } -} - -fun Activity.viewUrlWithApp( - url: String, - pkg: String -) { - val intent = Intent(Intent.ACTION_VIEW).apply { - data = url.toUri() - } - val resInfo = packageManager.queryIntentActivities(intent, 0) - for (info in resInfo) { - if (info.activityInfo.packageName.toLowerCase().contains(pkg) || - info.activityInfo.name.toLowerCase().contains(pkg) - ) { - startActivity(intent.apply { - setPackage(info.activityInfo.packageName) - }) - return - } - } - viewUrl(url) -} diff --git a/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt b/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt index 3c29301..5370d4b 100644 --- a/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt +++ b/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt @@ -1,69 +1,62 @@ -/** +/* + * Licensed under Apache-2.0 + * * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ -@file:Suppress("unused") - package com.afollestad.nocknock import android.app.Application -import com.afollestad.nocknock.BuildConfig.DEBUG -import com.afollestad.nocknock.engine.engineModule -import com.afollestad.nocknock.koin.mainModule -import com.afollestad.nocknock.koin.prefModule -import com.afollestad.nocknock.koin.viewModelModule -import com.afollestad.nocknock.logging.FabricTree +import android.util.Log +import com.afollestad.nocknock.di.AppComponent +import com.afollestad.nocknock.di.DaggerAppComponent +import com.afollestad.nocknock.engine.statuscheck.BootReceiver +import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob import com.afollestad.nocknock.notifications.NockNotificationManager -import com.afollestad.nocknock.notifications.notificationsModule -import com.afollestad.nocknock.utilities.commonModule -import com.crashlytics.android.Crashlytics -import io.fabric.sdk.android.Fabric -import org.koin.android.ext.android.inject -import org.koin.android.ext.android.startKoin -import timber.log.Timber -import timber.log.Timber.DebugTree -import timber.log.Timber.d as log +import com.afollestad.nocknock.ui.addsite.AddSiteActivity +import com.afollestad.nocknock.ui.main.MainActivity +import com.afollestad.nocknock.ui.viewsite.ViewSiteActivity +import com.afollestad.nocknock.utilities.Injector +import com.afollestad.nocknock.utilities.ext.systemService +import okhttp3.OkHttpClient +import javax.inject.Inject /** @author Aidan Follestad (@afollestad) */ -class NockNockApp : Application() { +class NockNockApp : Application(), Injector { + + companion object { + private fun log(message: String) { + if (BuildConfig.DEBUG) { + Log.d("NockNockApp", message) + } + } + } + + private lateinit var appComponent: AppComponent + @Inject lateinit var nockNotificationManager: NockNotificationManager private var resumedActivities: Int = 0 override fun onCreate() { super.onCreate() - if (DEBUG) { - Timber.plant(DebugTree()) - } + val okHttpClient = OkHttpClient.Builder() + .addNetworkInterceptor { chain -> + val request = chain.request() + .newBuilder() + .addHeader("User-Agent", "com.afollestad.nocknock") + .build() + chain.proceed(request) + } + .build() - Timber.plant(FabricTree()) - Fabric.with(this, Crashlytics()) + appComponent = DaggerAppComponent.builder() + .application(this) + .okHttpClient(okHttpClient) + .jobScheduler(systemService(JOB_SCHEDULER_SERVICE)) + .notificationManager(systemService(NOTIFICATION_SERVICE)) + .build() + appComponent.inject(this) - val modules = listOf( - prefModule, - mainModule, - engineModule, - commonModule, - notificationsModule, - viewModelModule - ) - startKoin( - androidContext = this, - modules = modules - ) - - val nockNotificationManager by inject() onActivityLifeChange { activity, resumed -> if (resumed) { resumedActivities++ @@ -76,4 +69,13 @@ class NockNockApp : Application() { nockNotificationManager.setIsAppOpen(resumedActivities > 0) } } + + override fun injectInto(target: Any) = when (target) { + is MainActivity -> appComponent.inject(target) + is ViewSiteActivity -> appComponent.inject(target) + is AddSiteActivity -> appComponent.inject(target) + is CheckStatusJob -> appComponent.inject(target) + is BootReceiver -> appComponent.inject(target) + else -> throw IllegalStateException("Can't inject into $target") + } } diff --git a/app/src/main/java/com/afollestad/nocknock/adapter/SiteAdapter.kt b/app/src/main/java/com/afollestad/nocknock/adapter/ServerAdapter.kt similarity index 51% rename from app/src/main/java/com/afollestad/nocknock/adapter/SiteAdapter.kt rename to app/src/main/java/com/afollestad/nocknock/adapter/ServerAdapter.kt index e1b034c..3bf58cc 100644 --- a/app/src/main/java/com/afollestad/nocknock/adapter/SiteAdapter.kt +++ b/app/src/main/java/com/afollestad/nocknock/adapter/ServerAdapter.kt @@ -1,30 +1,18 @@ -/** +/* + * Licensed under Apache-2.0 + * * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package com.afollestad.nocknock.adapter import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil.calculateDiff import androidx.recyclerview.widget.RecyclerView import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.model.Site -import com.afollestad.nocknock.data.model.Status.WAITING -import com.afollestad.nocknock.data.model.isPending -import com.afollestad.nocknock.data.model.textRes +import com.afollestad.nocknock.data.ServerModel +import com.afollestad.nocknock.data.isPending +import com.afollestad.nocknock.data.textRes import com.afollestad.nocknock.utilities.ui.onDebouncedClick import kotlinx.android.synthetic.main.list_item_server.view.iconStatus import kotlinx.android.synthetic.main.list_item_server.view.textInterval @@ -32,12 +20,12 @@ import kotlinx.android.synthetic.main.list_item_server.view.textName import kotlinx.android.synthetic.main.list_item_server.view.textStatus import kotlinx.android.synthetic.main.list_item_server.view.textUrl -typealias Listener = (model: Site, longClick: Boolean) -> Unit +typealias Listener = (model: ServerModel, longClick: Boolean) -> Unit /** @author Aidan Follestad (@afollestad) */ -class SiteViewHolder constructor( +class ServerVH constructor( itemView: View, - private val adapter: SiteAdapter + private val adapter: ServerAdapter ) : RecyclerView.ViewHolder(itemView), View.OnLongClickListener { init { @@ -47,32 +35,24 @@ class SiteViewHolder constructor( itemView.setOnLongClickListener(this) } - fun bind(model: Site) { - requireNotNull(model.settings) { "Settings must be populated." } - + fun bind(model: ServerModel) { itemView.textName.text = model.name itemView.textUrl.text = model.url + itemView.iconStatus.setStatus(model.status) - val lastResult = model.lastResult - if (lastResult != null) { - itemView.iconStatus.setStatus(lastResult.status) - val statusText = lastResult.status.textRes() - if (statusText == 0) { - itemView.textStatus.text = lastResult.reason - } else { - itemView.textStatus.setText(statusText) - } + val statusText = model.status.textRes() + if (statusText == 0) { + itemView.textStatus.text = model.reason } else { - itemView.iconStatus.setStatus(WAITING) - itemView.textStatus.setText(R.string.none) + itemView.textStatus.setText(statusText) } val res = itemView.resources when { - model.settings?.disabled == true -> { + model.disabled -> { itemView.textInterval.setText(R.string.checks_disabled) } - model.lastResult?.status.isPending() -> { + model.status.isPending() -> { itemView.textInterval.text = res.getString( R.string.next_check_x, res.getString(R.string.now) @@ -94,33 +74,70 @@ class SiteViewHolder constructor( } /** @author Aidan Follestad (@afollestad) */ -class SiteAdapter(private val listener: Listener) : RecyclerView.Adapter() { +class ServerAdapter(private val listener: Listener) : RecyclerView.Adapter() { - private var models = mutableListOf() + private val models = mutableListOf() internal fun performClick( index: Int, longClick: Boolean ) = listener.invoke(models[index], longClick) - fun set(newModels: List) { - val formerModels = this.models - this.models = newModels.toMutableList() - val diffResult = calculateDiff(SiteDiffCallback(formerModels, this.models)) - diffResult.dispatchUpdatesTo(this) + fun add(model: ServerModel) { + models.add(model) + notifyItemInserted(models.size - 1) + } + + fun update(target: ServerModel) { + for ((i, model) in models.withIndex()) { + if (model.id == target.id) { + update(i, target) + break + } + } + } + + private fun update( + index: Int, + model: ServerModel + ) { + models[index] = model + notifyItemChanged(index) + } + + fun remove(index: Int) { + models.removeAt(index) + notifyItemRemoved(index) + } + + fun remove(target: ServerModel) { + for ((i, model) in models.withIndex()) { + if (model.id == target.id) { + remove(i) + break + } + } + } + + fun set(newModels: List) { + this.models.clear() + if (!newModels.isEmpty()) { + this.models.addAll(newModels) + } + notifyDataSetChanged() } override fun onCreateViewHolder( parent: ViewGroup, viewType: Int - ): SiteViewHolder { + ): ServerVH { val v = LayoutInflater.from(parent.context) .inflate(R.layout.list_item_server, parent, false) - return SiteViewHolder(v, this) + return ServerVH(v, this) } override fun onBindViewHolder( - holder: SiteViewHolder, + holder: ServerVH, position: Int ) { val model = models[position] diff --git a/app/src/main/java/com/afollestad/nocknock/adapter/SiteDiffCallback.kt b/app/src/main/java/com/afollestad/nocknock/adapter/SiteDiffCallback.kt deleted file mode 100644 index de8b7ca..0000000 --- a/app/src/main/java/com/afollestad/nocknock/adapter/SiteDiffCallback.kt +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.adapter - -import androidx.recyclerview.widget.DiffUtil -import com.afollestad.nocknock.data.model.Site - -/** @author Aidan Follestad (@afollestad) */ -class SiteDiffCallback( - private val oldItems: List, - private val newItems: List -) : DiffUtil.Callback() { - - override fun getOldListSize() = oldItems.size - - override fun getNewListSize() = newItems.size - - override fun areItemsTheSame( - oldItemPosition: Int, - newItemPosition: Int - ) = oldItems[oldItemPosition].id == newItems[newItemPosition].id - - override fun areContentsTheSame( - oldItemPosition: Int, - newItemPosition: Int - ) = oldItems[oldItemPosition] == newItems[newItemPosition] -} diff --git a/app/src/main/java/com/afollestad/nocknock/adapter/TagAdapter.kt b/app/src/main/java/com/afollestad/nocknock/adapter/TagAdapter.kt deleted file mode 100644 index 7bf22e5..0000000 --- a/app/src/main/java/com/afollestad/nocknock/adapter/TagAdapter.kt +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.adapter - -import android.graphics.Color.WHITE -import android.view.LayoutInflater -import android.view.View -import android.view.View.OnClickListener -import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.ViewHolder -import com.afollestad.nocknock.R -import com.afollestad.nocknock.adapter.TagAdapter.TagViewHolder -import kotlinx.android.synthetic.main.list_item_tag.view.chip - -typealias TagsListener = (tags: List) -> Unit - -/** @author Aidan Follestad (@afollestad) */ -class TagAdapter( - private val listener: TagsListener -) : RecyclerView.Adapter() { - - private val tags = mutableListOf() - private val checked = mutableListOf() - - fun set(tags: List) { - this.tags.run { - clear() - addAll(tags) - } - notifyDataSetChanged() - } - - fun toggleChecked(index: Int) { - if (checked.contains(index)) { - checked.remove(index) - } else { - checked.add(index) - } - notifyItemChanged(index) - listener.invoke(getCheckedTags()) - } - - private fun getCheckedTags(): List { - return mutableListOf().apply { - checked.forEach { index -> add(tags[index]) } - } - } - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): TagViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.list_item_tag, parent, false) - return TagViewHolder(view, this) - } - - override fun getItemCount() = tags.size - - override fun onBindViewHolder( - holder: TagViewHolder, - position: Int - ) { - holder.bind(tags[position], checked.contains(position)) - } - - /** @author Aidan Follestad (@afollestad) */ - class TagViewHolder( - itemView: View, - private val adapter: TagAdapter - ) : ViewHolder(itemView), OnClickListener { - - override fun onClick(v: View) = adapter.toggleChecked(adapterPosition) - - init { - itemView.setOnClickListener(this) - } - - fun bind( - name: String, - checked: Boolean - ) = itemView.chip.run { - text = name - setTextColor( - if (checked) { - WHITE - } else { - ContextCompat.getColor(itemView.context, R.color.unchecked_chip_text) - } - ) - setBackgroundResource( - if (checked) { - R.drawable.checked_chip_selector - } else { - R.drawable.unchecked_chip_selector - } - ) - } - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/broadcasts/StatusUpdateIntentReceiver.kt b/app/src/main/java/com/afollestad/nocknock/broadcasts/StatusUpdateIntentReceiver.kt deleted file mode 100644 index c5567c5..0000000 --- a/app/src/main/java/com/afollestad/nocknock/broadcasts/StatusUpdateIntentReceiver.kt +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.broadcasts - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import androidx.lifecycle.Lifecycle.Event.ON_DESTROY -import androidx.lifecycle.Lifecycle.Event.ON_PAUSE -import androidx.lifecycle.Lifecycle.Event.ON_RESUME -import androidx.lifecycle.LifecycleObserver -import androidx.lifecycle.OnLifecycleEvent -import com.afollestad.nocknock.data.model.Site -import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.ACTION_STATUS_UPDATE -import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.KEY_UPDATE_MODEL -import com.afollestad.nocknock.utilities.providers.IntentProvider - -typealias SiteCallback = (Site) -> Unit - -/** @author Aidan Follestad (@afollestad) */ -class StatusUpdateIntentReceiver( - private val context: Context, - private val intentProvider: IntentProvider, - private var callback: SiteCallback? -) : LifecycleObserver { - - internal val intentReceiver = object : BroadcastReceiver() { - override fun onReceive( - context: Context, - intent: Intent - ) { - if (intent.action == ACTION_STATUS_UPDATE) { - val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? Site - ?: return - callback?.invoke(model) - } - } - } - - @OnLifecycleEvent(ON_RESUME) - fun onResume() { - val filter = intentProvider.createFilter(ACTION_STATUS_UPDATE) - context.registerReceiver(intentReceiver, filter) - } - - @OnLifecycleEvent(ON_PAUSE) - fun onPause() { - context.unregisterReceiver(intentReceiver) - } - - @OnLifecycleEvent(ON_DESTROY) - fun onDestroy() { - callback = null - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/di/AppComponent.kt b/app/src/main/java/com/afollestad/nocknock/di/AppComponent.kt new file mode 100644 index 0000000..7ae703b --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/di/AppComponent.kt @@ -0,0 +1,63 @@ +/* + * Licensed under Apache-2.0 + * + * Designed and developed by Aidan Follestad (@afollestad) + */ +package com.afollestad.nocknock.di + +import android.app.Application +import android.app.NotificationManager +import android.app.job.JobScheduler +import com.afollestad.nocknock.NockNockApp +import com.afollestad.nocknock.engine.EngineModule +import com.afollestad.nocknock.engine.statuscheck.BootReceiver +import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob +import com.afollestad.nocknock.notifications.NotificationsModule +import com.afollestad.nocknock.ui.addsite.AddSiteActivity +import com.afollestad.nocknock.ui.main.MainActivity +import com.afollestad.nocknock.ui.viewsite.ViewSiteActivity +import com.afollestad.nocknock.utilities.UtilitiesModule +import dagger.BindsInstance +import dagger.Component +import okhttp3.OkHttpClient +import javax.inject.Singleton + +/** @author Aidan Follestad (@afollestad) */ +@Singleton +@Component( + modules = [ + MainModule::class, + MainBindModule::class, + EngineModule::class, + NotificationsModule::class, + UtilitiesModule::class + ] +) +interface AppComponent { + + fun inject(app: NockNockApp) + + fun inject(activity: MainActivity) + + fun inject(activity: ViewSiteActivity) + + fun inject(activity: AddSiteActivity) + + fun inject(job: CheckStatusJob) + + fun inject(bootReceiver: BootReceiver) + + @Component.Builder + interface Builder { + + @BindsInstance fun application(application: Application): Builder + + @BindsInstance fun okHttpClient(okHttpClient: OkHttpClient): Builder + + @BindsInstance fun jobScheduler(jobScheduler: JobScheduler): Builder + + @BindsInstance fun notificationManager(notificationManager: NotificationManager): Builder + + fun build(): AppComponent + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/di/MainBindModule.kt b/app/src/main/java/com/afollestad/nocknock/di/MainBindModule.kt new file mode 100644 index 0000000..31d14d2 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/di/MainBindModule.kt @@ -0,0 +1,39 @@ +/* + * Licensed under Apache-2.0 + * + * Designed and developed by Aidan Follestad (@afollestad) + */ +package com.afollestad.nocknock.di + +import com.afollestad.nocknock.ui.addsite.AddSitePresenter +import com.afollestad.nocknock.ui.addsite.RealAddSitePresenter +import com.afollestad.nocknock.ui.main.MainPresenter +import com.afollestad.nocknock.ui.main.RealMainPresenter +import com.afollestad.nocknock.ui.viewsite.RealViewSitePresenter +import com.afollestad.nocknock.ui.viewsite.ViewSitePresenter +import dagger.Binds +import dagger.Module +import javax.inject.Singleton + +/** @author Aidan Follestad (@afollestad) */ +@Module +abstract class MainBindModule { + + @Binds + @Singleton + abstract fun provideMainPresenter( + presenter: RealMainPresenter + ): MainPresenter + + @Binds + @Singleton + abstract fun provideAddSitePresenter( + presenter: RealAddSitePresenter + ): AddSitePresenter + + @Binds + @Singleton + abstract fun provideViewSitePresenter( + presenter: RealViewSitePresenter + ): ViewSitePresenter +} diff --git a/app/src/main/java/com/afollestad/nocknock/di/MainModule.kt b/app/src/main/java/com/afollestad/nocknock/di/MainModule.kt new file mode 100644 index 0000000..6e873ca --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/di/MainModule.kt @@ -0,0 +1,29 @@ +/* + * Licensed under Apache-2.0 + * + * Designed and developed by Aidan Follestad (@afollestad) + */ +package com.afollestad.nocknock.di + +import com.afollestad.nocknock.R +import com.afollestad.nocknock.ui.main.MainActivity +import com.afollestad.nocknock.utilities.qualifiers.AppIconRes +import com.afollestad.nocknock.utilities.qualifiers.MainActivityClass +import dagger.Module +import dagger.Provides +import javax.inject.Singleton + +/** @author Aidan Follestad (@afollestad) */ +@Module +open class MainModule { + + @Provides + @Singleton + @AppIconRes + fun provideAppIconRes(): Int = R.mipmap.ic_launcher + + @Provides + @Singleton + @MainActivityClass + fun provideMainActivityClass(): Class<*> = MainActivity::class.java +} diff --git a/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.kt b/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.kt index f0c152f..4c16f20 100644 --- a/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.kt +++ b/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.kt @@ -1,17 +1,7 @@ -/** +/* + * Licensed under Apache-2.0 + * * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package com.afollestad.nocknock.dialogs @@ -20,7 +10,6 @@ import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.DialogFragment import com.afollestad.materialdialogs.MaterialDialog -import com.afollestad.nocknock.BuildConfig import com.afollestad.nocknock.R /** @author Aidan Follestad (@afollestad) */ @@ -35,9 +24,8 @@ class AboutDialog : DialogFragment() { } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val context = activity ?: throw IllegalStateException("Oh no!") - return MaterialDialog(context) - .title(text = getString(R.string.app_name_x, BuildConfig.VERSION_NAME)) + return MaterialDialog(activity!!) + .title(R.string.about) .positiveButton(R.string.dismiss) .message(R.string.about_body, html = true, lineHeightMultiplier = 1.4f) } diff --git a/app/src/main/java/com/afollestad/nocknock/koin/MainModule.kt b/app/src/main/java/com/afollestad/nocknock/koin/MainModule.kt deleted file mode 100644 index 07f6410..0000000 --- a/app/src/main/java/com/afollestad/nocknock/koin/MainModule.kt +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.koin - -import android.app.Application -import android.app.NotificationManager -import android.app.job.JobScheduler -import android.content.Context.JOB_SCHEDULER_SERVICE -import android.content.Context.NOTIFICATION_SERVICE -import androidx.room.Room.databaseBuilder -import com.afollestad.nocknock.data.AppDatabase -import com.afollestad.nocknock.data.Database1to2Migration -import com.afollestad.nocknock.data.Database2to3Migration -import com.afollestad.nocknock.data.Database3to4Migration -import com.afollestad.nocknock.data.Database4to5Migration -import com.afollestad.nocknock.notifications.Qualifiers.MAIN_ACTIVITY_CLASS -import com.afollestad.nocknock.ui.main.MainActivity -import com.afollestad.nocknock.utilities.ext.systemService -import okhttp3.OkHttpClient -import org.koin.dsl.module.module - -val mainActivityCls = MainActivity::class.java - -/** @author Aidan Follestad (@afollestad) */ -val mainModule = module { - - single(name = MAIN_ACTIVITY_CLASS) { mainActivityCls } - - single { - databaseBuilder(get(), AppDatabase::class.java, "NockNock.db") - .addMigrations( - Database1to2Migration(), - Database2to3Migration(), - Database3to4Migration(), - Database4to5Migration() - ) - .build() - } - - single { - OkHttpClient.Builder() - .addNetworkInterceptor { chain -> - val request = chain.request() - .newBuilder() - .addHeader("User-Agent", "com.afollestad.nocknock") - .build() - chain.proceed(request) - } - .build() - } - - single { - get().systemService(JOB_SCHEDULER_SERVICE) - } - - single { - get().systemService(NOTIFICATION_SERVICE) - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/koin/MainPrefModule.kt b/app/src/main/java/com/afollestad/nocknock/koin/MainPrefModule.kt deleted file mode 100644 index 654b76d..0000000 --- a/app/src/main/java/com/afollestad/nocknock/koin/MainPrefModule.kt +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.koin - -import com.afollestad.rxkprefs.RxkPrefs -import com.afollestad.rxkprefs.rxkPrefs -import org.koin.dsl.module.module - -const val PREF_DARK_MODE = "dark_mode" - -/** @author Aidan Follestad (@afollestad) */ -val prefModule = module { - - single { rxkPrefs(get(), "settings") } - - factory(name = PREF_DARK_MODE) { - get().boolean(PREF_DARK_MODE, false) - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/koin/ViewModelModule.kt b/app/src/main/java/com/afollestad/nocknock/koin/ViewModelModule.kt deleted file mode 100644 index ab8cd79..0000000 --- a/app/src/main/java/com/afollestad/nocknock/koin/ViewModelModule.kt +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.koin - -import com.afollestad.nocknock.ui.addsite.AddSiteViewModel -import com.afollestad.nocknock.ui.main.MainViewModel -import com.afollestad.nocknock.ui.viewsite.ViewSiteViewModel -import com.afollestad.nocknock.utilities.Qualifiers.IO_DISPATCHER -import com.afollestad.nocknock.utilities.Qualifiers.MAIN_DISPATCHER -import org.koin.androidx.viewmodel.ext.koin.viewModel -import org.koin.dsl.module.module - -/** @author Aidan Follestad (@afollestad) */ -val viewModelModule = module { - - viewModel { - MainViewModel( - get(), - get(), - get(), - get(name = MAIN_DISPATCHER), - get(name = IO_DISPATCHER) - ) - } - - viewModel { - AddSiteViewModel( - get(), - get(), - get(name = MAIN_DISPATCHER), - get(name = IO_DISPATCHER) - ) - } - - viewModel { - ViewSiteViewModel( - get(), - get(), - get(), - get(), - get(name = MAIN_DISPATCHER), - get(name = IO_DISPATCHER) - ) - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/logging/FabricTree.kt b/app/src/main/java/com/afollestad/nocknock/logging/FabricTree.kt deleted file mode 100644 index 10b97fb..0000000 --- a/app/src/main/java/com/afollestad/nocknock/logging/FabricTree.kt +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.logging - -import com.crashlytics.android.Crashlytics -import timber.log.Timber - -/** @author Aidan Follestad (@afollestad) */ -class FabricTree : Timber.Tree() { - - override fun log( - priority: Int, - tag: String?, - message: String, - t: Throwable? - ) { - if (t != null) { - Crashlytics.setString("crash_tag", tag) - Crashlytics.logException(t) - } else { - Crashlytics.log(priority, tag, message) - } - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt deleted file mode 100644 index 3220567..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.ui - -import android.content.res.Configuration -import android.os.Build -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import com.afollestad.nocknock.R -import com.afollestad.nocknock.koin.PREF_DARK_MODE -import com.afollestad.nocknock.ui.NightMode.DISABLED -import com.afollestad.nocknock.ui.NightMode.ENABLED -import com.afollestad.nocknock.ui.NightMode.UNKNOWN -import com.afollestad.nocknock.utilities.rx.attachLifecycle -import com.afollestad.rxkprefs.Pref -import org.koin.android.ext.android.inject -import timber.log.Timber.d as log - -/** @author Aidan Follestad (afollestad) */ -abstract class DarkModeSwitchActivity : AppCompatActivity() { - - private var isDarkModeEnabled: Boolean = false - private val darkModePref by inject>(name = PREF_DARK_MODE) - - override fun onCreate(savedInstanceState: Bundle?) { - isDarkModeEnabled = isDarkMode() - setTheme(themeRes()) - super.onCreate(savedInstanceState) - - if (getCurrentNightMode() == UNKNOWN) { - darkModePref.observe() - .filter { it != isDarkModeEnabled } - .subscribe { - log("Theme changed, recreating Activity.") - recreate() - } - .attachLifecycle(this) - } - } - - protected fun getCurrentNightMode(): NightMode { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { - return UNKNOWN - } - return when (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) { - Configuration.UI_MODE_NIGHT_YES -> return ENABLED - Configuration.UI_MODE_NIGHT_NO -> return DISABLED - else -> UNKNOWN - } - } - - protected fun isDarkMode(): Boolean { - return when (getCurrentNightMode()) { - ENABLED -> true - DISABLED -> false - else -> darkModePref.get() - } - } - - protected fun toggleDarkMode() = setDarkMode(!isDarkMode()) - - private fun setDarkMode(darkMode: Boolean) = darkModePref.set(darkMode) - - private fun themeRes() = if (isDarkMode()) { - R.style.AppTheme_Dark - } else { - R.style.AppTheme - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt b/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt deleted file mode 100644 index 2930fea..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.ui - -/** @author Aidan Follestad (@afollestad) */ -enum class NightMode { - /** Night mode is on at the system level. */ - ENABLED, - /** Night mode is off at the system level. */ - DISABLED, - /** We don't know about night mode, fallback to custom impl. */ - UNKNOWN -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/ScopedViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/ScopedViewModel.kt deleted file mode 100644 index 750173a..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/ScopedViewModel.kt +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.ui - -import androidx.lifecycle.ViewModel -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import org.jetbrains.annotations.TestOnly - -/** @author Aidan Follestad (@afollestad) */ -abstract class ScopedViewModel(mainDispatcher: CoroutineDispatcher) : ViewModel() { - - private val job = Job() - protected val scope = CoroutineScope(job + mainDispatcher) - - override fun onCleared() { - super.onCleared() - job.cancel() - } - - @TestOnly open fun destroy() = job.cancel() -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt index e15a29f..65e71c8 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt @@ -1,137 +1,88 @@ -/** +/* + * Licensed under Apache-2.0 + * * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package com.afollestad.nocknock.ui.addsite import android.annotation.SuppressLint -import android.content.Intent -import android.content.Intent.ACTION_OPEN_DOCUMENT -import android.content.Intent.CATEGORY_OPENABLE import android.os.Bundle import android.widget.ArrayAdapter -import androidx.lifecycle.Observer +import androidx.appcompat.app.AppCompatActivity import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.model.Site -import com.afollestad.nocknock.data.model.ValidationMode -import com.afollestad.nocknock.ui.DarkModeSwitchActivity -import com.afollestad.nocknock.ui.viewsite.KEY_SITE -import com.afollestad.nocknock.utilities.ext.onTextChanged -import com.afollestad.nocknock.utilities.ext.setTextAndMaintainSelection -import com.afollestad.nocknock.utilities.livedata.distinct -import com.afollestad.nocknock.viewcomponents.ext.dimenFloat -import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition -import com.afollestad.nocknock.viewcomponents.ext.onScroll -import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData -import com.afollestad.nocknock.viewcomponents.livedata.toViewText -import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility -import com.afollestad.vvalidator.form -import com.afollestad.vvalidator.form.Form +import com.afollestad.nocknock.data.ValidationMode +import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT +import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE +import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH +import com.afollestad.nocknock.data.indexToValidationMode +import com.afollestad.nocknock.utilities.ext.ScopeReceiver +import com.afollestad.nocknock.utilities.ext.injector +import com.afollestad.nocknock.utilities.ext.scopeWhileAttached +import com.afollestad.nocknock.viewcomponents.ext.conceal +import com.afollestad.nocknock.viewcomponents.ext.onItemSelected +import com.afollestad.nocknock.viewcomponents.ext.onLayout +import com.afollestad.nocknock.viewcomponents.ext.showOrHide +import com.afollestad.nocknock.viewcomponents.ext.trimmedText import kotlinx.android.synthetic.main.activity_addsite.checkIntervalLayout -import kotlinx.android.synthetic.main.activity_addsite.headersLayout +import kotlinx.android.synthetic.main.activity_addsite.doneBtn import kotlinx.android.synthetic.main.activity_addsite.inputName -import kotlinx.android.synthetic.main.activity_addsite.inputTags import kotlinx.android.synthetic.main.activity_addsite.inputUrl import kotlinx.android.synthetic.main.activity_addsite.loadingProgress -import kotlinx.android.synthetic.main.activity_addsite.responseTimeoutInput import kotlinx.android.synthetic.main.activity_addsite.responseValidationMode import kotlinx.android.synthetic.main.activity_addsite.responseValidationSearchTerm -import kotlinx.android.synthetic.main.activity_addsite.retryPolicyLayout +import kotlinx.android.synthetic.main.activity_addsite.rootView import kotlinx.android.synthetic.main.activity_addsite.scriptInputLayout -import kotlinx.android.synthetic.main.activity_addsite.scrollView -import kotlinx.android.synthetic.main.activity_addsite.sslCertificateBrowse -import kotlinx.android.synthetic.main.activity_addsite.sslCertificateInput import kotlinx.android.synthetic.main.activity_addsite.textUrlWarning +import kotlinx.android.synthetic.main.activity_addsite.toolbar import kotlinx.android.synthetic.main.activity_addsite.validationModeDescription -import kotlinx.android.synthetic.main.include_app_bar.toolbar -import org.koin.androidx.viewmodel.ext.android.viewModel -import kotlinx.android.synthetic.main.include_app_bar.app_toolbar as appToolbar -import kotlinx.android.synthetic.main.include_app_bar.toolbar_title as toolbarTitle +import javax.inject.Inject +import kotlin.coroutines.CoroutineContext +import kotlin.math.max +import kotlin.properties.Delegates.notNull + +const val KEY_FAB_X = "fab_x" +const val KEY_FAB_Y = "fab_y" +const val KEY_FAB_SIZE = "fab_size" /** @author Aidan Follestad (@afollestad) */ -class AddSiteActivity : DarkModeSwitchActivity() { - companion object { - private const val SELECT_CERT_FILE_RQ = 23 - } +class AddSiteActivity : AppCompatActivity(), AddSiteView { - private val viewModel by viewModel() - private lateinit var validationForm: Form + var isClosing: Boolean = false + var revealCx by notNull() + var revealCy by notNull() + var revealRadius by notNull() + + @Inject lateinit var presenter: AddSitePresenter @SuppressLint("SetTextI18n") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + injector().injectInto(this) setContentView(R.layout.activity_addsite) - setupUi() - setupValidation() + presenter.takeView(this) - lifecycle.addObserver(viewModel) + toolbar.setNavigationOnClickListener { closeActivityWithReveal() } - // Populate view model with initial data - val model = intent.getSerializableExtra(KEY_SITE) as? Site - model?.let { viewModel.prePopulateFromModel(model) } + if (savedInstanceState == null) { + rootView.conceal() + rootView.onLayout { + val fabSize = intent.getIntExtra(KEY_FAB_SIZE, 0) + val fabX = intent.getFloatExtra(KEY_FAB_X, 0f) + .toInt() + val fabY = intent.getFloatExtra(KEY_FAB_Y, 0f) + .toInt() - // Loading - loadingProgress.observe(this, viewModel.onIsLoading()) + revealCx = fabX + fabSize / 2 + revealCy = (fabY + toolbar.measuredHeight + fabSize / 2) + revealRadius = max(revealCx, revealCy).toFloat() - // Name - inputName.attachLiveData(this, viewModel.name) + circularRevealActivity() + } + } - // Tags - inputTags.attachLiveData(this, viewModel.tags) - - // Url - inputUrl.attachLiveData(this, viewModel.url) - viewModel.onUrlWarningVisibility() - .toViewVisibility(this, textUrlWarning) - - // Timeout - responseTimeoutInput.attachLiveData(this, viewModel.timeout) - - // Validation mode - responseValidationMode.attachLiveData( - lifecycleOwner = this, - data = viewModel.validationMode, - outTransformer = { ValidationMode.fromIndex(it) }, - inTransformer = { it.toIndex() } - ) - viewModel.onValidationModeDescription() - .toViewText(this, validationModeDescription) - - // Validation search term - responseValidationSearchTerm.attachLiveData( - lifecycleOwner = this, - data = viewModel.validationSearchTerm, - pullInChanges = false - ) - viewModel.onValidationSearchTermVisibility() - .toViewVisibility(this, responseValidationSearchTerm) - - // SSL certificate - sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it } - viewModel.certificateUri.distinct() - .observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) }) - - // Headers - headersLayout.attach(viewModel.headers) - } - - private fun setupUi() { - toolbarTitle.setText(R.string.add_site) - toolbar.run { - inflateMenu(R.menu.menu_addsite) - setNavigationIcon(R.drawable.ic_action_close) - setNavigationOnClickListener { finish() } + inputUrl.setOnFocusChangeListener { _, hasFocus -> + presenter.onUrlInputFocusChange(hasFocus, inputUrl.trimmedText()) } val validationOptionsAdapter = ArrayAdapter( @@ -140,96 +91,98 @@ class AddSiteActivity : DarkModeSwitchActivity() { resources.getStringArray(R.array.response_validation_options) ) validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown) + responseValidationMode.adapter = validationOptionsAdapter + responseValidationMode.onItemSelected(presenter::onValidationModeSelected) - scrollView.onScroll { - appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) { - appToolbar.dimenFloat(R.dimen.default_elevation) - } else { - 0f - } - } + doneBtn.setOnClickListener { + val checkInterval = checkIntervalLayout.getSelectedCheckInterval() + val validationMode = + responseValidationMode.selectedItemPosition.indexToValidationMode() - // SSL certificate - sslCertificateBrowse.setOnClickListener { - val intent = Intent(ACTION_OPEN_DOCUMENT).apply { - addCategory(CATEGORY_OPENABLE) - type = "*/*" - } - startActivityForResult(intent, SELECT_CERT_FILE_RQ) + isClosing = true + presenter.commit( + name = inputName.trimmedText(), + url = inputUrl.trimmedText(), + checkInterval = checkInterval, + validationMode = validationMode, + validationContent = validationMode.validationContent() + ) } } - private fun setupValidation() { - validationForm = form { - input(inputName, name = "Name") { - isNotEmpty().description(R.string.please_enter_name) - } - input(inputUrl, name = "URL") { - isNotEmpty().description(R.string.please_enter_url) - isUrl().description(R.string.please_enter_valid_url) - } - input(responseTimeoutInput, name = "Timeout", optional = true) { - isNumber().greaterThan(0) - .description(R.string.please_enter_networkTimeout) - } - input(responseValidationSearchTerm, name = "Search term") { - conditional(responseValidationSearchTerm.isVisibleCondition()) { - isNotEmpty().description(R.string.please_enter_search_term) - } - } - input(sslCertificateInput, name = "Certificate Path", optional = true) { - isUri().hasScheme("file", "content") - .that { it.host != null } - .description(R.string.please_enter_validCertUri) - } - submitWith(toolbar.menu, R.id.commit) { - viewModel.commit { - setResult(RESULT_OK) - finish() - } - } - } - - // Validation script - scriptInputLayout.attach( - codeData = viewModel.validationScript, - visibility = viewModel.onValidationScriptVisibility(), - form = validationForm - ) - - // Check interval - checkIntervalLayout.attach( - valueData = viewModel.checkIntervalValue, - multiplierData = viewModel.checkIntervalUnit, - form = validationForm - ) - - // Retry Policy - retryPolicyLayout.attach( - timesData = viewModel.retryPolicyTimes, - minutesData = viewModel.retryPolicyMinutes, - form = validationForm - ) + override fun onDestroy() { + presenter.dropView() + super.onDestroy() } - override fun onResume() { - super.onResume() - appToolbar.elevation = if (scrollView.scrollY > appToolbar.measuredHeight / 2) { - appToolbar.dimenFloat(R.dimen.default_elevation) + override fun setLoading() = loadingProgress.setLoading() + + override fun setDoneLoading() = loadingProgress.setDone() + + override fun showOrHideUrlSchemeWarning(show: Boolean) { + textUrlWarning.showOrHide(show) + if (show) { + textUrlWarning.setText(R.string.warning_http_url) + } + } + + override fun showOrHideValidationSearchTerm(show: Boolean) = + responseValidationSearchTerm.showOrHide(show) + + override fun showOrHideScriptInput(show: Boolean) = scriptInputLayout.showOrHide(show) + + override fun setValidationModeDescription(res: Int) = validationModeDescription.setText(res) + + override fun setInputErrors(errors: InputErrors) { + isClosing = false + inputName.error = if (errors.name != null) { + getString(errors.name!!) } else { - 0f + null } + inputUrl.error = if (errors.url != null) { + getString(errors.url!!) + } else { + null + } + checkIntervalLayout.setError( + if (errors.checkInterval != null) { + getString(errors.checkInterval!!) + } else { + null + } + ) + responseValidationSearchTerm.error = if (errors.termSearch != null) { + getString(errors.termSearch!!) + } else { + null + } + scriptInputLayout.setError( + if (errors.javaScript != null) { + getString(errors.javaScript!!) + } else { + null + } + ) } - override fun onActivityResult( - requestCode: Int, - resultCode: Int, - resultData: Intent? - ) { - super.onActivityResult(requestCode, resultCode, resultData) - if (requestCode == SELECT_CERT_FILE_RQ && resultCode == RESULT_OK) { - sslCertificateInput.setText(resultData?.data?.toString() ?: "") - } + override fun onSiteAdded() { + setResult(RESULT_OK) + finish() + overridePendingTransition(R.anim.fade_out, R.anim.fade_out) + } + + override fun scopeWhileAttached( + context: CoroutineContext, + exec: ScopeReceiver + ) = rootView.scopeWhileAttached(context, exec) + + override fun onBackPressed() = closeActivityWithReveal() + + private fun ValidationMode.validationContent() = when (this) { + STATUS_CODE -> null + TERM_SEARCH -> responseValidationSearchTerm.trimmedText() + JAVASCRIPT -> scriptInputLayout.getCode() } } diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivityExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivityExt.kt new file mode 100644 index 0000000..7f5eb7f --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivityExt.kt @@ -0,0 +1,43 @@ +/* + * Licensed under Apache-2.0 + * + * Designed and developed by Aidan Follestad (@afollestad) + */ +package com.afollestad.nocknock.ui.addsite + +import android.view.ViewAnimationUtils +import android.view.animation.AccelerateInterpolator +import android.view.animation.DecelerateInterpolator +import com.afollestad.nocknock.utilities.ext.onEnd +import com.afollestad.nocknock.viewcomponents.ext.conceal +import com.afollestad.nocknock.viewcomponents.ext.show +import kotlinx.android.synthetic.main.activity_addsite.rootView + +const val REVEAL_DURATION = 300L + +internal fun AddSiteActivity.circularRevealActivity() { + val circularReveal = + ViewAnimationUtils.createCircularReveal(rootView, revealCx, revealCy, 0f, revealRadius) + .apply { + duration = REVEAL_DURATION + interpolator = DecelerateInterpolator() + } + rootView.show() + circularReveal.start() +} + +internal fun AddSiteActivity.closeActivityWithReveal() { + if (isClosing) return + isClosing = true + ViewAnimationUtils.createCircularReveal(rootView, revealCx, revealCy, revealRadius, 0f) + .apply { + duration = REVEAL_DURATION + interpolator = AccelerateInterpolator() + onEnd { + rootView.conceal() + finish() + overridePendingTransition(0, 0) + } + start() + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSitePresenter.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSitePresenter.kt new file mode 100644 index 0000000..61e1b7e --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSitePresenter.kt @@ -0,0 +1,167 @@ +/* + * Licensed under Apache-2.0 + * + * Designed and developed by Aidan Follestad (@afollestad) + */ +package com.afollestad.nocknock.ui.addsite + +import androidx.annotation.CheckResult +import com.afollestad.nocknock.R +import com.afollestad.nocknock.data.ServerModel +import com.afollestad.nocknock.data.ServerStatus.WAITING +import com.afollestad.nocknock.data.ValidationMode +import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT +import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH +import com.afollestad.nocknock.engine.db.ServerModelStore +import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import okhttp3.HttpUrl +import javax.inject.Inject + +/** @author Aidan Follestad (@afollestad) */ +data class InputErrors( + var name: Int? = null, + var url: Int? = null, + var checkInterval: Int? = null, + var termSearch: Int? = null, + var javaScript: Int? = null +) { + @CheckResult fun any(): Boolean { + return name != null || url != null || checkInterval != null || + termSearch != null || javaScript != null + } +} + +/** @author Aidan Follestad (@afollestad) */ +interface AddSitePresenter { + + fun takeView(view: AddSiteView) + + fun onUrlInputFocusChange( + focused: Boolean, + content: String + ) + + fun onValidationModeSelected(index: Int) + + fun commit( + name: String, + url: String, + checkInterval: Long, + validationMode: ValidationMode, + validationContent: String? + ) + + fun dropView() +} + +/** @author Aidan Follestad (@afollestad) */ +class RealAddSitePresenter @Inject constructor( + private val serverModelStore: ServerModelStore, + private val checkStatusManager: CheckStatusManager +) : AddSitePresenter { + + private var view: AddSiteView? = null + + override fun takeView(view: AddSiteView) { + this.view = view + } + + override fun onUrlInputFocusChange( + focused: Boolean, + content: String + ) { + if (content.isEmpty() || focused) { + return + } + val url = HttpUrl.parse(content) + if (url == null || + (url.scheme() != "http" && + url.scheme() != "https") + ) { + view?.showOrHideUrlSchemeWarning(true) + } else { + view?.showOrHideUrlSchemeWarning(false) + } + } + + override fun onValidationModeSelected(index: Int) = with(view!!) { + showOrHideValidationSearchTerm(index == 1) + showOrHideScriptInput(index == 2) + setValidationModeDescription( + when (index) { + 0 -> R.string.validation_mode_status_desc + 1 -> R.string.validation_mode_term_desc + 2 -> R.string.validation_mode_javascript_desc + else -> throw IllegalStateException("Unknown validation mode position: $index") + } + ) + } + + override fun commit( + name: String, + url: String, + checkInterval: Long, + validationMode: ValidationMode, + validationContent: String? + ) { + val inputErrors = InputErrors() + + if (name.isEmpty()) { + inputErrors.name = R.string.please_enter_name + } + if (url.isEmpty()) { + inputErrors.url = R.string.please_enter_url + } else if (HttpUrl.parse(url) == null) { + inputErrors.url = R.string.please_enter_valid_url + } + if (checkInterval <= 0) { + inputErrors.checkInterval = R.string.please_enter_check_interval + } + if (validationMode == TERM_SEARCH && validationContent.isNullOrEmpty()) { + inputErrors.termSearch = R.string.please_enter_search_term + } else if (validationMode == JAVASCRIPT && validationContent.isNullOrEmpty()) { + inputErrors.javaScript = R.string.please_enter_javaScript + } + + if (inputErrors.any()) { + view?.setInputErrors(inputErrors) + return + } + + val newModel = ServerModel( + name = name, + url = url, + status = WAITING, + checkInterval = checkInterval, + validationMode = validationMode, + validationContent = validationContent + ) + + with(view!!) { + scopeWhileAttached(Main) { + launch(coroutineContext) { + setLoading() + val storedModel = async(IO) { + serverModelStore.put(newModel) + }.await() + + checkStatusManager.scheduleCheck( + site = storedModel, + rightNow = true, + cancelPrevious = true + ) + setDoneLoading() + onSiteAdded() + } + } + } + } + + override fun dropView() { + view = null + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteView.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteView.kt new file mode 100644 index 0000000..8c6084f --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteView.kt @@ -0,0 +1,35 @@ +/* + * Licensed under Apache-2.0 + * + * Designed and developed by Aidan Follestad (@afollestad) + */ +package com.afollestad.nocknock.ui.addsite + +import androidx.annotation.StringRes +import com.afollestad.nocknock.utilities.ext.ScopeReceiver +import kotlin.coroutines.CoroutineContext + +/** @author Aidan Follestad (@afollestad) */ +interface AddSiteView { + + fun setLoading() + + fun setDoneLoading() + + fun showOrHideUrlSchemeWarning(show: Boolean) + + fun showOrHideValidationSearchTerm(show: Boolean) + + fun showOrHideScriptInput(show: Boolean) + + fun setValidationModeDescription(@StringRes res: Int) + + fun setInputErrors(errors: InputErrors) + + fun onSiteAdded() + + fun scopeWhileAttached( + context: CoroutineContext, + exec: ScopeReceiver + ) +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt deleted file mode 100644 index d7d8ed5..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.ui.addsite - -import androidx.annotation.CheckResult -import androidx.annotation.VisibleForTesting -import androidx.annotation.VisibleForTesting.PRIVATE -import androidx.lifecycle.Lifecycle.Event.ON_START -import androidx.lifecycle.LifecycleObserver -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.OnLifecycleEvent -import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.AppDatabase -import com.afollestad.nocknock.data.model.Header -import com.afollestad.nocknock.data.model.RetryPolicy -import com.afollestad.nocknock.data.model.Site -import com.afollestad.nocknock.data.model.SiteSettings -import com.afollestad.nocknock.data.model.Status.WAITING -import com.afollestad.nocknock.data.model.ValidationMode -import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT -import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE -import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH -import com.afollestad.nocknock.data.model.ValidationResult -import com.afollestad.nocknock.data.putSite -import com.afollestad.nocknock.engine.validation.ValidationExecutor -import com.afollestad.nocknock.ui.ScopedViewModel -import com.afollestad.nocknock.utilities.ext.MINUTE -import com.afollestad.nocknock.utilities.livedata.map -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import okhttp3.HttpUrl -import java.lang.System.currentTimeMillis - -/** @author Aidan Follestad (@afollestad) */ -class AddSiteViewModel( - private val database: AppDatabase, - private val validationManager: ValidationExecutor, - mainDispatcher: CoroutineDispatcher, - private val ioDispatcher: CoroutineDispatcher -) : ScopedViewModel(mainDispatcher), LifecycleObserver { - - // Public properties - val name = MutableLiveData() - val tags = MutableLiveData() - val url = MutableLiveData() - val timeout = MutableLiveData() - val validationMode = MutableLiveData() - val validationSearchTerm = MutableLiveData() - val validationScript = MutableLiveData() - val checkIntervalValue = MutableLiveData() - val checkIntervalUnit = MutableLiveData() - val retryPolicyTimes = MutableLiveData() - val retryPolicyMinutes = MutableLiveData() - val headers = MutableLiveData>() - val certificateUri = MutableLiveData() - - @OnLifecycleEvent(ON_START) - fun setDefaults() { - timeout.value = 10000 - validationMode.value = STATUS_CODE - checkIntervalValue.value = 0 - checkIntervalUnit.value = MINUTE - retryPolicyMinutes.value = 0 - retryPolicyMinutes.value = 0 - tags.value = "" - headers.value = emptyList() - } - - private val isLoading = MutableLiveData() - - @CheckResult fun onIsLoading(): LiveData = isLoading - - @CheckResult fun onUrlWarningVisibility(): LiveData { - return url.map { - val parsed = HttpUrl.parse(it) - return@map it.isNotEmpty() && parsed == null - } - } - - @CheckResult fun onValidationModeDescription(): LiveData { - return validationMode.map { - when (it!!) { - STATUS_CODE -> R.string.validation_mode_status_desc - TERM_SEARCH -> R.string.validation_mode_term_desc - JAVASCRIPT -> R.string.validation_mode_javascript_desc - } - } - } - - @CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH } - - @CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT } - - // Actions - fun commit(done: () -> Unit) { - scope.launch { - val newModel = generateDbModel() ?: return@launch - isLoading.value = true - - val storedModel = withContext(ioDispatcher) { - database.putSite(newModel) - } - validationManager.scheduleValidation( - site = storedModel, - rightNow = true, - cancelPrevious = true - ) - - isLoading.value = false - done() - } - } - - // Utilities - @VisibleForTesting(otherwise = PRIVATE) - fun getCheckIntervalMs(): Long { - val value = checkIntervalValue.value ?: return 0 - val unit = checkIntervalUnit.value ?: return 0 - return value * unit - } - - @VisibleForTesting(otherwise = PRIVATE) - fun getValidationArgs(): String? { - return when (validationMode.value) { - TERM_SEARCH -> validationSearchTerm.value - JAVASCRIPT -> validationScript.value - else -> null - } - } - - private fun generateDbModel(): Site? { - val timeout = timeout.value ?: 10_000 - val cleanedTags = tags.value?.split(',')?.joinToString { it.trim() } ?: "" - - val newSettings = SiteSettings( - validationIntervalMs = getCheckIntervalMs(), - validationMode = validationMode.value!!, - validationArgs = getValidationArgs(), - networkTimeout = timeout, - disabled = false, - certificate = certificateUri.value?.toString() - ) - - val newLastResult = ValidationResult( - timestampMs = currentTimeMillis(), - status = WAITING, - reason = null - ) - - val retryPolicyTimes = retryPolicyTimes.value ?: 0 - val retryPolicyMinutes = retryPolicyMinutes.value ?: 0 - val newRetryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) { - RetryPolicy( - count = retryPolicyTimes, - minutes = retryPolicyMinutes - ) - } else { - null - } - - return Site( - id = 0, - name = name.value!!.trim(), - url = url.value!!.trim(), - tags = cleanedTags, - settings = newSettings, - lastResult = newLastResult, - retryPolicy = newRetryPolicy, - headers = headers.value ?: emptyList() - ) - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelExt.kt deleted file mode 100644 index c524555..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelExt.kt +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.ui.addsite - -import com.afollestad.nocknock.data.model.RetryPolicy -import com.afollestad.nocknock.data.model.Site -import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT -import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH -import com.afollestad.nocknock.utilities.ext.DAY -import com.afollestad.nocknock.utilities.ext.HOUR -import com.afollestad.nocknock.utilities.ext.MINUTE -import com.afollestad.nocknock.utilities.ext.WEEK -import kotlin.math.ceil - -fun AddSiteViewModel.prePopulateFromModel(site: Site) { - val settings = site.settings ?: throw IllegalArgumentException("Settings must be populated!") - - name.value = site.name - tags.value = site.tags - url.value = site.url - timeout.value = settings.networkTimeout - - validationMode.value = settings.validationMode - when (settings.validationMode) { - TERM_SEARCH -> { - validationSearchTerm.value = settings.validationArgs - validationScript.value = null - } - JAVASCRIPT -> { - validationSearchTerm.value = null - validationScript.value = settings.validationArgs - } - else -> { - validationSearchTerm.value = null - validationScript.value = null - } - } - - setCheckInterval(settings.validationIntervalMs) - setRetryPolicy(site.retryPolicy) - headers.value = site.headers -} - -private fun AddSiteViewModel.setCheckInterval(interval: Long) { - when { - interval >= WEEK -> { - checkIntervalValue.value = - getIntervalFromUnit(interval, WEEK) - checkIntervalUnit.value = WEEK - } - interval >= DAY -> { - checkIntervalValue.value = - getIntervalFromUnit(interval, DAY) - checkIntervalUnit.value = DAY - } - interval >= HOUR -> { - checkIntervalValue.value = - getIntervalFromUnit(interval, HOUR) - checkIntervalUnit.value = HOUR - } - interval >= MINUTE -> { - checkIntervalValue.value = - getIntervalFromUnit(interval, MINUTE) - checkIntervalUnit.value = MINUTE - } - else -> { - checkIntervalValue.value = 0 - checkIntervalUnit.value = MINUTE - } - } -} - -private fun AddSiteViewModel.setRetryPolicy(policy: RetryPolicy?) { - if (policy == null) return - retryPolicyTimes.value = policy.count - retryPolicyMinutes.value = policy.minutes -} - -private fun getIntervalFromUnit( - millis: Long, - unit: Long -): Int { - val intervalFloat = millis.toFloat() - val byFloat = unit.toFloat() - return ceil(intervalFloat / byFloat).toInt() -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt index aec76d8..e9e3e83 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt @@ -1,125 +1,78 @@ -/** +/* + * Licensed under Apache-2.0 + * * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package com.afollestad.nocknock.ui.main +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.os.Bundle -import androidx.lifecycle.Observer +import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.LinearLayoutManager.HORIZONTAL import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.list.listItems import com.afollestad.nocknock.R -import com.afollestad.nocknock.adapter.SiteAdapter -import com.afollestad.nocknock.adapter.TagAdapter -import com.afollestad.nocknock.broadcasts.StatusUpdateIntentReceiver -import com.afollestad.nocknock.data.model.Site +import com.afollestad.nocknock.adapter.ServerAdapter +import com.afollestad.nocknock.data.ServerModel import com.afollestad.nocknock.dialogs.AboutDialog -import com.afollestad.nocknock.notifications.NockNotificationManager -import com.afollestad.nocknock.ui.DarkModeSwitchActivity -import com.afollestad.nocknock.ui.NightMode.UNKNOWN -import com.afollestad.nocknock.utilities.providers.IntentProvider -import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility +import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE +import com.afollestad.nocknock.utilities.ext.ScopeReceiver +import com.afollestad.nocknock.utilities.ext.injector +import com.afollestad.nocknock.utilities.ext.safeRegisterReceiver +import com.afollestad.nocknock.utilities.ext.safeUnregisterReceiver +import com.afollestad.nocknock.utilities.ext.scopeWhileAttached +import com.afollestad.nocknock.viewcomponents.ext.showOrHide import kotlinx.android.synthetic.main.activity_main.fab import kotlinx.android.synthetic.main.activity_main.list -import kotlinx.android.synthetic.main.activity_main.loadingProgress -import kotlinx.android.synthetic.main.include_app_bar.toolbar +import kotlinx.android.synthetic.main.activity_main.rootView +import kotlinx.android.synthetic.main.activity_main.toolbar import kotlinx.android.synthetic.main.include_empty_view.emptyText -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel -import kotlinx.android.synthetic.main.activity_main.tags_list as tagsList +import javax.inject.Inject +import kotlin.coroutines.CoroutineContext /** @author Aidan Follestad (@afollestad) */ -class MainActivity : DarkModeSwitchActivity() { +class MainActivity : AppCompatActivity(), MainView { - private val notificationManager by inject() - private val intentProvider by inject() - - internal val viewModel by viewModel() - - private lateinit var siteAdapter: SiteAdapter - private lateinit var tagAdapter: TagAdapter - - private val statusUpdateReceiver by lazy { - StatusUpdateIntentReceiver(application, intentProvider) { - viewModel.postSiteUpdate(it) - } + private val intentReceiver = object : BroadcastReceiver() { + override fun onReceive( + context: Context, + intent: Intent + ) = presenter.onBroadcast(intent) } + @Inject lateinit var presenter: MainPresenter + + private lateinit var adapter: ServerAdapter + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + injector().injectInto(this) setContentView(R.layout.activity_main) - setupUi() + presenter.takeView(this) - notificationManager.createChannels() - - lifecycle.run { - addObserver(viewModel) - addObserver(statusUpdateReceiver) - } - - viewModel.onSites() - .observe(this, Observer { siteAdapter.set(it) }) - viewModel.onEmptyTextVisibility() - .toViewVisibility(this, emptyText) - viewModel.onTags() - .observe(this, Observer { tagAdapter.set(it) }) - viewModel.onTagsListVisibility() - .toViewVisibility(this, tagsList) - loadingProgress.observe(this, viewModel.onIsLoading()) - - processIntent(intent) - } - - private fun setupUi() { - toolbar.run { - inflateMenu(R.menu.menu_main) - menu.findItem(R.id.dark_mode) - .apply { - if (getCurrentNightMode() == UNKNOWN) { - isChecked = isDarkMode() - } else { - isVisible = false - } - } - setOnMenuItemClickListener { item -> - when (item.itemId) { - R.id.about -> AboutDialog.show(this@MainActivity) - R.id.dark_mode -> toggleDarkMode() - } - return@setOnMenuItemClickListener true + toolbar.inflateMenu(R.menu.menu_main) + toolbar.setOnMenuItemClickListener { item -> + if (item.itemId == R.id.about) { + AboutDialog.show(this) } + return@setOnMenuItemClickListener true } - siteAdapter = SiteAdapter(this::onSiteSelected) - list.run { - layoutManager = LinearLayoutManager(this@MainActivity) - adapter = siteAdapter - addItemDecoration(DividerItemDecoration(this@MainActivity, VERTICAL)) - } + adapter = ServerAdapter(this::onSiteSelected) - tagAdapter = TagAdapter(viewModel::onTagSelection) - tagsList.run { - layoutManager = LinearLayoutManager(this@MainActivity, HORIZONTAL, false) - adapter = tagAdapter - } + list.layoutManager = LinearLayoutManager(this) + list.adapter = adapter + list.addItemDecoration(DividerItemDecoration(this, VERTICAL)) fab.setOnClickListener { addSite() } + + processIntent(intent) } override fun onNewIntent(intent: Intent?) { @@ -127,8 +80,50 @@ class MainActivity : DarkModeSwitchActivity() { intent?.let(::processIntent) } + override fun onResume() { + super.onResume() + val filter = IntentFilter().apply { + addAction(ACTION_STATUS_UPDATE) + } + safeRegisterReceiver(intentReceiver, filter) + presenter.resume() + } + + override fun onPause() { + super.onPause() + safeUnregisterReceiver(intentReceiver) + } + + override fun onDestroy() { + presenter.dropView() + super.onDestroy() + } + + override fun setModels(models: List) { + list.post { + adapter.set(models) + emptyText.showOrHide(models.isEmpty()) + } + } + + override fun updateModel(model: ServerModel) { + list.post { adapter.update(model) } + } + + override fun onSiteDeleted(model: ServerModel) { + list.post { + adapter.remove(model) + emptyText.showOrHide(adapter.itemCount == 0) + } + } + + override fun scopeWhileAttached( + context: CoroutineContext, + exec: ScopeReceiver + ) = rootView.scopeWhileAttached(context, exec) + private fun onSiteSelected( - model: Site, + model: ServerModel, longClick: Boolean ) { if (longClick) { @@ -136,9 +131,8 @@ class MainActivity : DarkModeSwitchActivity() { title(R.string.options) listItems(R.array.site_long_options) { _, i, _ -> when (i) { - 0 -> viewModel.refreshSite(model) - 1 -> addSiteForDuplication(model) - 2 -> maybeRemoveSite(model) + 0 -> presenter.refreshSite(model) + 1 -> maybeRemoveSite(model) } } } diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt index e11ca08..b955307 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt @@ -1,73 +1,63 @@ -/** +/* + * Licensed under Apache-2.0 + * * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package com.afollestad.nocknock.ui.main import android.content.Intent import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.model.Site +import com.afollestad.nocknock.data.ServerModel import com.afollestad.nocknock.toHtml import com.afollestad.nocknock.ui.addsite.AddSiteActivity -import com.afollestad.nocknock.ui.viewsite.KEY_SITE +import com.afollestad.nocknock.ui.addsite.KEY_FAB_SIZE +import com.afollestad.nocknock.ui.addsite.KEY_FAB_X +import com.afollestad.nocknock.ui.addsite.KEY_FAB_Y +import com.afollestad.nocknock.ui.viewsite.KEY_VIEW_MODEL import com.afollestad.nocknock.ui.viewsite.ViewSiteActivity import com.afollestad.nocknock.utilities.providers.RealIntentProvider.Companion.KEY_VIEW_NOTIFICATION_MODEL +import kotlinx.android.synthetic.main.activity_main.fab internal const val VIEW_SITE_RQ = 6923 internal const val ADD_SITE_RQ = 6969 -// ADD - internal fun MainActivity.addSite() { - startActivityForResult(intentToAdd(), ADD_SITE_RQ) + startActivityForResult(intentToAdd(fab.x, fab.y, fab.measuredWidth), ADD_SITE_RQ) } -internal fun MainActivity.addSiteForDuplication(site: Site) { - startActivityForResult(intentToAdd(site), ADD_SITE_RQ) +private fun MainActivity.intentToAdd( + x: Float, + y: Float, + size: Int +) = Intent(this, AddSiteActivity::class.java).apply { + putExtra(KEY_FAB_X, x) + putExtra(KEY_FAB_Y, y) + putExtra(KEY_FAB_SIZE, size) + addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) } -private fun MainActivity.intentToAdd(model: Site? = null) = - Intent(this, AddSiteActivity::class.java).apply { - model?.let { putExtra(KEY_SITE, it) } - } - -// VIEW - -internal fun MainActivity.viewSite(model: Site) { +internal fun MainActivity.viewSite(model: ServerModel) { startActivityForResult(intentToView(model), VIEW_SITE_RQ) } -private fun MainActivity.intentToView(model: Site) = +private fun MainActivity.intentToView(model: ServerModel) = Intent(this, ViewSiteActivity::class.java).apply { - putExtra(KEY_SITE, model) + putExtra(KEY_VIEW_MODEL, model) } -// MISC - -internal fun MainActivity.maybeRemoveSite(model: Site) { +internal fun MainActivity.maybeRemoveSite(model: ServerModel) { MaterialDialog(this).show { title(R.string.remove_site) message(text = context.getString(R.string.remove_site_prompt, model.name).toHtml()) - positiveButton(R.string.remove) { viewModel.removeSite(model) } + positiveButton(R.string.remove) { presenter.removeSite(model) } negativeButton(android.R.string.cancel) } } internal fun MainActivity.processIntent(intent: Intent) { if (intent.hasExtra(KEY_VIEW_NOTIFICATION_MODEL)) { - val model = intent.getSerializableExtra(KEY_VIEW_NOTIFICATION_MODEL) as Site + val model = intent.getSerializableExtra(KEY_VIEW_NOTIFICATION_MODEL) as ServerModel viewSite(model) } } diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainPresenter.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainPresenter.kt new file mode 100644 index 0000000..69f3bdb --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainPresenter.kt @@ -0,0 +1,104 @@ +/* + * Licensed under Apache-2.0 + * + * Designed and developed by Aidan Follestad (@afollestad) + */ +package com.afollestad.nocknock.ui.main + +import android.content.Intent +import com.afollestad.nocknock.data.ServerModel +import com.afollestad.nocknock.engine.db.ServerModelStore +import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE +import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_UPDATE_MODEL +import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager +import com.afollestad.nocknock.notifications.NockNotificationManager +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** @author Aidan Follestad (@afollestad) */ +interface MainPresenter { + + fun takeView(view: MainView) + + fun onBroadcast(intent: Intent) + + fun resume() + + fun refreshSite(site: ServerModel) + + fun removeSite(site: ServerModel) + + fun dropView() +} + +/** @author Aidan Follestad (@afollestad) */ +class RealMainPresenter @Inject constructor( + private val serverModelStore: ServerModelStore, + private val notificationManager: NockNotificationManager, + private val checkStatusManager: CheckStatusManager +) : MainPresenter { + + private var view: MainView? = null + + override fun takeView(view: MainView) { + this.view = view + notificationManager.createChannels() + ensureCheckJobs() + } + + override fun onBroadcast(intent: Intent) { + if (intent.action == ACTION_STATUS_UPDATE) { + val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? ServerModel ?: return + view?.updateModel(model) + } + } + + override fun resume() { + notificationManager.cancelStatusNotifications() + view!!.run { + setModels(listOf()) + scopeWhileAttached(Main) { + launch(coroutineContext) { + val models = async(IO) { + serverModelStore.get() + }.await() + setModels(models) + } + } + } + } + + override fun refreshSite(site: ServerModel) { + checkStatusManager.scheduleCheck( + site = site, + rightNow = true, + cancelPrevious = true + ) + } + + override fun removeSite(site: ServerModel) { + checkStatusManager.cancelCheck(site) + notificationManager.cancelStatusNotification(site) + view!!.scopeWhileAttached(Main) { + launch(coroutineContext) { + async(IO) { serverModelStore.delete(site) }.await() + view?.onSiteDeleted(site) + } + } + } + + override fun dropView() { + view = null + } + + private fun ensureCheckJobs() { + view!!.scopeWhileAttached(IO) { + launch(coroutineContext) { + checkStatusManager.ensureScheduledChecks() + } + } + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainView.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainView.kt new file mode 100644 index 0000000..34665d2 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainView.kt @@ -0,0 +1,25 @@ +/* + * Licensed under Apache-2.0 + * + * Designed and developed by Aidan Follestad (@afollestad) + */ +package com.afollestad.nocknock.ui.main + +import com.afollestad.nocknock.data.ServerModel +import com.afollestad.nocknock.utilities.ext.ScopeReceiver +import kotlin.coroutines.CoroutineContext + +/** @author Aidan Follestad (@afollestad) */ +interface MainView { + + fun setModels(models: List) + + fun updateModel(model: ServerModel) + + fun onSiteDeleted(model: ServerModel) + + fun scopeWhileAttached( + context: CoroutineContext, + exec: ScopeReceiver + ) +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt deleted file mode 100644 index bad64fc..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.ui.main - -import androidx.annotation.CheckResult -import androidx.lifecycle.Lifecycle.Event.ON_RESUME -import androidx.lifecycle.LifecycleObserver -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.OnLifecycleEvent -import com.afollestad.nocknock.data.AppDatabase -import com.afollestad.nocknock.data.allSites -import com.afollestad.nocknock.data.deleteSite -import com.afollestad.nocknock.data.model.Site -import com.afollestad.nocknock.engine.validation.ValidationExecutor -import com.afollestad.nocknock.notifications.NockNotificationManager -import com.afollestad.nocknock.ui.ScopedViewModel -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** @author Aidan Follestad (@afollestad) */ -class MainViewModel( - private val database: AppDatabase, - private val notificationManager: NockNotificationManager, - private val validationManager: ValidationExecutor, - mainDispatcher: CoroutineDispatcher, - private val ioDispatcher: CoroutineDispatcher -) : ScopedViewModel(mainDispatcher), LifecycleObserver { - - private val sites = MutableLiveData>() - private val isLoading = MutableLiveData() - private val emptyTextVisibility = MutableLiveData() - private val tags = MutableLiveData>() - private val tagsListVisibility = MutableLiveData() - - @CheckResult fun onSites(): LiveData> = sites - - @CheckResult fun onIsLoading(): LiveData = isLoading - - @CheckResult fun onEmptyTextVisibility(): LiveData = emptyTextVisibility - - @CheckResult fun onTags(): LiveData> = tags - - @CheckResult fun onTagsListVisibility(): LiveData = tagsListVisibility - - @OnLifecycleEvent(ON_RESUME) - fun onResume() = loadSites(emptyList()) - - fun onTagSelection(tags: List) = loadSites(tags) - - fun postSiteUpdate(model: Site) { - val currentSites = sites.value ?: return - val index = currentSites.indexOfFirst { it.id == model.id } - if (index == -1) return - sites.value = currentSites.toMutableList() - .apply { - this[index] = model - } - } - - fun refreshSite(model: Site) { - validationManager.scheduleValidation( - site = model, - rightNow = true, - cancelPrevious = true - ) - } - - fun removeSite(model: Site) { - validationManager.cancelScheduledValidation(model) - notificationManager.cancelStatusNotification(model) - - scope.launch { - isLoading.value = true - withContext(ioDispatcher) { database.deleteSite(model) } - - val currentSites = sites.value ?: return@launch - val index = currentSites.indexOfFirst { it.id == model.id } - isLoading.value = false - if (index == -1) return@launch - - val newSitesList = currentSites.toMutableList() - .apply { - removeAt(index) - } - sites.value = newSitesList - emptyTextVisibility.value = newSitesList.isEmpty() - } - } - - private fun loadSites(forTags: List) { - scope.launch { - notificationManager.cancelStatusNotifications() - emptyTextVisibility.value = false - isLoading.value = true - - val unfiltered = withContext(ioDispatcher) { - database.allSites() - } - var result = unfiltered - - if (forTags.isNotEmpty()) { - result = result.filter { site -> - val itemTags = site.tags.toLowerCase() - .split(",") - itemTags.any { tag -> forTags.contains(tag) } - } - } - - sites.value = result - ensureCheckJobs() - isLoading.value = false - emptyTextVisibility.value = result.isEmpty() - - val tagsValues = pullOutTags(unfiltered) - tags.value = tagsValues - tagsListVisibility.value = tagsValues.isNotEmpty() - } - } - - private suspend fun ensureCheckJobs() { - withContext(ioDispatcher) { - validationManager.ensureScheduledValidations() - } - } - - private fun pullOutTags(sites: List): List { - return mutableListOf().apply { - for (site in sites) { - val splitTags = site.tags.toLowerCase() - .split(',') - splitTags - .filter { it.isNotEmpty() } - .forEach { tag -> - if (!this.contains(tag)) { - this.add(tag) - } - } - } - sort() - } - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt index 2aa312c..59d2a69 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt @@ -1,184 +1,104 @@ -/** +/* + * Licensed under Apache-2.0 + * * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package com.afollestad.nocknock.ui.viewsite import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent -import android.content.Intent.ACTION_OPEN_DOCUMENT -import android.content.Intent.CATEGORY_OPENABLE +import android.content.IntentFilter import android.os.Bundle import android.widget.ArrayAdapter -import androidx.lifecycle.Observer +import androidx.appcompat.app.AppCompatActivity import com.afollestad.nocknock.R -import com.afollestad.nocknock.broadcasts.StatusUpdateIntentReceiver -import com.afollestad.nocknock.data.model.Site -import com.afollestad.nocknock.data.model.ValidationMode -import com.afollestad.nocknock.ui.DarkModeSwitchActivity -import com.afollestad.nocknock.utilities.ext.onTextChanged -import com.afollestad.nocknock.utilities.ext.setTextAndMaintainSelection -import com.afollestad.nocknock.utilities.livedata.distinct -import com.afollestad.nocknock.utilities.providers.IntentProvider +import com.afollestad.nocknock.data.LAST_CHECK_NONE +import com.afollestad.nocknock.data.ServerModel +import com.afollestad.nocknock.data.ValidationMode +import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT +import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE +import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH +import com.afollestad.nocknock.data.indexToValidationMode +import com.afollestad.nocknock.data.textRes +import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE +import com.afollestad.nocknock.utilities.ext.ScopeReceiver +import com.afollestad.nocknock.utilities.ext.formatDate +import com.afollestad.nocknock.utilities.ext.injector +import com.afollestad.nocknock.utilities.ext.safeRegisterReceiver +import com.afollestad.nocknock.utilities.ext.safeUnregisterReceiver +import com.afollestad.nocknock.utilities.ext.scopeWhileAttached import com.afollestad.nocknock.viewcomponents.ext.dimenFloat -import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition +import com.afollestad.nocknock.viewcomponents.ext.onItemSelected import com.afollestad.nocknock.viewcomponents.ext.onScroll -import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData -import com.afollestad.nocknock.viewcomponents.livedata.toViewText -import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility -import com.afollestad.vvalidator.form -import com.afollestad.vvalidator.form.Form +import com.afollestad.nocknock.viewcomponents.ext.showOrHide +import com.afollestad.nocknock.viewcomponents.ext.trimmedText import kotlinx.android.synthetic.main.activity_viewsite.checkIntervalLayout -import kotlinx.android.synthetic.main.activity_viewsite.headersLayout +import kotlinx.android.synthetic.main.activity_viewsite.disableChecksButton +import kotlinx.android.synthetic.main.activity_viewsite.doneBtn import kotlinx.android.synthetic.main.activity_viewsite.iconStatus import kotlinx.android.synthetic.main.activity_viewsite.inputName -import kotlinx.android.synthetic.main.activity_viewsite.inputTags import kotlinx.android.synthetic.main.activity_viewsite.inputUrl import kotlinx.android.synthetic.main.activity_viewsite.loadingProgress -import kotlinx.android.synthetic.main.activity_viewsite.responseTimeoutInput import kotlinx.android.synthetic.main.activity_viewsite.responseValidationMode import kotlinx.android.synthetic.main.activity_viewsite.responseValidationSearchTerm -import kotlinx.android.synthetic.main.activity_viewsite.retryPolicyLayout +import kotlinx.android.synthetic.main.activity_viewsite.rootView import kotlinx.android.synthetic.main.activity_viewsite.scriptInputLayout import kotlinx.android.synthetic.main.activity_viewsite.scrollView -import kotlinx.android.synthetic.main.activity_viewsite.sslCertificateBrowse -import kotlinx.android.synthetic.main.activity_viewsite.sslCertificateInput import kotlinx.android.synthetic.main.activity_viewsite.textLastCheckResult import kotlinx.android.synthetic.main.activity_viewsite.textNextCheck import kotlinx.android.synthetic.main.activity_viewsite.textUrlWarning +import kotlinx.android.synthetic.main.activity_viewsite.toolbar import kotlinx.android.synthetic.main.activity_viewsite.validationModeDescription -import kotlinx.android.synthetic.main.include_app_bar.toolbar -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel -import kotlinx.android.synthetic.main.include_app_bar.app_toolbar as appToolbar -import kotlinx.android.synthetic.main.include_app_bar.toolbar_title as toolbarTitle +import javax.inject.Inject +import kotlin.coroutines.CoroutineContext /** @author Aidan Follestad (@afollestad) */ -class ViewSiteActivity : DarkModeSwitchActivity() { - companion object { - private const val SELECT_CERT_FILE_RQ = 23 - } +class ViewSiteActivity : AppCompatActivity(), ViewSiteView { - internal val viewModel by viewModel() - private lateinit var validationForm: Form + @Inject lateinit var presenter: ViewSitePresenter - private val intentProvider by inject() - private val statusUpdateReceiver by lazy { - StatusUpdateIntentReceiver(application, intentProvider) { - viewModel.setModel(it) - } + private val intentReceiver = object : BroadcastReceiver() { + override fun onReceive( + context: Context, + intent: Intent + ) = presenter.onBroadcast(intent) } @SuppressLint("SetTextI18n") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + injector().injectInto(this) setContentView(R.layout.activity_viewsite) - // Populate view model with initial data - val model = intent.getSerializableExtra(KEY_SITE) as Site - viewModel.setModel(model) - - setupUi() - setupValidation() - lifecycle.run { - addObserver(viewModel) - addObserver(statusUpdateReceiver) - } - - // Loading - loadingProgress.observe(this, viewModel.onIsLoading()) - - // Status - viewModel.status.observe(this, Observer { - iconStatus.setStatus(it) - invalidateMenuForStatus(it) - }) - - // Name - inputName.attachLiveData(this, viewModel.name) - - // Tags - inputTags.attachLiveData(this, viewModel.tags) - - // Url - inputUrl.attachLiveData(this, viewModel.url) - viewModel.onUrlWarningVisibility() - .toViewVisibility(this, textUrlWarning) - - // Timeout - responseTimeoutInput.attachLiveData(this, viewModel.timeout) - - // Validation mode - responseValidationMode.attachLiveData( - lifecycleOwner = this, - data = viewModel.validationMode, - outTransformer = { ValidationMode.fromIndex(it) }, - inTransformer = { it.toIndex() } - ) - viewModel.onValidationModeDescription() - .toViewText(this, validationModeDescription) - - // Validation search term - responseValidationSearchTerm.attachLiveData(this, viewModel.validationSearchTerm) - viewModel.onValidationSearchTermVisibility() - .toViewVisibility(this, responseValidationSearchTerm) - - // SSL certificate - sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it } - viewModel.certificateUri.distinct() - .observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) }) - - // Headers - headersLayout.attach(viewModel.headers) - - // Last/next check - viewModel.onLastCheckResultText() - .toViewText(this, textLastCheckResult) - viewModel.onNextCheckText() - .toViewText(this, textNextCheck) - } - - private fun setupUi() { - toolbarTitle.text = "" toolbar.run { - setNavigationIcon(R.drawable.ic_action_close) setNavigationOnClickListener { finish() } inflateMenu(R.menu.menu_viewsite) - menu.findItem(R.id.refresh) .setActionView(R.layout.menu_item_refresh_icon) .apply { - actionView.setOnClickListener { viewModel.checkNow() } + actionView.setOnClickListener { presenter.checkNow() } } - setOnMenuItemClickListener { - when (it.itemId) { - R.id.remove -> maybeRemoveSite() - R.id.disableChecks -> maybeDisableChecks() - } - true + maybeRemoveSite() + return@setOnMenuItemClickListener true } } scrollView.onScroll { - appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) { - appToolbar.dimenFloat(R.dimen.default_elevation) + toolbar.elevation = if (it > toolbar.height / 4) { + toolbar.dimenFloat(R.dimen.default_elevation) } else { 0f } } + inputUrl.setOnFocusChangeListener { _, hasFocus -> + presenter.onUrlInputFocusChange(hasFocus, inputUrl.trimmedText()) + } + val validationOptionsAdapter = ArrayAdapter( this, R.layout.list_item_spinner, @@ -187,105 +107,150 @@ class ViewSiteActivity : DarkModeSwitchActivity() { validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown) responseValidationMode.adapter = validationOptionsAdapter - // Disabled button - viewModel.onDisableChecksVisibility() - .observe(this, Observer { - toolbar.menu.findItem(R.id.disableChecks) - .isVisible = it - }) + responseValidationMode.onItemSelected(presenter::onValidationModeSelected) - // Done item text - viewModel.onDoneButtonText() - .observe(this, Observer { - toolbar.menu.findItem(R.id.commit) - .setTitle(it) - }) + doneBtn.setOnClickListener { + val checkInterval = checkIntervalLayout.getSelectedCheckInterval() + val validationMode = + responseValidationMode.selectedItemPosition.indexToValidationMode() - // SSL certificate - sslCertificateBrowse.setOnClickListener { - val intent = Intent(ACTION_OPEN_DOCUMENT).apply { - addCategory(CATEGORY_OPENABLE) - type = "*/*" - } - startActivityForResult(intent, SELECT_CERT_FILE_RQ) - } - } - - private fun setupValidation() { - validationForm = form { - input(inputName, name = "Name") { - isNotEmpty().description(R.string.please_enter_name) - } - input(inputUrl, name = "URL") { - isNotEmpty().description(R.string.please_enter_url) - isUrl().description(R.string.please_enter_valid_url) - } - input(responseValidationSearchTerm, name = "Search term") { - conditional(responseValidationSearchTerm.isVisibleCondition()) { - isNotEmpty().description(R.string.please_enter_search_term) - } - } - input(responseTimeoutInput, name = "Timeout", optional = true) { - isNumber().greaterThan(0) - .description(R.string.please_enter_networkTimeout) - } - input(sslCertificateInput, name = "Certificate Path", optional = true) { - isUri().hasScheme("file", "content") - .that { it.host != null } - .description(R.string.please_enter_validCertUri) - } - submitWith(toolbar.menu, R.id.commit) { - viewModel.commit { finish() } - } + presenter.commit( + name = inputName.trimmedText(), + url = inputUrl.trimmedText(), + checkInterval = checkInterval, + validationMode = validationMode, + validationContent = validationMode.validationContent() + ) } - // Validation script - scriptInputLayout.attach( - codeData = viewModel.validationScript, - visibility = viewModel.onValidationScriptVisibility(), - form = validationForm - ) + disableChecksButton.setOnClickListener { maybeDisableChecks() } - // Check interval - checkIntervalLayout.attach( - valueData = viewModel.checkIntervalValue, - multiplierData = viewModel.checkIntervalUnit, - form = validationForm - ) - - // Retry Policy - retryPolicyLayout.attach( - timesData = viewModel.retryPolicyTimes, - minutesData = viewModel.retryPolicyMinutes, - form = validationForm - ) - } - - override fun onResume() { - super.onResume() - appToolbar.elevation = if (scrollView.scrollY > appToolbar.measuredHeight / 2) { - appToolbar.dimenFloat(R.dimen.default_elevation) - } else { - 0f - } - } - - override fun onActivityResult( - requestCode: Int, - resultCode: Int, - resultData: Intent? - ) { - super.onActivityResult(requestCode, resultCode, resultData) - if (requestCode == SELECT_CERT_FILE_RQ && resultCode == RESULT_OK) { - sslCertificateInput.setText(resultData?.data?.toString() ?: "") - } + presenter.takeView(this, intent) } override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) - if (intent != null && intent.hasExtra(KEY_SITE)) { - val newModel = intent.getSerializableExtra(KEY_SITE) as Site - viewModel.setModel(newModel) + presenter.onNewIntent(intent) + } + + override fun onDestroy() { + presenter.dropView() + super.onDestroy() + } + + override fun setLoading() = loadingProgress.setLoading() + + override fun setDoneLoading() = loadingProgress.setDone() + + override fun showOrHideUrlSchemeWarning(show: Boolean) { + textUrlWarning.showOrHide(show) + if (show) { + textUrlWarning.setText(R.string.warning_http_url) } } + + override fun showOrHideValidationSearchTerm(show: Boolean) = + responseValidationSearchTerm.showOrHide(show) + + override fun showOrHideScriptInput(show: Boolean) = scriptInputLayout.showOrHide(show) + + override fun setValidationModeDescription(res: Int) = validationModeDescription.setText(res) + + override fun displayModel(model: ServerModel) = with(model) { + iconStatus.setStatus(this.status) + inputName.setText(this.name) + inputUrl.setText(this.url) + + if (this.lastCheck == LAST_CHECK_NONE) { + textLastCheckResult.setText(R.string.none) + } else { + val statusText = this.status.textRes() + textLastCheckResult.text = if (statusText == 0) { + this.reason + } else { + getString(statusText) + } + } + + if (this.disabled) { + textNextCheck.setText(R.string.auto_checks_disabled) + } else { + textNextCheck.text = (this.lastCheck + this.checkInterval).formatDate() + } + checkIntervalLayout.set(this.checkInterval) + + responseValidationMode.setSelection(validationMode.value - 1) + when (this.validationMode) { + TERM_SEARCH -> responseValidationSearchTerm.setText(this.validationContent ?: "") + JAVASCRIPT -> scriptInputLayout.setCode(this.validationContent) + else -> { + responseValidationSearchTerm.setText("") + scriptInputLayout.clear() + } + } + + disableChecksButton.showOrHide(!this.disabled) + doneBtn.setText( + if (this.disabled) R.string.renable_and_save_changes + else R.string.save_changes + ) + + invalidateMenuForStatus(model) + } + + override fun setInputErrors(errors: InputErrors) { + inputName.error = if (errors.name != null) { + getString(errors.name!!) + } else { + null + } + inputUrl.error = if (errors.url != null) { + getString(errors.url!!) + } else { + null + } + checkIntervalLayout.setError( + if (errors.checkInterval != null) { + getString(errors.checkInterval!!) + } else { + null + } + ) + responseValidationSearchTerm.error = if (errors.termSearch != null) { + getString(errors.termSearch!!) + } else { + null + } + scriptInputLayout.setError( + if (errors.javaScript != null) { + getString(errors.javaScript!!) + } else { + null + } + ) + } + + override fun scopeWhileAttached( + context: CoroutineContext, + exec: ScopeReceiver + ) = rootView.scopeWhileAttached(context, exec) + + override fun onResume() { + super.onResume() + val filter = IntentFilter().apply { + addAction(ACTION_STATUS_UPDATE) + } + safeRegisterReceiver(intentReceiver, filter) + } + + override fun onPause() { + super.onPause() + safeUnregisterReceiver(intentReceiver) + } + + private fun ValidationMode.validationContent() = when (this) { + STATUS_CODE -> null + TERM_SEARCH -> responseValidationSearchTerm.trimmedText() + JAVASCRIPT -> scriptInputLayout.getCode() + } } diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivityExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivityExt.kt index ab0a6ea..41858c4 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivityExt.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivityExt.kt @@ -1,59 +1,46 @@ -/** +/* + * Licensed under Apache-2.0 + * * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package com.afollestad.nocknock.ui.viewsite import android.widget.ImageView import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.model.Status -import com.afollestad.nocknock.data.model.isPending +import com.afollestad.nocknock.data.ServerModel +import com.afollestad.nocknock.data.isPending import com.afollestad.nocknock.toHtml import com.afollestad.nocknock.utilities.ext.animateRotation -import kotlinx.android.synthetic.main.include_app_bar.toolbar - -const val KEY_SITE = "site_model" +import kotlinx.android.synthetic.main.activity_viewsite.toolbar internal fun ViewSiteActivity.maybeRemoveSite() { - val model = viewModel.site + val model = presenter.currentModel() MaterialDialog(this).show { title(R.string.remove_site) message(text = context.getString(R.string.remove_site_prompt, model.name).toHtml()) - positiveButton(R.string.remove) { - viewModel.removeSite { finish() } - } + positiveButton(R.string.remove) { presenter.removeSite() } negativeButton(android.R.string.cancel) } } internal fun ViewSiteActivity.maybeDisableChecks() { - val model = viewModel.site + val model = presenter.currentModel() MaterialDialog(this).show { title(R.string.disable_automatic_checks) message( text = context.getString(R.string.disable_automatic_checks_prompt, model.name).toHtml() ) - positiveButton(R.string.disable) { viewModel.disableSite() } + positiveButton(R.string.disable) { presenter.disableChecks() } negativeButton(android.R.string.cancel) } } -internal fun ViewSiteActivity.invalidateMenuForStatus(status: Status) { +internal fun ViewSiteActivity.invalidateMenuForStatus(model: ServerModel) { val refreshIcon = toolbar.menu.findItem(R.id.refresh) .actionView as ImageView - if (status.isPending()) { + + if (model.status.isPending()) { refreshIcon.animateRotation() } else { refreshIcon.run { diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSitePresenter.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSitePresenter.kt new file mode 100644 index 0000000..376b3c0 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSitePresenter.kt @@ -0,0 +1,264 @@ +/* + * Licensed under Apache-2.0 + * + * Designed and developed by Aidan Follestad (@afollestad) + */ +package com.afollestad.nocknock.ui.viewsite + +import android.content.Intent +import androidx.annotation.CheckResult +import com.afollestad.nocknock.R +import com.afollestad.nocknock.data.ServerModel +import com.afollestad.nocknock.data.ServerStatus.WAITING +import com.afollestad.nocknock.data.ValidationMode +import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT +import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH +import com.afollestad.nocknock.engine.db.ServerModelStore +import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE +import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_UPDATE_MODEL +import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager +import com.afollestad.nocknock.notifications.NockNotificationManager +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import okhttp3.HttpUrl +import org.jetbrains.annotations.TestOnly +import javax.inject.Inject + +const val KEY_VIEW_MODEL = "site_model" + +/** @author Aidan Follestad (@afollestad) */ +data class InputErrors( + var name: Int? = null, + var url: Int? = null, + var checkInterval: Int? = null, + var termSearch: Int? = null, + var javaScript: Int? = null +) { + @CheckResult fun any(): Boolean { + return name != null || url != null || checkInterval != null || + termSearch != null || javaScript != null + } +} + +/** @author Aidan Follestad (@afollestad) */ +interface ViewSitePresenter { + + fun takeView( + view: ViewSiteView, + intent: Intent + ) + + fun onBroadcast(intent: Intent) + + fun onNewIntent(intent: Intent?) + + fun onUrlInputFocusChange( + focused: Boolean, + content: String + ) + + fun onValidationModeSelected(index: Int) + + fun commit( + name: String, + url: String, + checkInterval: Long, + validationMode: ValidationMode, + validationContent: String? + ) + + fun checkNow() + + fun disableChecks() + + fun removeSite() + + fun currentModel(): ServerModel + + fun dropView() +} + +/** @author Aidan Follestad (@afollestad) */ +class RealViewSitePresenter @Inject constructor( + private val serverModelStore: ServerModelStore, + private val checkStatusManager: CheckStatusManager, + private val notificationManager: NockNotificationManager +) : ViewSitePresenter { + + private var view: ViewSiteView? = null + private var currentModel: ServerModel? = null + + override fun takeView( + view: ViewSiteView, + intent: Intent + ) { + this.currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as ServerModel + this.view = view.apply { + displayModel(currentModel!!) + } + } + + override fun onBroadcast(intent: Intent) { + if (intent.action == ACTION_STATUS_UPDATE) { + val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? ServerModel ?: return + this.currentModel = model + view?.displayModel(model) + } + } + + override fun onNewIntent(intent: Intent?) { + if (intent != null && intent.hasExtra(KEY_VIEW_MODEL)) { + currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as ServerModel + view?.displayModel(currentModel!!) + } + } + + override fun onUrlInputFocusChange( + focused: Boolean, + content: String + ) { + if (content.isEmpty() || focused) { + return + } + val url = HttpUrl.parse(content) + if (url == null || + (url.scheme() != "http" && + url.scheme() != "https") + ) { + view?.showOrHideUrlSchemeWarning(true) + } else { + view?.showOrHideUrlSchemeWarning(false) + } + } + + override fun onValidationModeSelected(index: Int) = with(view!!) { + showOrHideValidationSearchTerm(index == 1) + showOrHideScriptInput(index == 2) + setValidationModeDescription( + when (index) { + 0 -> R.string.validation_mode_status_desc + 1 -> R.string.validation_mode_term_desc + 2 -> R.string.validation_mode_javascript_desc + else -> throw IllegalStateException("Unknown validation mode position: $index") + } + ) + } + + override fun commit( + name: String, + url: String, + checkInterval: Long, + validationMode: ValidationMode, + validationContent: String? + ) { + val inputErrors = InputErrors() + + if (name.isEmpty()) { + inputErrors.name = R.string.please_enter_name + } + if (url.isEmpty()) { + inputErrors.url = R.string.please_enter_url + } else if (HttpUrl.parse(url) == null) { + inputErrors.url = R.string.please_enter_valid_url + } + if (checkInterval <= 0) { + inputErrors.checkInterval = R.string.please_enter_check_interval + } + if (validationMode == TERM_SEARCH && validationContent.isNullOrEmpty()) { + inputErrors.termSearch = R.string.please_enter_search_term + } else if (validationMode == JAVASCRIPT && validationContent.isNullOrEmpty()) { + inputErrors.javaScript = R.string.please_enter_javaScript + } + + if (inputErrors.any()) { + view?.setInputErrors(inputErrors) + return + } + + val newModel = currentModel!!.copy( + name = name, + url = url, + status = WAITING, + checkInterval = checkInterval, + validationMode = validationMode, + validationContent = validationContent, + disabled = false + ) + + with(view!!) { + scopeWhileAttached(Main) { + launch(coroutineContext) { + setLoading() + async(IO) { serverModelStore.update(newModel) }.await() + checkStatusManager.scheduleCheck( + site = newModel, + rightNow = true, + cancelPrevious = true + ) + setDoneLoading() + view?.finish() + } + } + } + } + + override fun checkNow() = with(view!!) { + val checkModel = currentModel!!.copy( + status = WAITING + ) + view?.displayModel(checkModel) + checkStatusManager.scheduleCheck( + site = checkModel, + rightNow = true, + cancelPrevious = true + ) + } + + override fun disableChecks() { + val site = currentModel!! + checkStatusManager.cancelCheck(site) + notificationManager.cancelStatusNotification(site) + + with(view!!) { + scopeWhileAttached(Main) { + launch(coroutineContext) { + setLoading() + currentModel = currentModel!!.copy(disabled = true) + async(IO) { serverModelStore.update(currentModel!!) }.await() + setDoneLoading() + view?.displayModel(currentModel!!) + } + } + } + } + + override fun removeSite() { + val site = currentModel!! + checkStatusManager.cancelCheck(site) + notificationManager.cancelStatusNotification(site) + + with(view!!) { + scopeWhileAttached(Main) { + launch(coroutineContext) { + setLoading() + async(IO) { serverModelStore.delete(site) }.await() + setDoneLoading() + view?.finish() + } + } + } + } + + override fun currentModel() = this.currentModel!! + + override fun dropView() { + view = null + currentModel = null + } + + @TestOnly fun setModel(model: ServerModel) { + this.currentModel = model + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteView.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteView.kt new file mode 100644 index 0000000..0cbd515 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteView.kt @@ -0,0 +1,38 @@ +/* + * Licensed under Apache-2.0 + * + * Designed and developed by Aidan Follestad (@afollestad) + */ +package com.afollestad.nocknock.ui.viewsite + +import androidx.annotation.StringRes +import com.afollestad.nocknock.data.ServerModel +import com.afollestad.nocknock.utilities.ext.ScopeReceiver +import kotlin.coroutines.CoroutineContext + +/** @author Aidan Follestad (@afollestad) */ +interface ViewSiteView { + + fun setLoading() + + fun setDoneLoading() + + fun displayModel(model: ServerModel) + + fun showOrHideUrlSchemeWarning(show: Boolean) + + fun showOrHideValidationSearchTerm(show: Boolean) + + fun showOrHideScriptInput(show: Boolean) + + fun setValidationModeDescription(@StringRes res: Int) + + fun setInputErrors(errors: InputErrors) + + fun scopeWhileAttached( + context: CoroutineContext, + exec: ScopeReceiver + ) + + fun finish() +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt deleted file mode 100644 index b5c9f93..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt +++ /dev/null @@ -1,268 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.ui.viewsite - -import androidx.annotation.CheckResult -import androidx.annotation.VisibleForTesting -import androidx.annotation.VisibleForTesting.PRIVATE -import androidx.lifecycle.LifecycleObserver -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.AppDatabase -import com.afollestad.nocknock.data.deleteSite -import com.afollestad.nocknock.data.model.Header -import com.afollestad.nocknock.data.model.RetryPolicy -import com.afollestad.nocknock.data.model.Site -import com.afollestad.nocknock.data.model.Status -import com.afollestad.nocknock.data.model.Status.WAITING -import com.afollestad.nocknock.data.model.ValidationMode -import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT -import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE -import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH -import com.afollestad.nocknock.data.model.ValidationResult -import com.afollestad.nocknock.data.model.textRes -import com.afollestad.nocknock.data.updateSite -import com.afollestad.nocknock.engine.validation.ValidationExecutor -import com.afollestad.nocknock.notifications.NockNotificationManager -import com.afollestad.nocknock.ui.ScopedViewModel -import com.afollestad.nocknock.utilities.ext.formatDate -import com.afollestad.nocknock.utilities.livedata.map -import com.afollestad.nocknock.utilities.livedata.zip -import com.afollestad.nocknock.utilities.providers.StringProvider -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import okhttp3.HttpUrl -import java.lang.System.currentTimeMillis - -/** @author Aidan Follestad (@afollestad) */ -class ViewSiteViewModel( - private val stringProvider: StringProvider, - private val database: AppDatabase, - private val notificationManager: NockNotificationManager, - private val validationManager: ValidationExecutor, - mainDispatcher: CoroutineDispatcher, - private val ioDispatcher: CoroutineDispatcher -) : ScopedViewModel(mainDispatcher), LifecycleObserver { - - lateinit var site: Site - - // Public properties - val status = MutableLiveData() - val name = MutableLiveData() - val tags = MutableLiveData() - val url = MutableLiveData() - val timeout = MutableLiveData() - val validationMode = MutableLiveData() - val validationSearchTerm = MutableLiveData() - val validationScript = MutableLiveData() - val checkIntervalValue = MutableLiveData() - val checkIntervalUnit = MutableLiveData() - val retryPolicyTimes = MutableLiveData() - val retryPolicyMinutes = MutableLiveData() - val headers = MutableLiveData>() - val certificateUri = MutableLiveData() - internal val disabled = MutableLiveData() - internal val lastResult = MutableLiveData() - - private val isLoading = MutableLiveData() - - @CheckResult fun onIsLoading(): LiveData = isLoading - - @CheckResult fun onUrlWarningVisibility(): LiveData { - return url.map { - val parsed = HttpUrl.parse(it) - return@map it.isNotEmpty() && parsed == null - } - } - - @CheckResult fun onValidationModeDescription(): LiveData { - return validationMode.map { - when (it!!) { - STATUS_CODE -> R.string.validation_mode_status_desc - TERM_SEARCH -> R.string.validation_mode_term_desc - JAVASCRIPT -> R.string.validation_mode_javascript_desc - } - } - } - - @CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH } - - @CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT } - - @CheckResult fun onDisableChecksVisibility(): LiveData = disabled.map { !it } - - @CheckResult fun onDoneButtonText(): LiveData = - disabled.map { - if (it) R.string.renable_and_save_changes - else R.string.save_changes - } - - @CheckResult fun onLastCheckResultText(): LiveData = lastResult.map { - if (it == null) { - stringProvider.get(R.string.none) - } else { - val statusText = it.status.textRes() - if (statusText == 0) { - it.reason - } else { - stringProvider.get(statusText) - } - } - } - - @CheckResult fun onNextCheckText(): LiveData { - return zip(disabled, lastResult) - .map { - val disabled = it.first - val lastResult = it.second - if (disabled) { - stringProvider.get(R.string.auto_checks_disabled) - } else { - val lastCheck = lastResult?.timestampMs ?: currentTimeMillis() - (lastCheck + getCheckIntervalMs()).formatDate() - } - } - } - - // Actions - fun commit(done: () -> Unit) { - scope.launch { - val updatedModel = getUpdatedDbModel() ?: return@launch - isLoading.value = true - - withContext(ioDispatcher) { - database.updateSite(updatedModel) - } - validationManager.scheduleValidation( - site = updatedModel, - rightNow = true, - cancelPrevious = true - ) - - isLoading.value = false - done() - } - } - - fun checkNow() { - val checkModel = site.withStatus( - status = WAITING - ) - setModel(checkModel) - validationManager.scheduleValidation( - site = checkModel, - rightNow = true, - cancelPrevious = true - ) - } - - fun removeSite(done: () -> Unit) { - validationManager.cancelScheduledValidation(site) - notificationManager.cancelStatusNotification(site) - - scope.launch { - isLoading.value = true - withContext(ioDispatcher) { - database.deleteSite(site) - } - isLoading.value = false - done() - } - } - - fun disableSite() { - validationManager.cancelScheduledValidation(site) - notificationManager.cancelStatusNotification(site) - - scope.launch { - isLoading.value = true - val newModel = site.copy( - settings = site.settings!!.copy( - disabled = true - ) - ) - withContext(ioDispatcher) { - database.updateSite(newModel) - } - isLoading.value = false - setModel(newModel) - } - } - - // Utilities - @VisibleForTesting(otherwise = PRIVATE) - fun getCheckIntervalMs(): Long { - val value = checkIntervalValue.value ?: return 0 - val unit = checkIntervalUnit.value ?: return 0 - return value * unit - } - - @VisibleForTesting(otherwise = PRIVATE) - fun getValidationArgs(): String? { - return when (validationMode.value) { - TERM_SEARCH -> validationSearchTerm.value?.trim() - JAVASCRIPT -> validationScript.value?.trim() - else -> null - } - } - - private fun getUpdatedDbModel(): Site? { - val timeout = timeout.value ?: 10_000 - val cleanedTags = tags.value?.split(',')?.joinToString(separator = ",") ?: "" - - val newSettings = site.settings!!.copy( - validationIntervalMs = getCheckIntervalMs(), - validationMode = validationMode.value!!, - validationArgs = getValidationArgs(), - networkTimeout = timeout, - disabled = false, - certificate = certificateUri.value?.toString() - ) - - val retryPolicyTimes = retryPolicyTimes.value ?: 0 - val retryPolicyMinutes = retryPolicyMinutes.value ?: 0 - val retryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) { - if (site.retryPolicy != null) { - // Have existing policy, update it - site.retryPolicy!!.copy( - count = retryPolicyTimes, - minutes = retryPolicyMinutes - ) - } else { - // Create new policy - RetryPolicy( - count = retryPolicyTimes, - minutes = retryPolicyMinutes - ) - } - } else { - // No policy - null - } - - return site.copy( - name = name.value!!.trim(), - tags = cleanedTags, - url = url.value!!.trim(), - settings = newSettings, - retryPolicy = retryPolicy, - headers = headers.value ?: emptyList() - ) - .withStatus(status = WAITING) - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt deleted file mode 100644 index 800f235..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.ui.viewsite - -import com.afollestad.nocknock.data.model.RetryPolicy -import com.afollestad.nocknock.data.model.Site -import com.afollestad.nocknock.data.model.Status.WAITING -import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT -import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH -import com.afollestad.nocknock.utilities.ext.DAY -import com.afollestad.nocknock.utilities.ext.HOUR -import com.afollestad.nocknock.utilities.ext.MINUTE -import com.afollestad.nocknock.utilities.ext.WEEK -import kotlin.math.ceil - -fun ViewSiteViewModel.setModel(site: Site) { - val settings = site.settings ?: throw IllegalArgumentException("Settings must be populated!") - this.site = site - - status.value = site.lastResult?.status ?: WAITING - name.value = site.name - tags.value = site.tags - url.value = site.url - timeout.value = settings.networkTimeout - - validationMode.value = settings.validationMode - when (settings.validationMode) { - TERM_SEARCH -> { - validationSearchTerm.value = settings.validationArgs - validationScript.value = null - } - JAVASCRIPT -> { - validationSearchTerm.value = null - validationScript.value = settings.validationArgs - } - else -> { - validationSearchTerm.value = null - validationScript.value = null - } - } - - setCheckInterval(settings.validationIntervalMs) - setRetryPolicy(site.retryPolicy) - headers.value = site.headers - if (settings.certificate == "null") { - certificateUri.value = "" - } else { - certificateUri.value = settings.certificate - } - - this.disabled.value = settings.disabled - this.lastResult.value = site.lastResult -} - -private fun ViewSiteViewModel.setCheckInterval(interval: Long) { - when { - interval >= WEEK -> { - checkIntervalValue.value = - getIntervalFromUnit(interval, WEEK) - checkIntervalUnit.value = WEEK - } - interval >= DAY -> { - checkIntervalValue.value = - getIntervalFromUnit(interval, DAY) - checkIntervalUnit.value = DAY - } - interval >= HOUR -> { - checkIntervalValue.value = - getIntervalFromUnit(interval, HOUR) - checkIntervalUnit.value = HOUR - } - interval >= MINUTE -> { - checkIntervalValue.value = - getIntervalFromUnit(interval, MINUTE) - checkIntervalUnit.value = MINUTE - } - else -> { - checkIntervalValue.value = 0 - checkIntervalUnit.value = MINUTE - } - } -} - -private fun ViewSiteViewModel.setRetryPolicy(policy: RetryPolicy?) { - if (policy == null) return - retryPolicyTimes.value = policy.count - retryPolicyMinutes.value = policy.minutes -} - -private fun getIntervalFromUnit( - millis: Long, - unit: Long -): Int { - val intervalFloat = millis.toFloat() - val byFloat = unit.toFloat() - return ceil(intervalFloat / byFloat).toInt() -} diff --git a/app/src/main/res/anim/fade_out.xml b/app/src/main/res/anim/fade_out.xml new file mode 100644 index 0000000..da8bafd --- /dev/null +++ b/app/src/main/res/anim/fade_out.xml @@ -0,0 +1,9 @@ + + + + diff --git a/app/src/main/res/color/unchecked_chip_text.xml b/app/src/main/res/color/unchecked_chip_text.xml deleted file mode 100644 index 8e7f4df..0000000 --- a/app/src/main/res/color/unchecked_chip_text.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/checked_chip.xml b/app/src/main/res/drawable/checked_chip.xml deleted file mode 100644 index 85010f5..0000000 --- a/app/src/main/res/drawable/checked_chip.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/checked_chip_pressed.xml b/app/src/main/res/drawable/checked_chip_pressed.xml deleted file mode 100644 index 0d7c176..0000000 --- a/app/src/main/res/drawable/checked_chip_pressed.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/checked_chip_selector.xml b/app/src/main/res/drawable/checked_chip_selector.xml deleted file mode 100644 index fa9df00..0000000 --- a/app/src/main/res/drawable/checked_chip_selector.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/divider.xml b/app/src/main/res/drawable/divider.xml index 7561a3c..9be83b4 100644 --- a/app/src/main/res/drawable/divider.xml +++ b/app/src/main/res/drawable/divider.xml @@ -1,4 +1,4 @@ - + diff --git a/app/src/main/res/drawable/ic_action_close.xml b/app/src/main/res/drawable/ic_action_close.xml index 241f9be..369498e 100644 --- a/app/src/main/res/drawable/ic_action_close.xml +++ b/app/src/main/res/drawable/ic_action_close.xml @@ -4,6 +4,6 @@ android:viewportHeight="24.0" android:viewportWidth="24.0"> diff --git a/app/src/main/res/drawable/ic_action_delete.xml b/app/src/main/res/drawable/ic_action_delete.xml index 901c3e1..7539f69 100644 --- a/app/src/main/res/drawable/ic_action_delete.xml +++ b/app/src/main/res/drawable/ic_action_delete.xml @@ -4,6 +4,6 @@ android:viewportHeight="24.0" android:viewportWidth="24.0"> diff --git a/app/src/main/res/drawable/ic_action_refresh.xml b/app/src/main/res/drawable/ic_action_refresh.xml index 5175bda..0240dcb 100644 --- a/app/src/main/res/drawable/ic_action_refresh.xml +++ b/app/src/main/res/drawable/ic_action_refresh.xml @@ -4,6 +4,6 @@ android:viewportHeight="24.0" android:viewportWidth="24.0"> diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml deleted file mode 100644 index 00fc15d..0000000 --- a/app/src/main/res/drawable/ic_check.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/unchecked_chip.xml b/app/src/main/res/drawable/unchecked_chip.xml deleted file mode 100644 index 1864bc5..0000000 --- a/app/src/main/res/drawable/unchecked_chip.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/unchecked_chip_pressed.xml b/app/src/main/res/drawable/unchecked_chip_pressed.xml deleted file mode 100644 index c387d70..0000000 --- a/app/src/main/res/drawable/unchecked_chip_pressed.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/unchecked_chip_selector.xml b/app/src/main/res/drawable/unchecked_chip_selector.xml deleted file mode 100644 index ba01f74..0000000 --- a/app/src/main/res/drawable/unchecked_chip_selector.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/layout/activity_addsite.xml b/app/src/main/res/layout/activity_addsite.xml index 80f83da..90298cb 100644 --- a/app/src/main/res/layout/activity_addsite.xml +++ b/app/src/main/res/layout/activity_addsite.xml @@ -1,6 +1,7 @@ - + @@ -25,66 +34,66 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" - android:paddingBottom="@dimen/content_inset_double" + android:paddingBottom="@dimen/content_inset" android:paddingLeft="@dimen/content_inset" android:paddingRight="@dimen/content_inset" - android:paddingTop="@dimen/content_inset_half" > - + - + - + - + + + + + - - - - - - - - - - - - - - - - - - - - -