diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 17bc1fa..0000000 --- a/.editorconfig +++ /dev/null @@ -1,3 +0,0 @@ -[*.kt] -indent_size = 2 -continuation_indent_size=4 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index a574b92..0000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,28 +0,0 @@ -(`[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 new file mode 100644 index 0000000..2d7d09f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Bug_report.md @@ -0,0 +1,28 @@ +--- +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 new file mode 100644 index 0000000..77310ae --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Feature_request.md @@ -0,0 +1,15 @@ +--- +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 b4035a9..6307e10 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/pull_request_template.md @@ -1,9 +1,8 @@ - ### Guidelines -1. You must run the `spotlessApply` task before commiting, either through Android Studio or with `./gradlew spotlessApply`. +1. You must run the `spotlessApply` task before committing, 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.** \ No newline at end of file +**If you do not follow the guidelines, your PR will be rejected.** diff --git a/.gitignore b/.gitignore index 161128f..454e51a 100644 --- a/.gitignore +++ b/.gitignore @@ -180,4 +180,6 @@ gradle-app.setting .gradletasknamecache # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 -# gradle/wrapper/gradle-wrapper.properties \ No newline at end of file +# gradle/wrapper/gradle-wrapper.properties + +app/google-services.json \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 320b3df..50f0406 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -5,7 +5,42 @@ - + + + + + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 40a7b55..0000000 --- a/.travis.yml +++ /dev/null @@ -1,19 +0,0 @@ -language: android -jdk: oraclejdk8 -before_script: - - echo no | android create avd --force -n test -t android-22 --abi armeabi-v7a - - emulator -avd test -no-audio -no-window & - - android-wait-for-emulator - - adb shell input keyevent 82 & -android: - components: - - tools - - platform-tools - - build-tools-28.0.3 - - android-28 - - extra-android-support - - extra-android-m2repository - - extra-google-m2repository - - licenses: - - '.+' diff --git a/README.md b/README.md index 8e2ecb5..84ee16c 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ ## 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/showcasemain3.png) +![Showcase](https://raw.githubusercontent.com/afollestad/nock-nock/master/art/showcase5.png) Nock Nock is a simple app which allows you to monitor your websites for maximum uptime. diff --git a/app/build.gradle b/app/build.gradle index 7e04aaf..d6b315e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,18 +4,6 @@ apply plugin: 'kotlin-android' apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-android-extensions' -apply plugin: 'io.fabric' - -def getFabricApiKey() { - def propsFile = project.rootProject.file('local.properties') - if (!propsFile.exists()) { - return "" - } - Properties properties = new Properties() - properties.load(propsFile.newDataInputStream()) - return properties.getProperty("fabric.apikey") ?: "" -} - android { compileSdkVersion versions.compileSdk buildToolsVersion versions.buildTools @@ -26,16 +14,15 @@ android { targetSdkVersion versions.compileSdk versionCode versions.publishVersionCode versionName versions.publishVersion - manifestPlaceholders = [fabricKey:getFabricApiKey()] } - buildTypes { - debug { - buildConfigField "String", "FABRIC_API_KEY", "\"\"" - } - release { - buildConfigField "String", "FABRIC_API_KEY", "\"${getFabricApiKey()}\"" - } + compileOptions { + sourceCompatibility 1.8 + targetCompatibility 1.8 + } + + packagingOptions { + exclude 'META-INF/atomicfu.kotlin_module' } } @@ -51,6 +38,7 @@ dependencies { 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 // Lifecycle kapt 'androidx.lifecycle:lifecycle-compiler:' + versions.lifecycle @@ -84,4 +72,8 @@ dependencies { androidTestImplementation 'androidx.test:rules:' + versions.androidxTestRunner } -apply from: '../spotless.gradle' \ No newline at end of file +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 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b68a968..332578e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -50,9 +50,6 @@ - diff --git a/app/src/main/java/com/afollestad/nocknock/AppExt.kt b/app/src/main/java/com/afollestad/nocknock/AppExt.kt index c7af855..828e01f 100644 --- a/app/src/main/java/com/afollestad/nocknock/AppExt.kt +++ b/app/src/main/java/com/afollestad/nocknock/AppExt.kt @@ -20,12 +20,12 @@ import android.app.Application import android.app.Application.ActivityLifecycleCallbacks import android.content.ActivityNotFoundException import android.content.Intent -import android.net.Uri 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 @@ -57,8 +57,6 @@ fun Application.onActivityLifeChange(cb: ActivityLifeChange) { fun String.toHtml() = fromHtml(this, FROM_HTML_MODE_LEGACY) -fun String.toUri() = Uri.parse(this)!! - fun Activity.viewUrl(url: String) { val customTabsIntent = CustomTabsIntent.Builder() .apply { diff --git a/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt b/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt index 2cd0af3..3c29301 100644 --- a/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt +++ b/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt @@ -47,10 +47,8 @@ class NockNockApp : Application() { Timber.plant(DebugTree()) } - if (BuildConfig.FABRIC_API_KEY.isNotEmpty()) { - Timber.plant(FabricTree()) - Fabric.with(this, Crashlytics()) - } + Timber.plant(FabricTree()) + Fabric.with(this, Crashlytics()) val modules = listOf( prefModule, diff --git a/app/src/main/java/com/afollestad/nocknock/adapter/TagAdapter.kt b/app/src/main/java/com/afollestad/nocknock/adapter/TagAdapter.kt new file mode 100644 index 0000000..7bf22e5 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/adapter/TagAdapter.kt @@ -0,0 +1,115 @@ +/** + * 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/dialogs/AboutDialog.kt b/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.kt index 7c8e8e4..f0c152f 100644 --- a/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.kt +++ b/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.kt @@ -20,6 +20,7 @@ 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) */ @@ -34,8 +35,9 @@ class AboutDialog : DialogFragment() { } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - return MaterialDialog(activity!!) - .title(R.string.about) + val context = activity ?: throw IllegalStateException("Oh no!") + return MaterialDialog(context) + .title(text = getString(R.string.app_name_x, BuildConfig.VERSION_NAME)) .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 index ba6dc40..07f6410 100644 --- a/app/src/main/java/com/afollestad/nocknock/koin/MainModule.kt +++ b/app/src/main/java/com/afollestad/nocknock/koin/MainModule.kt @@ -23,6 +23,9 @@ 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 @@ -38,7 +41,12 @@ val mainModule = module { single { databaseBuilder(get(), AppDatabase::class.java, "NockNock.db") - .addMigrations(Database1to2Migration()) + .addMigrations( + Database1to2Migration(), + Database2to3Migration(), + Database3to4Migration(), + Database4to5Migration() + ) .build() } diff --git a/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt index c43b729..3220567 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt @@ -15,10 +15,15 @@ */ 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 @@ -35,16 +40,35 @@ abstract class DarkModeSwitchActivity : AppCompatActivity() { setTheme(themeRes()) super.onCreate(savedInstanceState) - darkModePref.observe() - .filter { it != isDarkModeEnabled } - .subscribe { - log("Theme changed, recreating Activity.") - recreate() - } - .attachLifecycle(this) + if (getCurrentNightMode() == UNKNOWN) { + darkModePref.observe() + .filter { it != isDarkModeEnabled } + .subscribe { + log("Theme changed, recreating Activity.") + recreate() + } + .attachLifecycle(this) + } } - protected fun isDarkMode() = darkModePref.get() + 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()) diff --git a/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt b/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt new file mode 100644 index 0000000..2930fea --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt @@ -0,0 +1,26 @@ +/** + * 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/addsite/AddSiteActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt index e3e9855..e15a29f 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 @@ -16,18 +16,32 @@ 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 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.toViewError 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 kotlinx.android.synthetic.main.activity_addsite.checkIntervalLayout -import kotlinx.android.synthetic.main.activity_addsite.doneBtn +import kotlinx.android.synthetic.main.activity_addsite.headersLayout 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 @@ -35,44 +49,54 @@ 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.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.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 /** @author Aidan Follestad (@afollestad) */ class AddSiteActivity : DarkModeSwitchActivity() { + companion object { + private const val SELECT_CERT_FILE_RQ = 23 + } private val viewModel by viewModel() + private lateinit var validationForm: Form @SuppressLint("SetTextI18n") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_addsite) setupUi() + setupValidation() lifecycle.addObserver(viewModel) + // Populate view model with initial data + val model = intent.getSerializableExtra(KEY_SITE) as? Site + model?.let { viewModel.prePopulateFromModel(model) } + // Loading loadingProgress.observe(this, viewModel.onIsLoading()) // Name inputName.attachLiveData(this, viewModel.name) - viewModel.onNameError() - .toViewError(this, inputName) + + // Tags + inputTags.attachLiveData(this, viewModel.tags) // Url inputUrl.attachLiveData(this, viewModel.url) - viewModel.onUrlError() - .toViewError(this, inputUrl) viewModel.onUrlWarningVisibility() .toViewVisibility(this, textUrlWarning) // Timeout responseTimeoutInput.attachLiveData(this, viewModel.timeout) - viewModel.onTimeoutError() - .toViewError(this, responseTimeoutInput) // Validation mode responseValidationMode.attachLiveData( @@ -81,8 +105,6 @@ class AddSiteActivity : DarkModeSwitchActivity() { outTransformer = { ValidationMode.fromIndex(it) }, inTransformer = { it.toIndex() } ) - viewModel.onValidationSearchTermError() - .toViewError(this, responseValidationSearchTerm) viewModel.onValidationModeDescription() .toViewText(this, validationModeDescription) @@ -95,30 +117,19 @@ class AddSiteActivity : DarkModeSwitchActivity() { viewModel.onValidationSearchTermVisibility() .toViewVisibility(this, responseValidationSearchTerm) - // Validation script - scriptInputLayout.attach( - codeData = viewModel.validationScript, - errorData = viewModel.onValidationScriptError(), - visibility = viewModel.onValidationScriptVisibility() - ) + // SSL certificate + sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it } + viewModel.certificateUri.distinct() + .observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) }) - // Check interval - checkIntervalLayout.attach( - valueData = viewModel.checkIntervalValue, - multiplierData = viewModel.checkIntervalUnit, - errorData = viewModel.onCheckIntervalError() - ) - - // Retry Policy - retryPolicyLayout.attach( - timesData = viewModel.retryPolicyTimes, - minutesData = viewModel.retryPolicyMinutes - ) + // 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() } } @@ -131,12 +142,94 @@ class AddSiteActivity : DarkModeSwitchActivity() { validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown) responseValidationMode.adapter = validationOptionsAdapter - // Done button - doneBtn.setOnClickListener { - viewModel.commit { - setResult(RESULT_OK) - finish() + scrollView.onScroll { + appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) { + appToolbar.dimenFloat(R.dimen.default_elevation) + } else { + 0f } } + + // 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(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 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() ?: "") + } } } 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 index ffc3619..d7d8ed5 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt @@ -25,7 +25,8 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.OnLifecycleEvent import com.afollestad.nocknock.R import com.afollestad.nocknock.data.AppDatabase -import com.afollestad.nocknock.data.RetryPolicy +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 @@ -35,11 +36,10 @@ 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.ValidationManager +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 com.afollestad.nocknock.viewcomponents.ext.isNullOrLessThan import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -49,13 +49,14 @@ import java.lang.System.currentTimeMillis /** @author Aidan Follestad (@afollestad) */ class AddSiteViewModel( private val database: AppDatabase, - private val validationManager: ValidationManager, + 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() @@ -65,6 +66,8 @@ class AddSiteViewModel( val checkIntervalUnit = MutableLiveData() val retryPolicyTimes = MutableLiveData() val retryPolicyMinutes = MutableLiveData() + val headers = MutableLiveData>() + val certificateUri = MutableLiveData() @OnLifecycleEvent(ON_START) fun setDefaults() { @@ -74,24 +77,14 @@ class AddSiteViewModel( checkIntervalUnit.value = MINUTE retryPolicyMinutes.value = 0 retryPolicyMinutes.value = 0 + tags.value = "" + headers.value = emptyList() } - // Private properties private val isLoading = MutableLiveData() - private val nameError = MutableLiveData() - private val urlError = MutableLiveData() - private val timeoutError = MutableLiveData() - private val validationSearchTermError = MutableLiveData() - private val validationScriptError = MutableLiveData() - private val checkIntervalValueError = MutableLiveData() - // Expose private properties or calculated properties @CheckResult fun onIsLoading(): LiveData = isLoading - @CheckResult fun onNameError(): LiveData = nameError - - @CheckResult fun onUrlError(): LiveData = urlError - @CheckResult fun onUrlWarningVisibility(): LiveData { return url.map { val parsed = HttpUrl.parse(it) @@ -99,8 +92,6 @@ class AddSiteViewModel( } } - @CheckResult fun onTimeoutError(): LiveData = timeoutError - @CheckResult fun onValidationModeDescription(): LiveData { return validationMode.map { when (it!!) { @@ -111,17 +102,9 @@ class AddSiteViewModel( } } - @CheckResult fun onValidationSearchTermError(): LiveData = validationSearchTermError + @CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH } - @CheckResult fun onValidationSearchTermVisibility() = - validationMode.map { it == TERM_SEARCH } - - @CheckResult fun onValidationScriptError(): LiveData = validationScriptError - - @CheckResult fun onValidationScriptVisibility() = - validationMode.map { it == JAVASCRIPT } - - @CheckResult fun onCheckIntervalError(): LiveData = checkIntervalValueError + @CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT } // Actions fun commit(done: () -> Unit) { @@ -132,7 +115,7 @@ class AddSiteViewModel( val storedModel = withContext(ioDispatcher) { database.putSite(newModel) } - validationManager.scheduleCheck( + validationManager.scheduleValidation( site = storedModel, rightNow = true, cancelPrevious = true @@ -161,75 +144,16 @@ class AddSiteViewModel( } private fun generateDbModel(): Site? { - var errorCount = 0 - - // Validation name - if (name.value.isNullOrEmpty()) { - nameError.value = R.string.please_enter_name - errorCount++ - } else { - nameError.value = null - } - - // Validate URL - when { - url.value.isNullOrEmpty() -> { - urlError.value = R.string.please_enter_url - errorCount++ - } - HttpUrl.parse(url.value!!) == null -> { - urlError.value = R.string.please_enter_valid_url - errorCount++ - } - else -> { - urlError.value = null - } - } - - // Validate timeout - if (timeout.value.isNullOrLessThan(1)) { - timeoutError.value = R.string.please_enter_networkTimeout - errorCount++ - } else { - timeoutError.value = null - } - - // Validate check interval - if (checkIntervalValue.value.isNullOrLessThan(1)) { - checkIntervalValueError.value = R.string.please_enter_check_interval - errorCount++ - } else { - checkIntervalValueError.value = null - } - - // Validate arguments - if (validationMode.value == TERM_SEARCH && - validationSearchTerm.value.isNullOrEmpty() - ) { - errorCount++ - validationSearchTermError.value = R.string.please_enter_search_term - validationScriptError.value = null - } else if (validationMode.value == JAVASCRIPT && - validationScript.value.isNullOrEmpty() - ) { - errorCount++ - validationSearchTermError.value = null - validationScriptError.value = R.string.please_enter_javaScript - } else { - validationSearchTermError.value = null - validationScriptError.value = null - } - - if (errorCount > 0) { - return null - } + 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.value!!, - disabled = false + networkTimeout = timeout, + disabled = false, + certificate = certificateUri.value?.toString() ) val newLastResult = ValidationResult( @@ -241,7 +165,10 @@ class AddSiteViewModel( val retryPolicyTimes = retryPolicyTimes.value ?: 0 val retryPolicyMinutes = retryPolicyMinutes.value ?: 0 val newRetryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) { - RetryPolicy(count = retryPolicyTimes, minutes = retryPolicyMinutes) + RetryPolicy( + count = retryPolicyTimes, + minutes = retryPolicyMinutes + ) } else { null } @@ -250,9 +177,11 @@ class AddSiteViewModel( id = 0, name = name.value!!.trim(), url = url.value!!.trim(), + tags = cleanedTags, settings = newSettings, lastResult = newLastResult, - retryPolicy = newRetryPolicy + 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 new file mode 100644 index 0000000..c524555 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelExt.kt @@ -0,0 +1,99 @@ +/** + * 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 eba93e4..aec76d8 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 @@ -21,20 +21,19 @@ import androidx.lifecycle.Observer 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.materialdialogs.list.listItemsSingleChoice 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.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.utilities.ui.toast -import com.afollestad.nocknock.viewUrl -import com.afollestad.nocknock.viewUrlWithApp import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility import kotlinx.android.synthetic.main.activity_main.fab import kotlinx.android.synthetic.main.activity_main.list @@ -43,6 +42,7 @@ import kotlinx.android.synthetic.main.include_app_bar.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 /** @author Aidan Follestad (@afollestad) */ class MainActivity : DarkModeSwitchActivity() { @@ -53,6 +53,7 @@ class MainActivity : DarkModeSwitchActivity() { internal val viewModel by viewModel() private lateinit var siteAdapter: SiteAdapter + private lateinit var tagAdapter: TagAdapter private val statusUpdateReceiver by lazy { StatusUpdateIntentReceiver(application, intentProvider) { @@ -76,6 +77,10 @@ class MainActivity : DarkModeSwitchActivity() { .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) @@ -85,24 +90,35 @@ class MainActivity : DarkModeSwitchActivity() { toolbar.run { inflateMenu(R.menu.menu_main) menu.findItem(R.id.dark_mode) - .isChecked = isDarkMode() + .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() - R.id.support_me -> supportMe() } return@setOnMenuItemClickListener true } } siteAdapter = SiteAdapter(this::onSiteSelected) - list.run { layoutManager = LinearLayoutManager(this@MainActivity) adapter = siteAdapter addItemDecoration(DividerItemDecoration(this@MainActivity, VERTICAL)) } + + tagAdapter = TagAdapter(viewModel::onTagSelection) + tagsList.run { + layoutManager = LinearLayoutManager(this@MainActivity, HORIZONTAL, false) + adapter = tagAdapter + } + fab.setOnClickListener { addSite() } } @@ -121,7 +137,8 @@ class MainActivity : DarkModeSwitchActivity() { listItems(R.array.site_long_options) { _, i, _ -> when (i) { 0 -> viewModel.refreshSite(model) - 1 -> maybeRemoveSite(model) + 1 -> addSiteForDuplication(model) + 2 -> maybeRemoveSite(model) } } } @@ -129,20 +146,4 @@ class MainActivity : DarkModeSwitchActivity() { viewSite(model) } } - - private fun supportMe() { - MaterialDialog(this).show { - title(R.string.support_me) - message(R.string.support_me_message, html = true, lineHeightMultiplier = 1.4f) - listItemsSingleChoice(R.array.donation_options) { _, index, _ -> - when (index) { - 0 -> viewUrl("https://paypal.me/AidanFollestad") - 1 -> viewUrlWithApp("https://cash.me/\$afollestad", pkg = "com.squareup.cash") - 2 -> viewUrlWithApp("https://venmo.com/afollestad", pkg = "com.venmo") - } - toast(R.string.thank_you) - } - positiveButton(R.string.next) - } - } } 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 422cdc5..e11ca08 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 @@ -28,10 +28,23 @@ import com.afollestad.nocknock.utilities.providers.RealIntentProvider.Companion. internal const val VIEW_SITE_RQ = 6923 internal const val ADD_SITE_RQ = 6969 +// ADD + internal fun MainActivity.addSite() { - startActivityForResult(Intent(this, AddSiteActivity::class.java), ADD_SITE_RQ) + startActivityForResult(intentToAdd(), ADD_SITE_RQ) } +internal fun MainActivity.addSiteForDuplication(site: Site) { + startActivityForResult(intentToAdd(site), ADD_SITE_RQ) +} + +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) { startActivityForResult(intentToView(model), VIEW_SITE_RQ) } @@ -41,6 +54,8 @@ private fun MainActivity.intentToView(model: Site) = putExtra(KEY_SITE, model) } +// MISC + internal fun MainActivity.maybeRemoveSite(model: Site) { MaterialDialog(this).show { title(R.string.remove_site) 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 index bfb9bb9..bad64fc 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt @@ -25,7 +25,7 @@ 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.ValidationManager +import com.afollestad.nocknock.engine.validation.ValidationExecutor import com.afollestad.nocknock.notifications.NockNotificationManager import com.afollestad.nocknock.ui.ScopedViewModel import kotlinx.coroutines.CoroutineDispatcher @@ -36,7 +36,7 @@ import kotlinx.coroutines.withContext class MainViewModel( private val database: AppDatabase, private val notificationManager: NockNotificationManager, - private val validationManager: ValidationManager, + private val validationManager: ValidationExecutor, mainDispatcher: CoroutineDispatcher, private val ioDispatcher: CoroutineDispatcher ) : ScopedViewModel(mainDispatcher), LifecycleObserver { @@ -44,6 +44,8 @@ class MainViewModel( 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 @@ -51,8 +53,14 @@ class MainViewModel( @CheckResult fun onEmptyTextVisibility(): LiveData = emptyTextVisibility + @CheckResult fun onTags(): LiveData> = tags + + @CheckResult fun onTagsListVisibility(): LiveData = tagsListVisibility + @OnLifecycleEvent(ON_RESUME) - fun onResume() = loadSites() + fun onResume() = loadSites(emptyList()) + + fun onTagSelection(tags: List) = loadSites(tags) fun postSiteUpdate(model: Site) { val currentSites = sites.value ?: return @@ -65,7 +73,7 @@ class MainViewModel( } fun refreshSite(model: Site) { - validationManager.scheduleCheck( + validationManager.scheduleValidation( site = model, rightNow = true, cancelPrevious = true @@ -73,7 +81,7 @@ class MainViewModel( } fun removeSite(model: Site) { - validationManager.cancelCheck(model) + validationManager.cancelScheduledValidation(model) notificationManager.cancelStatusNotification(model) scope.launch { @@ -94,27 +102,56 @@ class MainViewModel( } } - private fun loadSites() { + private fun loadSites(forTags: List) { scope.launch { notificationManager.cancelStatusNotifications() - sites.value = listOf() emptyTextVisibility.value = false isLoading.value = true - val result = withContext(ioDispatcher) { + 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.ensureScheduledChecks() + 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 a193c6f..2aa312c 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 @@ -17,6 +17,8 @@ package com.afollestad.nocknock.ui.viewsite 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 @@ -25,18 +27,23 @@ 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.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.toViewError 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 kotlinx.android.synthetic.main.activity_viewsite.checkIntervalLayout -import kotlinx.android.synthetic.main.activity_viewsite.disableChecksButton -import kotlinx.android.synthetic.main.activity_viewsite.doneBtn +import kotlinx.android.synthetic.main.activity_viewsite.headersLayout 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 @@ -45,6 +52,8 @@ import kotlinx.android.synthetic.main.activity_viewsite.responseValidationSearch import kotlinx.android.synthetic.main.activity_viewsite.retryPolicyLayout 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 @@ -52,12 +61,17 @@ import kotlinx.android.synthetic.main.activity_viewsite.validationModeDescriptio 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 /** @author Aidan Follestad (@afollestad) */ class ViewSiteActivity : DarkModeSwitchActivity() { + companion object { + private const val SELECT_CERT_FILE_RQ = 23 + } internal val viewModel by viewModel() + private lateinit var validationForm: Form private val intentProvider by inject() private val statusUpdateReceiver by lazy { @@ -70,17 +84,18 @@ class ViewSiteActivity : DarkModeSwitchActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_viewsite) - setupUi() - - lifecycle.run { - addObserver(viewModel) - addObserver(statusUpdateReceiver) - } // 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()) @@ -92,20 +107,17 @@ class ViewSiteActivity : DarkModeSwitchActivity() { // Name inputName.attachLiveData(this, viewModel.name) - viewModel.onNameError() - .toViewError(this, inputName) + + // Tags + inputTags.attachLiveData(this, viewModel.tags) // Url inputUrl.attachLiveData(this, viewModel.url) - viewModel.onUrlError() - .toViewError(this, inputUrl) viewModel.onUrlWarningVisibility() .toViewVisibility(this, textUrlWarning) // Timeout responseTimeoutInput.attachLiveData(this, viewModel.timeout) - viewModel.onTimeoutError() - .toViewError(this, responseTimeoutInput) // Validation mode responseValidationMode.attachLiveData( @@ -114,8 +126,6 @@ class ViewSiteActivity : DarkModeSwitchActivity() { outTransformer = { ValidationMode.fromIndex(it) }, inTransformer = { it.toIndex() } ) - viewModel.onValidationSearchTermError() - .toViewError(this, responseValidationSearchTerm) viewModel.onValidationModeDescription() .toViewText(this, validationModeDescription) @@ -124,25 +134,13 @@ class ViewSiteActivity : DarkModeSwitchActivity() { viewModel.onValidationSearchTermVisibility() .toViewVisibility(this, responseValidationSearchTerm) - // Validation script - scriptInputLayout.attach( - codeData = viewModel.validationScript, - errorData = viewModel.onValidationScriptError(), - visibility = viewModel.onValidationScriptVisibility() - ) + // SSL certificate + sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it } + viewModel.certificateUri.distinct() + .observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) }) - // Check interval - checkIntervalLayout.attach( - valueData = viewModel.checkIntervalValue, - multiplierData = viewModel.checkIntervalUnit, - errorData = viewModel.onCheckIntervalError() - ) - - // Retry Policy - retryPolicyLayout.attach( - timesData = viewModel.retryPolicyTimes, - minutesData = viewModel.retryPolicyMinutes - ) + // Headers + headersLayout.attach(viewModel.headers) // Last/next check viewModel.onLastCheckResultText() @@ -152,25 +150,30 @@ class ViewSiteActivity : DarkModeSwitchActivity() { } private fun setupUi() { - toolbarTitle.setText(R.string.view_site) + 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() } } + setOnMenuItemClickListener { - maybeRemoveSite() + when (it.itemId) { + R.id.remove -> maybeRemoveSite() + R.id.disableChecks -> maybeDisableChecks() + } true } } scrollView.onScroll { - toolbar.elevation = if (it > toolbar.height / 4) { - toolbar.dimenFloat(R.dimen.default_elevation) + appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) { + appToolbar.dimenFloat(R.dimen.default_elevation) } else { 0f } @@ -186,14 +189,95 @@ class ViewSiteActivity : DarkModeSwitchActivity() { // Disabled button viewModel.onDisableChecksVisibility() - .toViewVisibility(this, disableChecksButton) - disableChecksButton.setOnClickListener { maybeDisableChecks() } + .observe(this, Observer { + toolbar.menu.findItem(R.id.disableChecks) + .isVisible = it + }) - // Done button + // Done item text viewModel.onDoneButtonText() - .toViewText(this, doneBtn) - doneBtn.setOnClickListener { - viewModel.commit { finish() } + .observe(this, Observer { + toolbar.menu.findItem(R.id.commit) + .setTitle(it) + }) + + // 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() } + } + } + + // 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 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() ?: "") } } 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 index 536eadd..b5c9f93 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt @@ -23,8 +23,9 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import com.afollestad.nocknock.R import com.afollestad.nocknock.data.AppDatabase -import com.afollestad.nocknock.data.RetryPolicy 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 @@ -35,14 +36,13 @@ 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.ValidationManager +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 com.afollestad.nocknock.viewcomponents.ext.isNullOrLessThan import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -54,7 +54,7 @@ class ViewSiteViewModel( private val stringProvider: StringProvider, private val database: AppDatabase, private val notificationManager: NockNotificationManager, - private val validationManager: ValidationManager, + private val validationManager: ValidationExecutor, mainDispatcher: CoroutineDispatcher, private val ioDispatcher: CoroutineDispatcher ) : ScopedViewModel(mainDispatcher), LifecycleObserver { @@ -64,6 +64,7 @@ class ViewSiteViewModel( // Public properties val status = MutableLiveData() val name = MutableLiveData() + val tags = MutableLiveData() val url = MutableLiveData() val timeout = MutableLiveData() val validationMode = MutableLiveData() @@ -73,25 +74,15 @@ class ViewSiteViewModel( val checkIntervalUnit = MutableLiveData() val retryPolicyTimes = MutableLiveData() val retryPolicyMinutes = MutableLiveData() + val headers = MutableLiveData>() + val certificateUri = MutableLiveData() internal val disabled = MutableLiveData() internal val lastResult = MutableLiveData() - // Private properties private val isLoading = MutableLiveData() - private val nameError = MutableLiveData() - private val urlError = MutableLiveData() - private val timeoutError = MutableLiveData() - private val validationSearchTermError = MutableLiveData() - private val validationScriptError = MutableLiveData() - private val checkIntervalValueError = MutableLiveData() - // Expose private properties or calculated properties @CheckResult fun onIsLoading(): LiveData = isLoading - @CheckResult fun onNameError(): LiveData = nameError - - @CheckResult fun onUrlError(): LiveData = urlError - @CheckResult fun onUrlWarningVisibility(): LiveData { return url.map { val parsed = HttpUrl.parse(it) @@ -99,8 +90,6 @@ class ViewSiteViewModel( } } - @CheckResult fun onTimeoutError(): LiveData = timeoutError - @CheckResult fun onValidationModeDescription(): LiveData { return validationMode.map { when (it!!) { @@ -111,20 +100,11 @@ class ViewSiteViewModel( } } - @CheckResult fun onValidationSearchTermError(): LiveData = validationSearchTermError + @CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH } - @CheckResult fun onValidationSearchTermVisibility() = - validationMode.map { it == TERM_SEARCH } + @CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT } - @CheckResult fun onValidationScriptError(): LiveData = validationScriptError - - @CheckResult fun onValidationScriptVisibility() = - validationMode.map { it == JAVASCRIPT } - - @CheckResult fun onCheckIntervalError(): LiveData = checkIntervalValueError - - @CheckResult fun onDisableChecksVisibility(): LiveData = - disabled.map { !it } + @CheckResult fun onDisableChecksVisibility(): LiveData = disabled.map { !it } @CheckResult fun onDoneButtonText(): LiveData = disabled.map { @@ -168,7 +148,7 @@ class ViewSiteViewModel( withContext(ioDispatcher) { database.updateSite(updatedModel) } - validationManager.scheduleCheck( + validationManager.scheduleValidation( site = updatedModel, rightNow = true, cancelPrevious = true @@ -184,7 +164,7 @@ class ViewSiteViewModel( status = WAITING ) setModel(checkModel) - validationManager.scheduleCheck( + validationManager.scheduleValidation( site = checkModel, rightNow = true, cancelPrevious = true @@ -192,7 +172,7 @@ class ViewSiteViewModel( } fun removeSite(done: () -> Unit) { - validationManager.cancelCheck(site) + validationManager.cancelScheduledValidation(site) notificationManager.cancelStatusNotification(site) scope.launch { @@ -206,7 +186,7 @@ class ViewSiteViewModel( } fun disableSite() { - validationManager.cancelCheck(site) + validationManager.cancelScheduledValidation(site) notificationManager.cancelStatusNotification(site) scope.launch { @@ -242,75 +222,16 @@ class ViewSiteViewModel( } private fun getUpdatedDbModel(): Site? { - var errorCount = 0 - - // Validation name - if (name.value.isNullOrEmpty()) { - nameError.value = R.string.please_enter_name - errorCount++ - } else { - nameError.value = null - } - - // Validate URL - when { - url.value.isNullOrEmpty() -> { - urlError.value = R.string.please_enter_url - errorCount++ - } - HttpUrl.parse(url.value!!) == null -> { - urlError.value = R.string.please_enter_valid_url - errorCount++ - } - else -> { - urlError.value = null - } - } - - // Validate timeout - if (timeout.value.isNullOrLessThan(1)) { - timeoutError.value = R.string.please_enter_networkTimeout - errorCount++ - } else { - timeoutError.value = null - } - - // Validate check interval - if (checkIntervalValue.value.isNullOrLessThan(1)) { - checkIntervalValueError.value = R.string.please_enter_check_interval - errorCount++ - } else { - checkIntervalValueError.value = null - } - - // Validate arguments - if (validationMode.value == TERM_SEARCH && - validationSearchTerm.value.isNullOrEmpty() - ) { - errorCount++ - validationSearchTermError.value = R.string.please_enter_search_term - validationScriptError.value = null - } else if (validationMode.value == JAVASCRIPT && - validationScript.value.isNullOrEmpty() - ) { - errorCount++ - validationSearchTermError.value = null - validationScriptError.value = R.string.please_enter_javaScript - } else { - validationSearchTermError.value = null - validationScriptError.value = null - } - - if (errorCount > 0) { - return null - } + 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.value!!, - disabled = false + networkTimeout = timeout, + disabled = false, + certificate = certificateUri.value?.toString() ) val retryPolicyTimes = retryPolicyTimes.value ?: 0 @@ -318,10 +239,16 @@ class ViewSiteViewModel( val retryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) { if (site.retryPolicy != null) { // Have existing policy, update it - site.retryPolicy!!.copy(count = retryPolicyTimes, minutes = retryPolicyMinutes) + site.retryPolicy!!.copy( + count = retryPolicyTimes, + minutes = retryPolicyMinutes + ) } else { // Create new policy - RetryPolicy(count = retryPolicyTimes, minutes = retryPolicyMinutes) + RetryPolicy( + count = retryPolicyTimes, + minutes = retryPolicyMinutes + ) } } else { // No policy @@ -330,9 +257,11 @@ class ViewSiteViewModel( return site.copy( name = name.value!!.trim(), + tags = cleanedTags, url = url.value!!.trim(), settings = newSettings, - retryPolicy = retryPolicy + 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 index d13341a..800f235 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt @@ -15,7 +15,7 @@ */ package com.afollestad.nocknock.ui.viewsite -import com.afollestad.nocknock.data.RetryPolicy +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 @@ -32,6 +32,7 @@ fun ViewSiteViewModel.setModel(site: Site) { status.value = site.lastResult?.status ?: WAITING name.value = site.name + tags.value = site.tags url.value = site.url timeout.value = settings.networkTimeout @@ -53,6 +54,12 @@ fun ViewSiteViewModel.setModel(site: Site) { 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 @@ -62,22 +69,22 @@ private fun ViewSiteViewModel.setCheckInterval(interval: Long) { when { interval >= WEEK -> { checkIntervalValue.value = - getIntervalFromUnit(interval, WEEK) + getIntervalFromUnit(interval, WEEK) checkIntervalUnit.value = WEEK } interval >= DAY -> { checkIntervalValue.value = - getIntervalFromUnit(interval, DAY) + getIntervalFromUnit(interval, DAY) checkIntervalUnit.value = DAY } interval >= HOUR -> { checkIntervalValue.value = - getIntervalFromUnit(interval, HOUR) + getIntervalFromUnit(interval, HOUR) checkIntervalUnit.value = HOUR } interval >= MINUTE -> { checkIntervalValue.value = - getIntervalFromUnit(interval, MINUTE) + getIntervalFromUnit(interval, MINUTE) checkIntervalUnit.value = MINUTE } else -> { diff --git a/app/src/main/res/color/unchecked_chip_text.xml b/app/src/main/res/color/unchecked_chip_text.xml new file mode 100644 index 0000000..8e7f4df --- /dev/null +++ b/app/src/main/res/color/unchecked_chip_text.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/checked_chip.xml b/app/src/main/res/drawable/checked_chip.xml new file mode 100644 index 0000000..85010f5 --- /dev/null +++ b/app/src/main/res/drawable/checked_chip.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/src/main/res/drawable/checked_chip_pressed.xml b/app/src/main/res/drawable/checked_chip_pressed.xml new file mode 100644 index 0000000..0d7c176 --- /dev/null +++ b/app/src/main/res/drawable/checked_chip_pressed.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/src/main/res/drawable/checked_chip_selector.xml b/app/src/main/res/drawable/checked_chip_selector.xml new file mode 100644 index 0000000..fa9df00 --- /dev/null +++ b/app/src/main/res/drawable/checked_chip_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000..00fc15d --- /dev/null +++ b/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/unchecked_chip.xml b/app/src/main/res/drawable/unchecked_chip.xml new file mode 100644 index 0000000..1864bc5 --- /dev/null +++ b/app/src/main/res/drawable/unchecked_chip.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/src/main/res/drawable/unchecked_chip_pressed.xml b/app/src/main/res/drawable/unchecked_chip_pressed.xml new file mode 100644 index 0000000..c387d70 --- /dev/null +++ b/app/src/main/res/drawable/unchecked_chip_pressed.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/src/main/res/drawable/unchecked_chip_selector.xml b/app/src/main/res/drawable/unchecked_chip_selector.xml new file mode 100644 index 0000000..ba01f74 --- /dev/null +++ b/app/src/main/res/drawable/unchecked_chip_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/activity_addsite.xml b/app/src/main/res/layout/activity_addsite.xml index 77765d1..80f83da 100644 --- a/app/src/main/res/layout/activity_addsite.xml +++ b/app/src/main/res/layout/activity_addsite.xml @@ -16,6 +16,7 @@ @@ -24,59 +25,61 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" - android:paddingBottom="@dimen/content_inset" + android:paddingBottom="@dimen/content_inset_double" android:paddingLeft="@dimen/content_inset" android:paddingRight="@dimen/content_inset" + android:paddingTop="@dimen/content_inset_half" > - + - + - + - - - - - + + + + + @@ -88,35 +91,10 @@ android:layout_marginTop="@dimen/content_inset" /> - - - - + + - + + + + + + + + + +