diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..0126f4c --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,164 @@ +version: 2 + +references: + + ## Cache + + cache_key: &cache_key + key: cache-{{ checksum "gradle/wrapper/gradle-wrapper.properties" }}-{{ checksum "build.gradle" }}-{{ checksum "counterfab/build.gradle" }}-{{ checksum "sample/build.gradle" }} + restore_cache: &restore_cache + restore_cache: + <<: *cache_key + save_cache: &save_cache + save_cache: + <<: *cache_key + paths: + - ~/.gradle + - ~/.m2 + + ## Workspace + + workspace: &workspace + ~/workspace + attach_debug_workspace: &attach_debug_workspace + attach_workspace: + at: *workspace + persist_debug_workspace: &persist_debug_workspace + persist_to_workspace: + root: *workspace + paths: + - sample/build/outputs/androidTest-results + - sample/build/outputs/apk + - sample/build/outputs/code-coverage + attach_firebase_workspace: &attach_firebase_workspace + attach_workspace: + at: *workspace + persist_firebase_workspace: &persist_firebase_workspace + persist_to_workspace: + root: *workspace + paths: + - firebase + + ## Docker image configurations + + android_config: &android_config + working_directory: *workspace + docker: + - image: circleci/android:api-28-alpha + environment: + TERM: dumb + _JAVA_OPTIONS: "-Xmx2048m -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap" + GRADLE_OPTS: '-Dorg.gradle.jvmargs="-Xmx2048m"' + gcloud_config: &gcloud_config + working_directory: *workspace + docker: + - image: google/cloud-sdk:latest + environment: + TERM: dumb + + # Google Cloud Service + + export_gcloud_key: &export_gcloud_key + run: + name: Export Google Cloud Service key environment variable + command: echo 'export GCLOUD_SERVICE_KEY="$GCLOUD_SERVICE_KEY"' >> $BASH_ENV + decode_gcloud_key: &decode_gcloud_key + run: + name: Decode Google Cloud credentials + command: echo $GCLOUD_SERVICE_KEY | base64 -di > ${HOME}/client-secret.json + +jobs: + + ## Build debug APK and instrumented test APK + + build_debug: + <<: *android_config + steps: + - checkout + - *restore_cache + - run: + name: Download dependencies + command: ./gradlew androidDependencies + - *save_cache + - run: + name: Gradle build (debug) + command: ./gradlew assembleDebug assembleAndroidTest + - *persist_debug_workspace + - store_artifacts: + path: sample/build/outputs/apk/ + destination: apk + + ## Run instrumented tests + + test_instrumented: + <<: *gcloud_config + steps: + - *attach_debug_workspace + - *export_gcloud_key + - *decode_gcloud_key + - run: + name: Set Google Cloud target project + command: gcloud config set project counterfab-b6643 + - run: + name: Authenticate with Google Cloud + command: gcloud auth activate-service-account --key-file ${HOME}/client-secret.json + - run: + name: Run instrumented test on Firebase Test Lab + command: >- + gcloud firebase test android run --no-auto-google-login + --type instrumentation + --app sample/build/outputs/apk/debug/sample-debug.apk + --test sample/build/outputs/apk/androidTest/debug/sample-debug-androidTest.apk + --device model=walleye,version=28,locale=en_US,orientation=portrait + --environment-variables coverage=true,coverageFile=/sdcard/coverage.ec + --directories-to-pull=/sdcard + --timeout 20m + - run: + name: Create directory to store test results + command: mkdir firebase + - run: + name: Download instrumented test results from Firebase Test Lab + command: gsutil -m cp -r -U "`gsutil ls gs://test-lab-734qaq4mq93km-wb8y9z8s6fud2 | tail -1`*" /root/workspace/firebase/ + - *persist_firebase_workspace + - store_artifacts: + path: firebase/ + destination: instrumentation + - store_test_results: + path: firebase/ + + ## Submit screenshot tests + + report_screenshot_tests: + <<: *android_config + steps: + - checkout + - *restore_cache + - run: + name: Download dependencies + command: ./gradlew androidDependencies + - *attach_debug_workspace + - *attach_firebase_workspace + - run: + name: Move Firebase screenshot resources + command: >- + mkdir -p sample/screenshots && + cp -r firebase/walleye-28-en_US-portrait/artifacts/sdcard/screenshots/com.andremion.counterfab.sample.test/* + sample/screenshots/ + - run: + name: Generate Screenshot test report + command: ./gradlew executeScreenshotTests + - store_artifacts: + path: sample/build/reports/shot/verification/ + destination: reports + +workflows: + version: 2 + workflow: + jobs: + - build_debug + - test_instrumented: + requires: + - build_debug + - report_screenshot_tests: + requires: + - test_instrumented \ No newline at end of file diff --git a/README.md b/README.md index ff2a799..4730a8d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ [![License Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=true)](http://www.apache.org/licenses/LICENSE-2.0) ![minSdkVersion 16](https://img.shields.io/badge/minSdkVersion-16-red.svg?style=true) ![compileSdkVersion 24](https://img.shields.io/badge/compileSdkVersion-24-yellow.svg?style=true) +[![CircleCI](https://circleci.com/gh/andremion/CounterFab.svg?style=svg)](https://circleci.com/gh/andremion/CounterFab) [![Download](https://api.bintray.com/packages/andremion/github/CounterFab/images/download.svg)](https://bintray.com/andremion/github/CounterFab/_latestVersion) [![Android Arsenal CounterFab](https://img.shields.io/badge/Android%20Arsenal-CounterFab-green.svg?style=true)](https://android-arsenal.com/details/1/5052) diff --git a/build.gradle b/build.gradle index 10355b4..07e4368 100644 --- a/build.gradle +++ b/build.gradle @@ -1,13 +1,14 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. - buildscript { repositories { - jcenter() google() + jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:3.3.0' - classpath 'com.github.dcendents:android-maven-gradle-plugin:2.0' + apply from: 'configuration/properties.gradle' + classpath 'com.android.tools.build:gradle:3.3.2' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${compiling.kotlinVersion}" + classpath 'com.karumi:shot:2.2.0' + classpath 'com.github.dcendents:android-maven-gradle-plugin:2.1' } } @@ -17,22 +18,13 @@ plugins { allprojects { repositories { - jcenter() google() + jcenter() } project.ext { - compileSdkVersion = 28 - minSdkVersion = 16 - targetSdkVersion = 28 - - versionCode = 7 - versionName = "1.2.1" - - materialVersion = '1.0.0' - - junitVersion = '4.12' - espressoCoreVersion = '3.1.1' + versionCode = 9 + versionName = '1.2.2' name = 'CounterFab' description = 'A FloatingActionButton subclass that shows a counter badge on right top corner' @@ -40,7 +32,7 @@ allprojects { licenseName = 'The Apache Software License, Version 2.0' licenseUrl = 'http://www.apache.org/licenses/LICENSE-2.0.txt' - allLicenses = ["Apache-2.0"] + allLicenses = ['Apache-2.0'] bintrayRepo = 'github' group = 'com.github.andremion' diff --git a/configuration/properties.gradle b/configuration/properties.gradle new file mode 100644 index 0000000..cc5ce17 --- /dev/null +++ b/configuration/properties.gradle @@ -0,0 +1,39 @@ +project.ext { + + compiling = [ + javaVersion : JavaVersion.VERSION_1_8, + kotlinVersion: '1.3.21' + ] + + android = [ + compileSdkVersion: 28, + minSdkVersion : 16, + targetSdkVersion : 28 + ] + + application = [ + id: 'com.andremion.counterfab.sample' + ] + + aux = [ + appCompatVersion: '1.0.2', + ktxCoreVersion : '1.0.1' + ] + + ui = [ + materialVersion: '1.0.0' + ] + + testing = [ + junitVersion : '4.12', + espressoCoreVersion : '3.1.1', + rulesVersion : '1.1.1', + screenshotTestingVersion: '0.8.0' + ] + + building = [ + runningOnCI : System.getenv('CI') == 'true', + // allows for -DpreDex=false to be set + preDexEnabled: System.getProperty('preDex', 'true') == 'true' + ] +} \ No newline at end of file diff --git a/counterfab/build.gradle b/counterfab/build.gradle index c871c76..fc47f17 100644 --- a/counterfab/build.gradle +++ b/counterfab/build.gradle @@ -1,15 +1,19 @@ apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' apply plugin: 'com.github.dcendents.android-maven' apply plugin: 'com.jfrog.bintray' +def deps = rootProject.extensions.ext + android { - compileSdkVersion project.ext.compileSdkVersion + compileSdkVersion deps.android.compileSdkVersion defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - versionCode project.ext.versionCode - versionName project.ext.versionName - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + minSdkVersion deps.android.minSdkVersion + targetSdkVersion deps.android.targetSdkVersion + versionCode deps.versionCode + versionName deps.versionName + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } buildTypes { release { @@ -21,11 +25,14 @@ android { dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation "com.google.android.material:material:$materialVersion" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$compiling.kotlinVersion" + implementation "com.google.android.material:material:$ui.materialVersion" + implementation "androidx.appcompat:appcompat:$aux.appCompatVersion" + implementation "androidx.core:core-ktx:$aux.ktxCoreVersion" - testImplementation "junit:junit:$junitVersion" + testImplementation "junit:junit:$testing.junitVersion" - androidTestImplementation "androidx.test.espresso:espresso-core:$espressoCoreVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$testing.espressoCoreVersion" } //apply from: 'https://raw.githubusercontent.com/andremion/JCenter/master/deploy.gradle' diff --git a/counterfab/src/main/java/com/andremion/counterfab/CounterFab.java b/counterfab/src/main/java/com/andremion/counterfab/CounterFab.java deleted file mode 100644 index d5b5c43..0000000 --- a/counterfab/src/main/java/com/andremion/counterfab/CounterFab.java +++ /dev/null @@ -1,321 +0,0 @@ -/* - * Copyright (c) 2017. André Mion - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.andremion.counterfab; - -import android.animation.ObjectAnimator; -import android.content.Context; -import android.content.res.ColorStateList; -import android.content.res.TypedArray; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.Paint.Style; -import android.graphics.Rect; -import android.graphics.Typeface; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.Drawable; -import android.os.Bundle; -import android.os.Parcelable; -import android.util.AttributeSet; -import android.util.Property; -import android.view.animation.Interpolator; -import android.view.animation.OvershootInterpolator; - -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.google.android.material.stateful.ExtendableSavedState; - -import androidx.annotation.IntRange; -import androidx.annotation.Nullable; -import androidx.core.view.ViewCompat; - -import static com.google.android.material.R.attr; - -/** - * A {@link FloatingActionButton} subclass that shows a counter badge on right top corner. - */ -public class CounterFab extends FloatingActionButton { - - private static final String STATE_KEY = CounterFab.class.getName() + ".STATE"; - private static final String COUNT_STATE = "COUNT"; - - private final Property ANIMATION_PROPERTY = - new Property(Float.class, "animation") { - - @Override - public void set(CounterFab object, Float value) { - mAnimationFactor = value; - postInvalidateOnAnimation(); - } - - @Override - public Float get(CounterFab object) { - return 0f; - } - }; - - private static final int NORMAL_MAX_COUNT = 99; - private static final String NORMAL_MAX_COUNT_TEXT = "99+"; - - private static final int MINI_MAX_COUNT = 9; - private static final String MINI_MAX_COUNT_TEXT = "9+"; - - private static final int TEXT_SIZE_DP = 11; - private static final int TEXT_PADDING_DP = 2; - private static final int MASK_COLOR = Color.parseColor("#33000000"); // Translucent black as mask color - private static final Interpolator ANIMATION_INTERPOLATOR = new OvershootInterpolator(); - - private final Rect mContentBounds; - private final Paint mTextPaint; - private final float mTextSize; - private final Paint mCirclePaint; - private final Rect mCircleBounds; - private final Paint mMaskPaint; - private final int mAnimationDuration; - private float mAnimationFactor; - - private int mCount; - private String mText; - private final float mTextHeight; - private ObjectAnimator mAnimator; - - private int badgePosition = RIGHT_TOP_POSITION; - private static final int RIGHT_TOP_POSITION = 0; - private static final int LEFT_BOTTOM_POSITION = 1; - private static final int LEFT_TOP_POSITION = 2; - private static final int RIGHT_BOTTOM_POSITION = 3; - - public CounterFab(Context context) { - this(context, null); - } - - public CounterFab(Context context, AttributeSet attrs) { - this(context, attrs, attr.floatingActionButtonStyle); - } - - public CounterFab(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - - float density = getResources().getDisplayMetrics().density; - mTextSize = TEXT_SIZE_DP * density; - float textPadding = TEXT_PADDING_DP * density; - - mAnimationDuration = getResources().getInteger(android.R.integer.config_shortAnimTime); - mAnimationFactor = 1; - - mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - mTextPaint.setStyle(Style.FILL_AND_STROKE); - mTextPaint.setTextSize(mTextSize); - mTextPaint.setTextAlign(Paint.Align.CENTER); - mTextPaint.setTypeface(Typeface.SANS_SERIF); - - mCirclePaint = new Paint(Paint.ANTI_ALIAS_FLAG); - mCirclePaint.setStyle(Paint.Style.FILL); - - int defaultBadgeColor = getDefaultBadgeColor(); - - setupFromStyledAttributes(context, attrs, defaultBadgeColor); - - mMaskPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - mMaskPaint.setStyle(Paint.Style.FILL); - mMaskPaint.setColor(MASK_COLOR); - - Rect textBounds = new Rect(); - mTextPaint.getTextBounds(NORMAL_MAX_COUNT_TEXT, 0, NORMAL_MAX_COUNT_TEXT.length(), textBounds); - mTextHeight = textBounds.height(); - - float textWidth = mTextPaint.measureText(NORMAL_MAX_COUNT_TEXT); - float circleRadius = Math.max(textWidth, mTextHeight) / 2f + textPadding; - int circleEnd = (int) (circleRadius * 2); - if (isSizeMini()) { - int circleStart = (int) (circleRadius / 2); - mCircleBounds = new Rect(circleStart, circleStart, circleEnd, circleEnd); - } else { - int circleStart = 0; - mCircleBounds = new Rect(circleStart, circleStart, (int) (circleRadius * 2), (int) (circleRadius * 2)); - } - mContentBounds = new Rect(); - - onCountChanged(); - } - - private int getDefaultBadgeColor() { - int defaultBadgeColor = mCirclePaint.getColor(); - ColorStateList colorStateList = getBackgroundTintList(); - if (colorStateList != null) { - defaultBadgeColor = colorStateList.getDefaultColor(); - } else { - Drawable background = getBackground(); - if (background instanceof ColorDrawable) { - ColorDrawable colorDrawable = (ColorDrawable) background; - defaultBadgeColor = colorDrawable.getColor(); - } - } - return defaultBadgeColor; - } - - private void setupFromStyledAttributes(Context context, @Nullable AttributeSet attrs, int defaultBadgeColor) { - TypedArray styledAttributes = context.getTheme() - .obtainStyledAttributes(attrs, R.styleable.CounterFab, 0, 0); - mTextPaint.setColor(styledAttributes.getColor(R.styleable.CounterFab_badgeTextColor, Color.WHITE)); - mCirclePaint.setColor(styledAttributes.getColor(R.styleable.CounterFab_badgeBackgroundColor, defaultBadgeColor)); - badgePosition = styledAttributes.getInt(R.styleable.CounterFab_badgePosition, RIGHT_TOP_POSITION); - styledAttributes.recycle(); - } - - private boolean isSizeMini() { - return getSize() == SIZE_MINI; - } - - /** - * @return The current count value - */ - public int getCount() { - return mCount; - } - - /** - * Set the count to show on badge - * - * @param count The count value starting from 0 - */ - public void setCount(@IntRange(from = 0) int count) { - if (count == mCount) return; - mCount = count > 0 ? count : 0; - onCountChanged(); - if (ViewCompat.isLaidOut(this)) { - startAnimation(); - } - } - - /** - * Increase the current count value by 1 - */ - public void increase() { - setCount(mCount + 1); - } - - /** - * Decrease the current count value by 1 - */ - public void decrease() { - setCount(mCount > 0 ? mCount - 1 : 0); - } - - private void onCountChanged() { - if (isSizeMini()) { - if (mCount > MINI_MAX_COUNT) { - mText = String.valueOf(MINI_MAX_COUNT_TEXT); - } else { - mText = String.valueOf(mCount); - } - } else { - if (mCount > NORMAL_MAX_COUNT) { - mText = String.valueOf(NORMAL_MAX_COUNT_TEXT); - } else { - mText = String.valueOf(mCount); - } - } - } - - private void startAnimation() { - float start = 0f; - float end = 1f; - if (mCount == 0) { - start = 1f; - end = 0f; - } - if (isAnimating()) { - mAnimator.cancel(); - } - mAnimator = ObjectAnimator.ofObject(this, ANIMATION_PROPERTY, null, start, end); - mAnimator.setInterpolator(ANIMATION_INTERPOLATOR); - mAnimator.setDuration(mAnimationDuration); - mAnimator.start(); - } - - private boolean isAnimating() { - return mAnimator != null && mAnimator.isRunning(); - } - - @Override - protected void onDraw(Canvas canvas) { - super.onDraw(canvas); - if (mCount > 0 || isAnimating()) { - if (getContentRect(mContentBounds)) { - int newLeft; - int newTop; - switch (badgePosition) { - case LEFT_BOTTOM_POSITION: - newLeft = mContentBounds.left; - newTop = mContentBounds.bottom - mCircleBounds.height(); - break; - case LEFT_TOP_POSITION: - newLeft = mContentBounds.left; - newTop = mContentBounds.top; - break; - case RIGHT_BOTTOM_POSITION: - newLeft = mContentBounds.left + mContentBounds.width() - mCircleBounds.width(); - newTop = mContentBounds.bottom - mCircleBounds.height(); - break; - case RIGHT_TOP_POSITION: - default: - newLeft = mContentBounds.left + mContentBounds.width() - mCircleBounds.width(); - newTop = mContentBounds.top; - } - mCircleBounds.offsetTo(newLeft, newTop); - } - float cx = mCircleBounds.centerX(); - float cy = mCircleBounds.centerY(); - float radius = mCircleBounds.width() / 2f * mAnimationFactor; - // Solid circle - canvas.drawCircle(cx, cy, radius, mCirclePaint); - // Mask circle - canvas.drawCircle(cx, cy, radius, mMaskPaint); - // Count text - mTextPaint.setTextSize(mTextSize * mAnimationFactor); - canvas.drawText(mText, cx, cy + mTextHeight / 2f, mTextPaint); - } - } - - @Override - public Parcelable onSaveInstanceState() { - Parcelable superState = super.onSaveInstanceState(); - ExtendableSavedState state = new ExtendableSavedState(superState); - - Bundle bundle = new Bundle(); - bundle.putInt(COUNT_STATE, mCount); - state.extendableStates.put(STATE_KEY, bundle); - - return state; - } - - @Override - public void onRestoreInstanceState(Parcelable state) { - if (!(state instanceof ExtendableSavedState)) { - super.onRestoreInstanceState(state); - return; - } - - ExtendableSavedState extendableState = (ExtendableSavedState) state; - super.onRestoreInstanceState(extendableState.getSuperState()); - - Bundle bundle = extendableState.extendableStates.get(STATE_KEY); - setCount(bundle.getInt(COUNT_STATE)); - requestLayout(); - } - -} diff --git a/counterfab/src/main/java/com/andremion/counterfab/CounterFab.kt b/counterfab/src/main/java/com/andremion/counterfab/CounterFab.kt new file mode 100644 index 0000000..e6e33e1 --- /dev/null +++ b/counterfab/src/main/java/com/andremion/counterfab/CounterFab.kt @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2017. André Mion + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.andremion.counterfab + +import android.animation.ObjectAnimator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Paint.Style +import android.graphics.Rect +import android.graphics.Typeface +import android.graphics.drawable.ColorDrawable +import android.os.Parcelable +import android.util.AttributeSet +import android.util.Property +import android.view.animation.OvershootInterpolator +import androidx.annotation.IntRange +import androidx.core.graphics.ColorUtils +import androidx.core.os.bundleOf +import androidx.core.view.ViewCompat +import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.google.android.material.stateful.ExtendableSavedState +import kotlin.math.max + +private val STATE_KEY = CounterFab::class.java.name + ".STATE" +private const val COUNT_STATE = "COUNT" + +private const val NORMAL_MAX_COUNT = 99 +private const val NORMAL_MAX_COUNT_TEXT = "99+" + +private const val MINI_MAX_COUNT = 9 +private const val MINI_MAX_COUNT_TEXT = "9+" + +private const val TEXT_SIZE_DP = 11 +private const val TEXT_PADDING_DP = 2 +private val MASK_COLOR = Color.parseColor("#33000000") // Translucent black as mask color +private val ANIMATION_INTERPOLATOR = OvershootInterpolator() +private const val RIGHT_TOP_POSITION = 0 +private const val LEFT_BOTTOM_POSITION = 1 +private const val LEFT_TOP_POSITION = 2 +private const val RIGHT_BOTTOM_POSITION = 3 + +/** + * A [FloatingActionButton] subclass that shows a counter badge on right top corner. + */ +class CounterFab @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = R.attr.floatingActionButtonStyle +) : FloatingActionButton(context, attrs, defStyleAttr) { + + private val animationProperty = object : Property(Float::class.java, "animation") { + + override fun set(counterFab: CounterFab, value: Float) { + animationFactor = value + postInvalidateOnAnimation() + } + + override fun get(counterFab: CounterFab): Float { + return 0f + } + } + + private val textSize = TEXT_SIZE_DP * resources.displayMetrics.density + private val textPadding = TEXT_PADDING_DP * resources.displayMetrics.density + + private val circlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Style.FILL + } + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Style.FILL_AND_STROKE + textSize = this@CounterFab.textSize + textAlign = Paint.Align.CENTER + typeface = Typeface.SANS_SERIF + } + private val textBounds: Rect = run { + val maxCountText = NORMAL_MAX_COUNT_TEXT + val textBounds = Rect() + textPaint.getTextBounds(maxCountText, 0, maxCountText.length, textBounds) + textBounds + } + private val circleBounds = Rect() + private val contentBounds = Rect() + + private val animationDuration = resources.getInteger(android.R.integer.config_shortAnimTime) + private var animationFactor = 1f + private var animator = ObjectAnimator() + private val isAnimating: Boolean + get() = animator.isRunning + private val isSizeMini: Boolean + get() = size == SIZE_MINI + private val badgePosition: Int + private var countText: String = "" + + /** + * The count value to show on badge starting from 0 + */ + var count: Int = 0 + set(@IntRange(from = 0) value) { + if (value == field) return + field = if (value > 0) value else 0 + + updateCountText() + if (ViewCompat.isLaidOut(this)) { + startAnimation() + } + } + + init { + val styledAttributes = context.theme.obtainStyledAttributes( + attrs, R.styleable.CounterFab, 0, 0 + ) + textPaint.color = styledAttributes.getColor(R.styleable.CounterFab_badgeTextColor, Color.WHITE) + circlePaint.color = styledAttributes.getColor(R.styleable.CounterFab_badgeBackgroundColor, getDefaultBadgeColor()) + badgePosition = styledAttributes.getInt(R.styleable.CounterFab_badgePosition, RIGHT_TOP_POSITION) + styledAttributes.recycle() + + updateCountText() + } + + private fun updateCountText() { + countText = if (isSizeMini) when { + count > MINI_MAX_COUNT -> MINI_MAX_COUNT_TEXT + else -> count.toString() + } + else when { + count > NORMAL_MAX_COUNT -> NORMAL_MAX_COUNT_TEXT + else -> count.toString() + } + } + + private fun getDefaultBadgeColor(): Int = run { + val colorStateList = backgroundTintList + if (colorStateList != null) { + colorStateList.defaultColor + } else { + val background = background + if (background is ColorDrawable) { + background.color + } else { + circlePaint.color + } + } + }.applyColorMask() + + private fun Int.applyColorMask() = ColorUtils.compositeColors(MASK_COLOR, this) + + /** + * Increase the current count value by 1 + */ + fun increase() { + count += 1 + } + + /** + * Decrease the current count value by 1 + */ + fun decrease() { + count = if (count > 0) count - 1 else 0 + } + + private fun startAnimation() { + var start = 0f + var end = 1f + if (count == 0) { + start = 1f + end = 0f + } + + if (isAnimating) animator.cancel() + + animator = ObjectAnimator.ofObject( + this, animationProperty, null, start, end + ).apply { + interpolator = ANIMATION_INTERPOLATOR + duration = animationDuration.toLong() + start() + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + calculateCircleBounds() + } + + private fun calculateCircleBounds() { + val circleRadius = max(textBounds.width(), textBounds.height()) / 2f + textPadding + val circleEnd = (circleRadius * 2).toInt() + if (isSizeMini) { + val circleStart = (circleRadius / 2).toInt() + circleBounds.set(circleStart, circleStart, circleEnd, circleEnd) + } else { + val circleStart = 0 + circleBounds.set(circleStart, circleStart, (circleRadius * 2).toInt(), (circleRadius * 2).toInt()) + } + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + if (count > 0 || isAnimating) { + if (getContentRect(contentBounds)) { + val newLeft: Int + val newTop: Int + when (badgePosition) { + LEFT_BOTTOM_POSITION -> { + newLeft = contentBounds.left + newTop = contentBounds.bottom - circleBounds.height() + } + LEFT_TOP_POSITION -> { + newLeft = contentBounds.left + newTop = contentBounds.top + } + RIGHT_BOTTOM_POSITION -> { + newLeft = contentBounds.left + contentBounds.width() - circleBounds.width() + newTop = contentBounds.bottom - circleBounds.height() + } + RIGHT_TOP_POSITION -> { + newLeft = contentBounds.left + contentBounds.width() - circleBounds.width() + newTop = contentBounds.top + } + else -> { + newLeft = contentBounds.left + contentBounds.width() - circleBounds.width() + newTop = contentBounds.top + } + } + circleBounds.offsetTo(newLeft, newTop) + } + val cx = circleBounds.centerX().toFloat() + val cy = circleBounds.centerY().toFloat() + val radius = circleBounds.width() / 2f * animationFactor + // Solid circle + canvas.drawCircle(cx, cy, radius, circlePaint) + // Count text + textPaint.textSize = textSize * animationFactor + canvas.drawText(countText, cx, cy + textBounds.height() / 2f, textPaint) + } + } + + override fun onSaveInstanceState(): Parcelable? { + val superState = super.onSaveInstanceState() + if (superState is ExtendableSavedState) { + superState.extendableStates.put(STATE_KEY, bundleOf(COUNT_STATE to count)) + } + return superState + } + + override fun onRestoreInstanceState(state: Parcelable?) { + super.onRestoreInstanceState(state) + if (state !is ExtendableSavedState) return + + val bundle = state.extendableStates.get(STATE_KEY) + count = bundle?.getInt(COUNT_STATE) ?: 0 + + requestLayout() + } +} \ No newline at end of file diff --git a/sample/build.gradle b/sample/build.gradle index fdb8dee..6557d6c 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -1,14 +1,23 @@ apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' +apply plugin: 'shot' + +def deps = rootProject.extensions.ext android { - compileSdkVersion project.ext.compileSdkVersion + compileSdkVersion deps.android.compileSdkVersion defaultConfig { - applicationId "com.andremion.counterfab.sample" - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - versionCode project.ext.versionCode - versionName project.ext.versionName - testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + applicationId deps.application.id + minSdkVersion deps.android.minSdkVersion + targetSdkVersion deps.android.targetSdkVersion + versionCode deps.versionCode + versionName deps.versionName + testInstrumentationRunner "${deps.application.id}.helpers.ScreenshotTestRunner" + } + dexOptions { + // Skip pre-dexing when running on CI or when disabled via -DpreDex=false. + preDexLibraries !deps.building.runningOnCI && deps.building.preDexEnabled } buildTypes { release { @@ -18,12 +27,24 @@ android { } } +shot { + appId = deps.application.id + runInstrumentation = false +} + dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation project(':counterfab') - implementation "com.google.android.material:material:$materialVersion" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$compiling.kotlinVersion" + implementation "com.google.android.material:material:$ui.materialVersion" + implementation "androidx.appcompat:appcompat:$aux.appCompatVersion" - testImplementation "junit:junit:$junitVersion" + testImplementation "junit:junit:$testing.junitVersion" - androidTestImplementation "androidx.test.espresso:espresso-core:$espressoCoreVersion" -} + androidTestImplementation "androidx.test.espresso:espresso-core:$testing.espressoCoreVersion" + androidTestImplementation "androidx.test:rules:$testing.rulesVersion" + androidTestImplementation dependencies.create("com.facebook.testing.screenshot:core:$testing.screenshotTestingVersion") { + exclude group: 'com.crittercism.dexmaker', module: 'dexmaker' + exclude group: 'com.crittercism.dexmaker', module: 'dexmaker-dx' + } +} \ No newline at end of file diff --git a/sample/screenshots/com.andremion.counterfab.sample.MainActivityTest_shouldKeepStateAfterOrientationChanged.png b/sample/screenshots/com.andremion.counterfab.sample.MainActivityTest_shouldKeepStateAfterOrientationChanged.png new file mode 100644 index 0000000..fe4714f Binary files /dev/null and b/sample/screenshots/com.andremion.counterfab.sample.MainActivityTest_shouldKeepStateAfterOrientationChanged.png differ diff --git a/sample/screenshots/com.andremion.counterfab.sample.MainActivityTest_shouldRenderProperly.png b/sample/screenshots/com.andremion.counterfab.sample.MainActivityTest_shouldRenderProperly.png new file mode 100644 index 0000000..e7c7f14 Binary files /dev/null and b/sample/screenshots/com.andremion.counterfab.sample.MainActivityTest_shouldRenderProperly.png differ diff --git a/sample/screenshots/com.andremion.counterfab.sample.MainActivityTest_shouldRenderProperlyAfterOrientationChanged.png b/sample/screenshots/com.andremion.counterfab.sample.MainActivityTest_shouldRenderProperlyAfterOrientationChanged.png new file mode 100644 index 0000000..787d595 Binary files /dev/null and b/sample/screenshots/com.andremion.counterfab.sample.MainActivityTest_shouldRenderProperlyAfterOrientationChanged.png differ diff --git a/sample/screenshots/com.andremion.counterfab.sample.MainActivityTest_shouldRenderSizeMiniProperly.png b/sample/screenshots/com.andremion.counterfab.sample.MainActivityTest_shouldRenderSizeMiniProperly.png new file mode 100644 index 0000000..4bf7bc4 Binary files /dev/null and b/sample/screenshots/com.andremion.counterfab.sample.MainActivityTest_shouldRenderSizeMiniProperly.png differ diff --git a/sample/screenshots/com.andremion.counterfab.sample.MainActivityTest_shouldRenderSizeMiniWithCountProperly.png b/sample/screenshots/com.andremion.counterfab.sample.MainActivityTest_shouldRenderSizeMiniWithCountProperly.png new file mode 100644 index 0000000..47828c6 Binary files /dev/null and b/sample/screenshots/com.andremion.counterfab.sample.MainActivityTest_shouldRenderSizeMiniWithCountProperly.png differ diff --git a/sample/src/androidTest/AndroidManifest.xml b/sample/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..2ac9270 --- /dev/null +++ b/sample/src/androidTest/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/sample/src/androidTest/java/com/andremion/counterfab/sample/MainActivityTest.kt b/sample/src/androidTest/java/com/andremion/counterfab/sample/MainActivityTest.kt new file mode 100644 index 0000000..25426a4 --- /dev/null +++ b/sample/src/androidTest/java/com/andremion/counterfab/sample/MainActivityTest.kt @@ -0,0 +1,60 @@ +package com.andremion.counterfab.sample + +import android.content.pm.ActivityInfo +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.ActivityTestRule +import com.andremion.counterfab.sample.helpers.setFabSize +import com.facebook.testing.screenshot.Screenshot +import com.google.android.material.floatingactionbutton.FloatingActionButton +import org.junit.Rule +import org.junit.Test + +class MainActivityTest { + + @get:Rule + val testRule = ActivityTestRule(MainActivity::class.java) + + @Test + fun shouldRenderProperly() { + + Screenshot.snapActivity(testRule.activity).record() + } + + @Test + fun shouldRenderProperlyAfterOrientationChanged() { + + testRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + + Screenshot.snapActivity(testRule.activity).record() + } + + @Test + fun shouldKeepStateAfterOrientationChanged() { + onView(withId(R.id.fab)).perform(click(), click()) + + testRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + + Screenshot.snapActivity(testRule.activity).record() + } + + @Test + fun shouldRenderSizeMiniProperly() { + onView(withId(R.id.fab)).perform(setFabSize(FloatingActionButton.SIZE_MINI)) + + Screenshot.snapActivity(testRule.activity).record() + } + + @Test + fun shouldRenderSizeMiniWithCountProperly() { + onView(withId(R.id.fab)).perform( + setFabSize(FloatingActionButton.SIZE_MINI), click(), click() + ) + + Screenshot.snapActivity(testRule.activity).record() + } +} \ No newline at end of file diff --git a/sample/src/androidTest/java/com/andremion/counterfab/sample/helpers/ScreenshotTestRunner.kt b/sample/src/androidTest/java/com/andremion/counterfab/sample/helpers/ScreenshotTestRunner.kt new file mode 100644 index 0000000..f0d1953 --- /dev/null +++ b/sample/src/androidTest/java/com/andremion/counterfab/sample/helpers/ScreenshotTestRunner.kt @@ -0,0 +1,17 @@ +package com.andremion.counterfab.sample.helpers + +import android.os.Bundle +import androidx.test.runner.AndroidJUnitRunner +import com.facebook.testing.screenshot.ScreenshotRunner + +class ScreenshotTestRunner : AndroidJUnitRunner() { + override fun onCreate(arguments: Bundle) { + ScreenshotRunner.onCreate(this, arguments) + super.onCreate(arguments) + } + + override fun finish(resultCode: Int, results: Bundle) { + ScreenshotRunner.onDestroy() + super.finish(resultCode, results) + } +} \ No newline at end of file diff --git a/sample/src/androidTest/java/com/andremion/counterfab/sample/helpers/SetFabSizeAction.kt b/sample/src/androidTest/java/com/andremion/counterfab/sample/helpers/SetFabSizeAction.kt new file mode 100644 index 0000000..7c86e0c --- /dev/null +++ b/sample/src/androidTest/java/com/andremion/counterfab/sample/helpers/SetFabSizeAction.kt @@ -0,0 +1,25 @@ +package com.andremion.counterfab.sample.helpers + +import android.view.View +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.matcher.ViewMatchers +import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.google.android.material.floatingactionbutton.FloatingActionButton.Size +import org.hamcrest.Matcher + +fun setFabSize(@Size fabSize: Int) = SetFabSizeAction(fabSize) + +class SetFabSizeAction(@Size private val fabSize: Int) : ViewAction { + + override fun getConstraints(): Matcher { + return ViewMatchers.isAssignableFrom(FloatingActionButton::class.java) + } + + override fun perform(uiController: UiController, view: View) { + val floatingActionButton = view as FloatingActionButton + floatingActionButton.size = fabSize + } + + override fun getDescription() = "set fab size to" +} \ No newline at end of file diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index 53afacb..27e94fe 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ - +