diff --git a/android/ConductorsSensor/app/src/main/java/de/tonifetzer/conductorssensor/utilities/DataFolder.java b/android/ConductorsSensor/app/src/main/java/de/tonifetzer/conductorssensor/utilities/DataFolder.java new file mode 100644 index 0000000..9549cd0 --- /dev/null +++ b/android/ConductorsSensor/app/src/main/java/de/tonifetzer/conductorssensor/utilities/DataFolder.java @@ -0,0 +1,58 @@ +package de.tonifetzer.conductorssensor.utilities; + +/** + * Created by toni on 12/01/18. + */ + +import android.content.Context; +import android.os.Environment; +import android.util.Log; + +import java.io.File; + +/** + * Class: DataFolder + * Description: SDK save file class. Is able to open a folder on the device independent of the given + * device and android skd version. + */ +public class DataFolder { + + private File folder; + private static final String TAG = "DataFolder"; + + public DataFolder(Context context, String folderName){ + + // 1) try external data folder + folder = new File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), folderName); + if (isOK(folder)) {return;} + + // 2) try sd-card folder + folder = new File(Environment.getExternalStorageDirectory() + "/" + folderName); + if (isOK(folder)) {return;} + + // 3) try internal data folder + folder = new File(context.getApplicationInfo().dataDir); + if (isOK(folder)) {return;} + + // all failed + throw new RuntimeException("failed to create/access storage folder"); + + } + + /** ensure the given folder is OK */ + private static final boolean isOK(final File folder) { + folder.mkdirs(); + final boolean ok = folder.exists() && folder.isDirectory(); + if (ok) { + Log.d(TAG, "using: " + folder); + } else { + Log.d(TAG, "not OK: " + folder); + } + return ok; + } + + public File getFolder(){ + return folder; + } +} + diff --git a/android/ConductorsSensor/app/src/main/java/de/tonifetzer/conductorssensor/utilities/SensorDataFileWriter.java b/android/ConductorsSensor/app/src/main/java/de/tonifetzer/conductorssensor/utilities/SensorDataFileWriter.java new file mode 100644 index 0000000..daab381 --- /dev/null +++ b/android/ConductorsSensor/app/src/main/java/de/tonifetzer/conductorssensor/utilities/SensorDataFileWriter.java @@ -0,0 +1,138 @@ +package de.tonifetzer.conductorssensor.utilities; + +import android.app.Activity; +import android.content.Context; +import android.os.AsyncTask; +import android.util.Log; +import android.widget.EditText; +import android.widget.TextView; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; + +import de.tonifetzer.conductorssensor.R; + +/** + * Created by toni on 14/01/18. + */ + +public class SensorDataFileWriter { + + private static final int FLUSH_LIMIT = 2*1024*1024; + private static final String TAG = "SensorDataFileWriter"; + + private File mSensorDataFile; + private FileOutputStream mFileOutputStream; + private StringBuilder mStringBuilder = new StringBuilder(); + private final DataFolder mFolder; + private boolean mStreamOpenend = false; + + public SensorDataFileWriter(Context context) { + + mFolder = new DataFolder(context, "sensorOutFiles"); + + //write some description for this file in the first line + EditText editView = ((Activity) context).findViewById(R.id.comments); + TextView textView = ((Activity) context).findViewById(R.id.bpmText); + writeDescription("Kommentar: " + editView.getText().toString() + "\nMetronom: " + textView.getText().toString() + " bpm"); + + //open connection to file + open(); + } + + public final void writeVector3D(long ts, int id, double x, double y, double z){ + + synchronized (this) { + + if (mStreamOpenend) { + + mStringBuilder.append(ts).append(';').append(id).append(';').append(x).append(';').append(y).append(';').append(z).append('\n'); + + if (mStringBuilder.length() > FLUSH_LIMIT) {flush(false);} + } else { + open(); + } + } + } + + private void writeDescription(String str){ + synchronized (this) { + mStringBuilder.append(str).append('\n').append('\n'); + } + + } + + // flush data to disk + public final void toDisk(){ + synchronized (this) { + flush(true); + close(); + } + } + + /** helper method for exception-less writing. DO NOT CALL DIRECTLY! */ + private void _write(final byte[] data) { + try { + mFileOutputStream.write(data); + Log.d(TAG, "flushed " + data.length + " bytes to disk"); + } catch (final Exception e) { + throw new RuntimeException("error while writing log-file", e); + } + } + + /** helper-class for background writing */ + class FlushAsync extends AsyncTask { + @Override + protected final Integer doInBackground(byte[][] data) { + _write(data[0]); + return null; + } + }; + + /** flush current buffer-contents to disk */ + private void flush(boolean sync) { + + // fetch current buffer contents to write and hereafter empty the buffer + // this action MUST be atomic, just like the add-method + byte[] data = null; + synchronized (this) { + data = mStringBuilder.toString().getBytes(); // fetch data to write + mStringBuilder.setLength(0); // reset the buffer + } + + // write + if (sync) { + // write to disk using the current thread + _write(data); + } else { + // write to disk using a background-thread + new FlushAsync().execute(new byte[][] {data}); + } + } + + private void open() { + mSensorDataFile = new File(mFolder.getFolder(), System.currentTimeMillis() + ".csv"); + + try { + mFileOutputStream = new FileOutputStream(mSensorDataFile); + mStreamOpenend = true; + + Log.d(TAG, "will write to: " + mSensorDataFile.toString()); + } catch (FileNotFoundException e) { + e.printStackTrace(); + + mStreamOpenend = false; + } + } + + private void close() { + try { + mFileOutputStream.close(); + mStreamOpenend = false; + } catch (IOException e) { + e.printStackTrace(); + } + } +} diff --git a/android/ConductorsWatch/app/src/main/java/de/tonifetzer/conductorswatch/bpmEstimation/Estimator.java b/android/ConductorsWatch/app/src/main/java/de/tonifetzer/conductorswatch/bpmEstimation/Estimator.java new file mode 100644 index 0000000..efb966b --- /dev/null +++ b/android/ConductorsWatch/app/src/main/java/de/tonifetzer/conductorswatch/bpmEstimation/Estimator.java @@ -0,0 +1,247 @@ +package de.tonifetzer.conductorswatch.bpmEstimation; + +import android.content.Context; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; + +import java.util.LinkedList; +import java.util.List; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.CopyOnWriteArrayList; + +import de.tonifetzer.conductorswatch.network.SensorDataFileStreamer; +import de.tonifetzer.conductorswatch.utilities.ByteStreamWriter; +import de.tonifetzer.conductorswatch.utilities.Utils; + +/** + * Created by toni on 13/11/17. + */ + +public class Estimator implements SensorEventListener { + + private SensorManager mSensorManager; + private Sensor mAccelerometer; + private Sensor mRawAccelerometer; + private Context mContext; + private AccelerometerWindowBuffer mAccelerometerWindowBuffer; + private EstimatorAutoCorr mBpmEstimator; + private double mCurrentBpm; + + private ByteStreamWriter mByteStreamWriterAcc; + private ByteStreamWriter mByteStreamWriterGyro; + private SensorDataFileStreamer mStreamer; + + private Timer mTimer = new Timer(); + + + public Estimator(Context mContext){ + this.mContext = mContext; + this.mCurrentBpm = -1; + } + + public void start() { + + mSensorManager = (SensorManager) mContext.getSystemService(Context.SENSOR_SERVICE); + mAccelerometer = mSensorManager.getDefaultSensor(Sensor.TYPE_LINEAR_ACCELERATION); + mRawAccelerometer = mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); + mSensorManager.registerListener(this, mAccelerometer, SensorManager.SENSOR_DELAY_FASTEST); + mSensorManager.registerListener(this, mRawAccelerometer, SensorManager.SENSOR_DELAY_FASTEST); + + + mAccelerometerWindowBuffer = new AccelerometerWindowBuffer(6000, 750); + mBpmEstimator = new EstimatorAutoCorr(mAccelerometerWindowBuffer, 0, 5000); + + mTimer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + + if (mAccelerometerWindowBuffer.isNextWindowReady()) { + + LinkedList bpmList = new LinkedList<>(); + + //todo: wie viele dieser Klassen kann ich wegwerfen um das Ergebnis nich schlechter zu machen? + double bpm60 = mBpmEstimator.estimate(mAccelerometerWindowBuffer.getFixedSizedWindow()); + double bpm85 = mBpmEstimator.estimate(mAccelerometerWindowBuffer.getFixedSizedWindow(3500, 750)); + double bpm110 = mBpmEstimator.estimate(mAccelerometerWindowBuffer.getFixedSizedWindow(2600, 750)); + double bpm135 = mBpmEstimator.estimate(mAccelerometerWindowBuffer.getFixedSizedWindow(2000, 750)); + double bpm160 = mBpmEstimator.estimate(mAccelerometerWindowBuffer.getFixedSizedWindow(1600,750)); + double bpm200 = mBpmEstimator.estimate(mAccelerometerWindowBuffer.getFixedSizedWindow(1200, 750)); + + //add to list + //todo: make this cool... + //vielleicht einen weighted mean? + bpmList.add(bpm60); + bpmList.add(bpm85); + bpmList.add(bpm110); //110, 135, 160 langen auch schon + bpmList.add(bpm135); + bpmList.add(bpm160); + bpmList.add(bpm200); + + //Log.d("BPM: ", bpmList.toString()); + + //remove all -1 and calc bpmMean + while(bpmList.remove(Double.valueOf(-1))) {} + + //remove outliers + //todo: aktuell wird die liste hier sortiert.. eig net so schön. + Utils.removeOutliersZScore(bpmList, 3.4); + //Utils.removeOutliersHeuristic(); + + //Log.d("BPM: ", bpmList.toString()); + + double bpm = -1; + if(!bpmList.isEmpty()) { + double bpmMean = Utils.mean(bpmList); + double bpmMedian = Utils.median(bpmList); + + double bpmDiffSlowFast = bpmList.getFirst() - bpmList.getLast(); + if (Math.abs(bpmDiffSlowFast) > 25) { + + double tmpBPM = bpmMean + 25; + + while(bpm == -1){ + if (tmpBPM < 60) { + bpm = bpm60; + if(bpm == -1){ + bpm = bpmMean; + } + } + else if (tmpBPM < 85) { + bpm = bpm85; + } else if (tmpBPM < 110) { + bpm = bpm110; + } else if (tmpBPM < 135) { + bpm = bpm135; + } else if (tmpBPM < 160) { + bpm = bpm160; + } else { + bpm = bpm200; + } + + tmpBPM -= 5; + } + + //Log.d("BPM: ", "CHANGE"); + + } else { + + bpm = bpmMean; + //Log.d("BPM: ", "STAY"); + } + + + } + + + for (OnBpmEstimatorListener listener : listeners) { + listener.onNewDataAvailable(bpm); //135 gibt gute ergebnisse! + } + + //update the windowSize and updaterate depending on current bpm + //updateWindowSizeAndOverlap(bpm); + } + } + }, 0, 100); + + mByteStreamWriterAcc = new ByteStreamWriter(); + mByteStreamWriterGyro = new ByteStreamWriter(); + + mStreamer = new SensorDataFileStreamer(mContext); + new Thread(mStreamer).start(); + } + + + public void stop() { + + mTimer.cancel(); + mTimer.purge(); + + mSensorManager.unregisterListener(this); + + //send data and close the ByteStreamWriter + for (OnBpmEstimatorListener listener : listeners) { + //listener.onEstimationStopped(mByteStreamWriter.getByteArray()); + } + mByteStreamWriterAcc.close(); + mByteStreamWriterGyro.close(); + + mStreamer.close(); + } + + @Override + public void onSensorChanged(SensorEvent se) { + + //TODO: at the moment this runs in main thread... since worker fragment runs also in main thread + + //ca 200hz, every 5 to 6 ms we have an update + if (se.sensor.getType() == Sensor.TYPE_LINEAR_ACCELERATION) { + mAccelerometerWindowBuffer.add(new AccelerometerData(System.currentTimeMillis(), se.values[0], se.values[1], se.values[2])); + mByteStreamWriterAcc.writeSensor3D(Sensor.TYPE_LINEAR_ACCELERATION, se.values[0], se.values[1], se.values[2]); + + mStreamer.sendByteArray(mByteStreamWriterAcc.getByteArray()); + mByteStreamWriterAcc.reset(); + } + + if (se.sensor.getType() == Sensor.TYPE_ACCELEROMETER) { + mByteStreamWriterGyro.writeSensor3D(Sensor.TYPE_ACCELEROMETER, se.values[0], se.values[1], se.values[2]); + + mStreamer.sendByteArray(mByteStreamWriterGyro.getByteArray()); + mByteStreamWriterGyro.reset(); + } + } + + @Override + public void onAccuracyChanged(Sensor sensor, int i) { + // do nothin + } + + /** + * Interface for callback calculated bpm + */ + public interface OnBpmEstimatorListener { + void onNewDataAvailable(double bpm); + void onEstimationStopped(byte[] sensorData); + } + + private List listeners = new CopyOnWriteArrayList(); + public void add(OnBpmEstimatorListener listener){listeners.add(listener);} + public void remove(OnBpmEstimatorListener listener){listeners.remove(listener);} + + /** + * Simple function that sets die windowSize and Overlap time to a specific value + * depending on the currentBPM. Nothing rly dynamical. However, should work out + * for our purposes. + * @param bpm + */ + private void updateWindowSizeAndOverlap(double bpm){ + + //round to nearest tenner. this limits the number of windowsize and overlap changes. + int newBpmRounded = (int) Math.round(bpm / 10.0) * 10; + int curBpmRounded = (int) Math.round(mCurrentBpm / 10.0) * 10; + + //TODO: i guess this is not the best method.. if the default sizes always produces -1, we run into problems + if(bpm != -1 && curBpmRounded != newBpmRounded){ + + int overlap_ms = 60000 / newBpmRounded; + int window_ms = overlap_ms * 5; + + //idea: wenn man mehrere fenster parallel laufen lässt und beobachtet, müsste das kleinste fenster die tempowechsel eigentlich am + // besten mitbekommen. dieses fenster dann bestimmen lassen? + + mAccelerometerWindowBuffer.setWindowSize(window_ms); + mAccelerometerWindowBuffer.setOverlapSize(overlap_ms); + + } else if (bpm == -1){ + //if bpm is -1 due to a non-classification, reset to default. + + //idea: anstatt auf einen festen wert zu setzen, könnte man das fenster dann auch einfach ein wenig größer / kleiner machen. + mAccelerometerWindowBuffer.setWindowSize(3000); + mAccelerometerWindowBuffer.setOverlapSize(750); + } + + mCurrentBpm = bpm; + } +} diff --git a/android/ConductorsWatch/app/src/main/java/de/tonifetzer/conductorswatch/bpmEstimation/EstimatorAutoCorr.java b/android/ConductorsWatch/app/src/main/java/de/tonifetzer/conductorswatch/bpmEstimation/EstimatorAutoCorr.java new file mode 100644 index 0000000..7828aa9 --- /dev/null +++ b/android/ConductorsWatch/app/src/main/java/de/tonifetzer/conductorswatch/bpmEstimation/EstimatorAutoCorr.java @@ -0,0 +1,215 @@ +package de.tonifetzer.conductorswatch.bpmEstimation; + +import de.tonifetzer.conductorswatch.utilities.MovingFilter; +import de.tonifetzer.conductorswatch.utilities.SimpleKalman; +import de.tonifetzer.conductorswatch.utilities.Utils; + +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; + +/** + * Created by toni on 17/12/17. + */ +public class EstimatorAutoCorr { + + private AccelerometerWindowBuffer mBuffer; + private double mSampleRate_ms; + + private LinkedList mBpmHistory_X; + private LinkedList mBpmHistory_Y; + private LinkedList mBpmHistory_Z; + + private LinkedList mBpmHistory; + private int mResetCounter; + private int mResetLimit_ms; + + private MovingFilter mMvg; + //private SimpleKalman mKalman; + + + public EstimatorAutoCorr(AccelerometerWindowBuffer windowBuffer, double sampleRate_ms, int resetAfter_ms){ + mBuffer = windowBuffer; + mSampleRate_ms = sampleRate_ms; + + mBpmHistory_X = new LinkedList<>(); + mBpmHistory_Y = new LinkedList<>(); + mBpmHistory_Z = new LinkedList<>(); + + mBpmHistory = new LinkedList<>(); + mResetCounter = 0; + mResetLimit_ms = resetAfter_ms; + + + //TODO: this is to easy. since the new dyn. windowsize produces smaller update times, we need to consider something, that + //TODO: holds more values, if they are similar, an resets the history if not. + mMvg = new MovingFilter(2); + //mKalman = new SimpleKalman(); + } + + public double estimate(AccelerometerWindowBuffer fixedWindow){ + + double sampleRate = mSampleRate_ms; + if(sampleRate <= 0){ + sampleRate = Math.round(Utils.mean(Utils.diff(fixedWindow.getTs()))); + } + + if(sampleRate == 0){ + int breakhere = 0; + } + + //TODO: ist doch käse das zu interpolieren, das mach ich ja für jedes mal estimation. + //TODO: eigentlich muss ich ja nur das größte interpolieren und dann supwindows nehmen. + AccelerometerInterpolator interp = new AccelerometerInterpolator(fixedWindow, sampleRate); + + //are we conducting? + //just look at the newest 512 samples + //List subBuffer = mBuffer.subList(mBuffer.size() - 512, mBuffer.size()); + + double[] xAutoCorr = new AutoCorrelation(interp.getX(), fixedWindow.size()).getCorr(); + double[] yAutoCorr = new AutoCorrelation(interp.getY(), fixedWindow.size()).getCorr(); + double[] zAutoCorr = new AutoCorrelation(interp.getZ(), fixedWindow.size()).getCorr(); + + + //find a peak within range of 250 ms + int peakWidth = (int) Math.round(250 / sampleRate); + Peaks pX = new Peaks(xAutoCorr, peakWidth, 0.1f, 0, false); + Peaks pY = new Peaks(yAutoCorr, peakWidth, 0.1f, 0, false); + Peaks pZ = new Peaks(zAutoCorr, peakWidth, 0.1f, 0, false); + + mBpmHistory_X.add(pX.getBPM(sampleRate)); + mBpmHistory_Y.add(pY.getBPM(sampleRate)); + mBpmHistory_Z.add(pZ.getBPM(sampleRate)); + + double estimatedBPM = getBestBpmEstimation(pX, pY, pZ); + if(estimatedBPM != -1){ + + //moving avg (lohnt dann, wenn wir viele daten haben) + mMvg.add(estimatedBPM); + mBpmHistory.add(mMvg.getAverage()); + + //mBpmHistory.add(estimatedBPM); + + //moving median (lohnt nur bei konstantem tempo, da wir nur ein tempo damit gut halten können.) + //mMvg.add(estimatedBPM); + //mBpmHistory.add(mMvg.getMedian()); + + //kalman filter (lohnt dann, wenn wir konstantes tempo haben, mit startangabe!) + + //standard last element + //mBpmHistory.add(estimatedBPM); + //mResetCounter = 0; + } + else { + int resetAfter = (int) Math.round((double) (mResetLimit_ms / (mBuffer.getOverlapSize()))); + if(++mResetCounter > resetAfter){ + mBpmHistory.clear(); + + //TODO: send signal to clear. + //mBuffer.clear(); + mMvg.clear(); + mResetCounter = 0; + } + return -1; + } + + //last element + return mBpmHistory.getLast(); + } + + public double getMeanBpm(){ + return Utils.mean(mBpmHistory); + } + + public double getMedianBPM(){ + return Utils.median(mBpmHistory); + } + + private double getBestBpmEstimation(Peaks peaksX, Peaks peaksY, Peaks peaksZ) throws IllegalArgumentException { + + int cntNumAxis = 0; + double sumCorr = 0; //to prevent division by zero + double sumRms = 0; + double sumNumInter = 0; + + double corrMeanX = 0, corrRmsX = 0; + int corrNumInterX = 0; + if(peaksX.hasPeaks()){ + corrMeanX = Utils.geometricMean(peaksX.getPeaksValueWithoutZeroIdxAndNegativeValues()); + corrRmsX = Utils.rms(peaksX.getPeaksValueWithoutZeroIdx()); + corrNumInterX = Utils.intersectionNumber(peaksX.getData(), 0.2f); + + ++cntNumAxis; + sumCorr += corrMeanX; + sumRms += corrRmsX; + sumNumInter += corrNumInterX; + } + + double corrMeanY = 0, corrRmsY = 0; + int corrNumInterY = 0; + if(peaksY.hasPeaks()){ + corrMeanY = Utils.geometricMean(peaksY.getPeaksValueWithoutZeroIdxAndNegativeValues()); + corrRmsY = Utils.rms(peaksY.getPeaksValueWithoutZeroIdx()); + corrNumInterY = Utils.intersectionNumber(peaksY.getData(), 0.2f); + + ++cntNumAxis; + sumCorr += corrMeanY; + sumRms += corrRmsY; + sumNumInter += corrNumInterY; + } + + double corrMeanZ = 0, corrRmsZ = 0; + int corrNumInterZ = 0; + if(peaksZ.hasPeaks()){ + corrMeanZ = Utils.geometricMean(peaksZ.getPeaksValueWithoutZeroIdxAndNegativeValues()); + corrRmsZ = Utils.rms(peaksZ.getPeaksValueWithoutZeroIdx()); + corrNumInterZ = Utils.intersectionNumber(peaksZ.getData(), 0.2f); + + ++cntNumAxis; + sumCorr += corrMeanZ; + sumRms += corrRmsZ; + sumNumInter += corrNumInterZ; + } + + //no peaks, reject + if(cntNumAxis == 0){ + //throw new IllegalArgumentException("All Peaks are empty! -> Reject Estimation"); + return -1; + } + + /* + System.out.println("RMS-X: " + corrRmsX); + System.out.println("GEO-X: " + corrMeanX); + System.out.println("INTER-X: " + corrNumInterX); + + System.out.println("RMS-Y: " + corrRmsY); + System.out.println("GEO-Y: " + corrMeanY); + System.out.println("INTER-Y: " + corrNumInterY); + + System.out.println("RMS-Z: " + corrRmsZ); + System.out.println("GEO-Z: " + corrMeanZ); + System.out.println("INTER-Z: " + corrNumInterZ); + */ + + //values to low, reject + //TODO: this is a pretty simple assumption. first shot! + if(corrRmsX < 0.25 && corrRmsY < 0.25 && corrRmsZ < 0.25){ + return -1; + } + + double quantityX = ((corrMeanX / sumCorr) + (corrRmsX / sumRms) + (corrNumInterX / sumNumInter)) / cntNumAxis; + double quantityY = ((corrMeanY / sumCorr) + (corrRmsY / sumRms) + (corrNumInterY / sumNumInter)) / cntNumAxis; + double quantityZ = ((corrMeanZ / sumCorr) + (corrRmsZ / sumRms) + (corrNumInterZ / sumNumInter)) / cntNumAxis; + + //get best axis by quantity and estimate bpm + if(quantityX > quantityY && quantityX > quantityZ){ + return mBpmHistory_X.getLast(); + } + else if(quantityY > quantityZ){ + return mBpmHistory_Y.getLast(); + } + else { + return mBpmHistory_Z.getLast(); + } + } +} diff --git a/android/ConductorsWatch/app/src/main/java/de/tonifetzer/conductorswatch/ui/Croller.java b/android/ConductorsWatch/app/src/main/java/de/tonifetzer/conductorswatch/ui/Croller.java new file mode 100644 index 0000000..dfca669 --- /dev/null +++ b/android/ConductorsWatch/app/src/main/java/de/tonifetzer/conductorswatch/ui/Croller.java @@ -0,0 +1,828 @@ +package de.tonifetzer.conductorswatch.ui; + +import android.animation.ArgbEvaluator; +import android.animation.ValueAnimator; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; + +import de.tonifetzer.conductorswatch.R; +import de.tonifetzer.conductorswatch.utilities.Utils; + +public class Croller extends View { + + private float midx, midy; + private Paint textPaint, circlePaint, circlePaint2, linePaint; + private float currdeg = 0, deg = 3, downdeg = 0; + + private boolean isContinuous = false; + + private int backCircleColor = Color.parseColor("#222222"); + private int mainCircleColor = Color.parseColor("#000000"); + private int indicatorColor = Color.parseColor("#FFA036"); + private int progressPrimaryColor = Color.parseColor("#FFA036"); + private int progressSecondaryColor = Color.parseColor("#111111"); + + private float progressPrimaryCircleSize = -1; + private float progressSecondaryCircleSize = -1; + + private float progressPrimaryStrokeWidth = 25; + private float progressSecondaryStrokeWidth = 10; + + private float mainCircleRadius = -1; + private float backCircleRadius = -1; + private float progressRadius = -1; + + private float touchCircleRadiusMax = -1; + private float touchCircleRadiusMin = -1; + + private int max = 25; + private int min = 1; + + private float indicatorWidth = 7; + + private String label = "Label"; + private int labelSize = 20; + private int labelColor = Color.WHITE; + + private int startOffset = 30; + private int startOffset2 = 0; + private int sweepAngle = -1; + + private boolean isAntiClockwise = false; + + private boolean startEventSent = false; + + RectF oval; + + private onProgressChangedListener mProgressChangeListener; + private OnCrollerChangeListener mCrollerChangeListener; + + public interface onProgressChangedListener { + void onProgressChanged(int progress); + } + + public void setOnProgressChangedListener(onProgressChangedListener mProgressChangeListener) { + this.mProgressChangeListener = mProgressChangeListener; + } + + public interface OnCrollerChangeListener { + void onProgressChanged(Croller croller, int progress); + void onStartTrackingTouch(Croller croller); + void onStopTrackingTouch(Croller croller); + } + + public void setOnCrollerChangeListener(OnCrollerChangeListener mCrollerChangeListener) { + this.mCrollerChangeListener = mCrollerChangeListener; + } + + public Croller(Context context) { + super(context); + init(); + } + + public Croller(Context context, AttributeSet attrs) { + super(context, attrs); + initXMLAttrs(context, attrs); + init(); + } + + public Croller(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initXMLAttrs(context, attrs); + init(); + } + + private void init() { + textPaint = new Paint(); + textPaint.setAntiAlias(true); + textPaint.setColor(labelColor); + textPaint.setStyle(Paint.Style.FILL); + textPaint.setTextSize(labelSize); + textPaint.setFakeBoldText(true); + textPaint.setTextAlign(Paint.Align.CENTER); + + circlePaint = new Paint(); + circlePaint.setAntiAlias(true); + circlePaint.setColor(progressSecondaryColor); + circlePaint.setStrokeWidth(progressSecondaryStrokeWidth); + circlePaint.setStyle(Paint.Style.FILL); + + circlePaint2 = new Paint(); + circlePaint2.setAntiAlias(true); + circlePaint2.setColor(progressPrimaryColor); + circlePaint2.setStrokeWidth(progressPrimaryStrokeWidth); + circlePaint2.setStyle(Paint.Style.FILL); + + linePaint = new Paint(); + linePaint.setAntiAlias(true); + linePaint.setColor(indicatorColor); + linePaint.setStrokeWidth(indicatorWidth); + + oval = new RectF(); + + } + + private void initXMLAttrs(Context context, AttributeSet attrs) { + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.Croller); + final int N = a.getIndexCount(); + for (int i = 0; i < N; ++i) { + int attr = a.getIndex(i); + if (attr == R.styleable.Croller_progress) { + setProgress(a.getInt(attr, 1)); + } else if (attr == R.styleable.Croller_label) { + setLabel(a.getString(attr)); + } else if (attr == R.styleable.Croller_back_circle_color) { + setBackCircleColor(a.getColor(attr, Color.parseColor("#222222"))); + } else if (attr == R.styleable.Croller_main_circle_color) { + setMainCircleColor(a.getColor(attr, Color.parseColor("#000000"))); + } else if (attr == R.styleable.Croller_indicator_color) { + setIndicatorColor(a.getColor(attr, Color.parseColor("#FFA036"))); + } else if (attr == R.styleable.Croller_progress_primary_color) { + setProgressPrimaryColor(a.getColor(attr, Color.parseColor("#FFA036"))); + } else if (attr == R.styleable.Croller_progress_secondary_color) { + setProgressSecondaryColor(a.getColor(attr, Color.parseColor("#111111"))); + } else if (attr == R.styleable.Croller_label_size) { + setLabelSize(a.getInteger(attr, 40)); + } else if (attr == R.styleable.Croller_label_color) { + setLabelColor(a.getColor(attr, Color.WHITE)); + } else if (attr == R.styleable.Croller_indicator_width) { + setIndicatorWidth(a.getFloat(attr, 7)); + } else if (attr == R.styleable.Croller_is_continuous) { + setIsContinuous(a.getBoolean(attr, false)); + } else if (attr == R.styleable.Croller_progress_primary_circle_size) { + setProgressPrimaryCircleSize(a.getFloat(attr, -1)); + } else if (attr == R.styleable.Croller_progress_secondary_circle_size) { + setProgressSecondaryCircleSize(a.getFloat(attr, -1)); + } else if (attr == R.styleable.Croller_progress_primary_stroke_width) { + setProgressPrimaryStrokeWidth(a.getFloat(attr, 25)); + } else if (attr == R.styleable.Croller_progress_secondary_stroke_width) { + setProgressSecondaryStrokeWidth(a.getFloat(attr, 10)); + } else if (attr == R.styleable.Croller_sweep_angle) { + setSweepAngle(a.getInt(attr, -1)); + } else if (attr == R.styleable.Croller_start_offset) { + setStartOffset(a.getInt(attr, 30)); + } else if (attr == R.styleable.Croller_max) { + setMax(a.getInt(attr, 25)); + } else if (attr == R.styleable.Croller_min) { + setMin(a.getInt(attr, 1)); + deg = min + 2; + } else if (attr == R.styleable.Croller_main_circle_radius) { + setMainCircleRadius(a.getFloat(attr, -1)); + } else if (attr == R.styleable.Croller_back_circle_radius) { + setBackCircleRadius(a.getFloat(attr, -1)); + } else if (attr == R.styleable.Croller_progress_radius) { + setProgressRadius(a.getFloat(attr, -1)); + } else if (attr == R.styleable.Croller_touch_circle_radius_max) { + setTouchCircleRadiusMax(a.getFloat(attr, -1)); + } else if (attr == R.styleable.Croller_touch_circle_radius_min) { + setTouchCircleRadiusMin(a.getFloat(attr, -1)); + }else if (attr == R.styleable.Croller_anticlockwise) { + setAntiClockwise(a.getBoolean(attr, false)); + } + } + a.recycle(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + int minWidth = (int) Utils.convertDpToPixel(160, getContext()); + int minHeight = (int) Utils.convertDpToPixel(160, getContext()); + + int widthMode = MeasureSpec.getMode(widthMeasureSpec); + int widthSize = MeasureSpec.getSize(widthMeasureSpec); + int heightMode = MeasureSpec.getMode(heightMeasureSpec); + int heightSize = MeasureSpec.getSize(heightMeasureSpec); + + int width; + int height; + + if (widthMode == MeasureSpec.EXACTLY) { + width = widthSize; + } else if (widthMode == MeasureSpec.AT_MOST) { + width = Math.min(minWidth, widthSize); + } else { + // only in case of ScrollViews, otherwise MeasureSpec.UNSPECIFIED is never triggered + // If width is wrap_content i.e. MeasureSpec.UNSPECIFIED, then make width equal to height + width = heightSize; + } + + if (heightMode == MeasureSpec.EXACTLY) { + height = heightSize; + } else if (heightMode == MeasureSpec.AT_MOST) { + height = Math.min(minHeight, heightSize); + } else { + // only in case of ScrollViews, otherwise MeasureSpec.UNSPECIFIED is never triggered + // If height is wrap_content i.e. MeasureSpec.UNSPECIFIED, then make height equal to width + height = widthSize; + } + + if (widthMode == MeasureSpec.UNSPECIFIED && heightMode == MeasureSpec.UNSPECIFIED) { + width = minWidth; + height = minHeight; + } + + setMeasuredDimension(width, height); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + //called way to much! + if (mProgressChangeListener != null) + mProgressChangeListener.onProgressChanged((int) (deg - 2)); + + if (mCrollerChangeListener != null) + mCrollerChangeListener.onProgressChanged(this, (int) (deg - 2)); + + midx = canvas.getWidth() / 2; + midy = canvas.getHeight() / 2; + + if (!isContinuous) { + + startOffset2 = startOffset - 15; + + circlePaint.setColor(progressSecondaryColor); + circlePaint2.setColor(progressPrimaryColor); + linePaint.setStrokeWidth(indicatorWidth); + linePaint.setColor(indicatorColor); + textPaint.setColor(labelColor); + textPaint.setTextSize(labelSize); + + int radius = (int) (Math.min(midx, midy) * ((float) 14.5 / 16)); + + if (sweepAngle == -1) { + sweepAngle = 360 - (2 * startOffset2); + } + + if (mainCircleRadius == -1) { + mainCircleRadius = radius * ((float) 11 / 15); + } + if (backCircleRadius == -1) { + backCircleRadius = radius * ((float) 13 / 15); + } + if (progressRadius == -1) { + progressRadius = radius; + } + + if (touchCircleRadiusMax == -1) { + touchCircleRadiusMax = Math.max(mainCircleRadius, Math.max(backCircleRadius, progressRadius)); + } + + if (touchCircleRadiusMin == -1) { + touchCircleRadiusMin = 0; + } + + float x, y; + float deg2 = Math.max(3, deg); + float deg3 = Math.min(deg, max + 2); + for (int i = (int) (deg2); i < max + 3; i++) { + float tmp = ((float) startOffset2 / 360) + ((float) sweepAngle / 360) * (float) i / (max + 5); + + if (isAntiClockwise) { + tmp = 1.0f - tmp; + } + + x = midx + (float) (progressRadius * Math.sin(2 * Math.PI * (1.0 - tmp))); + y = midy + (float) (progressRadius * Math.cos(2 * Math.PI * (1.0 - tmp))); + circlePaint.setColor(progressSecondaryColor); + if (progressSecondaryCircleSize == -1) + canvas.drawCircle(x, y, ((float) radius / 30 * ((float) 20 / max) * ((float) sweepAngle / 270)), circlePaint); + else + canvas.drawCircle(x, y, progressSecondaryCircleSize, circlePaint); + } + for (int i = 3; i <= deg3; i++) { + float tmp = ((float) startOffset2 / 360) + ((float) sweepAngle / 360) * (float) i / (max + 5); + + if (isAntiClockwise) { + tmp = 1.0f - tmp; + } + + x = midx + (float) (progressRadius * Math.sin(2 * Math.PI * (1.0 - tmp))); + y = midy + (float) (progressRadius * Math.cos(2 * Math.PI * (1.0 - tmp))); + if (progressPrimaryCircleSize == -1) + canvas.drawCircle(x, y, (progressRadius / 15 * ((float) 20 / max) * ((float) sweepAngle / 270)), circlePaint2); + else + canvas.drawCircle(x, y, progressPrimaryCircleSize, circlePaint2); + } + + float tmp2 = ((float) startOffset2 / 360) + ((float) sweepAngle / 360) * deg / (max + 5); + + if (isAntiClockwise) { + tmp2 = 1.0f - tmp2; + } + + float x1 = midx + (float) (radius * ((float) 2 / 5) * Math.sin(2 * Math.PI * (1.0 - tmp2))); + float y1 = midy + (float) (radius * ((float) 2 / 5) * Math.cos(2 * Math.PI * (1.0 - tmp2))); + float x2 = midx + (float) (radius * ((float) 3 / 5) * Math.sin(2 * Math.PI * (1.0 - tmp2))); + float y2 = midy + (float) (radius * ((float) 3 / 5) * Math.cos(2 * Math.PI * (1.0 - tmp2))); + + circlePaint.setColor(backCircleColor); + canvas.drawCircle(midx, midy, backCircleRadius, circlePaint); + circlePaint.setColor(mainCircleColor); + canvas.drawCircle(midx, midy, mainCircleRadius, circlePaint); + canvas.drawText(label, midx, midy + (float) (radius * 1.1), textPaint); + canvas.drawLine(x1, y1, x2, y2, linePaint); + + } else { + + int radius = (int) (Math.min(midx, midy) * ((float) 14.5 / 16)); + + if (sweepAngle == -1) { + sweepAngle = 360 - (2 * startOffset); + } + + if (mainCircleRadius == -1) { + mainCircleRadius = radius * ((float) 11 / 15); + } + if (backCircleRadius == -1) { + backCircleRadius = radius * ((float) 13 / 15); + } + if (progressRadius == -1) { + progressRadius = radius; + } + + if (touchCircleRadiusMax == -1) { + touchCircleRadiusMax = Math.max(mainCircleRadius, Math.max(backCircleRadius, progressRadius)); + } + + if (touchCircleRadiusMin == -1) { + touchCircleRadiusMin = 0; + } + + circlePaint.setColor(progressSecondaryColor); + circlePaint.setStrokeWidth(progressSecondaryStrokeWidth); + circlePaint.setStyle(Paint.Style.STROKE); + circlePaint2.setColor(progressPrimaryColor); + circlePaint2.setStrokeWidth(progressPrimaryStrokeWidth); + circlePaint2.setStyle(Paint.Style.STROKE); + linePaint.setStrokeWidth(indicatorWidth); + linePaint.setColor(indicatorColor); + textPaint.setColor(labelColor); + textPaint.setTextSize(labelSize); + + float deg3 = Math.min(deg, max + 2); + + oval.set(midx - progressRadius, midy - progressRadius, midx + progressRadius, midy + progressRadius); + + canvas.drawArc(oval, (float) 90 + startOffset, (float) sweepAngle, false, circlePaint); + if (isAntiClockwise) { + canvas.drawArc(oval, (float) 90 - startOffset, -1 * ((deg3 - 2) * ((float) sweepAngle / max)), false, circlePaint2); + } else { + canvas.drawArc(oval, (float) 90 + startOffset, ((deg3 - 2) * ((float) sweepAngle / max)), false, circlePaint2); + } + + float tmp2 = ((float) startOffset / 360) + (((float) sweepAngle / 360) * ((deg - 2) / (max))); + + if (isAntiClockwise) { + tmp2 = 1.0f - tmp2; + } + + float x1 = midx + (float) (radius * ((float) 2 / 5) * Math.sin(2 * Math.PI * (1.0 - tmp2))); + float y1 = midy + (float) (radius * ((float) 2 / 5) * Math.cos(2 * Math.PI * (1.0 - tmp2))); + float x2 = midx + (float) (radius * ((float) 3 / 5) * Math.sin(2 * Math.PI * (1.0 - tmp2))); + float y2 = midy + (float) (radius * ((float) 3 / 5) * Math.cos(2 * Math.PI * (1.0 - tmp2))); + + circlePaint.setStyle(Paint.Style.FILL); + + circlePaint.setColor(backCircleColor); + canvas.drawCircle(midx, midy, backCircleRadius, circlePaint); + circlePaint.setColor(mainCircleColor); + canvas.drawCircle(midx, midy, mainCircleRadius, circlePaint); + canvas.drawText(label, midx, midy + (float) (radius * 0.9), textPaint); + canvas.drawLine(x1, y1, x2, y2, linePaint); + } + } + + @Override + public boolean onTouchEvent(MotionEvent e) { + + double distancePointToMiddle = Utils.getDistance(e.getX(), e.getY(), midx, midy); + if ((distancePointToMiddle > touchCircleRadiusMax || distancePointToMiddle < touchCircleRadiusMin)) { + if (startEventSent && mCrollerChangeListener != null) { + mCrollerChangeListener.onStopTrackingTouch(this); + startEventSent = false; + } + return super.onTouchEvent(e); + } + + if (e.getAction() == MotionEvent.ACTION_DOWN) { + float dx = e.getX() - midx; + float dy = e.getY() - midy; + downdeg = (float) ((Math.atan2(dy, dx) * 180) / Math.PI); + downdeg -= 90; + if (downdeg < 0) { + downdeg += 360; + } + downdeg = (float) Math.floor((downdeg / 360) * (max + 5)); + + if (mCrollerChangeListener != null) { + mCrollerChangeListener.onStartTrackingTouch(this); + startEventSent = true; + } + + return true; + } + if (e.getAction() == MotionEvent.ACTION_MOVE) { + float dx = e.getX() - midx; + float dy = e.getY() - midy; + currdeg = (float) ((Math.atan2(dy, dx) * 180) / Math.PI); + currdeg -= 90; + if (currdeg < 0) { + currdeg += 360; + } + currdeg = (float) Math.floor((currdeg / 360) * (max + 5)); + + if ((currdeg / (max + 4)) > 0.75f && ((downdeg - 0) / (max + 4)) < 0.25f) { + if (isAntiClockwise) { + deg++; + if (deg > max + 2) { + deg = max + 2; + } + } else { + deg--; + if (deg < (min + 2)) { + deg = (min + 2); + } + } + } else if ((downdeg / (max + 4)) > 0.75f && ((currdeg - 0) / (max + 4)) < 0.25f) { + if (isAntiClockwise) { + deg--; + if (deg < (min + 2)) { + deg = (min + 2); + } + } else { + deg++; + if (deg > max + 2) { + deg = max + 2; + } + } + } else { + if (isAntiClockwise) { + deg -= (currdeg - downdeg); + } else { + deg += (currdeg - downdeg); + } + if (deg > max + 2) { + deg = max + 2; + } + if (deg < (min + 2)) { + deg = (min + 2); + } + } + + downdeg = currdeg; + + invalidate(); + return true; + + } + if (e.getAction() == MotionEvent.ACTION_UP) { + if (mCrollerChangeListener != null) { + mCrollerChangeListener.onStopTrackingTouch(this); + startEventSent = false; + } + return true; + } + return super.onTouchEvent(e); + } + + @Override + public boolean dispatchTouchEvent(MotionEvent event) { + if (getParent() != null && event.getAction() == MotionEvent.ACTION_DOWN) { + getParent().requestDisallowInterceptTouchEvent(true); + } + return super.dispatchTouchEvent(event); + } + + public int getProgress() { + return (int) (deg - 2); + } + + public void setProgress(int x) { + if(deg != x + 2){ + deg = x + 2; + invalidate(); + } + } + + public String getLabel() { + return label; + } + + public void setLabel(String txt) { + if(!label.equals(txt)){ + label = txt; + invalidate(); + } + } + + public int getBackCircleColor() { + return backCircleColor; + } + + public void setBackCircleColor(int backCircleColor) { + if(this.backCircleColor != backCircleColor){ + this.backCircleColor = backCircleColor; + invalidate(); + } + } + + public int getMainCircleColor() { + return mainCircleColor; + } + + public void setMainCircleColor(int mainCircleColor) { + if(this.mainCircleColor != mainCircleColor){ + this.mainCircleColor = mainCircleColor; + invalidate(); + } + } + + private ValueAnimator mainCircleAnimation; + public void setMainCircleColorAnimated(int startColor, int endColor, int duration) { + + mainCircleAnimation = ValueAnimator.ofObject(new ArgbEvaluator(), startColor, endColor); + mainCircleAnimation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + + @Override + public void onAnimationUpdate(ValueAnimator animator) { + setMainCircleColor((Integer) animator.getAnimatedValue()); + } + + }); + + mainCircleAnimation.setDuration(duration); + mainCircleAnimation.start(); + } + + public void stopMainCircleColorAnimated(){ + if(mainCircleAnimation != null){ + if(mainCircleAnimation.isRunning()){ + mainCircleAnimation.reverse(); + } + } + } + + public void interruptMainCircleColorAnimated(){ + if(mainCircleAnimation != null){ + if(mainCircleAnimation.isRunning()){ + mainCircleAnimation.setDuration(0); + mainCircleAnimation.reverse(); + } + } + } + + public void interruptBackCircleAnimated(){ + if(backCircleAnimation != null){ + if(backCircleAnimation.isRunning()){ + backCircleAnimation.setDuration(0); + backCircleAnimation.reverse(); + } + } + } + + public boolean isMainCircleAnimationRunning(){ + if(mainCircleAnimation != null){ + return mainCircleAnimation.isRunning(); + } + return false; + } + + public boolean isBackCircleAnimationRunning(){ + if(backCircleAnimation != null){ + return backCircleAnimation.isRunning(); + } + return false; + } + + private ValueAnimator backCircleAnimation; + public void setBackCircleColorAnimated(int startColor, int endColor, int duration) { + + backCircleAnimation = ValueAnimator.ofObject(new ArgbEvaluator(), startColor, endColor); + backCircleAnimation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + + @Override + public void onAnimationUpdate(ValueAnimator animator) { + setBackCircleColor((Integer) animator.getAnimatedValue()); + } + + }); + + backCircleAnimation.setDuration(duration); + backCircleAnimation.start(); + } + + public void stopBackCircleColorAnimated(){ + backCircleAnimation.reverse(); + } + + public int getIndicatorColor() { + return indicatorColor; + } + + public void setIndicatorColor(int indicatorColor) { + this.indicatorColor = indicatorColor; + invalidate(); + } + + public int getProgressPrimaryColor() { + return progressPrimaryColor; + } + + public void setProgressPrimaryColor(int progressPrimaryColor) { + this.progressPrimaryColor = progressPrimaryColor; + invalidate(); + } + + public int getProgressSecondaryColor() { + return progressSecondaryColor; + } + + public void setProgressSecondaryColor(int progressSecondaryColor) { + this.progressSecondaryColor = progressSecondaryColor; + invalidate(); + } + + public int getLabelSize() { + return labelSize; + } + + public void setLabelSize(int labelSize) { + this.labelSize = labelSize; + invalidate(); + } + + public int getLabelColor() { + return labelColor; + } + + public void setLabelColor(int labelColor) { + this.labelColor = labelColor; + invalidate(); + } + + public float getIndicatorWidth() { + return indicatorWidth; + } + + public void setIndicatorWidth(float indicatorWidth) { + this.indicatorWidth = indicatorWidth; + invalidate(); + } + + public boolean isContinuous() { + return isContinuous; + } + + public void setIsContinuous(boolean isContinuous) { + this.isContinuous = isContinuous; + invalidate(); + } + + public float getProgressPrimaryCircleSize() { + return progressPrimaryCircleSize; + } + + public void setProgressPrimaryCircleSize(float progressPrimaryCircleSize) { + this.progressPrimaryCircleSize = progressPrimaryCircleSize; + invalidate(); + } + + public float getProgressSecondaryCircleSize() { + return progressSecondaryCircleSize; + } + + public void setProgressSecondaryCircleSize(float progressSecondaryCircleSize) { + this.progressSecondaryCircleSize = progressSecondaryCircleSize; + invalidate(); + } + + public float getProgressPrimaryStrokeWidth() { + return progressPrimaryStrokeWidth; + } + + public void setProgressPrimaryStrokeWidth(float progressPrimaryStrokeWidth) { + this.progressPrimaryStrokeWidth = progressPrimaryStrokeWidth; + invalidate(); + } + + public float getProgressSecondaryStrokeWidth() { + return progressSecondaryStrokeWidth; + } + + public void setProgressSecondaryStrokeWidth(float progressSecondaryStrokeWidth) { + this.progressSecondaryStrokeWidth = progressSecondaryStrokeWidth; + invalidate(); + } + + public int getSweepAngle() { + return sweepAngle; + } + + public void setSweepAngle(int sweepAngle) { + this.sweepAngle = sweepAngle; + invalidate(); + } + + public int getStartOffset() { + return startOffset; + } + + public void setStartOffset(int startOffset) { + this.startOffset = startOffset; + invalidate(); + } + + public int getMax() { + return max; + } + + public void setMax(int max) { + if (max < min) { + this.max = min; + } else { + this.max = max; + } + invalidate(); + } + + public int getMin() { + return min; + } + + public void setMin(int min) { + if (min < 0) { + this.min = 0; + } else if (min > max) { + this.min = max; + } else { + this.min = min; + } + invalidate(); + } + + public float getMainCircleRadius() { + return mainCircleRadius; + } + + public void setMainCircleRadius(float mainCircleRadius) { + this.mainCircleRadius = mainCircleRadius; + invalidate(); + } + + public float getBackCircleRadius() { + return backCircleRadius; + } + + public void setBackCircleRadius(float backCircleRadius) { + this.backCircleRadius = backCircleRadius; + invalidate(); + } + + public float getProgressRadius() { + return progressRadius; + } + + public void setProgressRadius(float progressRadius) { + this.progressRadius = progressRadius; + invalidate(); + } + + public float getTouchCircleRadiusMax() { + return touchCircleRadiusMax; + } + + public void setTouchCircleRadiusMax(float touchCircleRadiusMax) { + this.touchCircleRadiusMax = touchCircleRadiusMax; + invalidate(); + } + + + public float getTouchCircleRadiusMin() { + return touchCircleRadiusMin; + } + + public void setTouchCircleRadiusMin(float touchCircleRadiusMin) { + this.touchCircleRadiusMin = touchCircleRadiusMin; + invalidate(); + } + + + public boolean isAntiClockwise() { + return isAntiClockwise; + } + + public void setAntiClockwise(boolean antiClockwise) { + isAntiClockwise = antiClockwise; + invalidate(); + } +} diff --git a/android/ConductorsWatch/app/src/main/java/de/tonifetzer/conductorswatch/ui/Metronome.java b/android/ConductorsWatch/app/src/main/java/de/tonifetzer/conductorswatch/ui/Metronome.java new file mode 100644 index 0000000..529f492 --- /dev/null +++ b/android/ConductorsWatch/app/src/main/java/de/tonifetzer/conductorswatch/ui/Metronome.java @@ -0,0 +1,42 @@ +package de.tonifetzer.conductorswatch.ui; + +import android.content.Context; +import android.os.Vibrator; + +import java.util.List; +import java.util.TimerTask; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Created by toni on 13/11/17. + */ +public class Metronome extends TimerTask { + + private Vibrator mVibrator; + + public Metronome(Context context){ + mVibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); + } + + @Override + public void run() { + + mVibrator.vibrate(10); + + for (OnMetronomeListener listener:listeners) { + listener.onNewClick(); + } + } + + /** + * Interface for callback metronome clicks + * I know that java has observable.. but this why it is more clean and easy + */ + public interface OnMetronomeListener { + void onNewClick(); + } + + private List listeners = new CopyOnWriteArrayList(); + public void add(OnMetronomeListener listener){listeners.add(listener);} + public void remove(OnMetronomeListener listener){listeners.remove(listener);} +} diff --git a/android/ConductorsWatch/app/src/main/java/de/tonifetzer/conductorswatch/ui/TapBpm.java b/android/ConductorsWatch/app/src/main/java/de/tonifetzer/conductorswatch/ui/TapBpm.java new file mode 100644 index 0000000..c3b75f6 --- /dev/null +++ b/android/ConductorsWatch/app/src/main/java/de/tonifetzer/conductorswatch/ui/TapBpm.java @@ -0,0 +1,69 @@ +package de.tonifetzer.conductorswatch.ui; + +import java.util.List; +import java.util.Vector; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Created by toni on 20/11/17. + */ + +public class TapBpm implements Runnable { + + private Vector mReceivedTabs = new Vector(); + + @Override + public void run() { + int breakCounter = 2000; + int bpmTapped = 0; + int calcNewBpmCounter = 3; + + if(!mReceivedTabs.isEmpty()){ + do{ + if(bpmTapped > 0){ + breakCounter = 2 * (60000 / bpmTapped); + } + + if (mReceivedTabs.size() > calcNewBpmCounter) { + + long sumDifferenceMs = 0L; + for (int i = 0; i < mReceivedTabs.size() -1; ++i) { + sumDifferenceMs += mReceivedTabs.get(i + 1)- mReceivedTabs.get(i); + } + bpmTapped = (int) (60000 / (sumDifferenceMs / (mReceivedTabs.size() - 1))); + + for (TapBpm.OnTapBpmListener listener:listeners) { + listener.onNewTapEstimation(bpmTapped); + } + + //only update everytime a new timestamp arrives + ++calcNewBpmCounter; + } + }while(System.currentTimeMillis() - mReceivedTabs.lastElement() < breakCounter); + } + + for (TapBpm.OnTapBpmListener listener:listeners) { + listener.onTapFinished(); + } + } + + public void addTimestamp(Long ts){ + mReceivedTabs.add(ts); + } + + public void clearTimestamps(){ + mReceivedTabs.clear(); + } + + /** + * Interface for callback calculated bpm + */ + public interface OnTapBpmListener { + void onTapFinished(); + void onNewTapEstimation(int bpm); + } + + private List listeners = new CopyOnWriteArrayList(); + public void add(TapBpm.OnTapBpmListener listener){listeners.add(listener);} + public void remove(TapBpm.OnTapBpmListener listener){listeners.remove(listener);} +}