using System; namespace PropertyFacadeExample.ViewModel.Features { public sealed class EditWorkingAttendanceViewModel : UXViewModel, ITracksChanges { public ChangeTracker ChangeTracker { get; } = new ChangeTracker(); public EditWorkingAttendanceViewModel() { // Also includes change tracking support, which is optional. // DisposeWith registers the subscription with DisposableTracker. // This is necessary if you expect the domain model to live longer than the viewmodel. // When you close the viewmodel, call DisposableTracker.Dispose(). // // You can dispose a PropertyFacade as many times as you need; this only disposes the underlying // subscription if one is present. Likewise you can re-use and re-dispose the tracker. // // Take care when creating and registering regular Rx subscriptions here in the constructor if you // intend to reuse the viewmodel after Dispose. If you dispose those subscriptions, they're gone. _adminA = this.PropFacade(vm => vm.AdminA).TrackChanges(this).DisposeWith(this); _adminB = this.PropFacade(vm => vm.AdminB).TrackChanges(this).DisposeWith(this); _nonSalary = this.PropFacade(vm => vm.NonSalary).TrackChanges(this).DisposeWith(this); _salary = this.PropFacade(vm => vm.Salary).TrackChanges(this).DisposeWith(this); _travel = this.PropFacade(vm => vm.Travel).TrackChanges(this).DisposeWith(this); _leave = this.PropFacade(vm => vm.Leave).TrackChanges(this).DisposeWith(this); _total = this.ReadOnlyPropFacade(vm => vm.Total).DisposeWith(this); } public decimal AdminA { get => _adminA.Value; set => _adminA.Value = value; } private readonly PropertyFacade _adminA; public decimal AdminB { get => _adminB.Value; set => _adminB.Value = value; } private readonly PropertyFacade _adminB; public decimal NonSalary { get => _nonSalary.Value; set => _nonSalary.Value = value; } private readonly PropertyFacade _nonSalary; public decimal Salary { get => _salary.Value; set => _salary.Value = value; } private readonly PropertyFacade _salary; public decimal Travel { get => _travel.Value; set => _travel.Value = value; } private readonly PropertyFacade _travel; public decimal Leave { get => _leave.Value; set => _leave.Value = value; } private readonly PropertyFacade _leave; public decimal Total => _total.Value; private readonly ReadOnlyPropertyFacade _total; public void Load(Domain.Models.WorkingAttendance model) { if (model is null) { throw new ArgumentNullException(nameof(model)); } // PropertyFacade supports hot-swapping subscriptions. // If we want to treat the domain model as more-or-less discardable while keeping the viewmodel alive, // we can replace an older model with a new one. // We can also use ConvertOneWay or ConvertTwoWay extension methods from FacadeConverters.cs to convert value types // if the PropertyFacade type and the domain property type do not match. We may want to do this if we // need to "play nice" with a UI control that doesn't like the property on the domain. // To use this, place ConvertTwoWay() before ToProperty(). model.AdminATime.ToProperty(this, _adminA); model.AdminBTime.ToProperty(this, _adminB); model.NonSalaryTime.ToProperty(this, _nonSalary); model.SalaryTime.ToProperty(this, _salary); model.TravelTime.ToProperty(this, _travel); model.LeaveTime.ToProperty(this, _leave); model.TotalTime.ToProperty(this, _total); } } } using PropertyFacadeExample.Domain; using System; using System.Collections.Generic; using System.Reactive.Subjects; namespace PropertyFacadeExample.ViewModel { public sealed class ChangeTracker : IHasChanges { private readonly List _subjects = new List(); public IReadOnlyValueObservable HasChanges { get; } private readonly BehaviorSubject _hasChangesSubject = new BehaviorSubject(default); private int TrueCount { get => _trueCount; set { if (value < 0) { throw new ArgumentOutOfRangeException(nameof(value), $"Value must not be negative ({value})."); } _trueCount = value; } } private int _trueCount; public ChangeTracker() { HasChanges = _hasChangesSubject.ToCached(); } public void Add(IHasChanges subject) { if (subject is null) { throw new ArgumentNullException(nameof(subject)); } _subjects.Add(subject); bool init = true; subject.HasChanges.Subscribe(hasChanges => { // Do not count no-changes when attaching to the observable. if (init && !hasChanges) { return; } TrueCount += hasChanges ? 1 : -1; if (TrueCount == 0) { _hasChangesSubject.OnNext(false); } else if (TrueCount == 1 && hasChanges) { _hasChangesSubject.OnNext(true); } }); init = false; } public void ResetValue() { foreach (var facade in _subjects) { facade.ResetValue(); } } public void UpdateOriginalValue() { foreach (var facade in _subjects) { facade.UpdateOriginalValue(); } } } } using System; using System.Collections.Generic; namespace PropertyFacadeExample.ViewModel { /// /// Tracks a list of objects. Prefer this over /// to avoid unnecessary allocations where no items need to be tracked. /// public sealed class DisposableTracker { private List Disposables { get; set; } public void Add(IDisposable disposable) { if (disposable is null) { throw new ArgumentNullException(nameof(disposable)); } if (Disposables is null) { Disposables = new List(); } Disposables.Add(disposable); } /// /// Disposes all tracked items and removes them from the list. /// public void Dispose() { if (!(Disposables is null)) { foreach (var disposable in Disposables) { disposable.Dispose(); } Disposables.Clear(); } } } } using System; using System.Reactive.Linq; using System.Reactive.Subjects; namespace PropertyFacadeExample.ViewModel { public abstract class OneWayFacadeConverter { public abstract TFacade SourceToFacade(TSource sourceValue); } public abstract class TwoWayFacadeConverter : OneWayFacadeConverter { public abstract TSource FacadeToSource(TFacade facadeValue); } public class ObservableFacadeConverterAdapter : IObservable { private readonly IObservable _observable; private readonly OneWayFacadeConverter _converter; public IDisposable Subscribe(IObserver observer) => _observable .Select(sourceValue => _converter.SourceToFacade(sourceValue)) .Subscribe(observer); public ObservableFacadeConverterAdapter(IObservable observable, OneWayFacadeConverter converter) { _observable = observable ?? throw new ArgumentNullException(nameof(observable)); _converter = converter ?? throw new ArgumentNullException(nameof(converter)); } } public class SubjectFacadeConverterAdapter : ObservableFacadeConverterAdapter, ISubject { private readonly ISubject _subject; private readonly TwoWayFacadeConverter _converter; public void OnNext(TFacade value) => _subject.OnNext(_converter.FacadeToSource(value)); public void OnError(Exception error) => _subject.OnError(error); public void OnCompleted() => _subject.OnCompleted(); public SubjectFacadeConverterAdapter(ISubject subject, TwoWayFacadeConverter converter) : base(subject, converter) { _subject = subject ?? throw new ArgumentNullException(nameof(subject)); _converter = converter ?? throw new ArgumentNullException(nameof(converter)); } } public static class FacadeConverterExtensions { private sealed class OneWayFuncConverter : OneWayFacadeConverter { private readonly Func _sourceToFacade; public override TFacade SourceToFacade(TSource sourceValue) => _sourceToFacade.Invoke(sourceValue); public OneWayFuncConverter(Func sourceToFacade) { _sourceToFacade = sourceToFacade ?? throw new ArgumentNullException(nameof(sourceToFacade)); } } private sealed class TwoWayFuncConverter : TwoWayFacadeConverter { private readonly Func _sourceToFacade; private readonly Func _facadeToSource; public override TFacade SourceToFacade(TSource sourceValue) => _sourceToFacade.Invoke(sourceValue); public override TSource FacadeToSource(TFacade facadeValue) => _facadeToSource.Invoke(facadeValue); public TwoWayFuncConverter(Func sourceToFacade, Func facadeToSource) { _sourceToFacade = sourceToFacade ?? throw new ArgumentNullException(nameof(sourceToFacade)); _facadeToSource = facadeToSource ?? throw new ArgumentNullException(nameof(facadeToSource)); } } public static SubjectFacadeConverterAdapter ConvertTwoWay( this ISubject source, Func sourceToFacade, Func facadeToSource) => new SubjectFacadeConverterAdapter( subject: source, converter: new TwoWayFuncConverter( sourceToFacade: sourceToFacade, facadeToSource: facadeToSource)); public static ObservableFacadeConverterAdapter ConvertOneWay( this IObservable source, Func sourceToFacade) => new ObservableFacadeConverterAdapter( observable: source, converter: new OneWayFuncConverter( sourceToFacade: sourceToFacade)); public static SubjectFacadeConverterAdapter ConvertTwoWay( this ISubject source, TwoWayFacadeConverter converter) => new SubjectFacadeConverterAdapter(source, converter); public static ObservableFacadeConverterAdapter ConvertOneWay( this IObservable source, OneWayFacadeConverter converter) => new ObservableFacadeConverterAdapter(source, converter); } } using PropertyFacadeExample.Domain; using System; using System.Reactive.Subjects; namespace PropertyFacadeExample.ViewModel { /// /// Encapsulates an observable or subject to provide an efficient way to get or set the latest value. /// /// public class FacadeValueCache : IObservable, IDisposable { private IDisposable _disposable; private IObservable _observable; private Func _getValueFunc; private Action _setValueFunc; public bool CanSet => !(_setValueFunc is null); public bool HasObservable => !(_observable is null); public void Attach(IObservable obs) { if (obs is null) { throw new ArgumentNullException(nameof(obs)); } Dispose(); if (obs is IReadOnlyValueObservable iObsVal) { _observable = iObsVal; _getValueFunc = () => iObsVal.Value; } else if (obs is BehaviorSubject behSub) { _observable = behSub; _getValueFunc = () => behSub.Value; } else { var cached = obs.ToCached(); _observable = cached; _disposable = cached; _getValueFunc = () => cached.Value; } if (obs is IObserver iSub) { _setValueFunc = value => iSub.OnNext(value); } } private void AssertHasObservable() { if (_observable is null) { throw new InvalidOperationException("No observable has been attached."); } } public T GetValue() { AssertHasObservable(); return _getValueFunc.Invoke(); } public void SetValue(T value) { AssertHasObservable(); if (CanSet) { _setValueFunc.Invoke(value); } else { throw new InvalidOperationException("The observable does not implement IObserver."); } } public IDisposable Subscribe(IObserver observer) { AssertHasObservable(); return _observable.Subscribe(observer); } public void Dispose() { _disposable?.Dispose(); _observable = null; _getValueFunc = null; _setValueFunc = null; } } } using PropertyFacadeExample.Domain; namespace PropertyFacadeExample.ViewModel { public interface IHasChanges { IReadOnlyValueObservable HasChanges { get; } void ResetValue(); void UpdateOriginalValue(); } } namespace PropertyFacadeExample.ViewModel { public interface ITracksChanges { ChangeTracker ChangeTracker { get; } } } using System.Collections.Generic; using System; namespace PropertyFacadeExample { public sealed class NullOrEmptyStringEqualityComparer : EqualityComparer { public static new NullOrEmptyStringEqualityComparer Default { get; } = new NullOrEmptyStringEqualityComparer(StringComparer.InvariantCulture); public IEqualityComparer InnerComparer { get; } public NullOrEmptyStringEqualityComparer(IEqualityComparer innerComparer) { InnerComparer = innerComparer ?? throw new ArgumentNullException(nameof(innerComparer)); } public override bool Equals(string x, string y) { if (string.IsNullOrEmpty(x) && string.IsNullOrEmpty(y)) { return true; } else { return InnerComparer.Equals(x, y); } } public override int GetHashCode(string obj) => InnerComparer.GetHashCode(obj); } } using PropertyFacadeExample.Domain; using System; namespace PropertyFacadeExample.ViewModel { public class PropertyFacade : ReadOnlyPropertyFacade, IHasChanges { public new T Value { get => base.Value; set { if (IsDisposed) { throw new InvalidOperationException("The subscription for this observable has been disposed. Attach a new subscription by calling Observe."); } if (_valueCache is null) { throw new InvalidOperationException("The observable has not been set yet. Attach a new subscription by calling Observe."); } _valueCache.SetValue(value); } } public PropertyFacade(UXViewModel viewModel, string propertyName) : base(viewModel, propertyName) { } public PropertyFacade(UXViewModel viewModel, IValueObservable observable, string propertyName) : base(viewModel, observable, propertyName) { } public void ResetValue() => Value = OriginalValue; } } using PropertyFacadeExample.Domain; using System; using System.Collections.Generic; using System.Diagnostics; using System.Reactive.Linq; using System.Reactive.Subjects; namespace PropertyFacadeExample.ViewModel { [DebuggerDisplay("(PropertyFacade) {Value}")] public class ReadOnlyPropertyFacade : IDisposable { private readonly UXViewModel _viewModel; private readonly string _propertyName; protected FacadeValueCache _valueCache; private IDisposable _subscription; private T _oldValue; public bool IsDisposed { get; private set; } public T Value => _valueCache.HasObservable ? _valueCache.GetValue() : default; public T OriginalValue { get; protected set; } public IReadOnlyValueObservable HasChanges { get; } private readonly BehaviorSubject _hasChangesSubject = new BehaviorSubject(false); public bool SuppressDebugOutput { get; set; } public ReadOnlyPropertyFacade(UXViewModel viewModel, string propertyName) { _viewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel)); _propertyName = propertyName; _valueCache = new FacadeValueCache(); HasChanges = _hasChangesSubject.ToCached(); } public ReadOnlyPropertyFacade(UXViewModel viewModel, IReadOnlyValueObservable observable, string propertyName) : this(viewModel, propertyName) { Observe(observable); } /// /// Attach the facade to an observable. If an observable is already attached, the subscription is disposed and the new one is attached in its place. /// /// /// public void Observe(IObservable newObservable, IEqualityComparer equalityComparer = null) { if (newObservable is null) { throw new ArgumentNullException(nameof(newObservable)); } Dispose(); _valueCache.Attach(newObservable); OriginalValue = _valueCache.GetValue(); DebugMessage("PropertyFacade: New observable mounted on viewmodel='{0}', property='{1}', OriginalValue='{2}'", _viewModel, _propertyName, OriginalValue); equalityComparer = equalityComparer ?? GetEqualityComparer(); IsDisposed = false; _subscription = _valueCache.Subscribe(newValue => { DebugMessage("PropertyFacade: Received value on viewmodel='{0}', property='{1}', value='{2}'", _viewModel, _propertyName, newValue); if (!equalityComparer.Equals(_oldValue, newValue)) { // Only pump HasChanges if the new HasChanges value is different. bool changed = !equalityComparer.Equals(OriginalValue, newValue); if (_hasChangesSubject.Value != changed) { _hasChangesSubject.OnNext(changed); } RaisePropertyChanged(newValue); } }); } private IEqualityComparer GetEqualityComparer() { if (typeof(TValue) == typeof(string)) { return (IEqualityComparer)(IEqualityComparer)NullOrEmptyStringEqualityComparer.Default; } else { return EqualityComparer.Default; } } private void RaisePropertyChanged(T newValue) { DebugMessage("PropertyFacade: RaisePropertyChanging: '{0}', property='{1}', old='{2}', new='{3}'", _viewModel, _propertyName, _oldValue, newValue); _viewModel.RaisePropertyChanging(_propertyName); var oldTemp = _oldValue; _oldValue = newValue; DebugMessage("PropertyFacade: RaisePropertyChanged: '{0}', property='{1}', old='{2}', new='{3}'", _viewModel, _propertyName, oldTemp, newValue); _viewModel.RaisePropertyChanged(_propertyName); } public void UpdateOriginalValue() { OriginalValue = Value; if (HasChanges.Value) { _hasChangesSubject.OnNext(false); } } /// /// Disconnects any existing subscription. If none exists, no action is taken. /// public void Dispose() { IsDisposed = true; _subscription?.Dispose(); _valueCache.Dispose(); } [DebuggerStepThrough] protected void DebugMessage(string message, params object[] args) { if (!SuppressDebugOutput && Debugger.IsAttached) { Debug.WriteLine(message, args); } } } } using System.ComponentModel; namespace PropertyFacadeExample.ViewModel { /// /// A base view model implementation. If we were using ReactiveUI, this would also extend from ReactiveObject. /// public abstract class UXViewModel : INotifyPropertyChanged, INotifyPropertyChanging { public event PropertyChangedEventHandler PropertyChanged; public event PropertyChangingEventHandler PropertyChanging; public void RaisePropertyChanged(PropertyChangedEventArgs args) => PropertyChanged?.Invoke(this, args); public void RaisePropertyChanged(string propertyName) => RaisePropertyChanged(new PropertyChangedEventArgs(propertyName)); public void RaisePropertyChanging(PropertyChangingEventArgs args) => PropertyChanging?.Invoke(this, args); public void RaisePropertyChanging(string propertyName) => RaisePropertyChanging(new PropertyChangingEventArgs(propertyName)); public DisposableTracker DisposableTracker { get; } = new DisposableTracker(); } } using System; using System.Collections.Generic; using System.Linq.Expressions; namespace PropertyFacadeExample.ViewModel { public static class UXViewModelExtensions { public static PropertyFacade PropFacade(this TViewModel vm, Expression> property) where TViewModel : UXViewModel { if (vm is null) { throw new ArgumentNullException(nameof(vm)); } if (property is null) { throw new ArgumentNullException(nameof(property)); } var memberExpr = (MemberExpression)property.Body; string propName = memberExpr.Member.Name; return new PropertyFacade(vm, propName); } public static ReadOnlyPropertyFacade ReadOnlyPropFacade(this TViewModel vm, Expression> property) where TViewModel : UXViewModel { if (property is null) { throw new ArgumentNullException(nameof(property)); } var memberExpr = (MemberExpression)property.Body; string propName = memberExpr.Member.Name; return new ReadOnlyPropertyFacade(vm, propName); } public static void ToProperty(this IObservable observable, UXViewModel viewModel, ReadOnlyPropertyFacade propertyFacade, IEqualityComparer equalityComparer = null) { if (observable is null) { throw new ArgumentNullException(nameof(observable)); } if (viewModel is null) { throw new ArgumentNullException(nameof(viewModel)); } if (propertyFacade is null) { throw new ArgumentNullException(nameof(propertyFacade), "The property facade has not been initialized yet."); } propertyFacade.Observe(observable, equalityComparer); viewModel.DisposableTracker.Add(propertyFacade); } public static T DisposeWith(this T disposable, UXViewModel viewModel) where T : class, IDisposable { if (disposable is null) { throw new ArgumentNullException(nameof(disposable)); } if (viewModel is null) { throw new ArgumentNullException(nameof(viewModel)); } viewModel.DisposableTracker.Add(disposable); return disposable; } public static T TrackChanges(this T subject, ChangeTracker changeTracker) where T : class, IHasChanges { if (subject is null) { throw new ArgumentNullException(nameof(subject)); } if (changeTracker is null) { throw new ArgumentNullException(nameof(changeTracker)); } changeTracker.Add(subject); return subject; } public static T TrackChanges(this T subject, ITracksChanges trackerOwner) where T : class, IHasChanges { if (subject is null) { throw new ArgumentNullException(nameof(subject)); } if (trackerOwner is null) { throw new ArgumentNullException(nameof(trackerOwner)); } return TrackChanges(subject, trackerOwner.ChangeTracker); } } } using System; using System.Diagnostics; using System.Reactive; namespace PropertyFacadeExample.Domain { public interface ICachedObservable : IReadOnlyValueObservable { bool HasValue { get; } } /// /// Represents an observable that caches the latest value of another observable. /// This observable has the same subscription semantics as ReplaySubject(1): if there is a value present, the value will replay when a subscription is made. /// /// [DebuggerDisplay("(CachedObservable) {Value}")] public sealed class CachedObservable : ObservableBase, ICachedObservable { private readonly IDisposable _registration; private readonly IObservable _source; public bool HasValue { get; private set; } public T Value { get; private set; } public CachedObservable(IObservable source) { _source = source ?? throw new ArgumentNullException(nameof(source)); _registration = _source.Subscribe(x => { HasValue = true; Value = x; }); } public void Dispose() => _registration.Dispose(); protected override IDisposable SubscribeCore(IObserver observer) { if (observer is null) { throw new ArgumentNullException(nameof(observer)); } var sub = _source.SubscribeSafe(observer); if (HasValue) { observer.OnNext(Value); } return sub; } public override string ToString() => $"(CachedObservable) {Value}"; } public static class CachedObservableExtensions { public static ICachedObservable ToCached(this IObservable source) => new CachedObservable(source); } } using System; namespace PropertyFacadeExample.Domain { internal static class RxDomain { public static IValueObservable ObservableProperty() => new ValueObservable(); public static IValueObservable ObservableProperty(T defaultValue, CoerceValueHandler coerceValue = null) => new ValueObservable(defaultValue, coerceValue); public static IValueObservable ObservableProperty(T defaultValue, CoerceValueHandler coerceValue, out IDisposable coercerDelayToken) { var obs = new ValueObservable(defaultValue, coerceValue, delayCoercer: true); coercerDelayToken = obs.GetCoercerDelayToken(); return obs; } public static IValueObservable ObservableProperty(CoerceValueHandler coerceValue) => new ValueObservable(default, coerceValue); public static IReadOnlyValueObservable ReadOnlyConstant(T value) => new ReadOnlyConstantValueObservable(value); } } using System; using System.Diagnostics; using System.Reactive.Disposables; using System.Reactive.Subjects; namespace PropertyFacadeExample.Domain { public interface IReadOnlyValueObservable : IObservable, IDisposable { T Value { get; } } public interface IValueObservable : IReadOnlyValueObservable, ISubject { new T Value { get; set; } } /// /// Same thing as Observable.Return() except as a . /// /// public sealed class ReadOnlyConstantValueObservable : IReadOnlyValueObservable { public T Value { get; } public ReadOnlyConstantValueObservable(T value) => Value = value; public void Dispose() { } public IDisposable Subscribe(IObserver observer) { if (observer is null) { throw new ArgumentNullException(nameof(observer)); } observer.OnNext(Value); return Disposable.Empty; } } public delegate T CoerceValueHandler(T oldValue, T newValue); /// /// Represents a reactive subject that caches the latest value. It exhibits the same behavior as /// except it has a settable property. /// /// [DebuggerDisplay("(ValueObservable) {Value}")] public sealed class ValueObservable : IValueObservable { private readonly BehaviorSubject _subject; private readonly CoerceValueHandler _coerceValue; public ValueObservable(T initialValue = default, CoerceValueHandler coerceValue = default, bool delayCoercer = false) { _coerceValue = coerceValue ?? ((_, newValue) => newValue); _subject = new BehaviorSubject(delayCoercer ? initialValue : _coerceValue.Invoke(default, initialValue)); } public T Value { get => _subject.Value; set => OnNext(value); } public IDisposable Subscribe(IObserver observer) => _subject.SubscribeSafe(observer); public void OnCompleted() => _subject.OnCompleted(); public void OnError(Exception error) => _subject.OnError(error); public void OnNext(T newValue) => _subject.OnNext(_coerceValue.Invoke(Value, newValue)); public void Dispose() => _subject.Dispose(); public override string ToString() => $"(ValueObservable<{typeof(T).Name}>) {Value}"; public void CoerceCurrent() => _coerceValue.Invoke(Value, Value); public IDisposable GetCoercerDelayToken() => Disposable.Create(() => CoerceCurrent()); } /// /// Allows you to build custom observer logic for complex coercion scenarios. /// /// public sealed class ValueObservableFacade : IValueObservable { private readonly IObservable _observable; private readonly IObserver _observer; private readonly Func _getValue; private readonly Action _setValue; public ValueObservableFacade(IObservable observable, IObserver observer, Func getValue, Action setValue = null) { _observable = observable ?? throw new ArgumentNullException(nameof(observable)); _observer = observer ?? throw new ArgumentNullException(nameof(observer)); _getValue = getValue ?? throw new ArgumentNullException(nameof(getValue)); _setValue = setValue; _observable.Subscribe(next => Value = next); } /// /// Gets or sets the value. If the value is the same as the one stored, observers will not be notified. Use to override this. /// public T Value { get => _getValue(); set { if (!Equals(value, _getValue())) { OnNext(value); } } } public void Dispose() { if (_observer is IDisposable d) { d.Dispose(); } } public void OnCompleted() => _observer.OnCompleted(); public void OnError(Exception error) => _observer.OnError(error); public void OnNext(T value) { _setValue?.Invoke(value); _observer.OnNext(value); } public IDisposable Subscribe(IObserver observer) => _observable.SubscribeSafe(observer); } } using System.Linq; using System.Reactive.Linq; using static PropertyFacadeExample.Domain.RxDomain; namespace PropertyFacadeExample.Domain.Models { /// /// An example domain model that encapsulates working time in hours. /// public sealed class WorkingAttendance { public IValueObservable AdminATime { get; } public IValueObservable AdminBTime { get; } public IValueObservable NonSalaryTime { get; } public IValueObservable SalaryTime { get; } public IValueObservable TravelTime { get; } public IValueObservable LeaveTime { get; } public IReadOnlyValueObservable TotalTime { get; } public WorkingAttendance( decimal administrativeATime, decimal administrativeBTime, decimal nonSalaryTime, decimal salaryTime, decimal travelTime, decimal leaveTime) { AdminATime = ObservableProperty(administrativeATime, ClipNegativeHoursToZero); AdminBTime = ObservableProperty(administrativeBTime, ClipNegativeHoursToZero); NonSalaryTime = ObservableProperty(nonSalaryTime); SalaryTime = ObservableProperty(salaryTime); TravelTime = ObservableProperty(travelTime); LeaveTime = ObservableProperty(leaveTime, ClipNegativeHoursToZero); TotalTime = Observable.CombineLatest( AdminATime, AdminBTime, NonSalaryTime, SalaryTime, TravelTime, LeaveTime) .Select(hours => hours.Sum()) .ToCached(); } private static decimal ClipNegativeHoursToZero(decimal _, decimal newTime) => newTime < 0 ? 0 : newTime; } }