001package arez.dom;
002
003import akasha.GeolocationCoordinates;
004import akasha.GeolocationPositionError;
005import akasha.WindowGlobal;
006import arez.Arez;
007import arez.ArezContext;
008import arez.ComputableValue;
009import arez.Task;
010import arez.annotations.Action;
011import arez.annotations.ArezComponent;
012import arez.annotations.ComponentNameRef;
013import arez.annotations.ComputableValueRef;
014import arez.annotations.ContextRef;
015import arez.annotations.DepType;
016import arez.annotations.Feature;
017import arez.annotations.Memoize;
018import arez.annotations.OnActivate;
019import arez.annotations.OnDeactivate;
020import java.util.Objects;
021import javax.annotation.Nonnull;
022import javax.annotation.Nullable;
023
024/**
025 * A component that exposes the current geo position as an observable property. This component relies on the
026 * underlying <a href="https://developer.mozilla.org/en-US/docs/Web/API/Geolocation_API">Geolocation API</a> and
027 * it's usage is restricted in the same way as the underlying API (i.e. it is only available in secure contexts
028 * and it asks user permission before providing data.).
029 *
030 * <pre>{@code
031 * EventDrivenValue<Window, Integer> innerWidth = EventDrivenValue.create( window, "resize", () -> window.innerWidth )
032 * }</pre>
033 *
034 * <p>It is important that the code not add a listener to the underlying event source until there is an
035 * observer accessing the <code>"value"</code> observable defined by the EventDrivenValue class. The first
036 * observer that observes the observable will result in an event listener being added to the event source
037 * and this listener will not be removed until there is no observers left observing the value. This means
038 * that a component that is not being used has very little overhead.</p>
039 */
040@ArezComponent( requireId = Feature.DISABLE, disposeNotifier = Feature.DISABLE )
041public abstract class GeoPosition
042{
043  @SuppressWarnings( "unused" )
044  public static final class Status
045  {
046    /**
047     * Position data is yet to start loading.
048     */
049    public static final int INITIAL = -2;
050    /**
051     * Position data is loading.
052     */
053    public static final int LOADING = -1;
054    /**
055     * No error acquiring position.
056     */
057    public static final int POSITION_LOADED = 0;
058    /**
059     * The acquisition of the geolocation information failed because the page didn't have the permission to do it.
060     */
061    public static final int PERMISSION_DENIED = GeolocationPositionError.PERMISSION_DENIED;
062    /**
063     * The acquisition of the geolocation failed because at least one internal source of position returned an internal error.
064     */
065    public static final int POSITION_UNAVAILABLE = GeolocationPositionError.POSITION_UNAVAILABLE;
066    /**
067     * The time allowed to acquire the geolocation, defined by PositionOptions.timeout information was reached before the information was obtained.
068     */
069    public static final int TIMEOUT = GeolocationPositionError.TIMEOUT;
070
071    private Status()
072    {
073    }
074  }
075
076  @Nullable
077  private Position _position;
078  private int _status;
079  @Nullable
080  private String _errorMessage;
081  private int _activateCount;
082  private int _watcherId;
083
084  /**
085   * Create the GeoPosition component.
086   *
087   * @return the newly created GeoPosition component.
088   */
089  @Nonnull
090  public static GeoPosition create()
091  {
092    return new Arez_GeoPosition();
093  }
094
095  GeoPosition()
096  {
097    _status = Status.INITIAL;
098    _activateCount = 0;
099  }
100
101  /**
102   * Return an immutable representation of the current position.
103   * This will be null unless {@link #getStatus()} has returned a {@link Status#POSITION_LOADED} value.
104   *
105   * @return the current position as reported by the geolocation API.
106   */
107  @Memoize( depType = DepType.AREZ_OR_EXTERNAL )
108  @Nullable
109  public Position getPosition()
110  {
111    return _position;
112  }
113
114  /**
115   * Return the status indicating whether the position is available.
116   * It will be one of the values provided by {@link Status}.
117   *
118   * @return the status of the position data.
119   */
120  @Memoize( depType = DepType.AREZ_OR_EXTERNAL )
121  public int getStatus()
122  {
123    return _status;
124  }
125
126  /**
127   * Return the error message reported by the geolocation API when position could not be loaded else null.
128   *
129   * @return the error message if any.
130   */
131  @Memoize( depType = DepType.AREZ_OR_EXTERNAL )
132  @Nullable
133  public String getErrorMessage()
134  {
135    return _errorMessage;
136  }
137
138  @ComputableValueRef
139  abstract ComputableValue<?> getPositionComputableValue();
140
141  @ComputableValueRef
142  abstract ComputableValue<?> getStatusComputableValue();
143
144  @ComputableValueRef
145  abstract ComputableValue<?> getErrorMessageComputableValue();
146
147  @OnActivate
148  void onPositionActivate()
149  {
150    activate();
151  }
152
153  @OnDeactivate
154  void onPositionDeactivate()
155  {
156    deactivate();
157  }
158
159  @OnActivate
160  void onStatusActivate()
161  {
162    activate();
163  }
164
165  @OnDeactivate
166  void onStatusDeactivate()
167  {
168    deactivate();
169  }
170
171  @OnActivate
172  void onErrorMessageActivate()
173  {
174    activate();
175  }
176
177  @OnDeactivate
178  void onErrorMessageDeactivate()
179  {
180    deactivate();
181  }
182
183  private void activate()
184  {
185    if ( 0 == _activateCount )
186    {
187      context().task( Arez.areNamesEnabled() ? componentName() + ".setLoadingStatus" : null,
188                      () -> setStatus( Status.LOADING ),
189                      Task.Flags.DISPOSE_ON_COMPLETE );
190      _watcherId = WindowGlobal.navigator().geolocation().watchPosition( e -> onSuccess( e.coords() ), this::onFailure );
191    }
192    _activateCount++;
193  }
194
195  private void deactivate()
196  {
197    _activateCount--;
198    if ( 0 == _activateCount )
199    {
200      setStatus( Status.INITIAL );
201      WindowGlobal.navigator().geolocation().clearWatch( _watcherId );
202      _watcherId = 0;
203    }
204  }
205
206  @Action
207  void onFailure( @Nonnull final GeolocationPositionError e )
208  {
209    setStatus( e.code() );
210    final String errorMessage = e.message();
211    if ( !Objects.equals( errorMessage, _errorMessage ) )
212    {
213      _errorMessage = errorMessage;
214      getErrorMessageComputableValue().reportPossiblyChanged();
215    }
216    if ( null != _position )
217    {
218      _position = null;
219      getPositionComputableValue().reportPossiblyChanged();
220    }
221  }
222
223  @Action
224  void setStatus( final int status )
225  {
226    if ( status != _status )
227    {
228      _status = status;
229      getStatusComputableValue().reportPossiblyChanged();
230    }
231  }
232
233  @Action
234  void onSuccess( @Nonnull final GeolocationCoordinates coords )
235  {
236    setStatus( Status.POSITION_LOADED );
237    if ( null != _errorMessage )
238    {
239      _errorMessage = null;
240      getErrorMessageComputableValue().reportPossiblyChanged();
241    }
242    _position =
243      new Position( coords.accuracy(), coords.altitude(), coords.heading(), coords.latitude(), coords.longitude(), coords.longitude() );
244    getPositionComputableValue().reportPossiblyChanged();
245  }
246
247  @ComponentNameRef
248  abstract String componentName();
249
250  @ContextRef
251  abstract ArezContext context();
252}