package com.pluscubed.plustimer.model; import android.content.Context; import android.os.Parcel; import android.os.Parcelable; import android.support.annotation.NonNull; import android.support.annotation.WorkerThread; import com.couchbase.lite.CouchbaseLiteException; import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonProperty; import com.pluscubed.plustimer.R; import com.pluscubed.plustimer.base.RecyclerViewUpdate; import com.pluscubed.plustimer.utils.Utils; import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import rx.Completable; import rx.Observable; import rx.Single; import rx.android.schedulers.AndroidSchedulers; import rx.schedulers.Schedulers; /** * Session data */ @JsonAutoDetect(creatorVisibility = JsonAutoDetect.Visibility.NONE, fieldVisibility = JsonAutoDetect.Visibility.NONE, getterVisibility = JsonAutoDetect.Visibility.NONE, isGetterVisibility = JsonAutoDetect.Visibility.NONE, setterVisibility = JsonAutoDetect.Visibility.NONE ) public class Session extends CbObject implements Parcelable { public static final String TYPE_SESSION = "session"; public static final String ID_CURRENT_PREFIX = "current-"; public static final int GET_AVERAGE_INVALID_NOT_ENOUGH = -1; public static final long TIMESTAMP_NO_SOLVES = Long.MIN_VALUE; public static final Parcelable.Creator<Session> CREATOR = new Parcelable.Creator<Session>() { public Session createFromParcel(Parcel source) { return new Session(source); } public Session[] newArray(int size) { return new Session[size]; } }; private static Map<String, Set<SolvesListener>> sListenerMap; @JsonProperty("solves") @NonNull private Set<String> mSolves; public Session() { mSolves = new HashSet<>(); } public Session(Context context) throws CouchbaseLiteException, IOException { super(context); mSolves = new HashSet<>(); updateCb(context); } public Session(Context context, String id) throws CouchbaseLiteException, IOException { super(context, id); mSolves = new HashSet<>(); updateCb(context); } protected Session(Parcel in) { this.mId = in.readString(); List<String> list = new ArrayList<>(); in.readStringList(list); mSolves = new HashSet<>(); mSolves.addAll(list); } private static Map<String, Set<SolvesListener>> getListenerMap() { if (sListenerMap == null) { sListenerMap = new HashMap<>(); } return sListenerMap; } public static void notifyListeners(String sessionId, Solve solve, RecyclerViewUpdate update) { Completable.fromCallable(() -> { if (sListenerMap.containsKey(sessionId)) { for (SolvesListener listener : sListenerMap.get(sessionId)) { listener.notifyChange(update, solve); } } return null; }).subscribeOn(AndroidSchedulers.mainThread()) .subscribe(); } /** * Returns a String of the current average of some number of solves. * If the number less than 3 or if the number of solves is less than the * number, it'll return a blank String. * * @param number the number of solves to average * @param millisecondsEnabled whether to display milliseconds * @return the current average of some number of solves */ public static Single<String> getStringCurrentAverageOf(List<Solve> sortedSolves, int number, boolean millisecondsEnabled) { if (number >= 3 && sortedSolves.size() >= number) { return Observable.from(sortedSolves) .takeLast(number) .toList() .toSingle() .map(Utils::getAverageOf) .map(average -> average == Long.MAX_VALUE ? "DNF" : Utils.timeStringFromNs(average, millisecondsEnabled)); } else { return Single.just(""); } } public static String getStringMean(List<Solve> solves, boolean milliseconds) { long sum = 0; for (Solve i : solves) { if (!(i.getPenalty() == Solve.PENALTY_DNF)) { sum += i.getTimeTwo(); } else { return "DNF"; } } return Utils.timeStringFromNs(sum / solves.size(), milliseconds); } //TODO public void addListener(SolvesListener listener) { if (getListenerMap().containsKey(mId)) { Set<SolvesListener> set = sListenerMap.get(mId); set.add(listener); } else { Set<SolvesListener> set = new HashSet<>(); set.add(listener); sListenerMap.put(mId, set); } } public void removeListener(SolvesListener listener) { if (getListenerMap().containsKey(mId)) { Set<SolvesListener> solvesListeners = getListenerMap().get(mId); solvesListeners.remove(listener); if (solvesListeners.size() == 0) { getListenerMap().remove(mId); } } } @Override protected String getType() { return TYPE_SESSION; } /** * ID stays the same. */ /*public Session(Context context, Session s) { this(s.getId()); }*/ @WorkerThread public Solve getSolve(Context context, String solveId) throws CouchbaseLiteException, IOException { return fromDocId(context, solveId, Solve.class); } public Observable<Solve> getSolves(Context context) { return Observable.from(new ArrayList<>(mSolves)) .subscribeOn(Schedulers.io()) .flatMap(id -> { try { return Observable.just(getSolve(context, id)); } catch (CouchbaseLiteException | IOException e) { return Observable.error(e); } }); } public String getId() { return mId; } protected void setId(String id) { mId = id; } public SolveBuilder newSolve(Context context) { return new SolveBuilder(context); } /** * Creates the Solve Document and adds it to the session */ public void addDisconnectedSolve(Context context, Solve solve) throws CouchbaseLiteException, IOException { solve.connectCb(context); mSolves.add(solve.getId()); updateCb(context); notifyListeners(solve, RecyclerViewUpdate.INSERT); } public Completable deleteSolveAsync(Context context, String id) { return Completable.fromCallable(() -> { deleteSolve(context, id); return null; }); } public void deleteSolve(Context context, String id) throws CouchbaseLiteException, IOException { Solve solve = getSolve(context, id); solve.getDocument(context).delete(); mSolves.remove(id); updateCb(context); notifyListeners(solve, RecyclerViewUpdate.REMOVE); } private void notifyListeners(Solve solve, RecyclerViewUpdate update) { notifyListeners(mId, solve, update); } /** * @return Whether there are solves. */ /*public Single<Boolean> solvesExist() { return FirebaseDbUtil.getSolvesRef(mId) .flatMap(firebase -> Single.create(new Single.OnSubscribe<Boolean>() { @Override public void call(SingleSubscriber<? super Boolean> singleSubscriber) { firebase.addListenerForSingleValueEvent(new ValueEventListener() { @Override public void onDataChange(DataSnapshot dataSnapshot) { singleSubscriber.onSuccess(dataSnapshot.exists()); } @Override public void onCancelled(FirebaseError firebaseError) { } }); } })); }*/ public int getNumberOfSolves() { return mSolves.size(); } public Observable<Solve> getLastSolve(Context context) { return getSortedSolves(context) .takeLast(1); } //TODO @NonNull public Observable<Solve> getSortedSolves(Context context) { return getSolves(context) .toSortedList((solve, solve2) -> solve.getTimestamp() < solve2.getTimestamp() ? -1 : (solve.getTimestamp() == solve2.getTimestamp() ? 0 : 1)) .flatMap(Observable::from); } /** * IndexOutOfBoundsException if no solves */ public Single<Solve> getSolveByPosition(Context context, int position) { return getSortedSolves(context) .elementAt(position) .toSingle(); } /** * Returns a String of the best average of some number of solves. * Returns a blank String if the number is less than 3. * * @param number the number of solves to average * @param millisecondsEnabled whether to display milliseconds * @return the best average of some number of solves */ public Single<String> getStringBestAverageOf(List<Solve> solves, int number, boolean millisecondsEnabled) { return getBestAverageOf(solves, number).map(bestAverage -> { if (bestAverage == GET_AVERAGE_INVALID_NOT_ENOUGH) { return ""; } if (bestAverage == Long.MAX_VALUE) { return "DNF"; } return Utils.timeStringFromNs(bestAverage, millisecondsEnabled); }); } /** * Returns the milliseconds value of the best average of some number of * solves. * Returns {@link Long#MAX_VALUE} for DNF and {@link * Session#GET_AVERAGE_INVALID_NOT_ENOUGH} if the list size is less than * 3 or if the number of solves is less than the number. * * @param number the number of solves to average * @return the best average of some number of solves */ public Single<Long> getBestAverageOf(List<Solve> solves, int number) { if (number >= 3 && getNumberOfSolves() >= number) { return Observable.just(solves) .toSingle() .map(lastSolves -> { long bestAverage = 0; //Iterates through the list, starting with the [number] most recent solves for (int i = 0; lastSolves.size() - (number + i) >= 0; i++) { //Sublist the [number] of solves, offset by i from the most // recent. Gets the average. long average = Utils.getAverageOf(lastSolves.subList(lastSolves.size() - (number + i), lastSolves.size() - i)); //If the average is less than the current best (or on the first loop), // set the best average to the average if (i == 0 || average < bestAverage) { bestAverage = average; } } return bestAverage; }); } else { return Single.just((long) GET_AVERAGE_INVALID_NOT_ENOUGH); } } public Single<String> getTimestampString(Context context) { return getTimestamp(context) .map(timestamp -> Utils.dateTimeSecondsStringFromTimestamp(context, timestamp)); } public Single<Long> getTimestamp(Context context) { return getLastSolve(context) .map(Solve::getTimestamp) .defaultIfEmpty(TIMESTAMP_NO_SOLVES) .toSingle(); } public Single<String> getStatsDeferred(Context context, String puzzleTypeName, boolean current, boolean displaySolves, boolean milliseconds, boolean sign) { return Single.defer(() -> Single.just(getStats(context, puzzleTypeName, current, displaySolves, milliseconds, sign))) .subscribeOn(Schedulers.io()); } @WorkerThread public String getStats(Context context, String puzzleTypeName, boolean current, boolean displaySolves, boolean milliseconds, boolean sign) { StringBuilder statsBuilder = new StringBuilder(); if (displaySolves) { PuzzleType puzzleType = PuzzleType.get(context, puzzleTypeName).toBlocking().value(); statsBuilder.append(puzzleType.getName()).append("\n\n"); } int numberOfSolves = getNumberOfSolves(); statsBuilder.append(context.getString(R.string.number_solves)).append(numberOfSolves); if (numberOfSolves == 0) { return statsBuilder.toString(); } List<Solve> sortedSolves = getSortedSolves(context).toList().toBlocking().first(); statsBuilder.append("\n") .append(context.getString(R.string.mean)) .append(getStringMean(sortedSolves, milliseconds)); Solve bestSolve = Utils.getBestSolveOfList(sortedSolves); Solve worstSolve = Utils.getWorstSolveOfList(sortedSolves); if (numberOfSolves >= 2) { statsBuilder.append("\n") .append(context.getString(R.string.best)) .append(bestSolve.getTimeString(milliseconds)); statsBuilder.append("\n") .append(context.getString(R.string.worst)) .append(worstSolve.getTimeString(milliseconds)); if (numberOfSolves >= 3) { long average = Utils.getAverageOf(sortedSolves); if (average != Long.MAX_VALUE) { statsBuilder.append("\n") .append(context.getString(R.string.average)) .append(Utils.timeStringFromNs(average, milliseconds)); } else { statsBuilder.append("\n") .append(context.getString(R.string.average)) .append("DNF"); } int[] averages = {1000, 100, 50, 12, 5}; for (int i : averages) { if (numberOfSolves >= i) { if (current) { statsBuilder.append("\n") .append(String.format(context.getString(R.string.cao), i)) .append(": ").append(getStringCurrentAverageOf(sortedSolves, i, milliseconds).toBlocking().value()); } else { statsBuilder.append("\n").append(String.format(context.getString(R.string.lao), i)) .append(": ").append(getStringCurrentAverageOf(sortedSolves, i, milliseconds).toBlocking().value()); } statsBuilder.append("\n").append(String.format(context.getString(R.string.bao), i)) .append(": ").append(getStringBestAverageOf(sortedSolves, i, milliseconds).toBlocking().value()); } } } } if (displaySolves) { statsBuilder.append("\n\n"); for (int i = 0; i < sortedSolves.size(); i++) { Solve solve = sortedSolves.get(i); statsBuilder.append(i + 1).append(". "); if (solve == bestSolve || solve == worstSolve) { statsBuilder.append("(").append(solve.getDescriptiveTimeString(milliseconds)).append(")"); } else { statsBuilder.append(solve.getDescriptiveTimeString(milliseconds)); } statsBuilder.append("\n ").append(Utils.dateTimeSecondsStringFromTimestamp(context, solve.getTimestamp())) .append("\n ").append(Utils.getUiScramble(context, solve.getScramble(), sign, puzzleTypeName).toBlocking().value()) .append("\n\n"); } } return statsBuilder.toString(); } public void reset(Context context) { mSolves.clear(); updateCb(context); notifyListeners(null, RecyclerViewUpdate.REMOVE_ALL); } @Override public int describeContents() { return 0; } @Override public void writeToParcel(Parcel dest, int flags) { dest.writeString(this.mId); dest.writeStringList(new ArrayList<>(mSolves)); } public interface SolvesListener { void notifyChange(RecyclerViewUpdate update, Solve solve); } //TODO: Use rx builder pattern public class SolveBuilder extends Solve.Builder { public SolveBuilder(Context context) { super(context); } @Override public SolveBuilder setRawTime(long time) { super.setRawTime(time); return this; } @Override public SolveBuilder setTimestamp(long timestamp) { super.setTimestamp(timestamp); return this; } @Override public SolveBuilder setPenalty(@Solve.Penalty int penalty) { super.setPenalty(penalty); return this; } @Override public SolveBuilder setScramble(String scramble) { super.setScramble(scramble); return this; } @Override public Solve build() throws CouchbaseLiteException, IOException { super.build(); mSolves.add(solve.getId()); updateCb(context); notifyListeners(solve, RecyclerViewUpdate.INSERT); return solve; } } }