Skip to content

Commit

Permalink
Merge pull request #33 from JetBrains-Research/store-survey
Browse files Browse the repository at this point in the history
Store activity tracker key and survey info
elena-lyulina authored Jul 30, 2020

Verified

This commit was signed with the committer’s verified signature.
nonrational Alan Norton
2 parents f16d1d1 + c348788 commit 7f2414f
Showing 11 changed files with 252 additions and 86 deletions.
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -43,6 +43,7 @@ dependencies {
implementation("org.controlsfx:controlsfx:11.0.2")
compile("com.google.auto.service:auto-service:1.0-rc7")
implementation("org.eclipse.mylyn.github", "org.eclipse.egit.github.core", "2.1.5")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-runtime-native:0.9.1")

testCompile("junit", "junit", "4.12")
}
19 changes: 2 additions & 17 deletions src/main/kotlin/org/jetbrains/research/ml/codetracker/Plugin.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.jetbrains.research.ml.codetracker

import com.intellij.openapi.application.PathManager
import com.intellij.openapi.diagnostic.Logger
import org.jetbrains.research.ml.codetracker.server.TrackerQueryExecutor
import org.jetbrains.research.ml.codetracker.tracking.DocumentLogger
@@ -8,28 +9,12 @@ import org.jetbrains.research.ml.codetracker.tracking.TaskFileHandler

object Plugin {
const val PLUGIN_ID = "codetracker"
val codeTrackerFolderPath = "${PathManager.getPluginsPath()}/${PLUGIN_ID}"

private val logger: Logger = Logger.getInstance(javaClass)

init {
logger.info("$PLUGIN_ID: init plugin")
}

// fun stopTracking(): Boolean {
// logger.info("$PLUGIN_ID: close IDE")
// logger.info("$PLUGIN_ID: prepare fo sending ${DocumentLogger.getFiles().size} files")
// if (DocumentLogger.getFiles().isNotEmpty()) {
// DocumentLogger.logCurrentDocuments()
// DocumentLogger.flush()
// DocumentLogger.documentsToPrinters.forEach { (d, p) ->
// TrackerQueryExecutor.sendCodeTrackerData(
// p.file,
// { TrackerQueryExecutor.isLastSuccessful }
// ) { DocumentLogger.close(d, p) }
// }
// }
//// TaskFileHandler.stopTracking()
// return TrackerQueryExecutor.isLastSuccessful
// }

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.jetbrains.research.ml.codetracker.models

import kotlinx.serialization.Serializable

@Serializable
data class StoredInfo(
var loggedUIData: Map<String, String> = mapOf(),
var activityTrackerKey: String? = null
)
Original file line number Diff line number Diff line change
@@ -15,8 +15,8 @@ data class TaskInfo(

@Serializable
data class Task(
val key: String,
override val key: String,
val id: Int = -1,
val infoTranslation: Map<PaneLanguage, TaskInfo>,
val examples: List<Example> = emptyList()
)
) : Keyed
Original file line number Diff line number Diff line change
@@ -2,17 +2,21 @@ package org.jetbrains.research.ml.codetracker.models

import kotlinx.serialization.Serializable

interface Keyed {
val key: String
}

@Serializable
data class PaneLanguage(val key: String)
data class PaneLanguage(override val key: String) : Keyed

@Serializable
data class Gender(
val key: String,
override val key: String,
val translation: Map<PaneLanguage, String>
)
) : Keyed

