diff --git a/app/build.gradle b/app/build.gradle index ae96ef1..7e04aaf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -66,9 +66,6 @@ dependencies { // afollestad implementation 'com.afollestad.material-dialogs:core:' + versions.materialDialogs - // Misc - implementation 'com.github.okdroid:checkablechipview:' + versions.chipView - // Debugging implementation 'com.jakewharton.timber:timber:' + versions.timber implementation("com.crashlytics.sdk.android:crashlytics:${versions.fabric}") { 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/ui/addsite/AddSiteActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt index e3e9855..7c46f41 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 @@ -28,6 +28,7 @@ import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility import kotlinx.android.synthetic.main.activity_addsite.checkIntervalLayout 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 @@ -62,6 +63,9 @@ class AddSiteActivity : DarkModeSwitchActivity() { viewModel.onNameError() .toViewError(this, inputName) + // Tags + inputTags.attachLiveData(this, viewModel.tags) + // Url inputUrl.attachLiveData(this, viewModel.url) viewModel.onUrlError() 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 df010e4..32119af 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 @@ -56,6 +56,7 @@ class AddSiteViewModel( // Public properties val name = MutableLiveData() + val tags = MutableLiveData() val url = MutableLiveData() val timeout = MutableLiveData() val validationMode = MutableLiveData() @@ -74,6 +75,7 @@ class AddSiteViewModel( checkIntervalUnit.value = MINUTE retryPolicyMinutes.value = 0 retryPolicyMinutes.value = 0 + tags.value = "" } // Private properties @@ -224,6 +226,8 @@ class AddSiteViewModel( return null } + val cleanedTags = tags.value?.split(',')?.joinToString { it.trim() } ?: "" + val newSettings = SiteSettings( validationIntervalMs = getCheckIntervalMs(), validationMode = validationMode.value!!, @@ -252,6 +256,7 @@ class AddSiteViewModel( id = 0, name = name.value!!.trim(), url = url.value!!.trim(), + tags = cleanedTags, settings = newSettings, lastResult = newLastResult, retryPolicy = newRetryPolicy 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..9f3609a 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,11 +21,13 @@ 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 @@ -43,6 +45,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 +56,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 +80,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) @@ -97,12 +105,18 @@ class MainActivity : DarkModeSwitchActivity() { } 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() } } 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..b0ee2f1 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 @@ -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 @@ -94,21 +102,26 @@ 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) { - database.allSites() + database.allSites(forTags) } sites.value = result ensureCheckJobs() isLoading.value = false emptyTextVisibility.value = result.isEmpty() + + if (forTags.isEmpty()) { + val tagsValues = pullOutTags(result) + tags.value = tagsValues + tagsListVisibility.value = tagsValues.isNotEmpty() + } } } @@ -117,4 +130,21 @@ class MainViewModel( validationManager.ensureScheduledChecks() } } + + 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..9cb4942 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 @@ -37,6 +37,7 @@ 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 @@ -95,6 +96,9 @@ class ViewSiteActivity : DarkModeSwitchActivity() { viewModel.onNameError() .toViewError(this, inputName) + // Tags + inputTags.attachLiveData(this, viewModel.tags) + // Url inputUrl.attachLiveData(this, viewModel.url) viewModel.onUrlError() 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 c305ae3..4c74670 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 @@ -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() @@ -305,6 +306,8 @@ class ViewSiteViewModel( return null } + val cleanedTags = tags.value?.split(',')?.joinToString { it.trim() } ?: "" + val newSettings = site.settings!!.copy( validationIntervalMs = getCheckIntervalMs(), validationMode = validationMode.value!!, @@ -332,6 +335,7 @@ class ViewSiteViewModel( return site.copy( name = name.value!!.trim(), + tags = cleanedTags, url = url.value!!.trim(), settings = newSettings, retryPolicy = retryPolicy 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 3d83539..9e602d3 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 @@ -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 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/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..b6b6162 100644 --- a/app/src/main/res/layout/activity_addsite.xml +++ b/app/src/main/res/layout/activity_addsite.xml @@ -49,6 +49,27 @@ + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f6465db..d69b862 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -9,5 +9,7 @@ #303030 #FF6E40 + #E44615 + #40FF6E40 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3493d93..7e7c993 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -21,6 +21,8 @@ Add Site Site Name Site URL + Site Tags + Site Tags (one, two, three) Please enter a name! Please enter a URL. Please enter a valid URL. diff --git a/data/src/main/java/com/afollestad/nocknock/data/AppDatabase.kt b/data/src/main/java/com/afollestad/nocknock/data/AppDatabase.kt index 61c035d..c56d7c6 100644 --- a/data/src/main/java/com/afollestad/nocknock/data/AppDatabase.kt +++ b/data/src/main/java/com/afollestad/nocknock/data/AppDatabase.kt @@ -32,7 +32,7 @@ import com.afollestad.nocknock.data.model.ValidationResult SiteSettings::class, Site::class ], - version = 2, + version = 3, exportSchema = false ) @TypeConverters(Converters::class) @@ -52,13 +52,13 @@ abstract class AppDatabase : RoomDatabase() { * * @author Aidan Follestad (@afollestad) */ -fun AppDatabase.allSites(forTag: String = ""): List { - val lowercaseTag = forTag.toLowerCase() +fun AppDatabase.allSites(tags: List = emptyList()): List { var all = siteDao().all() - if (!forTag.isEmpty()) { - all = all.filter { - forTag.isEmpty() || - it.tags.toLowerCase().split(",").contains(lowercaseTag) + if (tags.isNotEmpty()) { + all = all.filter { site -> + val itemTags = site.tags.toLowerCase() + .split(",") + return@filter itemTags.any { tag -> tags.contains(tag) } } } return all.map { diff --git a/data/src/main/java/com/afollestad/nocknock/data/DatabaseMigrations.kt b/data/src/main/java/com/afollestad/nocknock/data/DatabaseMigrations.kt index a7f4d2e..d1587b7 100644 --- a/data/src/main/java/com/afollestad/nocknock/data/DatabaseMigrations.kt +++ b/data/src/main/java/com/afollestad/nocknock/data/DatabaseMigrations.kt @@ -27,7 +27,7 @@ class Database1to2Migration : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL( - "CREATE TABLE `retry_policies` (siteId INTEGER PRIMARY KEY NOT NULL, count INTEGER NOT NULL, minutes INTEGER NOT NULL)" + "CREATE TABLE `retry_policies` (siteId INTEGER PRIMARY KEY NOT NULL, count INTEGER NOT NULL, minutes INTEGER NOT NULL, lastTryTimestamp INTEGER NOT NULL, triesLeft INTEGER NOT NULL)" ) } } @@ -37,9 +37,9 @@ class Database1to2Migration : Migration(1, 2) { * * @author Aidan Follestad (@afollestad) */ -class Database2to3Migration : Migration(1, 2) { +class Database2to3Migration : Migration(2, 3) { override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("ALTER TABLE `sites` ADD COLUMN tags TEXT NOT NULL") + database.execSQL("ALTER TABLE `sites` ADD COLUMN tags TEXT NOT NULL DEFAULT ''") } } diff --git a/dependencies.gradle b/dependencies.gradle index 1c34978..9432a8e 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -15,7 +15,6 @@ ext.versions = [ // Misc okHttp : '3.12.1', rhino : '1.7.10', - chipView : '1.0.3', // Kotlin kotlin : '1.3.11', diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationJob.kt b/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationJob.kt index ce6886a..f1d484f 100644 --- a/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationJob.kt +++ b/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationJob.kt @@ -225,6 +225,7 @@ class ValidationJob : JobService() { triesLeft: Int ) { retryPolicy.triesLeft = triesLeft + retryPolicy.lastTryTimestamp = currentTimeMillis() withContext(IO) { database.retryPolicyDao() .update(retryPolicy)