diff --git a/.gitignore b/.gitignore
index b7ebccf366..7137efbf39 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,7 @@ tags
/docs/build/doctrees
/test-utils/build
/client/build
+/explorer/build
# gradle's buildSrc build/
/buildSrc/build/
diff --git a/.idea/modules.xml b/.idea/modules.xml
index 63596a0a6f..ceb764a238 100644
--- a/.idea/modules.xml
+++ b/.idea/modules.xml
@@ -18,6 +18,9 @@
+
+
+
diff --git a/client/src/integration-test/kotlin/com/r3corda/client/WalletMonitorClientTests.kt b/client/src/integration-test/kotlin/com/r3corda/client/WalletMonitorClientTests.kt
index bc7d228089..007fd885b1 100644
--- a/client/src/integration-test/kotlin/com/r3corda/client/WalletMonitorClientTests.kt
+++ b/client/src/integration-test/kotlin/com/r3corda/client/WalletMonitorClientTests.kt
@@ -105,9 +105,9 @@ class WalletMonitorClientTests {
}
),
expect { tx: ServiceToClientEvent.Transaction ->
- require(tx.transaction.tx.inputs.isEmpty())
- require(tx.transaction.tx.outputs.size == 1)
- val signaturePubKeys = tx.transaction.sigs.map { it.by }.toSet()
+ require(tx.transaction.inputs.isEmpty())
+ require(tx.transaction.outputs.size == 1)
+ val signaturePubKeys = tx.transaction.mustSign.toSet()
// Only Alice signed
require(signaturePubKeys.size == 1)
require(signaturePubKeys.contains(aliceNode.identity.owningKey))
@@ -137,9 +137,9 @@ class WalletMonitorClientTests {
}
),
expect { tx: ServiceToClientEvent.Transaction ->
- require(tx.transaction.tx.inputs.size == 1)
- require(tx.transaction.tx.outputs.size == 1)
- val signaturePubKeys = tx.transaction.sigs.map { it.by }.toSet()
+ require(tx.transaction.inputs.size == 1)
+ require(tx.transaction.outputs.size == 1)
+ val signaturePubKeys = tx.transaction.mustSign.toSet()
// Alice and Notary signed
require(signaturePubKeys.size == 2)
require(signaturePubKeys.contains(aliceNode.identity.owningKey))
diff --git a/client/src/main/kotlin/com/r3corda/client/fxutils/ChosenList.kt b/client/src/main/kotlin/com/r3corda/client/fxutils/ChosenList.kt
index 1c33e2a3bb..5191ea6cc8 100644
--- a/client/src/main/kotlin/com/r3corda/client/fxutils/ChosenList.kt
+++ b/client/src/main/kotlin/com/r3corda/client/fxutils/ChosenList.kt
@@ -21,7 +21,7 @@ import javafx.collections.ObservableListBase
* The above will create a list that chooses and delegates to the appropriate filtered list based on the type of filter.
*/
class ChosenList(
- private val chosenListObservable: ObservableValue>
+ private val chosenListObservable: ObservableValue>
): ObservableListBase() {
private var currentList = chosenListObservable.value
@@ -48,7 +48,7 @@ class ChosenList(
}
}
- private fun pick(list: ObservableList) {
+ private fun pick(list: ObservableList) {
currentList.removeListener(listener)
list.addListener(listener)
beginChange()
diff --git a/client/src/main/kotlin/com/r3corda/client/fxutils/FlattenedList.kt b/client/src/main/kotlin/com/r3corda/client/fxutils/FlattenedList.kt
new file mode 100644
index 0000000000..a417fa8e8f
--- /dev/null
+++ b/client/src/main/kotlin/com/r3corda/client/fxutils/FlattenedList.kt
@@ -0,0 +1,117 @@
+package com.r3corda.client.fxutils
+
+import javafx.beans.InvalidationListener
+import javafx.beans.value.ChangeListener
+import javafx.beans.value.ObservableValue
+import javafx.collections.ListChangeListener
+import javafx.collections.ObservableList
+import javafx.collections.transformation.TransformationList
+import org.eclipse.jetty.server.Authentication
+import java.util.*
+
+/**
+ * [FlattenedList] flattens the passed in list of [ObservableValue]s so that changes in individual updates to the values
+ * are reflected in the exposed list as expected.
+ */
+class FlattenedList(val sourceList: ObservableList>) : TransformationList>(sourceList) {
+
+ /**
+ * We maintain an ObservableValue->index map. This is needed because we need the ObservableValue's index in order to
+ * propagate a change and if the listener closure captures the index at the time of the call to
+ * [ObservableValue.addListener] it will become incorrect if the indices shift around later.
+ *
+ * Note that because of the bookkeeping required for this map, any remove operation and any add operation that
+ * inserts to the middle of the list will be O(N) as we need to scan the map and shift indices accordingly.
+ *
+ * Note also that we're wrapping each ObservableValue, this is required because we want to support reusing of
+ * ObservableValues and we need each to have a different hash.
+ */
+ class WrappedObservableValue(
+ val observableValue: ObservableValue
+ )
+ val indexMap = HashMap, Pair>>()
+ init {
+ sourceList.forEachIndexed { index, observableValue ->
+ val wrappedObservableValue = WrappedObservableValue(observableValue)
+ indexMap[wrappedObservableValue] = Pair(index, createListener(wrappedObservableValue))
+ }
+ }
+
+ private fun createListener(wrapped: WrappedObservableValue): ChangeListener {
+ val listener = ChangeListener { _observableValue, oldValue, newValue ->
+ val currentIndex = indexMap[wrapped]!!.first
+ beginChange()
+ nextReplace(currentIndex, currentIndex + 1, listOf(oldValue))
+ endChange()
+ }
+ wrapped.observableValue.addListener(listener)
+ return listener
+ }
+
+ override fun sourceChanged(c: ListChangeListener.Change>) {
+ beginChange()
+ while (c.next()) {
+ if (c.wasPermutated()) {
+ val from = c.from
+ val to = c.to
+ val permutation = IntArray(to, { c.getPermutation(it) })
+ indexMap.replaceAll { _observableValue, pair -> Pair(permutation[pair.first], pair.second) }
+ nextPermutation(from, to, permutation)
+ } else if (c.wasUpdated()) {
+ throw UnsupportedOperationException("FlattenedList doesn't support Update changes")
+ } else {
+ val removed = c.removed
+ if (removed.size != 0) {
+ // TODO this assumes that if wasAdded() == true then we are adding elements to the getFrom() position
+ val removeStart = c.from
+ val removeRange = c.removed.size
+ val removeEnd = c.from + removeRange
+ val iterator = indexMap.iterator()
+ for (entry in iterator) {
+ val (wrapped, pair) = entry
+ val (index, listener) = pair
+ if (index >= removeStart) {
+ if (index < removeEnd) {
+ wrapped.observableValue.removeListener(listener)
+ iterator.remove()
+ } else {
+ // Shift indices
+ entry.setValue(Pair(index - removeRange, listener))
+ }
+ }
+ }
+ nextRemove(removeStart, removed.map { it.value })
+ }
+ if (c.wasAdded()) {
+ val addStart = c.from
+ val addEnd = c.to
+ val addRange = addEnd - addStart
+ // If it was a push to the end we don't need to shift indices
+ if (addStart != indexMap.size) {
+ val iterator = indexMap.iterator()
+ for (entry in iterator) {
+ val (index, listener) = entry.value
+ if (index >= addStart) {
+ // Shift indices
+ entry.setValue(Pair(index + addRange, listener))
+ }
+ }
+ }
+ c.addedSubList.forEachIndexed { sublistIndex, observableValue ->
+ val wrapped = WrappedObservableValue(observableValue)
+ indexMap[wrapped] = Pair(addStart + sublistIndex, createListener(wrapped))
+ }
+ nextAdd(addStart, addEnd)
+ }
+ }
+ }
+ endChange()
+ require(sourceList.size == indexMap.size)
+ }
+
+ override fun get(index: Int) = sourceList.get(index).value
+
+ override fun getSourceIndex(index: Int) = index
+
+ override val size: Int get() = sourceList.size
+}
diff --git a/client/src/main/kotlin/com/r3corda/client/fxutils/ObservableUtilities.kt b/client/src/main/kotlin/com/r3corda/client/fxutils/ObservableUtilities.kt
new file mode 100644
index 0000000000..d88c886616
--- /dev/null
+++ b/client/src/main/kotlin/com/r3corda/client/fxutils/ObservableUtilities.kt
@@ -0,0 +1,104 @@
+package com.r3corda.client.fxutils
+
+import javafx.beans.binding.Bindings
+import javafx.beans.property.ReadOnlyObjectWrapper
+import javafx.beans.value.ObservableValue
+import javafx.collections.ObservableList
+import javafx.collections.transformation.FilteredList
+import org.fxmisc.easybind.EasyBind
+import java.util.function.Predicate
+
+/**
+ * Here follows utility extension functions that help reduce the visual load when developing RX code. Each function should
+ * have a short accompanying example code.
+ */
+
+/**
+ * val person: ObservableValue = (..)
+ * val personName: ObservableValue = person.map { it.name }
+ */
+fun ObservableValue.map(function: (A) -> B): ObservableValue = EasyBind.map(this, function)
+
+/**
+ * val dogs: ObservableList = (..)
+ * val dogOwners: ObservableList = dogs.map { it.owner }
+ */
+fun ObservableList.map(function: (A) -> B): ObservableList = EasyBind.map(this, function)
+
+/**
+ * val aliceHeight: ObservableValue = (..)
+ * val bobHeight: ObservableValue = (..)
+ * fun sumHeight(a: Long, b: Long): Long { .. }
+ *
+ * val aliceBobSumHeight = ::sumHeight.lift(aliceHeight, bobHeight)
+ * val aliceHeightPlus2 = ::sumHeight.lift(aliceHeight, 2L.lift())
+ */
+fun A.lift(): ObservableValue = ReadOnlyObjectWrapper(this)
+fun ((A) -> R).lift(
+ arg0: ObservableValue
+): ObservableValue = EasyBind.map(arg0, this)
+fun ((A, B) -> R).lift(
+ arg0: ObservableValue,
+ arg1: ObservableValue
+): ObservableValue = EasyBind.combine(arg0, arg1, this)
+fun ((A, B, C) -> R).lift(
+ arg0: ObservableValue,
+ arg1: ObservableValue,
+ arg2: ObservableValue
+): ObservableValue = EasyBind.combine(arg0, arg1, arg2, this)
+fun ((A, B, C, D) -> R).lift(
+ arg0: ObservableValue,
+ arg1: ObservableValue,
+ arg2: ObservableValue,
+ arg3: ObservableValue
+): ObservableValue = EasyBind.combine(arg0, arg1, arg2, arg3, this)
+
+/**
+ * data class Person(val height: ObservableValue)
+ * val person: ObservableValue = (..)
+ * val personHeight: ObservableValue = person.bind { it.height }
+ */
+fun ObservableValue.bind(function: (A) -> ObservableValue): ObservableValue =
+ // We cast here to enforce variance, flatMap should be covariant
+ @Suppress("UNCHECKED_CAST")
+ EasyBind.monadic(this).flatMap(function as (A) -> ObservableValue)
+
+/**
+ * enum class FilterCriterion { HEIGHT, NAME }
+ * val filterCriterion: ObservableValue = (..)
+ * val people: ObservableList = (..)
+ * fun filterFunction(filterCriterion: FilterCriterion): (Person) -> Boolean { .. }
+ *
+ * val filteredPeople: ObservableList = people.filter(filterCriterion.map(filterFunction))
+ */
+fun ObservableList.filter(predicate: ObservableValue Boolean>): ObservableList {
+ // We cast here to enforce variance, FilteredList should be covariant
+ @Suppress("UNCHECKED_CAST")
+ return FilteredList(this as ObservableList).apply {
+ predicateProperty().bind(predicate.map { predicateFunction ->
+ Predicate { predicateFunction(it) }
+ })
+ }
+}
+
+/**
+ * val people: ObservableList = (..)
+ * val concatenatedNames = people.fold("", { names, person -> names + person.name })
+ * val concatenatedNames2 = people.map(Person::name).fold("", String::plus)
+ */
+fun ObservableList.fold(initial: B, folderFunction: (B, A) -> B): ObservableValue {
+ return Bindings.createObjectBinding({
+ var current = initial
+ forEach {
+ current = folderFunction(current, it)
+ }
+ current
+ }, arrayOf(this))
+}
+
+/**
+ * data class Person(val height: ObservableValue)
+ * val people: ObservableList = (..)
+ * val heights: ObservableList = people.map(Person::height).flatten()
+ */
+fun ObservableList>.flatten(): ObservableList = FlattenedList(this)
diff --git a/client/src/main/kotlin/com/r3corda/client/mock/EventGenerator.kt b/client/src/main/kotlin/com/r3corda/client/mock/EventGenerator.kt
index 9692ef04f6..1f234d783d 100644
--- a/client/src/main/kotlin/com/r3corda/client/mock/EventGenerator.kt
+++ b/client/src/main/kotlin/com/r3corda/client/mock/EventGenerator.kt
@@ -88,12 +88,21 @@ class EventGenerator(
)
}
+ val exitCashGenerator =
+ amountIssuedGenerator.map {
+ ClientToServiceCommand.ExitCash(
+ it.withoutIssuer(),
+ it.token.issuer.reference
+ )
+ }
+
val serviceToClientEventGenerator = Generator.frequency(
1.0 to outputStateGenerator
)
val clientToServiceCommandGenerator = Generator.frequency(
- 0.33 to issueCashGenerator,
- 0.33 to moveCashGenerator
+ 0.4 to issueCashGenerator,
+ 0.5 to moveCashGenerator,
+ 0.1 to exitCashGenerator
)
}
diff --git a/client/src/main/kotlin/com/r3corda/client/model/ContractStateModel.kt b/client/src/main/kotlin/com/r3corda/client/model/ContractStateModel.kt
index a16225c6d1..5ee19705c7 100644
--- a/client/src/main/kotlin/com/r3corda/client/model/ContractStateModel.kt
+++ b/client/src/main/kotlin/com/r3corda/client/model/ContractStateModel.kt
@@ -1,20 +1,23 @@
package com.r3corda.client.model
+import com.r3corda.client.fxutils.foldToObservableList
import com.r3corda.contracts.asset.Cash
import com.r3corda.core.contracts.ContractState
import com.r3corda.core.contracts.StateAndRef
import com.r3corda.core.contracts.StateRef
-import com.r3corda.client.fxutils.foldToObservableList
import com.r3corda.node.services.monitor.ServiceToClientEvent
import com.r3corda.node.services.monitor.StateSnapshotMessage
import javafx.collections.ObservableList
import kotlinx.support.jdk8.collections.removeIf
import rx.Observable
-class StatesDiff(
- val added: Collection>,
- val removed: Collection
-)
+sealed class StatesModification{
+ class Diff(
+ val added: Collection>,
+ val removed: Collection
+ ) : StatesModification()
+ class Reset(val states: Collection>) : StatesModification()
+}
/**
* This model exposes the list of owned contract states.
@@ -24,16 +27,45 @@ class ContractStateModel {
private val snapshot: Observable by observable(WalletMonitorModel::snapshot)
private val outputStates = serviceToClient.ofType(ServiceToClientEvent.OutputState::class.java)
- val contractStatesDiff = outputStates.map { StatesDiff(it.produced, it.consumed) }
+ val contractStatesDiff: Observable> =
+ outputStates.map { StatesModification.Diff(it.produced, it.consumed) }
// We filter the diff first rather than the complete contract state list.
- // TODO wire up snapshot once it holds StateAndRefs
- val cashStatesDiff = contractStatesDiff.map {
- StatesDiff(it.added.filterIsInstance>(), it.removed)
- }
+ val cashStatesModification: Observable> = Observable.merge(
+ arrayOf(
+ contractStatesDiff.map {
+ StatesModification.Diff(it.added.filterCashStateAndRefs(), it.removed)
+ },
+ snapshot.map {
+ StatesModification.Reset(it.contractStates.filterCashStateAndRefs())
+ }
+ )
+ )
val cashStates: ObservableList> =
- cashStatesDiff.foldToObservableList(Unit) { statesDiff, _accumulator, observableList ->
- observableList.removeIf { it.ref in statesDiff.removed }
- observableList.addAll(statesDiff.added)
+ cashStatesModification.foldToObservableList(Unit) { statesDiff, _accumulator, observableList ->
+ when (statesDiff) {
+ is StatesModification.Diff -> {
+ observableList.removeIf { it.ref in statesDiff.removed }
+ observableList.addAll(statesDiff.added)
+ }
+ is StatesModification.Reset -> {
+ observableList.setAll(statesDiff.states)
+ }
+ }
}
+
+ companion object {
+ private fun Collection>.filterCashStateAndRefs(): List> {
+ return this.map { stateAndRef ->
+ @Suppress("UNCHECKED_CAST")
+ if (stateAndRef.state.data is Cash.State) {
+ // Kotlin doesn't unify here for some reason
+ stateAndRef as StateAndRef
+ } else {
+ null
+ }
+ }.filterNotNull()
+ }
+ }
+
}
diff --git a/client/src/main/kotlin/com/r3corda/client/model/GatheredTransactionDataModel.kt b/client/src/main/kotlin/com/r3corda/client/model/GatheredTransactionDataModel.kt
index 070700b6da..49e262f33b 100644
--- a/client/src/main/kotlin/com/r3corda/client/model/GatheredTransactionDataModel.kt
+++ b/client/src/main/kotlin/com/r3corda/client/model/GatheredTransactionDataModel.kt
@@ -1,13 +1,16 @@
package com.r3corda.client.model
import com.r3corda.client.fxutils.foldToObservableList
-import com.r3corda.core.transactions.SignedTransaction
+import com.r3corda.core.crypto.SecureHash
+import com.r3corda.core.transactions.LedgerTransaction
import com.r3corda.node.services.monitor.ServiceToClientEvent
import com.r3corda.node.services.monitor.TransactionBuildResult
import com.r3corda.node.utilities.AddOrRemove
import javafx.beans.property.SimpleObjectProperty
import javafx.beans.value.ObservableValue
+import javafx.collections.FXCollections
import javafx.collections.ObservableList
+import org.jetbrains.exposed.sql.transactions.transaction
import rx.Observable
import java.time.Instant
import java.util.UUID
@@ -15,11 +18,12 @@ import java.util.UUID
interface GatheredTransactionData {
val fiberId: ObservableValue
val uuid: ObservableValue
- val protocolName: ObservableValue
val protocolStatus: ObservableValue
- val transaction: ObservableValue
+ val stateMachineStatus: ObservableValue
+ val transaction: ObservableValue
val status: ObservableValue
val lastUpdate: ObservableValue
+ val allEvents: ObservableList
}
sealed class TransactionCreateStatus(val message: String?) {
@@ -28,21 +32,24 @@ sealed class TransactionCreateStatus(val message: String?) {
override fun toString(): String = message ?: javaClass.simpleName
}
-sealed class ProtocolStatus(val status: String?) {
- object Added: ProtocolStatus(null)
- object Removed: ProtocolStatus(null)
- class InProgress(status: String): ProtocolStatus(status)
- override fun toString(): String = status ?: javaClass.simpleName
+data class ProtocolStatus(
+ val status: String
+)
+sealed class StateMachineStatus(val stateMachineName: String) {
+ class Added(stateMachineName: String): StateMachineStatus(stateMachineName)
+ class Removed(stateMachineName: String): StateMachineStatus(stateMachineName)
+ override fun toString(): String = "${javaClass.simpleName}($stateMachineName)"
}
data class GatheredTransactionDataWritable(
override val fiberId: SimpleObjectProperty = SimpleObjectProperty(null),
override val uuid: SimpleObjectProperty = SimpleObjectProperty(null),
- override val protocolName: SimpleObjectProperty = SimpleObjectProperty(null),
+ override val stateMachineStatus: SimpleObjectProperty = SimpleObjectProperty(null),
override val protocolStatus: SimpleObjectProperty = SimpleObjectProperty(null),
- override val transaction: SimpleObjectProperty = SimpleObjectProperty(null),
+ override val transaction: SimpleObjectProperty = SimpleObjectProperty(null),
override val status: SimpleObjectProperty = SimpleObjectProperty(null),
- override val lastUpdate: SimpleObjectProperty
+ override val lastUpdate: SimpleObjectProperty,
+ override val allEvents: ObservableList = FXCollections.observableArrayList()
) : GatheredTransactionData
/**
@@ -54,7 +61,7 @@ class GatheredTransactionDataModel {
/**
* Aggregation of updates to transactions. We use the observable list as the only container and do linear search for
- * matching transactions because we have two keys(fiber ID and UUID) and this way it's easier to avoid syncing issues.
+ * matching transactions because we have three keys(fiber ID, UUID, tx id) and this way it's easier to avoid syncing issues.
*
* The Fiber ID is used to identify events that relate to the same transaction server-side, whereas the UUID is
* generated on the UI and is used to identify events with the UI action that triggered them. Currently a UUID is
@@ -64,46 +71,49 @@ class GatheredTransactionDataModel {
* (Note that a transaction may be mapped by one or both)
* TODO: Expose a writable stream to combine [serviceToClient] with to allow recording of transactions made locally(UUID)
*/
- val gatheredGatheredTransactionDataList: ObservableList =
+ val gatheredTransactionDataList: ObservableList =
serviceToClient.foldToObservableList(
initialAccumulator = Unit,
folderFun = { serviceToClientEvent, _unit, transactionStates ->
return@foldToObservableList when (serviceToClientEvent) {
is ServiceToClientEvent.Transaction -> {
- // TODO handle this once we have some id to associate the tx with
+ newTransactionIdTransactionStateOrModify(transactionStates, serviceToClientEvent,
+ transaction = serviceToClientEvent.transaction,
+ tweak = {}
+ )
}
is ServiceToClientEvent.OutputState -> {}
is ServiceToClientEvent.StateMachine -> {
- newFiberIdTransactionStateOrModify(transactionStates,
+ newFiberIdTransactionStateOrModify(transactionStates, serviceToClientEvent,
fiberId = serviceToClientEvent.fiberId,
- lastUpdate = serviceToClientEvent.time,
tweak = {
- protocolName.set(serviceToClientEvent.label)
- protocolStatus.set(when (serviceToClientEvent.addOrRemove) {
- AddOrRemove.ADD -> ProtocolStatus.Added
- AddOrRemove.REMOVE -> ProtocolStatus.Removed
+ stateMachineStatus.set(when (serviceToClientEvent.addOrRemove) {
+ AddOrRemove.ADD -> StateMachineStatus.Added(serviceToClientEvent.label)
+ AddOrRemove.REMOVE -> StateMachineStatus.Removed(serviceToClientEvent.label)
})
}
)
}
is ServiceToClientEvent.Progress -> {
- newFiberIdTransactionStateOrModify(transactionStates,
+ newFiberIdTransactionStateOrModify(transactionStates, serviceToClientEvent,
fiberId = serviceToClientEvent.fiberId,
- lastUpdate = serviceToClientEvent.time,
tweak = {
- protocolStatus.set(ProtocolStatus.InProgress(serviceToClientEvent.message))
+ protocolStatus.set(ProtocolStatus(serviceToClientEvent.message))
}
)
}
is ServiceToClientEvent.TransactionBuild -> {
val state = serviceToClientEvent.state
- newUuidTransactionStateOrModify(transactionStates,
+ newUuidTransactionStateOrModify(transactionStates, serviceToClientEvent,
uuid = serviceToClientEvent.id,
fiberId = when (state) {
is TransactionBuildResult.ProtocolStarted -> state.fiberId
is TransactionBuildResult.Failed -> null
},
- lastUpdate = serviceToClientEvent.time,
+ transactionId = when (state) {
+ is TransactionBuildResult.ProtocolStarted -> state.transaction?.id
+ is TransactionBuildResult.Failed -> null
+ },
tweak = {
return@newUuidTransactionStateOrModify when (state) {
is TransactionBuildResult.ProtocolStarted -> {
@@ -122,50 +132,84 @@ class GatheredTransactionDataModel {
)
companion object {
- private fun newFiberIdTransactionStateOrModify(
+
+ private fun newTransactionIdTransactionStateOrModify(
transactionStates: ObservableList,
- fiberId: Long,
- lastUpdate: Instant,
+ event: ServiceToClientEvent,
+ transaction: LedgerTransaction,
tweak: GatheredTransactionDataWritable.() -> Unit
) {
- val index = transactionStates.indexOfFirst { it.fiberId.value == fiberId }
- if (index < 0) {
+ val index = transactionStates.indexOfFirst { transaction.id == it.transaction.value?.id }
+ val state = if (index < 0) {
val newState = GatheredTransactionDataWritable(
- fiberId = SimpleObjectProperty(fiberId),
- lastUpdate = SimpleObjectProperty(lastUpdate)
+ transaction = SimpleObjectProperty(transaction),
+ lastUpdate = SimpleObjectProperty(event.time)
)
tweak(newState)
transactionStates.add(newState)
+ newState
} else {
val existingState = transactionStates[index]
- existingState.lastUpdate.set(lastUpdate)
+ existingState.lastUpdate.set(event.time)
tweak(existingState)
+ existingState
}
+ state.allEvents.add(event)
+ }
+
+ private fun newFiberIdTransactionStateOrModify(
+ transactionStates: ObservableList,
+ event: ServiceToClientEvent,
+ fiberId: Long,
+ tweak: GatheredTransactionDataWritable.() -> Unit
+ ) {
+ val index = transactionStates.indexOfFirst { it.fiberId.value == fiberId }
+ val state = if (index < 0) {
+ val newState = GatheredTransactionDataWritable(
+ fiberId = SimpleObjectProperty(fiberId),
+ lastUpdate = SimpleObjectProperty(event.time)
+ )
+ tweak(newState)
+ transactionStates.add(newState)
+ newState
+ } else {
+ val existingState = transactionStates[index]
+ existingState.lastUpdate.set(event.time)
+ tweak(existingState)
+ existingState
+ }
+ state.allEvents.add(event)
}
private fun newUuidTransactionStateOrModify(
transactionStates: ObservableList,
+ event: ServiceToClientEvent,
uuid: UUID,
fiberId: Long?,
- lastUpdate: Instant,
+ transactionId: SecureHash?,
tweak: GatheredTransactionDataWritable.() -> Unit
) {
val index = transactionStates.indexOfFirst {
- it.uuid.value == uuid || (fiberId != null && it.fiberId.value == fiberId)
+ it.uuid.value == uuid ||
+ (fiberId != null && it.fiberId.value == fiberId) ||
+ (transactionId != null && it.transaction.value?.id == transactionId)
}
- if (index < 0) {
+ val state = if (index < 0) {
val newState = GatheredTransactionDataWritable(
uuid = SimpleObjectProperty(uuid),
fiberId = SimpleObjectProperty(fiberId),
- lastUpdate = SimpleObjectProperty(lastUpdate)
+ lastUpdate = SimpleObjectProperty(event.time)
)
tweak(newState)
transactionStates.add(newState)
+ newState
} else {
val existingState = transactionStates[index]
- existingState.lastUpdate.set(lastUpdate)
+ existingState.lastUpdate.set(event.time)
tweak(existingState)
+ existingState
}
+ state.allEvents.add(event)
}
}
diff --git a/client/src/test/kotlin/com/r3corda/client/fxutils/AggregatedListTest.kt b/client/src/test/kotlin/com/r3corda/client/fxutils/AggregatedListTest.kt
index 3a65ae920e..2f24118ad0 100644
--- a/client/src/test/kotlin/com/r3corda/client/fxutils/AggregatedListTest.kt
+++ b/client/src/test/kotlin/com/r3corda/client/fxutils/AggregatedListTest.kt
@@ -8,28 +8,31 @@ import kotlin.test.fail
class AggregatedListTest {
var sourceList = FXCollections.observableArrayList()
+ var aggregatedList = AggregatedList(sourceList, { it % 3 }) { mod3, group -> Pair(mod3, group) }
+ var replayedList = ReplayedList(aggregatedList)
@Before
fun setup() {
sourceList = FXCollections.observableArrayList()
+ aggregatedList = AggregatedList(sourceList, { it % 3 }) { mod3, group -> Pair(mod3, group) }
+ replayedList = ReplayedList(aggregatedList)
}
@Test
fun addWorks() {
- val aggregatedList = AggregatedList(sourceList, { it % 3 }) { mod3, group -> Pair(mod3, group) }
- require(aggregatedList.size == 0) { "Aggregation is empty is source list is" }
+ require(replayedList.size == 0) { "Aggregation is empty is source list is" }
sourceList.add(9)
- require(aggregatedList.size == 1) { "Aggregation list has one element if one was added to source list" }
- require(aggregatedList[0]!!.first == 0)
+ require(replayedList.size == 1) { "Aggregation list has one element if one was added to source list" }
+ require(replayedList[0]!!.first == 0)
sourceList.add(8)
- require(aggregatedList.size == 2) { "Aggregation list has two elements if two were added to source list with different keys" }
+ require(replayedList.size == 2) { "Aggregation list has two elements if two were added to source list with different keys" }
sourceList.add(6)
- require(aggregatedList.size == 2) { "Aggregation list's size doesn't change if element with existing key is added" }
+ require(replayedList.size == 2) { "Aggregation list's size doesn't change if element with existing key is added" }
- aggregatedList.forEach {
+ replayedList.forEach {
when (it.first) {
0 -> require(it.second.toSet() == setOf(6, 9))
2 -> require(it.second.size == 1)
@@ -40,11 +43,10 @@ class AggregatedListTest {
@Test
fun removeWorks() {
- val aggregatedList = AggregatedList(sourceList, { it % 3 }) { mod3, group -> Pair(mod3, group) }
sourceList.addAll(0, 1, 2, 3, 4)
- require(aggregatedList.size == 3)
- aggregatedList.forEach {
+ require(replayedList.size == 3)
+ replayedList.forEach {
when (it.first) {
0 -> require(it.second.toSet() == setOf(0, 3))
1 -> require(it.second.toSet() == setOf(1, 4))
@@ -54,8 +56,8 @@ class AggregatedListTest {
}
sourceList.remove(4)
- require(aggregatedList.size == 3)
- aggregatedList.forEach {
+ require(replayedList.size == 3)
+ replayedList.forEach {
when (it.first) {
0 -> require(it.second.toSet() == setOf(0, 3))
1 -> require(it.second.toSet() == setOf(1))
@@ -65,8 +67,8 @@ class AggregatedListTest {
}
sourceList.remove(2, 4)
- require(aggregatedList.size == 2)
- aggregatedList.forEach {
+ require(replayedList.size == 2)
+ replayedList.forEach {
when (it.first) {
0 -> require(it.second.toSet() == setOf(0))
1 -> require(it.second.toSet() == setOf(1))
@@ -75,7 +77,7 @@ class AggregatedListTest {
}
sourceList.removeAll(0, 1)
- require(aggregatedList.size == 0)
+ require(replayedList.size == 0)
}
}
diff --git a/client/src/test/kotlin/com/r3corda/client/fxutils/FlattenedListTest.kt b/client/src/test/kotlin/com/r3corda/client/fxutils/FlattenedListTest.kt
new file mode 100644
index 0000000000..646418b3ae
--- /dev/null
+++ b/client/src/test/kotlin/com/r3corda/client/fxutils/FlattenedListTest.kt
@@ -0,0 +1,113 @@
+package com.r3corda.client.fxutils
+
+import javafx.beans.property.SimpleObjectProperty
+import javafx.collections.FXCollections
+import org.junit.Before
+import org.junit.Test
+
+class FlattenedListTest {
+
+ var sourceList = FXCollections.observableArrayList(SimpleObjectProperty(1234))
+ var flattenedList = FlattenedList(sourceList)
+ var replayedList = ReplayedList(flattenedList)
+
+ @Before
+ fun setup() {
+ sourceList = FXCollections.observableArrayList(SimpleObjectProperty(1234))
+ flattenedList = FlattenedList(sourceList)
+ replayedList = ReplayedList(flattenedList)
+ }
+
+ @Test
+ fun addWorks() {
+ require(replayedList.size == 1)
+ require(replayedList[0] == 1234)
+
+ sourceList.add(SimpleObjectProperty(12))
+ require(replayedList.size == 2)
+ require(replayedList[0] == 1234)
+ require(replayedList[1] == 12)
+
+ sourceList.add(SimpleObjectProperty(34))
+ require(replayedList.size == 3)
+ require(replayedList[0] == 1234)
+ require(replayedList[1] == 12)
+ require(replayedList[2] == 34)
+
+ sourceList.add(0, SimpleObjectProperty(56))
+ require(replayedList.size == 4)
+ require(replayedList[0] == 56)
+ require(replayedList[1] == 1234)
+ require(replayedList[2] == 12)
+ require(replayedList[3] == 34)
+
+ sourceList.addAll(2, listOf(SimpleObjectProperty(78), SimpleObjectProperty(910)))
+ require(replayedList.size == 6)
+ require(replayedList[0] == 56)
+ require(replayedList[1] == 1234)
+ require(replayedList[2] == 78)
+ require(replayedList[3] == 910)
+ require(replayedList[4] == 12)
+ require(replayedList[5] == 34)
+ }
+
+ @Test
+ fun removeWorks() {
+ val firstRemoved = sourceList.removeAt(0)
+ require(firstRemoved.get() == 1234)
+ require(replayedList.size == 0)
+ firstRemoved.set(123)
+
+ sourceList.add(SimpleObjectProperty(12))
+ sourceList.add(SimpleObjectProperty(34))
+ sourceList.add(SimpleObjectProperty(56))
+ require(replayedList.size == 3)
+ val secondRemoved = sourceList.removeAt(1)
+ require(secondRemoved.get() == 34)
+ require(replayedList.size == 2)
+ require(replayedList[0] == 12)
+ require(replayedList[1] == 56)
+ secondRemoved.set(123)
+
+ sourceList.clear()
+ require(replayedList.size == 0)
+ }
+
+ @Test
+ fun updatingObservableWorks() {
+ require(replayedList[0] == 1234)
+ sourceList[0].set(4321)
+ require(replayedList[0] == 4321)
+
+ sourceList.add(0, SimpleObjectProperty(12))
+ sourceList[1].set(8765)
+ require(replayedList[0] == 12)
+ require(replayedList[1] == 8765)
+
+ sourceList[0].set(34)
+ require(replayedList[0] == 34)
+ require(replayedList[1] == 8765)
+ }
+
+ @Test
+ fun reusingObservableWorks() {
+ val observable = SimpleObjectProperty(12)
+ sourceList.add(observable)
+ sourceList.add(observable)
+ require(replayedList.size == 3)
+ require(replayedList[0] == 1234)
+ require(replayedList[1] == 12)
+ require(replayedList[2] == 12)
+
+ observable.set(34)
+ require(replayedList.size == 3)
+ require(replayedList[0] == 1234)
+ require(replayedList[1] == 34)
+ require(replayedList[2] == 34)
+
+ sourceList.removeAt(1)
+ require(replayedList.size == 2)
+ require(replayedList[0] == 1234)
+ require(replayedList[1] == 34)
+ }
+}
diff --git a/client/src/test/kotlin/com/r3corda/client/fxutils/ReplayedList.kt b/client/src/test/kotlin/com/r3corda/client/fxutils/ReplayedList.kt
new file mode 100644
index 0000000000..c9b828e9ab
--- /dev/null
+++ b/client/src/test/kotlin/com/r3corda/client/fxutils/ReplayedList.kt
@@ -0,0 +1,63 @@
+package com.r3corda.client.fxutils
+
+import javafx.collections.ListChangeListener
+import javafx.collections.ObservableList
+import javafx.collections.transformation.TransformationList
+import java.util.*
+
+/**
+ * This list type just replays changes propagated from the underlying source list. Used for testing changes.
+ */
+class ReplayedList(sourceList: ObservableList) : TransformationList(sourceList) {
+
+ val replayedList = ArrayList(sourceList)
+
+ override val size: Int get() = replayedList.size
+
+ override fun sourceChanged(c: ListChangeListener.Change) {
+
+ beginChange()
+ while (c.next()) {
+ if (c.wasPermutated()) {
+ val from = c.from
+ val to = c.to
+ val permutation = IntArray(to, { c.getPermutation(it) })
+ val permutedSubList = ArrayList(to - from)
+ for (i in 0 .. (to - from - 1)) {
+ permutedSubList.add(replayedList[permutation[from + i]])
+ }
+ permutedSubList.forEachIndexed { i, element ->
+ replayedList[from + i] = element
+ }
+ nextPermutation(from, to, permutation)
+ } else if (c.wasUpdated()) {
+ for (i in c.from .. c.to - 1) {
+ replayedList[i] = c.list[i]
+ nextUpdate(i)
+ }
+ } else {
+ if (c.wasRemoved()) {
+ // TODO this assumes that if wasAdded() == true then we are adding elements to the getFrom() position
+ val removePosition = c.from
+ for (i in 0 .. c.removedSize - 1) {
+ replayedList.removeAt(removePosition)
+ }
+ nextRemove(c.from, c.removed)
+ }
+ if (c.wasAdded()) {
+ val addStart = c.from
+ val addEnd = c.to
+ for (i in addStart .. addEnd - 1) {
+ replayedList.add(i, c.list[i])
+ }
+ nextAdd(addStart, addEnd)
+ }
+ }
+ }
+ endChange()
+ }
+
+ override fun getSourceIndex(index: Int) = index
+
+ override fun get(index: Int) = replayedList[index]
+}
diff --git a/client/src/test/kotlin/com/r3corda/client/fxutils/ReplayedListTest.kt b/client/src/test/kotlin/com/r3corda/client/fxutils/ReplayedListTest.kt
new file mode 100644
index 0000000000..e0653eeb2e
--- /dev/null
+++ b/client/src/test/kotlin/com/r3corda/client/fxutils/ReplayedListTest.kt
@@ -0,0 +1,86 @@
+package com.r3corda.client.fxutils
+
+import javafx.collections.FXCollections
+import org.junit.Before
+import org.junit.Test
+
+class ReplayedListTest {
+
+ var sourceList = FXCollections.observableArrayList(1234)
+ var replayedList = ReplayedList(sourceList)
+
+ @Before
+ fun setup() {
+ sourceList = FXCollections.observableArrayList(1234)
+ replayedList = ReplayedList(sourceList)
+ }
+
+ @Test
+ fun addWorks() {
+ require(replayedList.size == 1)
+ require(replayedList[0] == 1234)
+
+ sourceList.add(12)
+ require(replayedList.size == 2)
+ require(replayedList[0] == 1234)
+ require(replayedList[1] == 12)
+
+ sourceList.add(34)
+ require(replayedList.size == 3)
+ require(replayedList[0] == 1234)
+ require(replayedList[1] == 12)
+ require(replayedList[2] == 34)
+
+ sourceList.add(0, 56)
+ require(replayedList.size == 4)
+ require(replayedList[0] == 56)
+ require(replayedList[1] == 1234)
+ require(replayedList[2] == 12)
+ require(replayedList[3] == 34)
+
+ sourceList.addAll(2, listOf(78, 910))
+ require(replayedList.size == 6)
+ require(replayedList[0] == 56)
+ require(replayedList[1] == 1234)
+ require(replayedList[2] == 78)
+ require(replayedList[3] == 910)
+ require(replayedList[4] == 12)
+ require(replayedList[5] == 34)
+ }
+
+ @Test
+ fun removeWorks() {
+ val firstRemoved = sourceList.removeAt(0)
+ require(firstRemoved == 1234)
+ require(replayedList.size == 0)
+
+ sourceList.add(12)
+ sourceList.add(34)
+ sourceList.add(56)
+ require(replayedList.size == 3)
+ val secondRemoved = sourceList.removeAt(1)
+ require(secondRemoved == 34)
+ require(replayedList.size == 2)
+ require(replayedList[0] == 12)
+ require(replayedList[1] == 56)
+
+ sourceList.clear()
+ require(replayedList.size == 0)
+ }
+
+ @Test
+ fun updateWorks() {
+ require(replayedList[0] == 1234)
+ sourceList[0] = 4321
+ require(replayedList[0] == 4321)
+
+ sourceList.add(0, 12)
+ sourceList[1] = 8765
+ require(replayedList[0] == 12)
+ require(replayedList[1] == 8765)
+
+ sourceList[0] = 34
+ require(replayedList[0] == 34)
+ require(replayedList[1] == 8765)
+ }
+}
diff --git a/explorer/build.gradle b/explorer/build.gradle
new file mode 100644
index 0000000000..f74e581f3d
--- /dev/null
+++ b/explorer/build.gradle
@@ -0,0 +1,77 @@
+group 'com.r3corda'
+version '1.0-SNAPSHOT'
+
+buildscript {
+ ext.kotlin_version = '1.0.3'
+
+ repositories {
+ mavenCentral()
+ }
+
+ dependencies {
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+ }
+}
+
+repositories {
+ mavenCentral()
+ maven {
+ url 'https://dl.bintray.com/kotlin/exposed'
+ }
+}
+
+apply plugin: 'java'
+apply plugin: 'kotlin'
+apply plugin: 'application'
+
+sourceCompatibility = 1.8
+
+applicationDefaultJvmArgs = ["-javaagent:${rootProject.configurations.quasar.singleFile}"]
+mainClassName = 'com.r3corda.explorer.Main'
+
+sourceSets {
+ main {
+ resources {
+ srcDir "../config/dev"
+ }
+ }
+ test {
+ resources {
+ srcDir "../config/test"
+ }
+ }
+}
+
+repositories {
+ jcenter()
+ mavenCentral()
+ mavenLocal()
+}
+
+dependencies {
+ compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
+ testCompile group: 'junit', name: 'junit', version: '4.11'
+
+ // TornadoFX: A lightweight Kotlin framework for working with JavaFX UI's.
+ compile 'no.tornado:tornadofx:1.5.1'
+
+ // Corda Core: Data structures and basic types needed to work with Corda.
+ compile project(':core')
+ compile project(':client')
+ compile project(':node')
+ compile project(':contracts')
+
+ // FontAwesomeFX: The "FontAwesome" icon library.
+ compile 'de.jensd:fontawesomefx-fontawesome:4.6.1-2'
+
+ // ReactFX: Functional reactive UI programming.
+ compile 'org.reactfx:reactfx:2.0-M5'
+ compile 'org.fxmisc.easybind:easybind:1.0.3'
+
+ // JFXtras: useful widgets including a calendar control.
+ compile 'org.jfxtras:jfxtras-agenda:8.0-r5'
+ compile 'org.jfxtras:jfxtras-font-roboto:8.0-r5'
+
+ // Humanize: formatting
+ compile 'com.github.mfornos:humanize-icu:1.2.2'
+}
diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/AmountDiff.kt b/explorer/src/main/kotlin/com/r3corda/explorer/AmountDiff.kt
new file mode 100644
index 0000000000..968f9c2480
--- /dev/null
+++ b/explorer/src/main/kotlin/com/r3corda/explorer/AmountDiff.kt
@@ -0,0 +1,26 @@
+package com.r3corda.explorer
+
+import com.r3corda.core.contracts.Amount
+
+enum class Positivity {
+ Positive,
+ Negative
+}
+
+val Positivity.sign: String get() = when (this) {
+ Positivity.Positive -> ""
+ Positivity.Negative -> "-"
+}
+
+data class AmountDiff(
+ val positivity: Positivity,
+ val amount: Amount
+) {
+ companion object {
+ fun fromLong(quantity: Long, token: T) =
+ AmountDiff(
+ positivity = if (quantity < 0) Positivity.Negative else Positivity.Positive,
+ amount = Amount(Math.abs(quantity), token)
+ )
+ }
+}
diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/Main.kt b/explorer/src/main/kotlin/com/r3corda/explorer/Main.kt
new file mode 100644
index 0000000000..af6b79b929
--- /dev/null
+++ b/explorer/src/main/kotlin/com/r3corda/explorer/Main.kt
@@ -0,0 +1,82 @@
+package com.r3corda.explorer
+
+import com.r3corda.client.WalletMonitorClient
+import com.r3corda.client.mock.EventGenerator
+import com.r3corda.client.mock.Generator
+import com.r3corda.client.mock.oneOf
+import com.r3corda.client.model.Models
+import com.r3corda.client.model.WalletMonitorModel
+import com.r3corda.client.model.observer
+import com.r3corda.core.contracts.ClientToServiceCommand
+import com.r3corda.explorer.model.IdentityModel
+import com.r3corda.node.driver.PortAllocation
+import com.r3corda.node.driver.driver
+import com.r3corda.node.driver.startClient
+import com.r3corda.node.services.monitor.ServiceToClientEvent
+import com.r3corda.node.services.transactions.SimpleNotaryService
+import javafx.stage.Stage
+import rx.Observer
+import rx.subjects.PublishSubject
+import tornadofx.App
+import java.util.*
+
+class Main : App() {
+ override val primaryView = MainWindow::class
+ val aliceOutStream: Observer by observer(WalletMonitorModel::clientToService)
+
+ override fun start(stage: Stage) {
+
+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
+ throwable.printStackTrace()
+ System.exit(1)
+ }
+
+ super.start(stage)
+
+ // start the driver on another thread
+ // TODO Change this to connecting to an actual node (specified on cli/in a config) once we're happy with the code
+ Thread({
+
+ val portAllocation = PortAllocation.Incremental(20000)
+ driver(portAllocation = portAllocation) {
+
+ val aliceNodeFuture = startNode("Alice")
+ val bobNodeFuture = startNode("Bob")
+ val notaryNodeFuture = startNode("Notary", advertisedServices = setOf(SimpleNotaryService.Type))
+
+ val aliceNode = aliceNodeFuture.get()
+ val bobNode = bobNodeFuture.get()
+ val notaryNode = notaryNodeFuture.get()
+
+ val aliceClient = startClient(aliceNode).get()
+
+ Models.get(Main::class).myIdentity.set(aliceNode.identity)
+ Models.get(Main::class).register(aliceClient, aliceNode)
+
+ val bobInStream = PublishSubject.create()
+ val bobOutStream = PublishSubject.create()
+
+ val bobClient = startClient(bobNode).get()
+ val bobMonitorClient = WalletMonitorClient(bobClient, bobNode, bobOutStream, bobInStream, PublishSubject.create())
+ assert(bobMonitorClient.register().get())
+
+ for (i in 0 .. 10000) {
+ Thread.sleep(500)
+
+ val eventGenerator = EventGenerator(
+ parties = listOf(aliceNode.identity, bobNode.identity),
+ notary = notaryNode.identity
+ )
+
+ eventGenerator.clientToServiceCommandGenerator.combine(Generator.oneOf(listOf(aliceOutStream, bobOutStream))) {
+ command, stream -> stream.onNext(command)
+ }.generate(Random())
+ }
+
+ waitForAllNodesToFinish()
+ }
+
+ }).start()
+ }
+}
+
diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/MainWindow.kt b/explorer/src/main/kotlin/com/r3corda/explorer/MainWindow.kt
new file mode 100644
index 0000000000..f9537b1400
--- /dev/null
+++ b/explorer/src/main/kotlin/com/r3corda/explorer/MainWindow.kt
@@ -0,0 +1,26 @@
+package com.r3corda.explorer
+
+import com.r3corda.explorer.views.TopLevel
+import de.jensd.fx.glyphs.fontawesome.utils.FontAwesomeIconFactory
+import jfxtras.resources.JFXtrasFontRoboto
+import tornadofx.*
+
+/**
+ * The root view embeds the [Shell] and provides support for the status bar, and modal dialogs.
+ */
+class MainWindow : View() {
+ private val toplevel: TopLevel by inject()
+ override val root = toplevel.root
+
+ init {
+ // Do this first before creating the notification bar, so it can autosize itself properly.
+ loadFontsAndStyles()
+ }
+
+ private fun loadFontsAndStyles() {
+ JFXtrasFontRoboto.loadAll()
+ importStylesheet("/com/r3corda/explorer/css/wallet.css")
+ FontAwesomeIconFactory.get() // Force initialisation.
+ root.styleClass += "root"
+ }
+}
diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/formatters/AmountFormatter.kt b/explorer/src/main/kotlin/com/r3corda/explorer/formatters/AmountFormatter.kt
new file mode 100644
index 0000000000..0ec38d6c40
--- /dev/null
+++ b/explorer/src/main/kotlin/com/r3corda/explorer/formatters/AmountFormatter.kt
@@ -0,0 +1,17 @@
+package com.r3corda.explorer.formatters
+
+import com.r3corda.core.contracts.Amount
+import java.util.Currency
+
+/**
+ * A note on formatting: Currently we don't have any fancy locale/use-case-specific formatting of amounts. This is a
+ * non-trivial problem that requires substantial work.
+ * Libraries to evaluate: IBM ICU currency library, github.com/mfornos/humanize, JSR 354 ref. implementation
+ */
+
+object AmountFormatter {
+ // TODO replace this once we settled on how we do formatting
+ val boring = object : Formatter> {
+ override fun format(value: Amount) = "${value.quantity} ${value.token}"
+ }
+}
diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/formatters/Formatter.kt b/explorer/src/main/kotlin/com/r3corda/explorer/formatters/Formatter.kt
new file mode 100644
index 0000000000..71e11f193b
--- /dev/null
+++ b/explorer/src/main/kotlin/com/r3corda/explorer/formatters/Formatter.kt
@@ -0,0 +1,6 @@
+package com.r3corda.explorer.formatters
+
+
+interface Formatter {
+ fun format(value: T): String
+}
diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/formatters/NumberFormatter.kt b/explorer/src/main/kotlin/com/r3corda/explorer/formatters/NumberFormatter.kt
new file mode 100644
index 0000000000..9fe6616a78
--- /dev/null
+++ b/explorer/src/main/kotlin/com/r3corda/explorer/formatters/NumberFormatter.kt
@@ -0,0 +1,10 @@
+package com.r3corda.explorer.formatters
+
+object NumberFormatter {
+ // TODO replace this once we settled on how we do formatting
+ val boring = object : Formatter {
+ override fun format(value: Any) = value.toString()
+ }
+
+ val boringLong: Formatter = boring
+}
diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/model/IdentityModel.kt b/explorer/src/main/kotlin/com/r3corda/explorer/model/IdentityModel.kt
new file mode 100644
index 0000000000..c2813d849b
--- /dev/null
+++ b/explorer/src/main/kotlin/com/r3corda/explorer/model/IdentityModel.kt
@@ -0,0 +1,9 @@
+package com.r3corda.explorer.model
+
+import com.r3corda.core.crypto.Party
+import javafx.beans.property.SimpleObjectProperty
+
+
+class IdentityModel {
+ val myIdentity = SimpleObjectProperty()
+}
diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/model/ReportingCurrencyModel.kt b/explorer/src/main/kotlin/com/r3corda/explorer/model/ReportingCurrencyModel.kt
new file mode 100644
index 0000000000..9d49555abc
--- /dev/null
+++ b/explorer/src/main/kotlin/com/r3corda/explorer/model/ReportingCurrencyModel.kt
@@ -0,0 +1,23 @@
+package com.r3corda.explorer.model
+
+import com.r3corda.core.contracts.Amount
+import com.r3corda.client.fxutils.AmountBindings
+import com.r3corda.client.model.ExchangeRate
+import com.r3corda.client.model.ExchangeRateModel
+import com.r3corda.client.model.observableValue
+import javafx.beans.value.ObservableValue
+import org.fxmisc.easybind.EasyBind
+import java.util.*
+
+class ReportingCurrencyModel {
+ private val exchangeRate: ObservableValue by observableValue(ExchangeRateModel::exchangeRate)
+ val reportingCurrency: ObservableValue by observableValue(SettingsModel::reportingCurrency)
+ /**
+ * This stream provides a stream of exchange() functions that updates when either the reporting currency or the
+ * exchange rates change
+ */
+ val reportingExchange: ObservableValue) -> Amount>> =
+ EasyBind.map(AmountBindings.exchange(reportingCurrency, exchangeRate)) { Pair(it.first) { amount: Amount ->
+ Amount(it.second(amount), it.first)
+ }}
+}
diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/model/SettingsModel.kt b/explorer/src/main/kotlin/com/r3corda/explorer/model/SettingsModel.kt
new file mode 100644
index 0000000000..994a91c263
--- /dev/null
+++ b/explorer/src/main/kotlin/com/r3corda/explorer/model/SettingsModel.kt
@@ -0,0 +1,11 @@
+package com.r3corda.explorer.model
+
+import com.r3corda.core.contracts.USD
+import javafx.beans.property.SimpleObjectProperty
+import java.util.*
+
+class SettingsModel {
+
+ val reportingCurrency: SimpleObjectProperty = SimpleObjectProperty(USD)
+
+}
diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/model/TopLevelModel.kt b/explorer/src/main/kotlin/com/r3corda/explorer/model/TopLevelModel.kt
new file mode 100644
index 0000000000..f7e424fb44
--- /dev/null
+++ b/explorer/src/main/kotlin/com/r3corda/explorer/model/TopLevelModel.kt
@@ -0,0 +1,13 @@
+package com.r3corda.explorer.model
+
+import javafx.beans.property.SimpleObjectProperty
+
+enum class SelectedView {
+ Home,
+ Cash,
+ Transaction
+}
+
+class TopLevelModel {
+ val selectedView = SimpleObjectProperty(SelectedView.Home)
+}
diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/ui/ListViewUtilities.kt b/explorer/src/main/kotlin/com/r3corda/explorer/ui/ListViewUtilities.kt
new file mode 100644
index 0000000000..3cde116ecc
--- /dev/null
+++ b/explorer/src/main/kotlin/com/r3corda/explorer/ui/ListViewUtilities.kt
@@ -0,0 +1,38 @@
+package com.r3corda.explorer.ui
+
+import com.r3corda.explorer.formatters.Formatter
+import javafx.scene.Node
+import javafx.scene.control.ListCell
+import javafx.scene.control.ListView
+import javafx.util.Callback
+
+fun Formatter.toListCellFactory() = Callback, ListCell> {
+ object : ListCell() {
+ override fun updateItem(value: T?, empty: Boolean) {
+ super.updateItem(value, empty)
+ text = if (value == null || empty) {
+ ""
+ } else {
+ format(value)
+ }
+ }
+ }
+}
+
+fun ListView.setCustomCellFactory(toNode: (T) -> Node) {
+ setCellFactory {
+ object : ListCell() {
+ init {
+ text = null
+ }
+ override fun updateItem(value: T?, empty: Boolean) {
+ super.updateItem(value, empty)
+ graphic = if (value != null && !empty) {
+ toNode(value)
+ } else {
+ null
+ }
+ }
+ }
+ }
+}
diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/ui/SingleRowSelection.kt b/explorer/src/main/kotlin/com/r3corda/explorer/ui/SingleRowSelection.kt
new file mode 100644
index 0000000000..bb441fabad
--- /dev/null
+++ b/explorer/src/main/kotlin/com/r3corda/explorer/ui/SingleRowSelection.kt
@@ -0,0 +1,6 @@
+package com.r3corda.explorer.ui
+
+sealed class SingleRowSelection {
+ class None : SingleRowSelection()
+ class Selected(val node: A) : SingleRowSelection()
+}
diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/ui/TableViewUtilities.kt b/explorer/src/main/kotlin/com/r3corda/explorer/ui/TableViewUtilities.kt
new file mode 100644
index 0000000000..b4887f5099
--- /dev/null
+++ b/explorer/src/main/kotlin/com/r3corda/explorer/ui/TableViewUtilities.kt
@@ -0,0 +1,76 @@
+package com.r3corda.explorer.ui
+
+import com.r3corda.explorer.formatters.Formatter
+import javafx.beans.binding.Bindings
+import javafx.beans.value.ObservableValue
+import javafx.scene.Node
+import javafx.scene.control.ListCell
+import javafx.scene.control.TableCell
+import javafx.scene.control.TableColumn
+import javafx.scene.control.TableView
+import javafx.util.Callback
+import org.fxmisc.easybind.EasyBind
+
+fun TableView.setColumnPrefWidthPolicy(
+ getColumnWidth: (tableWidthWithoutPaddingAndBorder: Number, column: TableColumn) -> Number
+) {
+ val tableWidthWithoutPaddingAndBorder = Bindings.createDoubleBinding({
+ val padding = padding
+ val borderInsets = border?.insets
+ width -
+ (if (padding != null) padding.left + padding.right else 0.0) -
+ (if (borderInsets != null) borderInsets.left + borderInsets.right else 0.0)
+ }, arrayOf(columns, widthProperty(), paddingProperty(), borderProperty()))
+
+ columns.forEach {
+ it.setPrefWidthPolicy(tableWidthWithoutPaddingAndBorder, getColumnWidth)
+ }
+}
+
+private fun TableColumn.setPrefWidthPolicy(
+ widthWithoutPaddingAndBorder: ObservableValue,
+ getColumnWidth: (tableWidthWithoutPaddingAndBorder: Number, column: TableColumn) -> Number
+) {
+ prefWidthProperty().bind(EasyBind.map(widthWithoutPaddingAndBorder) {
+ getColumnWidth(it, this)
+ })
+}
+
+fun Formatter.toTableCellFactory() = Callback, TableCell> {
+ object : TableCell() {
+ override fun updateItem(value: T?, empty: Boolean) {
+ super.updateItem(value, empty)
+ text = if (value == null || empty) {
+ ""
+ } else {
+ format(value)
+ }
+ }
+ }
+}
+
+fun TableView.singleRowSelection() = Bindings.createObjectBinding({
+ if (selectionModel.selectedItems.size == 0) {
+ SingleRowSelection.None()
+ } else {
+ SingleRowSelection.Selected(selectionModel.selectedItems[0])
+ }
+}, arrayOf(selectionModel.selectedItems))
+
+fun TableColumn.setCustomCellFactory(toNode: (T) -> Node) {
+ setCellFactory {
+ object : TableCell() {
+ init {
+ text = null
+ }
+ override fun updateItem(value: T?, empty: Boolean) {
+ super.updateItem(value, empty)
+ graphic = if (value != null && !empty) {
+ toNode(value)
+ } else {
+ null
+ }
+ }
+ }
+ }
+}
diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/ui/TreeTableViewUtilities.kt b/explorer/src/main/kotlin/com/r3corda/explorer/ui/TreeTableViewUtilities.kt
new file mode 100644
index 0000000000..f2730f4196
--- /dev/null
+++ b/explorer/src/main/kotlin/com/r3corda/explorer/ui/TreeTableViewUtilities.kt
@@ -0,0 +1,58 @@
+package com.r3corda.explorer.ui
+
+import com.r3corda.explorer.formatters.Formatter
+import javafx.beans.binding.Bindings
+import javafx.beans.value.ObservableValue
+import javafx.scene.control.TreeTableCell
+import javafx.scene.control.TreeTableColumn
+import javafx.scene.control.TreeTableView
+import javafx.util.Callback
+import org.fxmisc.easybind.EasyBind
+
+
+fun TreeTableView.setColumnPrefWidthPolicy(
+ getColumnWidth: (tableWidthWithoutPaddingAndBorder: Number, column: TreeTableColumn) -> Number
+) {
+ val tableWidthWithoutPaddingAndBorder = Bindings.createDoubleBinding({
+ val padding = padding
+ val borderInsets = border?.insets
+ width -
+ (if (padding != null) padding.left + padding.right else 0.0) -
+ (if (borderInsets != null) borderInsets.left + borderInsets.right else 0.0)
+ }, arrayOf(columns, widthProperty(), paddingProperty(), borderProperty()))
+
+ columns.forEach {
+ it.setPrefWidthPolicy(tableWidthWithoutPaddingAndBorder, getColumnWidth)
+ }
+}
+
+private fun TreeTableColumn.setPrefWidthPolicy(
+ widthWithoutPaddingAndBorder: ObservableValue,
+ getColumnWidth: (tableWidthWithoutPaddingAndBorder: Number, column: TreeTableColumn) -> Number
+) {
+ prefWidthProperty().bind(EasyBind.map(widthWithoutPaddingAndBorder) {
+ getColumnWidth(it, this)
+ })
+}
+
+fun Formatter.toTreeTableCellFactory() = Callback, TreeTableCell> {
+ object : TreeTableCell() {
+ override fun updateItem(value: T?, empty: Boolean) {
+ super.updateItem(value, empty)
+ text = if (value == null || empty) {
+ ""
+ } else {
+ format(value)
+ }
+ }
+ }
+}
+
+fun TreeTableView.singleRowSelection(): ObservableValue> =
+ Bindings.createObjectBinding({
+ if (selectionModel.selectedItems.size == 0) {
+ SingleRowSelection.None()
+ } else {
+ SingleRowSelection.Selected(selectionModel.selectedItems[0].value)
+ }
+ }, arrayOf(selectionModel.selectedItems))
diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/views/CashViewer.kt b/explorer/src/main/kotlin/com/r3corda/explorer/views/CashViewer.kt
new file mode 100644
index 0000000000..26fb675534
--- /dev/null
+++ b/explorer/src/main/kotlin/com/r3corda/explorer/views/CashViewer.kt
@@ -0,0 +1,364 @@
+package com.r3corda.explorer.views
+
+import com.r3corda.client.fxutils.*
+import com.r3corda.client.model.ContractStateModel
+import com.r3corda.client.model.observableList
+import com.r3corda.client.model.observableValue
+import com.r3corda.contracts.asset.Cash
+import com.r3corda.core.contracts.Amount
+import com.r3corda.core.contracts.StateAndRef
+import com.r3corda.core.contracts.withoutIssuer
+import com.r3corda.core.crypto.Party
+import com.r3corda.explorer.formatters.AmountFormatter
+import com.r3corda.explorer.model.ReportingCurrencyModel
+import com.r3corda.explorer.model.SettingsModel
+import com.r3corda.explorer.ui.*
+import javafx.beans.binding.Bindings
+import javafx.beans.value.ObservableValue
+import javafx.collections.FXCollections
+import javafx.collections.ObservableList
+import javafx.scene.Node
+import javafx.scene.control.*
+import javafx.scene.image.ImageView
+import javafx.scene.input.MouseButton
+import javafx.scene.input.MouseEvent
+import javafx.scene.layout.HBox
+import javafx.scene.layout.VBox
+import org.fxmisc.easybind.EasyBind
+import tornadofx.UIComponent
+import tornadofx.View
+import java.time.LocalDateTime
+import java.util.*
+
+sealed class FilterCriteria {
+ abstract fun matches(string: String): Boolean
+
+ object All : FilterCriteria() {
+ override fun matches(string: String) = true
+ }
+
+ class FilterString(val filterString: String) : FilterCriteria() {
+ override fun matches(string: String) = string.contains(filterString)
+ }
+}
+
+class CashViewer : View() {
+ // Inject UI elements
+ override val root: SplitPane by fxml()
+
+ val topSplitPane: SplitPane by fxid()
+ // Left pane
+ val leftPane: VBox by fxid()
+ val searchCriteriaTextField: TextField by fxid()
+ val searchCancelImageView: ImageView by fxid()
+ val totalMatchingLabel: Label by fxid()
+ val cashViewerTable: TreeTableView by fxid()
+ val cashViewerTableIssuerCurrency: TreeTableColumn by fxid()
+ val cashViewerTableLocalCurrency: TreeTableColumn?> by fxid()
+ val cashViewerTableEquiv: TreeTableColumn?> by fxid()
+
+ // Right pane
+ val rightPane: VBox by fxid()
+ val totalPositionsLabel: Label by fxid()
+ val equivSumLabel: Label by fxid()
+ val cashStatesList: ListView by fxid()
+
+ // Inject observables
+ val cashStates by observableList(ContractStateModel::cashStates)
+ val reportingCurrency: ObservableValue by observableValue(SettingsModel::reportingCurrency)
+ val reportingExchange: ObservableValue) -> Amount>>
+ by observableValue(ReportingCurrencyModel::reportingExchange)
+
+ /**
+ * This holds the data for each row in the TreeTable.
+ */
+ sealed class ViewerNode {
+ object Root : ViewerNode()
+ class IssuerNode(
+ val issuer: Party,
+ val sumEquivAmount: ObservableValue>,
+ val states: ObservableList>
+ ) : ViewerNode()
+ class CurrencyNode(
+ val amount: ObservableValue>,
+ val equivAmount: ObservableValue>,
+ val states: ObservableList>
+ ) : ViewerNode()
+ }
+
+ /**
+ * We allow filtering by both issuer and currency. We do this by filtering by both at the same time and picking the
+ * one which produces more results, which seems to work, as the set of currency strings don't really overlap with
+ * issuer strings.
+ */
+
+ /**
+ * Holds the filtering criterion based on the input text
+ */
+ private val filterCriteria = searchCriteriaTextField.textProperty().map { text ->
+ if (text.isBlank()) {
+ FilterCriteria.All
+ } else {
+ FilterCriteria.FilterString(text)
+ }
+ }
+
+ /**
+ * Filter cash states based on issuer.
+ */
+ private val issueFilteredCashStates = cashStates.filter(filterCriteria.map { criteria ->
+ { state: StateAndRef ->
+ criteria.matches(state.state.data.amount.token.issuer.party.toString())
+ }
+ })
+ /**
+ * Now filter cash states based on currency.
+ */
+ private val currencyFilteredCashStates = cashStates.filter(filterCriteria.map { criteria ->
+ { state: StateAndRef ->
+ criteria.matches(state.state.data.amount.token.product.toString())
+ }
+ })
+
+ /**
+ * Now we pick which one to use.
+ */
+ private val filteredCashStates = ChosenList(filterCriteria.map {
+ if (issueFilteredCashStates.size > currencyFilteredCashStates.size) {
+ issueFilteredCashStates
+ } else {
+ currencyFilteredCashStates
+ }
+ })
+
+ /**
+ * This is where we aggregate the list of cash states into the TreeTable structure.
+ */
+ val cashViewerIssueNodes: ObservableList> =
+ /**
+ * First we group the states based on the issuer. [memberStates] is all states holding currency issued by [issuer]
+ */
+ AggregatedList(filteredCashStates, { it.state.data.amount.token.issuer.party }) { issuer, memberStates ->
+ /**
+ * Next we create subgroups based on currency. [memberStates] here is all states holding currency [currency] issued by [issuer] above.
+ * Note that these states will not be displayed in the TreeTable, but rather in the side pane if the user clicks on the row.
+ */
+ val currencyNodes = AggregatedList(memberStates, { it.state.data.amount.token.product }) { currency, memberStates ->
+ /**
+ * We sum the states in the subgroup, to be displayed in the "Local Currency" column
+ */
+ val amounts = memberStates.map { it.state.data.amount.withoutIssuer() }
+ val sumAmount = amounts.fold(Amount(0, currency), Amount::plus)
+
+ /**
+ * We exchange the sum to the reporting currency, to be displayed in the " Equiv" column.
+ */
+ val equivSumAmount = EasyBind.combine(sumAmount, reportingExchange) { sum, exchange ->
+ exchange.second(sum)
+ }
+ /**
+ * Finally assemble the actual TreeTable Currency node.
+ */
+ TreeItem(ViewerNode.CurrencyNode(sumAmount, equivSumAmount, memberStates))
+ }
+
+ /**
+ * Now that we have all nodes per currency, we sum the exchanged amounts, to be displayed in the
+ * " Equiv" column, this time on the issuer level.
+ */
+ val equivAmounts = currencyNodes.map { it.value.equivAmount }.flatten()
+ val equivSumAmount = reportingCurrency.bind { currency ->
+ equivAmounts.fold(Amount(0, currency), Amount::plus)
+ }
+
+ /**
+ * Assemble the Issuer node.
+ */
+ val treeItem = TreeItem(ViewerNode.IssuerNode(issuer, equivSumAmount, memberStates))
+
+ /**
+ * Bind the children in the TreeTable structure.
+ *
+ * TODO Perhaps we shouldn't do this here, but rather have a generic way of binding nodes to the treetable once.
+ */
+ treeItem.isExpanded = true
+ val children: List> = treeItem.children
+ Bindings.bindContent(children, currencyNodes)
+ treeItem
+ }
+
+ /**
+ * Now we build up the Observables needed for the side pane, given that the user clicks on a row.
+ */
+ val selectedViewerNode = cashViewerTable.singleRowSelection()
+
+ /**
+ * Holds data for a single state, to be displayed in the list in the side pane.
+ */
+ data class StateRow (
+ val originated: LocalDateTime,
+ val stateAndRef: StateAndRef
+ )
+
+ /**
+ * A small class describing the graphics of a single state.
+ */
+ inner class StateRowGraphic(
+ val stateRow: StateRow
+ ) : UIComponent() {
+ override val root: HBox by fxml("CashStateViewer.fxml")
+
+ val equivLabel: Label by fxid()
+ val stateIdValueLabel: Label by fxid()
+ val issuerValueLabel: Label by fxid()
+ val originatedValueLabel: Label by fxid()
+ val amountValueLabel: Label by fxid()
+ val equivValueLabel: Label by fxid()
+
+ val equivAmount: ObservableValue> = reportingExchange.map {
+ it.second(stateRow.stateAndRef.state.data.amount.withoutIssuer())
+ }
+
+ init {
+ val amountNoIssuer = stateRow.stateAndRef.state.data.amount.withoutIssuer()
+ val amountFormatter = AmountFormatter.boring
+ val equivFormatter = AmountFormatter.boring
+
+ equivLabel.textProperty().bind(equivAmount.map { it.token.currencyCode.toString() })
+ stateIdValueLabel.text = stateRow.stateAndRef.ref.toString()
+ issuerValueLabel.text = stateRow.stateAndRef.state.data.amount.token.issuer.toString()
+ originatedValueLabel.text = stateRow.originated.toString()
+ amountValueLabel.text = amountFormatter.format(amountNoIssuer)
+ equivValueLabel.textProperty().bind(equivAmount.map { equivFormatter.format(it) })
+ }
+ }
+
+ /**
+ * The list of states related to the current selection. If none or the root is selected it's empty, if an issuer or
+ * currency node is selected it's the relevant states.
+ */
+ private val noSelectionStates = FXCollections.observableArrayList>()
+ private val selectedViewerNodeStates = ChosenList(selectedViewerNode.map { selection ->
+ when (selection) {
+ is SingleRowSelection.None -> noSelectionStates
+ is SingleRowSelection.Selected ->
+ when (selection.node) {
+ CashViewer.ViewerNode.Root -> noSelectionStates
+ is CashViewer.ViewerNode.IssuerNode -> selection.node.states
+ is CashViewer.ViewerNode.CurrencyNode -> selection.node.states
+ }
+ }
+ })
+
+ /**
+ * We re-display the exchanged sum amount, if we have a selection.
+ */
+ private val noSelectionSumEquiv = reportingCurrency.map { Amount(0, it) }
+ private val selectedViewerNodeSumEquiv = selectedViewerNode.bind { selection ->
+ when (selection) {
+ is SingleRowSelection.None -> noSelectionSumEquiv
+ is SingleRowSelection.Selected ->
+ when (selection.node) {
+ ViewerNode.Root -> noSelectionSumEquiv
+ is ViewerNode.IssuerNode -> selection.node.sumEquivAmount
+ is ViewerNode.CurrencyNode -> selection.node.equivAmount
+ }
+ }
+ }
+
+ /**
+ * We add some extra timestamp data here to the selected states.
+ *
+ * TODO update this once we have actual timestamps.
+ */
+ private val stateRows = selectedViewerNodeStates.map { StateRow(LocalDateTime.now(), it) }
+
+ /**
+ * We only display the right pane if a node is selected in the TreeTable.
+ */
+ private val onlyLeftPaneShown = FXCollections.observableArrayList(leftPane)
+ private val bothPanesShown = FXCollections.observableArrayList(leftPane, rightPane)
+ private val panesShown = ChosenList(selectedViewerNode.map {
+ when (it) {
+ is SingleRowSelection.None -> onlyLeftPaneShown
+ is SingleRowSelection.Selected -> bothPanesShown
+ }
+ })
+
+ // Wire up UI
+ init {
+ searchCancelImageView.setOnMouseClicked { event: MouseEvent ->
+ if (event.button == MouseButton.PRIMARY) {
+ searchCriteriaTextField.text = ""
+ }
+ }
+
+ Bindings.bindContent(topSplitPane.items, panesShown)
+
+ totalPositionsLabel.textProperty().bind(Bindings.size(selectedViewerNodeStates).map {
+ val plural = if (it == 1) "" else "s"
+ "Total $it position$plural"
+ })
+
+ val equivSumLabelFormatter = AmountFormatter.boring
+ equivSumLabel.textProperty().bind(selectedViewerNodeSumEquiv.map {
+ equivSumLabelFormatter.format(it)
+ })
+
+ Bindings.bindContent(cashStatesList.items, stateRows)
+
+ cashStatesList.setCustomCellFactory { StateRowGraphic(it).root }
+
+ val cellFactory = AmountFormatter.boring.toTreeTableCellFactory>()
+
+ // TODO use smart resize
+ cashViewerTable.setColumnPrefWidthPolicy { tableWidthWithoutPaddingAndBorder, column ->
+ Math.floor(tableWidthWithoutPaddingAndBorder.toDouble() / cashViewerTable.columns.size).toInt()
+ }
+
+ cashViewerTableIssuerCurrency.setCellValueFactory {
+ val node = it.value.value
+ when (node) {
+ ViewerNode.Root -> "".lift()
+ is ViewerNode.IssuerNode -> node.issuer.toString().lift()
+ is ViewerNode.CurrencyNode -> node.amount.map { it.token.toString() }
+ }
+ }
+ cashViewerTableLocalCurrency.setCellValueFactory {
+ val node = it.value.value
+ when (node) {
+ ViewerNode.Root -> null.lift()
+ is ViewerNode.IssuerNode -> null.lift()
+ is ViewerNode.CurrencyNode -> node.amount.map { it }
+ }
+ }
+ cashViewerTableLocalCurrency.cellFactory = cellFactory
+ /**
+ * We must set this, otherwise on sort an exception will be thrown, as it will try to compare Amounts of differing currency
+ */
+ cashViewerTableLocalCurrency.isSortable = false
+ cashViewerTableEquiv.setCellValueFactory {
+ val node = it.value.value
+ when (node) {
+ ViewerNode.Root -> null.lift()
+ is ViewerNode.IssuerNode -> node.sumEquivAmount.map { it }
+ is ViewerNode.CurrencyNode -> node.equivAmount.map { it }
+ }
+ }
+ cashViewerTableEquiv.cellFactory = cellFactory
+ cashViewerTableEquiv.textProperty().bind(reportingCurrency.map { "$it Equiv" })
+
+ cashViewerTable.root = TreeItem(ViewerNode.Root)
+ val children: List> = cashViewerTable.root.children
+ Bindings.bindContent(children, cashViewerIssueNodes)
+
+ cashViewerTable.root.isExpanded = true
+ cashViewerTable.isShowRoot = false
+
+ // TODO Think about i18n!
+ totalMatchingLabel.textProperty().bind(Bindings.size(cashViewerIssueNodes).map {
+ val plural = if (it == 1) "" else "s"
+ "Total $it matching issuer$plural"
+ })
+ }
+}
diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/views/Header.kt b/explorer/src/main/kotlin/com/r3corda/explorer/views/Header.kt
new file mode 100644
index 0000000000..927f5f1259
--- /dev/null
+++ b/explorer/src/main/kotlin/com/r3corda/explorer/views/Header.kt
@@ -0,0 +1,63 @@
+package com.r3corda.explorer.views
+
+import com.r3corda.client.model.observableValue
+import com.r3corda.explorer.model.SelectedView
+import com.r3corda.explorer.model.TopLevelModel
+import javafx.beans.value.ObservableValue
+import javafx.scene.control.Button
+import javafx.scene.control.Label
+import javafx.scene.image.Image
+import javafx.scene.image.ImageView
+import javafx.scene.layout.VBox
+import org.fxmisc.easybind.EasyBind
+import tornadofx.View
+
+class Header : View() {
+ override val root: VBox by fxml()
+
+ private val sectionIcon: ImageView by fxid()
+ private val sectionIconContainer: VBox by fxid()
+ private val sectionLabel: Label by fxid()
+ private val debugNextButton: Button by fxid()
+ private val debugGoStopButton: Button by fxid()
+
+ private val selectedView: ObservableValue by observableValue(TopLevelModel::selectedView)
+
+ private val homeImage = Image("/com/r3corda/explorer/images/home.png")
+ private val cashImage = Image("/com/r3corda/explorer/images/cash.png")
+ private val transactionImage = Image("/com/r3corda/explorer/images/tx.png")
+
+ init {
+ sectionLabel.textProperty().bind(EasyBind.map(selectedView) {
+ when (it) {
+ SelectedView.Home -> "Home"
+ SelectedView.Cash -> "Cash"
+ SelectedView.Transaction -> "Transactions"
+ null -> "Home"
+ }
+ })
+
+ sectionIcon.imageProperty().bind(EasyBind.map(selectedView) {
+ when (it) {
+ SelectedView.Home -> homeImage
+ SelectedView.Cash -> cashImage
+ SelectedView.Transaction -> transactionImage
+ null -> homeImage
+ }
+ })
+
+ // JavaFX bugs and doesn't invalidate the wrapping Box's height if the icon fit height is first set to
+ // unbounded (0.0) - which is what the label's height is initially, so we set it to 1.0 instead
+ val secionLabelHeightNonZero = EasyBind.map(sectionLabel.heightProperty()) {
+ if (it == 0.0) {
+ 1.0
+ } else {
+ it.toDouble()
+ }
+ }
+
+ sectionIconContainer.minWidthProperty().bind(secionLabelHeightNonZero)
+ sectionIcon.fitWidthProperty().bind(secionLabelHeightNonZero)
+ sectionIcon.fitHeightProperty().bind(sectionIcon.fitWidthProperty())
+ }
+}
diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/views/Home.kt b/explorer/src/main/kotlin/com/r3corda/explorer/views/Home.kt
new file mode 100644
index 0000000000..8c8cb7fdf0
--- /dev/null
+++ b/explorer/src/main/kotlin/com/r3corda/explorer/views/Home.kt
@@ -0,0 +1,68 @@
+package com.r3corda.explorer.views
+
+import com.r3corda.client.fxutils.AmountBindings
+import com.r3corda.client.fxutils.map
+import com.r3corda.client.model.*
+import com.r3corda.contracts.asset.Cash
+import com.r3corda.core.contracts.StateAndRef
+import com.r3corda.core.contracts.withoutIssuer
+import com.r3corda.explorer.formatters.AmountFormatter
+import com.r3corda.explorer.model.SelectedView
+import com.r3corda.explorer.model.SettingsModel
+import com.r3corda.explorer.model.TopLevelModel
+import javafx.beans.binding.Bindings
+import javafx.beans.value.ObservableValue
+import javafx.beans.value.WritableValue
+import javafx.collections.ObservableList
+import javafx.scene.control.Label
+import javafx.scene.control.TitledPane
+import javafx.scene.input.MouseButton
+import javafx.scene.layout.TilePane
+import org.fxmisc.easybind.EasyBind
+import tornadofx.View
+import java.util.*
+
+
+class Home : View() {
+ override val root: TilePane by fxml()
+
+ private val ourCashPane: TitledPane by fxid()
+ private val ourCashLabel: Label by fxid()
+
+ private val ourTransactionsPane: TitledPane by fxid()
+ private val ourTransactionsLabel: Label by fxid()
+
+ private val selectedView: WritableValue by writableValue(TopLevelModel::selectedView)
+ private val cashStates: ObservableList> by observableList(ContractStateModel::cashStates)
+ private val gatheredTransactionDataList: ObservableList
+ by observableListReadOnly(GatheredTransactionDataModel::gatheredTransactionDataList)
+ private val reportingCurrency: ObservableValue by observableValue(SettingsModel::reportingCurrency)
+ private val exchangeRate: ObservableValue by observableValue(ExchangeRateModel::exchangeRate)
+
+ private val sumAmount = AmountBindings.sumAmountExchange(
+ cashStates.map { it.state.data.amount.withoutIssuer() },
+ reportingCurrency,
+ exchangeRate
+ )
+
+ init {
+ val formatter = AmountFormatter.boring
+
+ ourCashLabel.textProperty().bind(sumAmount.map { formatter.format(it) })
+ ourCashPane.setOnMouseClicked { clickEvent ->
+ if (clickEvent.button == MouseButton.PRIMARY) {
+ selectedView.value = SelectedView.Cash
+ }
+ }
+
+ ourTransactionsLabel.textProperty().bind(
+ Bindings.size(gatheredTransactionDataList).map { it.toString() }
+ )
+ ourTransactionsPane.setOnMouseClicked { clickEvent ->
+ if (clickEvent.button == MouseButton.PRIMARY) {
+ selectedView.value = SelectedView.Transaction
+ }
+ }
+
+ }
+}
diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/views/TopLevel.kt b/explorer/src/main/kotlin/com/r3corda/explorer/views/TopLevel.kt
new file mode 100644
index 0000000000..0509ca5dbe
--- /dev/null
+++ b/explorer/src/main/kotlin/com/r3corda/explorer/views/TopLevel.kt
@@ -0,0 +1,49 @@
+package com.r3corda.explorer.views
+
+import com.r3corda.client.model.objectProperty
+import com.r3corda.explorer.model.SelectedView
+import com.r3corda.explorer.model.TopLevelModel
+import javafx.beans.property.ObjectProperty
+import javafx.scene.input.KeyCode
+import javafx.scene.input.KeyEvent
+import javafx.scene.layout.BorderPane
+import javafx.scene.layout.Priority
+import javafx.scene.layout.VBox
+import org.fxmisc.easybind.EasyBind
+import tornadofx.View
+
+class TopLevel : View() {
+ override val root: VBox by fxml()
+ val selectionBorderPane: BorderPane by fxid()
+
+ private val header: Header by inject()
+ private val home: Home by inject()
+ private val cash: CashViewer by inject()
+ private val transaction: TransactionViewer by inject()
+
+ // Note: this is weirdly very important, as it forces the initialisation of Views. Therefore this is the entry
+ // point to the top level observable/stream wiring! Any events sent before this init may be lost!
+ private val homeRoot = home.root
+ private val cashRoot = cash.root
+ private val transactionRoot = transaction.root
+
+ private fun getView(selection: SelectedView) = when (selection) {
+ SelectedView.Home -> homeRoot
+ SelectedView.Cash -> cashRoot
+ SelectedView.Transaction -> transactionRoot
+ }
+ val selectedView: ObjectProperty by objectProperty(TopLevelModel::selectedView)
+
+ init {
+ VBox.setVgrow(selectionBorderPane, Priority.ALWAYS)
+ selectionBorderPane.centerProperty().bind(EasyBind.map(selectedView) { getView(it) })
+
+ primaryStage.addEventHandler(KeyEvent.KEY_RELEASED) { keyEvent ->
+ if (keyEvent.code == KeyCode.ESCAPE) {
+ selectedView.value = SelectedView.Home
+ }
+ }
+
+ root.children.add(0, header.root)
+ }
+}
diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/views/TransactionViewer.kt b/explorer/src/main/kotlin/com/r3corda/explorer/views/TransactionViewer.kt
new file mode 100644
index 0000000000..e03d3a9192
--- /dev/null
+++ b/explorer/src/main/kotlin/com/r3corda/explorer/views/TransactionViewer.kt
@@ -0,0 +1,399 @@
+package com.r3corda.explorer.views
+
+import com.r3corda.client.fxutils.ChosenList
+import com.r3corda.client.fxutils.bind
+import com.r3corda.client.fxutils.lift
+import com.r3corda.client.fxutils.map
+import com.r3corda.client.model.*
+import com.r3corda.contracts.asset.Cash
+import com.r3corda.core.contracts.*
+import com.r3corda.core.crypto.Party
+import com.r3corda.core.crypto.SecureHash
+import com.r3corda.core.crypto.toStringShort
+import com.r3corda.core.transactions.LedgerTransaction
+import com.r3corda.explorer.AmountDiff
+import com.r3corda.explorer.formatters.AmountFormatter
+import com.r3corda.explorer.formatters.Formatter
+import com.r3corda.explorer.formatters.NumberFormatter
+import com.r3corda.explorer.model.IdentityModel
+import com.r3corda.explorer.model.ReportingCurrencyModel
+import com.r3corda.explorer.sign
+import com.r3corda.explorer.ui.*
+import com.r3corda.node.services.monitor.ServiceToClientEvent
+import javafx.beans.binding.Bindings
+import javafx.beans.value.ObservableValue
+import javafx.collections.FXCollections
+import javafx.collections.ObservableList
+import javafx.geometry.Insets
+import javafx.scene.Node
+import javafx.scene.control.*
+import javafx.scene.layout.Background
+import javafx.scene.layout.BackgroundFill
+import javafx.scene.layout.CornerRadii
+import javafx.scene.layout.VBox
+import javafx.scene.paint.Color
+import org.fxmisc.easybind.EasyBind
+import tornadofx.View
+import java.security.PublicKey
+import java.time.Instant
+import java.util.*
+
+class TransactionViewer: View() {
+ override val root: VBox by fxml()
+
+ val topSplitPane: SplitPane by fxid()
+
+ // Top half (transactions table)
+ private val transactionViewTable: TableView by fxid()
+ private val transactionViewTransactionId: TableColumn by fxid()
+ private val transactionViewFiberId: TableColumn by fxid()
+ private val transactionViewClientUuid: TableColumn by fxid()
+ private val transactionViewTransactionStatus: TableColumn by fxid()
+ private val transactionViewProtocolStatus: TableColumn by fxid()
+ private val transactionViewStateMachineStatus: TableColumn by fxid()
+ private val transactionViewCommandTypes: TableColumn by fxid()
+ private val transactionViewTotalValueEquiv: TableColumn> by fxid()
+
+ // Bottom half (details)
+ private val contractStatesTitledPane: TitledPane by fxid()
+
+ private val contractStatesInputsCountLabel: Label by fxid()
+ private val contractStatesInputStatesTable: TableView by fxid()
+ private val contractStatesInputStatesId: TableColumn by fxid()
+ private val contractStatesInputStatesType: TableColumn> by fxid()
+ private val contractStatesInputStatesOwner: TableColumn by fxid()
+ private val contractStatesInputStatesLocalCurrency: TableColumn by fxid()
+ private val contractStatesInputStatesAmount: TableColumn by fxid()
+ private val contractStatesInputStatesEquiv: TableColumn> by fxid()
+
+ private val contractStatesOutputsCountLabel: Label by fxid()
+ private val contractStatesOutputStatesTable: TableView by fxid()
+ private val contractStatesOutputStatesId: TableColumn by fxid()
+ private val contractStatesOutputStatesType: TableColumn> by fxid()
+ private val contractStatesOutputStatesOwner: TableColumn by fxid()
+ private val contractStatesOutputStatesLocalCurrency: TableColumn by fxid()
+ private val contractStatesOutputStatesAmount: TableColumn by fxid()
+ private val contractStatesOutputStatesEquiv: TableColumn> by fxid()
+
+ private val signaturesTitledPane: TitledPane by fxid()
+ private val signaturesList: ListView by fxid()
+
+ private val lowLevelEventsTitledPane: TitledPane by fxid()
+ private val lowLevelEventsTable: TableView by fxid()
+ private val lowLevelEventsTimestamp: TableColumn by fxid()
+ private val lowLevelEventsEvent: TableColumn by fxid()
+
+ private val matchingTransactionsLabel: Label by fxid()
+
+ // Inject data
+ private val gatheredTransactionDataList: ObservableList
+ by observableListReadOnly(GatheredTransactionDataModel::gatheredTransactionDataList)
+ private val reportingExchange: ObservableValue) -> Amount>>
+ by observableValue(ReportingCurrencyModel::reportingExchange)
+ private val myIdentity: ObservableValue by observableValue(IdentityModel::myIdentity)
+
+ /**
+ * This is what holds data for a single transaction node. Note how a lot of these are nullable as we often simply don't
+ * have the data.
+ */
+ data class ViewerNode(
+ val transactionId: ObservableValue