@Serializable
data class Country(
val key: String,
override val key: String,
val translation: Map<PaneLanguage, String>
)
) : Keyed
Original file line number Diff line number Diff line change
@@ -10,6 +10,7 @@ import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.jetbrains.research.ml.codetracker.tracking.StoredInfoWrapper
import java.io.File
import java.lang.IllegalStateException
import java.net.URL
@@ -24,7 +25,12 @@ object TrackerQueryExecutor : QueryExecutor() {
var activityTrackerKey: String? = null

init {
initActivityTrackerInfo()
StoredInfoWrapper.info.activityTrackerKey?.let {
activityTrackerKey = it
} ?: run {
initActivityTrackerInfo()
StoredInfoWrapper.updateStoredInfo(activityTrackerKey = activityTrackerKey)
}
}

private fun initActivityTrackerInfo() {
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package org.jetbrains.research.ml.codetracker.tracking

import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.PathManager
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.Document
import com.intellij.openapi.fileEditor.FileDocumentManager
@@ -13,6 +12,7 @@ import com.intellij.util.messages.Topic
import org.apache.commons.csv.CSVFormat
import org.apache.commons.csv.CSVPrinter
import org.jetbrains.research.ml.codetracker.Plugin
import org.jetbrains.research.ml.codetracker.Plugin.codeTrackerFolderPath
import org.jetbrains.research.ml.codetracker.models.Task
import com.intellij.openapi.progress.Task as IntellijTask
import org.jetbrains.research.ml.codetracker.server.TrackerQueryExecutor
@@ -44,7 +44,6 @@ object DocumentLogger {
private val logger: Logger = Logger.getInstance(javaClass)
private val myDocumentsToPrinters: HashMap<Document, Printer> = HashMap()

private val folderPath = "${PathManager.getPluginsPath()}/codetracker/"
private const val MAX_FILE_SIZE = 50 * 1024 * 1024
private const val MAX_DIF_SIZE = 300

@@ -73,10 +72,10 @@ object DocumentLogger {


private fun createLogFile(document: Document): File {
File(folderPath).mkdirs()
File(codeTrackerFolderPath).mkdirs()
val file = FileDocumentManager.getInstance().getFile(document)
logger.info("${Plugin.PLUGIN_ID}: create log file for file ${file?.name}")
val logFile = File("$folderPath${file?.nameWithoutExtension}_${file.hashCode()}_${document.hashCode()}.csv")
val logFile = File("$codeTrackerFolderPath${file?.nameWithoutExtension}_${file.hashCode()}_${document.hashCode()}.csv")
FileUtil.createIfDoesntExist(logFile)
return logFile
}
Original file line number Diff line number Diff line change
@@ -20,11 +20,20 @@ abstract class LoggedData<T, S> {
}
}

enum class UiLoggedDataHeader(val header: String) {
AGE("age"),
GENDER("gender"),
PROGRAM_EXPERIENCE_YEARS("programExperienceYears"),
PROGRAM_EXPERIENCE_MONTHS("programExperienceMonths"),
COUNTRY("country"),
CHOSEN_TASK("chosenTask")
}

object UiLoggedData : LoggedData<Unit, String>() {

override val loggedDataGetters: List<LoggedDataGetter<Unit, String>> = arrayListOf(
LoggedDataGetter("age") { SurveyUiData.age.uiValue.toString() },
LoggedDataGetter("gender") {
LoggedDataGetter(UiLoggedDataHeader.AGE.header) { SurveyUiData.age.uiValue.toString() },
LoggedDataGetter(UiLoggedDataHeader.GENDER.header) {
// Todo: make it better: delete duplicates of code
val currentValue = SurveyUiData.gender.uiValue
if (!isDefaultValue(currentValue)) {
@@ -33,17 +42,17 @@ object UiLoggedData : LoggedData<Unit, String>() {
currentValue.toString()
}
},
LoggedDataGetter("programExperienceYears") { SurveyUiData.peYears.uiValue.toString() },
LoggedDataGetter("programExperienceMonths") { SurveyUiData.peMonths.uiValue.toString() },
LoggedDataGetter("country") {
LoggedDataGetter(UiLoggedDataHeader.PROGRAM_EXPERIENCE_YEARS.header) { SurveyUiData.peYears.uiValue.toString() },
LoggedDataGetter(UiLoggedDataHeader.PROGRAM_EXPERIENCE_MONTHS.header) { SurveyUiData.peMonths.uiValue.toString() },
LoggedDataGetter(UiLoggedDataHeader.COUNTRY.header) {
val currentValue = SurveyUiData.country.uiValue
if (!isDefaultValue(currentValue)) {
SurveyUiData.country.dataList[currentValue].key
} else {
currentValue.toString()
}
},
LoggedDataGetter("chosenTask") {
LoggedDataGetter(UiLoggedDataHeader.CHOSEN_TASK.header) {
val currentValue = TaskChoosingUiData.chosenTask.uiValue
if (!isDefaultValue(currentValue)) {
TaskChoosingUiData.chosenTask.dataList[currentValue].key
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package org.jetbrains.research.ml.codetracker.tracking

import com.intellij.openapi.diagnostic.Logger
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonConfiguration
import org.jetbrains.research.ml.codetracker.Plugin.codeTrackerFolderPath
import org.jetbrains.research.ml.codetracker.models.Keyed
import org.jetbrains.research.ml.codetracker.models.StoredInfo
import java.io.File
import java.io.PrintWriter


object StoredInfoHandler{

val logger: Logger = Logger.getInstance(javaClass)

fun getIntStoredField(field: UiLoggedDataHeader, defaultValue: Int): Int {
return run{
val storedField = StoredInfoWrapper.info.loggedUIData[field.header]?.toIntOrNull()
logger.info("Stored field $storedField for the ${field.header} value has been received successfully")
storedField
} ?: run {
logger.info("Default value $defaultValue for the ${field.header} value has been received successfully")
defaultValue
}
}

fun <T : Keyed> getIndexByStoredKey(field: UiLoggedDataHeader, list: List<T>, defaultValue: Int): Int {
StoredInfoWrapper.info.loggedUIData[field.header]?.let { storedKey ->
val storedKeyIndex = list.indexOfFirst { it.key == storedKey }
logger.info("Stored index $storedKeyIndex for the ${field.header} value has been received successfully")
return storedKeyIndex
}
logger.info("Default value $defaultValue for the ${field.header} value has been received successfully")
return defaultValue
}
}

/*
This class provides storing survey info and activity tracker key
*/
object StoredInfoWrapper {

private const val storedInfoFileName = "storedInfo.txt"
private val storedInfoFilePath = "${codeTrackerFolderPath}/$storedInfoFileName"
private val json by lazy {
Json(JsonConfiguration.Stable)
}
private val serializer = StoredInfo.serializer()

var info: StoredInfo = readStoredInfo()

private fun readStoredInfo(): StoredInfo {
val file = File(storedInfoFilePath)
if (!file.exists()) {
return StoredInfo()
}
return json.parse(serializer, file.readText())
}

fun updateStoredInfo(surveyInfo: Map<String, String>? = null,
activityTrackerKey: String? = null) {
surveyInfo?.let{ info.loggedUIData = it }
activityTrackerKey?.let{ info.activityTrackerKey = it }
writeStoredInfo()
}

private fun writeStoredInfo() {
val file = File(storedInfoFilePath)
val writer = PrintWriter(file)
writer.print(json.stringify(serializer, info))
writer.close()
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.jetbrains.research.ml.codetracker.ui.panes

import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.project.Project
import com.intellij.util.messages.Topic
import javafx.beans.binding.Bindings
@@ -17,6 +18,10 @@ import org.jetbrains.research.ml.codetracker.Plugin
import org.jetbrains.research.ml.codetracker.models.Country
import org.jetbrains.research.ml.codetracker.models.Gender
import org.jetbrains.research.ml.codetracker.server.PluginServer
import org.jetbrains.research.ml.codetracker.tracking.StoredInfoHandler
import org.jetbrains.research.ml.codetracker.tracking.StoredInfoWrapper
import org.jetbrains.research.ml.codetracker.tracking.UiLoggedData
import org.jetbrains.research.ml.codetracker.tracking.UiLoggedDataHeader
import org.jetbrains.research.ml.codetracker.ui.panes.util.*
import java.net.URL
import java.util.*
@@ -72,13 +77,33 @@ object SurveyUiData : LanguagePaneUiData() {
private val countries: List<Country> = PluginServer.countries
private val genders: List<Gender> = PluginServer.genders

val age = UiField(-1, AgeNotifier.AGE_TOPIC)
val gender = ListedUiField(genders, -1, GenderNotifier.GENDER_TOPIC)
val peYears = UiField(-1, PeYearsNotifier.PE_YEARS_TOPIC)
val peMonths = UiField( -1, PeMonthsNotifier.PE_MONTHS_TOPIC, false)
val country = ListedUiField(countries, -1, CountryNotifier.COUNTRY_TOPIC,
compareBy { c -> c.translation.getOrDefault(language.currentValue,"") },
CountryComparatorNotifier.COUNTRY_COMPARATOR_TOPIC)
val age = UiField(-1, AgeNotifier.AGE_TOPIC, StoredInfoHandler.getIntStoredField(UiLoggedDataHeader.AGE, -1))
val gender = ListedUiField(
genders,
-1,
GenderNotifier.GENDER_TOPIC,
initValue = StoredInfoHandler.getIndexByStoredKey(UiLoggedDataHeader.GENDER, genders, -1)
)
val peYears = UiField(
-1,
PeYearsNotifier.PE_YEARS_TOPIC,
StoredInfoHandler.getIntStoredField(UiLoggedDataHeader.PROGRAM_EXPERIENCE_YEARS, -1)

)
val peMonths = UiField(
-1,
PeMonthsNotifier.PE_MONTHS_TOPIC,
StoredInfoHandler.getIntStoredField(UiLoggedDataHeader.PROGRAM_EXPERIENCE_MONTHS, -1),
false
)
val country = ListedUiField(
countries,
-1,
CountryNotifier.COUNTRY_TOPIC,
compareBy { c -> c.translation.getOrDefault(language.currentValue, "") },
CountryComparatorNotifier.COUNTRY_COMPARATOR_TOPIC,
StoredInfoHandler.getIndexByStoredKey(UiLoggedDataHeader.COUNTRY, countries, -1)
)

override fun getData() = listOf(
age,
@@ -90,43 +115,85 @@ object SurveyUiData : LanguagePaneUiData() {
)
}

class SurveyController(project: Project, scale: Double, fxPanel: JFXPanel, id: Int) : LanguagePaneController(project, scale, fxPanel, id) {
class SurveyController(project: Project, scale: Double, fxPanel: JFXPanel, id: Int) :
LanguagePaneController(project, scale, fxPanel, id) {
// Age
@FXML private lateinit var ageLabel: Label
@FXML private lateinit var ageTextField: TextField
@FXML
private lateinit var ageLabel: Label

@FXML
private lateinit var ageTextField: TextField

// Gender
@FXML private lateinit var genderLabel: Label
@FXML private lateinit var genderGroup: ToggleGroup
@FXML private lateinit var gender1: RadioButton
@FXML private lateinit var gender2: RadioButton
@FXML private lateinit var gender3: RadioButton
@FXML private lateinit var gender4: RadioButton
@FXML private lateinit var gender5: RadioButton
@FXML private lateinit var gender6: RadioButton
@FXML private lateinit var genderRadioButtons: List<RadioButton>
@FXML
private lateinit var genderLabel: Label

@FXML
private lateinit var genderGroup: ToggleGroup

@FXML
private lateinit var gender1: RadioButton

@FXML
private lateinit var gender2: RadioButton

@FXML
private lateinit var gender3: RadioButton

@FXML
private lateinit var gender4: RadioButton

@FXML
private lateinit var gender5: RadioButton

@FXML
private lateinit var gender6: RadioButton

@FXML
private lateinit var genderRadioButtons: List<RadioButton>

// Program Experience
@FXML private lateinit var experienceLabel: Label
@FXML private lateinit var peYearsLabel: Label
@FXML private lateinit var peYearsTextField: TextField
@FXML private lateinit var peMonthsHBox: HBox
@FXML private lateinit var peMonthsLabel: Label
@FXML private lateinit var peMonthsTextField: TextField
@FXML
private lateinit var experienceLabel: Label

@FXML
private lateinit var peYearsLabel: Label

@FXML
private lateinit var peYearsTextField: TextField

@FXML
private lateinit var peMonthsHBox: HBox

@FXML
private lateinit var peMonthsLabel: Label

@FXML
private lateinit var peMonthsTextField: TextField

// Country
@FXML private lateinit var countryLabel: Label
@FXML private lateinit var countryComboBox: ComboBox<Country>
@FXML
private lateinit var countryLabel: Label

@FXML
private lateinit var countryComboBox: ComboBox<Country>
private lateinit var countryObservableList: ObservableList<Country>

// StartWorking
@FXML private lateinit var startWorkingButton: Button
@FXML private lateinit var startWorkingText: Text
@FXML
private lateinit var startWorkingButton: Button

@FXML private lateinit var mainPane: Pane
@FXML
private lateinit var startWorkingText: Text

@FXML private lateinit var orangePolygon: Polygon
@FXML private lateinit var bluePolygon: Polygon
@FXML
private lateinit var mainPane: Pane

@FXML
private lateinit var orangePolygon: Polygon

@FXML
private lateinit var bluePolygon: Polygon

override val paneUiData = SurveyUiData
private val translations = PluginServer.paneText?.surveyPane
@@ -150,9 +217,9 @@ class SurveyController(project: Project, scale: Double, fxPanel: JFXPanel, id: I
}

private fun initAge() {
val converter = ageTextField.addIntegerFormatter(regexFilter("[1-9][0-9]{0,1}"))
ageTextField.addIntegerFormatter(regexFilter("[1-9][0-9]{0,1}"))
ageTextField.textProperty().addListener { _, _, new ->
paneUiData.age.uiValue = new.toIntOrNull() ?: paneUiData.age.defaultUiValue
paneUiData.age.uiValue = new.toIntOrNull() ?: paneUiData.age.defaultValue
}
subscribe(AgeNotifier.AGE_TOPIC, object : AgeNotifier {
override fun accept(newAge: Int) {
@@ -165,7 +232,7 @@ class SurveyController(project: Project, scale: Double, fxPanel: JFXPanel, id: I
private fun initGender() {
genderRadioButtons = listOf(gender1, gender2, gender3, gender4, gender5, gender6)
val gendersSize = paneUiData.gender.dataList.size
genderRadioButtons.forEachIndexed { i, rb -> rb.isVisible = i < gendersSize }
genderRadioButtons.forEachIndexed { i, rb -> rb.isVisible = i < gendersSize }

genderGroup.selectedToggleProperty().addListener { _, _, new ->
paneUiData.gender.uiValue = genderRadioButtons.indexOf(new)
@@ -183,16 +250,17 @@ class SurveyController(project: Project, scale: Double, fxPanel: JFXPanel, id: I
private fun initPeYears() {
peYearsTextField.addIntegerFormatter(regexFilter("0|[1-9][0-9]{0,1}"))
peYearsTextField.textProperty().addListener { _, _, new ->
paneUiData.peYears.uiValue = new.toIntOrNull() ?: paneUiData.peYears.defaultUiValue
paneUiData.peYears.uiValue = new.toIntOrNull() ?: paneUiData.peYears.defaultValue
}
subscribe(PeYearsNotifier.PE_YEARS_TOPIC, object : PeYearsNotifier {
override fun accept(newPeYears: Int) {
peYearsTextField.text = newPeYears.toString()
val isPeMonthsRequired = !paneUiData.peYears.isUiValueDefault && newPeYears < PE_YEARS_NUMBER_TO_SHOW_MONTHS
val isPeMonthsRequired =
!paneUiData.peYears.isUiValueDefault && newPeYears < PE_YEARS_NUMBER_TO_SHOW_MONTHS
paneUiData.peMonths.isRequired = isPeMonthsRequired
peMonthsHBox.isVisible = isPeMonthsRequired
if (!isPeMonthsRequired) {
paneUiData.peMonths.uiValue = paneUiData.peMonths.defaultUiValue
paneUiData.peMonths.uiValue = paneUiData.peMonths.defaultValue
}
startWorkingButton.isDisable = paneUiData.anyRequiredDataDefault()
}
@@ -203,7 +271,7 @@ class SurveyController(project: Project, scale: Double, fxPanel: JFXPanel, id: I
peMonthsHBox.isVisible = paneUiData.peMonths.isRequired
peMonthsTextField.addIntegerFormatter(regexFilter("[0-9]|1[01]"))
peMonthsTextField.textProperty().addListener { _, old, new ->
paneUiData.peMonths.uiValue = new.toIntOrNull() ?: paneUiData.peMonths.defaultUiValue
paneUiData.peMonths.uiValue = new.toIntOrNull() ?: paneUiData.peMonths.defaultValue
}
subscribe(PeMonthsNotifier.PE_MONTHS_TOPIC, object : PeMonthsNotifier {
override fun accept(newPeMonths: Int) {
@@ -249,11 +317,16 @@ class SurveyController(project: Project, scale: Double, fxPanel: JFXPanel, id: I
countryComboBox.items = countryObservableList.sorted(newComparator)
}
})

}

private fun initStartWorkingButton() {
startWorkingButton.onMouseClicked { changeVisiblePane(TaskChoosingControllerManager) }
startWorkingButton.onMouseClicked {
ApplicationManager.getApplication().invokeLater {
val surveyInfo: Map<String, String> = UiLoggedData.headers.zip(UiLoggedData.getData(Unit)).toMap()
StoredInfoWrapper.updateStoredInfo(surveyInfo)
}
changeVisiblePane(TaskChoosingControllerManager)
}
}

private fun makeTranslatable() {
@@ -269,9 +342,12 @@ class SurveyController(project: Project, scale: Double, fxPanel: JFXPanel, id: I
peMonthsLabel.text = it.months
countryLabel.text = it.country
startWorkingText.text = it.startSession
paneUiData.country.dataListComparator = compareBy { c -> c.translation.getOrDefault(newLanguage, "") }
paneUiData.country.dataListComparator =
compareBy { c -> c.translation.getOrDefault(newLanguage, "") }
}
genderRadioButtons.zip(paneUiData.gender.dataList) { rb, g ->
rb.text = g.translation[newLanguage] ?: ""
}
genderRadioButtons.zip(paneUiData.gender.dataList) { rb, g -> rb.text = g.translation[newLanguage] ?: "" }
}
})
}
Original file line number Diff line number Diff line change
@@ -8,24 +8,26 @@ import kotlin.properties.Delegates

/**
* Represents pane data with [uiValue], that triggers notifier when it changes.
* When it's required, user has to change it to be differ from [defaultUiValue], for example fill out age field in ProfilePane
* When it's required, user has to change it to be differ from [defaultValue], for example fill out age field in ProfilePane
*/
open class UiField <T> (val defaultUiValue: T, private val notifierTopic: Topic<out Consumer<T>>, var isRequired: Boolean = true) {
open class UiField <T> (val defaultValue: T, private val notifierTopic: Topic<out Consumer<T>>,
initValue: T = defaultValue,
var isRequired: Boolean = true) {
/**
* We need additional field for checking, is [uiValue] default, because it may be in process of changing so we cannot
* just compare [uiValue] with [defaultUiValue]
* just compare [uiValue] with [defaultValue]
*/
var isUiValueDefault: Boolean = true
var isUiValueDefault: Boolean = initValue == defaultValue
protected set

open var uiValue: T by Delegates.observable(defaultUiValue) { _, old, new ->
open var uiValue: T by Delegates.observable(initValue) { _, old, new ->
if (old != new) {
changeUiValue(new)
}
}

protected fun changeUiValue(new: T) {
isUiValueDefault = new == defaultUiValue
isUiValueDefault = new == defaultValue
val publisher = ApplicationManager.getApplication().messageBus.syncPublisher(notifierTopic)
publisher.accept(new)
}
@@ -47,9 +49,10 @@ open class ListedUiField<T>(
valueNotifierTopic: Topic<out Consumer<Int>>,
defaultComparator: Comparator<T>? = null,
private val comparatorNotifierTopic: Topic<out Consumer<Comparator<T>>>? = null,
isRequired: Boolean = true) : UiField<Int>(defaultValue, valueNotifierTopic, isRequired) {
initValue: Int = defaultValue,
isRequired: Boolean = true) : UiField<Int>(defaultValue, valueNotifierTopic, initValue, isRequired) {

override var uiValue: Int by Delegates.observable(defaultUiValue) { _, old, new ->
override var uiValue: Int by Delegates.observable(initValue) { _, old, new ->
if (old != new) {
changeUiValue(new)
}

0 comments on commit 7f2414f

Please sign in to comment.