Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-21 15:11:39 +01:00
parent d6b5d53060
commit d90a1dc8df
2145 changed files with 210227 additions and 2 deletions

1
ui-weather-view/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,16 @@
plugins {
id("breezy.library")
kotlin("android")
}
android {
namespace = "org.breezyweather.ui.theme.weatherView"
defaultConfig {
consumerProguardFiles("consumer-rules.pro")
}
}
dependencies {
implementation(libs.core.ktx)
}

View file

21
ui-weather-view/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -0,0 +1,36 @@
/*
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.ui.theme.weatherView
import android.content.Context
import android.content.res.Configuration
import android.hardware.SensorManager
import androidx.core.content.getSystemService
/**
* Duplicate of existing extensions, so that the lib can be compiled separately
* TODO: Move into a dedicated module to avoid duplicate
*/
val Context.isLandscape: Boolean
get() = this.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
fun Context.dpToPx(dp: Float): Float {
return dp * (this.resources.displayMetrics.densityDpi / 160f)
}
val Context.sensorManager: SensorManager?
get() = getSystemService()

View file

@ -0,0 +1,61 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.ui.theme.weatherView
import android.content.Context
import androidx.annotation.ColorInt
import androidx.annotation.Size
interface WeatherThemeDelegate {
fun getWeatherView(context: Context): WeatherView
/**
* @return colors[] {
* theme color,
* color of daytime chart line,
* color of nighttime chart line
* }
*/
@ColorInt
@Size(3)
fun getThemeColors(
context: Context,
weatherKind: Int,
daylight: Boolean,
): IntArray
fun isLightBackground(
context: Context,
weatherKind: Int,
daylight: Boolean,
): Boolean
@ColorInt
fun getBackgroundColor(
context: Context,
weatherKind: Int,
daylight: Boolean,
): Int
@ColorInt
fun getOnBackgroundColor(
context: Context,
weatherKind: Int,
daylight: Boolean,
): Int
}

View file

@ -0,0 +1,69 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.ui.theme.weatherView
import androidx.annotation.IntDef
/**
* Weather view.
*
* This view is used to draw the weather phenomenon.
*/
interface WeatherView {
@IntDef(
WEATHER_KIND_NULL,
WEATHER_KIND_CLEAR,
WEATHER_KIND_CLOUD,
WEATHER_KIND_CLOUDY,
WEATHER_KIND_RAINY,
WEATHER_KIND_SNOW,
WEATHER_KIND_SLEET,
WEATHER_KIND_HAIL,
WEATHER_KIND_FOG,
WEATHER_KIND_HAZE,
WEATHER_KIND_THUNDER,
WEATHER_KIND_THUNDERSTORM,
WEATHER_KIND_WIND
)
annotation class WeatherKindRule
fun setWeather(@WeatherKindRule weatherKind: Int, daytime: Boolean, darkMode: Boolean)
fun onScroll(scrollY: Int)
@get:WeatherKindRule
val weatherKind: Int
fun setDrawable(drawable: Boolean)
fun setDoAnimate(animate: Boolean)
fun setGravitySensorEnabled(enabled: Boolean)
companion object {
const val WEATHER_KIND_NULL = 0
const val WEATHER_KIND_CLEAR = 1
const val WEATHER_KIND_CLOUD = 2 // Partly cloudy
const val WEATHER_KIND_CLOUDY = 3 // Cloudy
const val WEATHER_KIND_RAINY = 4
const val WEATHER_KIND_SNOW = 5
const val WEATHER_KIND_SLEET = 6
const val WEATHER_KIND_HAIL = 7
const val WEATHER_KIND_FOG = 8
const val WEATHER_KIND_HAZE = 9
const val WEATHER_KIND_THUNDER = 10
const val WEATHER_KIND_THUNDERSTORM = 11
const val WEATHER_KIND_WIND = 12
}
}

View file

@ -0,0 +1,96 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.ui.theme.weatherView.materialWeatherView
import org.breezyweather.ui.theme.weatherView.materialWeatherView.MaterialWeatherView.RotateController
import kotlin.math.abs
import kotlin.math.pow
/**
* Delay Rotate controller.
*/
class DelayRotateController(
initRotation: Double,
) : RotateController() {
private var mTargetRotation: Double = getRotationInScope(initRotation)
private var mCurrentRotation: Double = mTargetRotation
private var mVelocity: Double = 0.0
private var mAcceleration: Double = 0.0
override fun updateRotation(rotation: Double, interval: Double) {
mTargetRotation = getRotationInScope(rotation)
val rotationDiff = mTargetRotation - mCurrentRotation
// no need to move
if (rotationDiff == 0.0) {
mAcceleration = 0.0
mVelocity = 0.0
return
}
val accelSign = when (mTargetRotation > mCurrentRotation) {
true -> 1
false -> -1
}
val oldVelocity = mVelocity
if (mVelocity == 0.0 || rotationDiff * mVelocity < 0) {
// start or turn around.
mAcceleration = accelSign * DEFAULT_ABS_ACCELERATION
mVelocity = mAcceleration * interval
} else if (mVelocity.pow(2.0) / (2.0 * DEFAULT_ABS_ACCELERATION) < abs(rotationDiff)) {
// speed up
mAcceleration = accelSign * DEFAULT_ABS_ACCELERATION
mVelocity += mAcceleration * interval
} else {
// slow down
mAcceleration = -1 * accelSign * mVelocity.pow(2.0) / (2.0 * abs(rotationDiff))
mVelocity += mAcceleration * interval
}
val distance = oldVelocity * interval + mAcceleration * interval.pow(2.0) / 2.0
if (abs(distance) > abs(rotationDiff)) {
mAcceleration = 0.0
mCurrentRotation = mTargetRotation
mVelocity = 0.0
} else {
mCurrentRotation += distance
}
}
override val rotation: Double
get() = mCurrentRotation
private fun getRotationInScope(rotationP: Double): Double {
var rotation = rotationP
rotation %= 180.0
return if (abs(rotation) <= 90) {
rotation
} else { // abs(rotation) < 180
if (rotation > 0) {
90 - (rotation - 90)
} else {
-90 - (rotation + 90)
}
}
}
companion object {
private const val DEFAULT_ABS_ACCELERATION = 90.0 / 200.0 / 800.0
}
}

View file

@ -0,0 +1,40 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.ui.theme.weatherView.materialWeatherView
class IntervalComputer {
private var mCurrentTime: Long = 0
private var mLastTime: Long = 0
var interval = 0.0
private set
init {
reset()
}
fun reset() {
mCurrentTime = -1
mLastTime = -1
interval = 0.0
}
fun invalidate() {
mCurrentTime = System.currentTimeMillis()
interval = (if (mLastTime == -1L) 0 else mCurrentTime - mLastTime).toDouble()
mLastTime = mCurrentTime
}
}

View file

@ -0,0 +1,354 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.ui.theme.weatherView.materialWeatherView
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.view.OrientationEventListener
import android.view.View
import androidx.annotation.FloatRange
import androidx.annotation.Size
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.withTranslation
import org.breezyweather.ui.theme.weatherView.WeatherView.WeatherKindRule
import org.breezyweather.ui.theme.weatherView.isLandscape
import org.breezyweather.ui.theme.weatherView.sensorManager
import kotlin.math.abs
import kotlin.math.acos
import kotlin.math.sqrt
@SuppressLint("ViewConstructor")
class MaterialPainterView(
context: Context,
@WeatherKindRule private var weatherKind: Int,
private var daylight: Boolean,
isDrawable: Boolean,
currentScrollRate: Float,
var gravitySensorEnabled: Boolean,
var animatable: Boolean,
) : View(context) {
private var intervalComputer: IntervalComputer? = null
private var impl: MaterialWeatherView.WeatherAnimationImplementor? = null
private var rotators: Array<MaterialWeatherView.RotateController>? = null
private var gravitySensor: Sensor? = null
@Size(2)
private var canvasSize = IntArray(2)
private var rotation2D = 0f
private var rotation3D = 0f
@FloatRange(from = 0.0)
private var lastScrollRate = 0f
@FloatRange(from = 0.0)
var scrollRate = 0f
set(value) {
field = value
if (lastScrollRate >= 1 && field < 1) {
postInvalidate()
}
}
var drawable = false
set(value) {
if (field == value) {
return
}
field = value
if (value) {
resetDrawer()
return
}
context.sensorManager?.unregisterListener(mGravityListener, gravitySensor)
orientationListener.disable()
}
private var hasDrawn = false
private var mDeviceOrientation: DeviceOrientation? = null
private enum class DeviceOrientation {
TOP,
LEFT,
BOTTOM,
RIGHT,
}
private val mGravityListener: SensorEventListener = object : SensorEventListener {
override fun onSensorChanged(ev: SensorEvent) {
// x : (+) fall to the left / (-) fall to the right.
// y : (+) stand / (-) head stand.
// z : (+) look down / (-) look up.
// rotation2D : (+) anticlockwise / (-) clockwise.
// rotation3D : (+) look down / (-) look up.
if (gravitySensorEnabled) {
val aX = ev.values[0]
val aY = ev.values[1]
val aZ = ev.values[2]
val g2D = sqrt((aX * aX + aY * aY).toDouble())
val g3D = sqrt((aX * aX + aY * aY + aZ * aZ).toDouble())
val cos2D = 1.0.coerceAtMost(aY / g2D).coerceAtLeast(-1.0)
val cos3D = 1.0.coerceAtMost(g2D / g3D).coerceAtLeast(-1.0)
rotation2D = Math.toDegrees(acos(cos2D)).toFloat() * if (aX >= 0) 1 else -1
rotation3D = Math.toDegrees(acos(cos3D)).toFloat() * if (aZ >= 0) 1 else -1
when (mDeviceOrientation) {
DeviceOrientation.TOP -> {
// do nothing.
}
DeviceOrientation.LEFT -> {
rotation2D -= 90f
}
DeviceOrientation.RIGHT -> {
rotation2D += 90f
}
DeviceOrientation.BOTTOM -> {
if (rotation2D > 0) {
rotation2D -= 180f
} else {
rotation2D += 180f
}
}
else -> {
// do nothing.
}
}
if (60 < abs(rotation3D) && abs(rotation3D) < 120) {
rotation2D *= (abs(abs(rotation3D) - 90) / 30.0).toFloat()
}
} else {
rotation2D = 0f
rotation3D = 0f
}
}
override fun onAccuracyChanged(sensor: Sensor, i: Int) {
// do nothing.
}
}
private val orientationListener: OrientationEventListener = object : OrientationEventListener(
getContext()
) {
override fun onOrientationChanged(orientation: Int) {
mDeviceOrientation = getDeviceOrientation(orientation)
}
private fun getDeviceOrientation(orientation: Int): DeviceOrientation {
return if (context.isLandscape) {
if (orientation in 1..179) {
DeviceOrientation.RIGHT
} else {
DeviceOrientation.LEFT
}
} else {
if (270 < orientation || orientation < 90) {
DeviceOrientation.TOP
} else {
DeviceOrientation.BOTTOM
}
}
}
}
init {
gravitySensor = context.sensorManager?.getDefaultSensor(Sensor.TYPE_GRAVITY)
val metrics = resources.displayMetrics
canvasSize = intArrayOf(
metrics.widthPixels,
metrics.heightPixels
)
drawable = isDrawable
lastScrollRate = currentScrollRate
scrollRate = currentScrollRate
mDeviceOrientation = DeviceOrientation.TOP
background = getWeatherBackgroundDrawable(weatherKind, daylight)
}
fun update(
@WeatherKindRule weatherKind: Int,
daylight: Boolean,
gravitySensorEnabled: Boolean,
animate: Boolean,
) {
this.weatherKind = weatherKind
this.daylight = daylight
this.gravitySensorEnabled = gravitySensorEnabled
this.animatable = animate
if (drawable) {
setWeatherImplementor()
setIntervalComputer()
postInvalidate()
}
background = getWeatherBackgroundDrawable(weatherKind, daylight)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
if (measuredWidth != 0 && measuredHeight != 0) {
val width = measuredWidth
val height = measuredHeight
if (canvasSize[0] != width || canvasSize[1] != height) {
canvasSize[0] = width
canvasSize[1] = height
setWeatherImplementor()
}
}
}
// this is inefficient for cases when animations are disabled,
// as basic view clears canvas on each call, forcing us to redraw with same data
// maybe use TextureView/SurfaceView or save to bitmap?
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (intervalComputer == null || rotators == null || impl == null) {
return
}
intervalComputer!!.invalidate()
rotators!![0].updateRotation(rotation2D.toDouble(), intervalComputer!!.interval)
rotators!![1].updateRotation(rotation3D.toDouble(), intervalComputer!!.interval)
var interval = intervalComputer!!.interval
if (!animatable) {
if (hasDrawn) {
interval = 0.0
} else {
hasDrawn = true
}
}
impl!!.updateData(
canvasSize,
interval.toLong(),
rotators!![0].rotation.toFloat(),
rotators!![1].rotation.toFloat()
)
if (impl != null && rotators != null) {
canvas.withTranslation(
(measuredWidth - canvasSize[0]) / 2f,
(measuredHeight - canvasSize[1]) / 2f
) {
impl!!.draw(
canvasSize,
this,
scrollRate,
rotators!![0].rotation.toFloat(),
rotators!![1].rotation.toFloat()
)
}
}
if (!drawable) {
return
}
if (lastScrollRate >= 1 && scrollRate >= 1) {
lastScrollRate = scrollRate
setIntervalComputer()
return
}
lastScrollRate = scrollRate
postInvalidate()
}
private fun resetDrawer() {
rotation2D = 0f
rotation3D = 0f
context.sensorManager?.registerListener(
mGravityListener,
gravitySensor,
SensorManager.SENSOR_DELAY_FASTEST
)
if (orientationListener.canDetectOrientation()) {
orientationListener.enable()
}
setWeatherImplementor()
setIntervalComputer()
postInvalidate()
}
private fun getWeatherBackgroundDrawable(
weatherKind: Int,
daylight: Boolean,
) = ResourcesCompat.getDrawable(
resources,
WeatherImplementorFactory.getBackgroundId(weatherKind, daylight),
null
)
private fun setWeatherImplementor() {
hasDrawn = false
impl = WeatherImplementorFactory.getWeatherImplementor(
context,
weatherKind,
daylight,
canvasSize,
animatable
)
rotators = arrayOf(
DelayRotateController(
rotation2D.toDouble()
),
DelayRotateController(
rotation3D.toDouble()
)
)
}
private fun setIntervalComputer() {
if (intervalComputer == null) {
intervalComputer =
IntervalComputer()
} else {
intervalComputer!!.reset()
}
}
}

View file

@ -0,0 +1,114 @@
/*
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.ui.theme.weatherView.materialWeatherView
import android.content.Context
import android.content.res.Configuration
import android.graphics.Color
import androidx.core.graphics.ColorUtils
import org.breezyweather.ui.theme.weatherView.WeatherThemeDelegate
import org.breezyweather.ui.theme.weatherView.WeatherView
import org.breezyweather.ui.theme.weatherView.WeatherView.WeatherKindRule
import org.breezyweather.ui.theme.weatherView.materialWeatherView.implementor.CloudImplementor
import org.breezyweather.ui.theme.weatherView.materialWeatherView.implementor.HailImplementor
import org.breezyweather.ui.theme.weatherView.materialWeatherView.implementor.MeteorShowerImplementor
import org.breezyweather.ui.theme.weatherView.materialWeatherView.implementor.RainImplementor
import org.breezyweather.ui.theme.weatherView.materialWeatherView.implementor.SnowImplementor
import org.breezyweather.ui.theme.weatherView.materialWeatherView.implementor.SunImplementor
import org.breezyweather.ui.theme.weatherView.materialWeatherView.implementor.WindImplementor
class MaterialWeatherThemeDelegate : WeatherThemeDelegate {
companion object {
fun getBrighterColor(color: Int): Int {
val hsv = FloatArray(3)
Color.colorToHSV(color, hsv)
hsv[1] = hsv[1] - 0.25f
hsv[2] = hsv[2] + 0.25f
return Color.HSVToColor(hsv)
}
private fun innerGetBackgroundColor(
@WeatherKindRule weatherKind: Int,
daytime: Boolean,
): Int = when (weatherKind) {
WeatherView.WEATHER_KIND_CLEAR -> if (daytime) {
SunImplementor.themeColor
} else {
MeteorShowerImplementor.themeColor
}
WeatherView.WEATHER_KIND_CLOUD -> CloudImplementor.getThemeColor(CloudImplementor.TYPE_CLOUD, daytime)
WeatherView.WEATHER_KIND_CLOUDY -> CloudImplementor.getThemeColor(CloudImplementor.TYPE_CLOUDY, daytime)
WeatherView.WEATHER_KIND_FOG -> CloudImplementor.getThemeColor(CloudImplementor.TYPE_FOG, daytime)
WeatherView.WEATHER_KIND_HAIL -> HailImplementor.getThemeColor(daytime)
WeatherView.WEATHER_KIND_HAZE -> CloudImplementor.getThemeColor(CloudImplementor.TYPE_HAZE, daytime)
WeatherView.WEATHER_KIND_RAINY -> RainImplementor.getThemeColor(RainImplementor.TYPE_RAIN, daytime)
WeatherView.WEATHER_KIND_SLEET -> RainImplementor.getThemeColor(RainImplementor.TYPE_SLEET, daytime)
WeatherView.WEATHER_KIND_SNOW -> SnowImplementor.getThemeColor(daytime)
WeatherView.WEATHER_KIND_THUNDERSTORM ->
RainImplementor.getThemeColor(RainImplementor.TYPE_THUNDERSTORM, daytime)
WeatherView.WEATHER_KIND_THUNDER -> CloudImplementor.getThemeColor(CloudImplementor.TYPE_THUNDER, daytime)
WeatherView.WEATHER_KIND_WIND -> WindImplementor.getThemeColor(daytime)
else -> Color.TRANSPARENT
}
}
override fun getWeatherView(context: Context): WeatherView = MaterialWeatherView(context)
override fun getThemeColors(
context: Context,
weatherKind: Int,
daylight: Boolean,
): IntArray {
var color = innerGetBackgroundColor(weatherKind, daylight)
if (!daylight) {
color = getBrighterColor(color)
}
return intArrayOf(
color,
color,
ColorUtils.setAlphaComponent(color, (0.5 * 255).toInt())
)
}
override fun isLightBackground(
context: Context,
weatherKind: Int,
daylight: Boolean,
): Boolean {
return daylight &&
(context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) !=
Configuration.UI_MODE_NIGHT_YES
}
override fun getBackgroundColor(
context: Context,
weatherKind: Int,
daylight: Boolean,
): Int {
return innerGetBackgroundColor(weatherKind, daylight)
}
override fun getOnBackgroundColor(
context: Context,
weatherKind: Int,
daylight: Boolean,
): Int {
return if (isLightBackground(context, weatherKind, daylight)) Color.BLACK else Color.WHITE
}
}

View file

@ -0,0 +1,212 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.ui.theme.weatherView.materialWeatherView
import android.animation.Animator
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.content.Context
import android.graphics.Canvas
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.annotation.Size
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import org.breezyweather.ui.theme.weatherView.WeatherView
import org.breezyweather.ui.theme.weatherView.WeatherView.WeatherKindRule
import kotlin.math.min
class MaterialWeatherView(
context: Context,
) : ViewGroup(context), WeatherView {
private var mCurrentView: MaterialPainterView? = null
private var mPreviousView: MaterialPainterView? = null
private var mSwitchAnimator: Animator? = null
@WeatherKindRule
override var weatherKind = 0
private set
private var mDaytime = false
private var mDarkMode = false
private var mFirstCardMarginTop = 0
private var mGravitySensorEnabled: Boolean = true
private var mAnimate: Boolean = true
private var mDrawable: Boolean = false
/**
* This class is used to implement different kinds of weather animations.
*/
abstract class WeatherAnimationImplementor {
abstract fun updateData(
@Size(2) canvasSizes: IntArray,
interval: Long,
rotation2D: Float,
rotation3D: Float,
)
// return true if finish drawing.
abstract fun draw(
@Size(2) canvasSizes: IntArray,
canvas: Canvas,
scrollRate: Float,
rotation2D: Float,
rotation3D: Float,
)
}
abstract class RotateController {
abstract fun updateRotation(rotation: Double, interval: Double)
abstract val rotation: Double
}
init {
setWeather(WeatherView.WEATHER_KIND_NULL, daytime = true, darkMode = false)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
// At what position will animations disappear
for (index in 0 until childCount) {
val child = getChildAt(index)
child.measure(
MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.EXACTLY)
)
}
val insets = ViewCompat.getRootWindowInsets(this)
val i = insets?.getInsets(WindowInsetsCompat.Type.systemBars() + WindowInsetsCompat.Type.displayCutout())
// TODO: Arbitrary value. If too high, will blink. See also #2241 for attempt at a more effective fix
mFirstCardMarginTop = ((i?.top ?: 0) + 500) // 0.66
}
override fun onLayout(b: Boolean, i: Int, i1: Int, i2: Int, i3: Int) {
for (index in 0 until childCount) {
val child = getChildAt(index)
child.layout(
0,
0,
child.measuredWidth,
child.measuredHeight
)
}
}
// interface.
// weather view.
override fun setWeather(
@WeatherKindRule weatherKind: Int,
daytime: Boolean,
darkMode: Boolean,
) {
// do nothing if weather not change.
if (this.weatherKind == weatherKind && mDaytime == daytime && mDarkMode == darkMode) {
return
}
// cache weather state.
this.weatherKind = weatherKind
mDaytime = daytime
mDarkMode = darkMode
// cancel the previous switch animation if necessary.
mSwitchAnimator?.let {
it.cancel()
mSwitchAnimator = null
}
// stop current painting work.
mCurrentView?.let {
it.drawable = false
}
// generate new painter view or update painter cache.
val prev = mPreviousView
mPreviousView = mCurrentView
mCurrentView = prev
mCurrentView?.let {
it.update(weatherKind, daytime, mGravitySensorEnabled, mAnimate)
it.drawable = mDrawable
} ?: run {
mCurrentView = MaterialPainterView(
context,
weatherKind,
daytime,
mDrawable,
mPreviousView?.scrollRate ?: 0f,
mGravitySensorEnabled,
mAnimate
)
addView(mCurrentView)
}
// execute switch animation.
mPreviousView?.let {
mSwitchAnimator = AnimatorSet().apply {
duration = SWITCH_ANIMATION_DURATION
interpolator = AccelerateDecelerateInterpolator()
playTogether(
ObjectAnimator.ofFloat(mCurrentView as MaterialPainterView, "alpha", 0f, 1f),
ObjectAnimator.ofFloat(
mPreviousView as MaterialPainterView,
"alpha",
it.alpha,
0f
)
)
}.also { it.start() }
} ?: run {
mCurrentView?.alpha = 1f
}
}
override fun onScroll(scrollY: Int) {
val scrollRate = min(1.0, 1.0 * scrollY / mFirstCardMarginTop).toFloat()
mCurrentView?.let {
it.scrollRate = scrollRate
}
mPreviousView?.let {
it.scrollRate = scrollRate
}
}
override fun setDrawable(drawable: Boolean) {
if (mDrawable == drawable) {
return
}
mDrawable = drawable
mCurrentView?.let {
it.drawable = drawable
}
mPreviousView?.let {
it.drawable = drawable
}
}
override fun setDoAnimate(animate: Boolean) {
mAnimate = animate
}
override fun setGravitySensorEnabled(enabled: Boolean) {
mGravitySensorEnabled = enabled
}
companion object {
private const val SWITCH_ANIMATION_DURATION: Long = 300
}
}

View file

@ -0,0 +1,174 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.ui.theme.weatherView.materialWeatherView
import android.content.Context
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.annotation.Size
import org.breezyweather.ui.theme.weatherView.R
import org.breezyweather.ui.theme.weatherView.WeatherView
import org.breezyweather.ui.theme.weatherView.WeatherView.WeatherKindRule
import org.breezyweather.ui.theme.weatherView.materialWeatherView.MaterialWeatherView.WeatherAnimationImplementor
import org.breezyweather.ui.theme.weatherView.materialWeatherView.implementor.CloudImplementor
import org.breezyweather.ui.theme.weatherView.materialWeatherView.implementor.HailImplementor
import org.breezyweather.ui.theme.weatherView.materialWeatherView.implementor.MeteorShowerImplementor
import org.breezyweather.ui.theme.weatherView.materialWeatherView.implementor.RainImplementor
import org.breezyweather.ui.theme.weatherView.materialWeatherView.implementor.SnowImplementor
import org.breezyweather.ui.theme.weatherView.materialWeatherView.implementor.SunImplementor
import org.breezyweather.ui.theme.weatherView.materialWeatherView.implementor.WindImplementor
object WeatherImplementorFactory {
fun getWeatherImplementor(
context: Context,
@WeatherKindRule weatherKind: Int,
daytime: Boolean,
@Size(2) sizes: IntArray,
animate: Boolean,
): WeatherAnimationImplementor? {
val darkMode = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
Configuration.UI_MODE_NIGHT_YES
return when (weatherKind) {
WeatherView.WEATHER_KIND_CLEAR -> if (daytime) {
SunImplementor(
sizes,
animate
)
} else {
MeteorShowerImplementor(
sizes,
animate
)
}
WeatherView.WEATHER_KIND_CLOUD ->
CloudImplementor(
sizes,
animate,
CloudImplementor.TYPE_CLOUD,
daytime,
darkMode
)
WeatherView.WEATHER_KIND_CLOUDY ->
CloudImplementor(
sizes,
animate,
CloudImplementor.TYPE_CLOUDY,
daytime,
darkMode
)
WeatherView.WEATHER_KIND_FOG ->
CloudImplementor(
sizes,
animate,
CloudImplementor.TYPE_FOG,
daytime,
darkMode
)
WeatherView.WEATHER_KIND_HAZE ->
CloudImplementor(
sizes,
animate,
CloudImplementor.TYPE_HAZE,
daytime,
darkMode
)
WeatherView.WEATHER_KIND_RAINY ->
RainImplementor(
sizes,
animate,
RainImplementor.TYPE_RAIN,
daytime
)
WeatherView.WEATHER_KIND_SLEET ->
RainImplementor(
sizes,
animate,
RainImplementor.TYPE_SLEET,
daytime
)
WeatherView.WEATHER_KIND_SNOW ->
SnowImplementor(
sizes,
animate,
daytime
)
WeatherView.WEATHER_KIND_HAIL ->
HailImplementor(
sizes,
animate,
daytime
)
WeatherView.WEATHER_KIND_THUNDERSTORM ->
RainImplementor(
sizes,
animate,
RainImplementor.TYPE_THUNDERSTORM,
daytime
)
WeatherView.WEATHER_KIND_THUNDER ->
CloudImplementor(
sizes,
animate,
CloudImplementor.TYPE_THUNDER,
daytime
)
WeatherView.WEATHER_KIND_WIND ->
WindImplementor(
sizes,
animate,
daytime
)
else -> null
}
}
@DrawableRes
fun getBackgroundId(
@WeatherKindRule weatherKind: Int,
daylight: Boolean,
): Int = when (weatherKind) {
WeatherView.WEATHER_KIND_CLEAR -> if (daylight) {
R.drawable.weather_background_clear_day
} else {
R.drawable.weather_background_clear_night
}
WeatherView.WEATHER_KIND_CLOUD -> R.drawable.weather_background_partly_cloudy
WeatherView.WEATHER_KIND_CLOUDY -> R.drawable.weather_background_cloudy
WeatherView.WEATHER_KIND_FOG -> R.drawable.weather_background_fog
WeatherView.WEATHER_KIND_HAIL -> R.drawable.weather_background_hail
WeatherView.WEATHER_KIND_HAZE -> R.drawable.weather_background_haze
WeatherView.WEATHER_KIND_RAINY -> R.drawable.weather_background_rain
WeatherView.WEATHER_KIND_SLEET -> R.drawable.weather_background_sleet
WeatherView.WEATHER_KIND_SNOW -> R.drawable.weather_background_snow
WeatherView.WEATHER_KIND_THUNDER, WeatherView.WEATHER_KIND_THUNDERSTORM -> R.drawable.weather_background_thunder
WeatherView.WEATHER_KIND_WIND -> R.drawable.weather_background_wind
else -> R.drawable.weather_background_default
}
}

View file

@ -0,0 +1,603 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.ui.theme.weatherView.materialWeatherView.implementor
import android.annotation.SuppressLint
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import androidx.annotation.ColorInt
import androidx.annotation.IntDef
import androidx.annotation.Size
import androidx.core.graphics.toColorInt
import org.breezyweather.ui.theme.weatherView.materialWeatherView.MaterialWeatherView.WeatherAnimationImplementor
import java.util.Random
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
@SuppressLint("SwitchIntDef")
class CloudImplementor(
@Size(2) canvasSizes: IntArray,
animate: Boolean,
@TypeRule type: Int,
daylight: Boolean,
darkMode: Boolean = !daylight,
) : WeatherAnimationImplementor() {
private val mAnimate = animate
private var mPaint = Paint().apply {
style = Paint.Style.FILL
isAntiAlias = true
}
private var mClouds: Array<Cloud> = emptyArray()
private var mStars: Array<Star> = emptyArray()
private var mThunder: Thunder? = null
private val mRandom: Random
@IntDef(TYPE_CLOUD, TYPE_CLOUDY, TYPE_THUNDER, TYPE_FOG, TYPE_HAZE)
internal annotation class TypeRule
private class Cloud(
private val mInitCX: Float,
private val mInitCY: Float,
var radius: Float,
val scaleRatio: Float,
val moveFactor: Float,
@ColorInt val color: Int,
val alpha: Float,
val duration: Long,
initProgress: Long,
) {
var centerX: Float = mInitCX
var centerY: Float = mInitCY
var initRadius: Float = radius
var progress: Long = initProgress % duration
init {
computeRadius(duration, progress)
}
fun move(interval: Long, rotation2D: Float, rotation3D: Float) {
centerX = (mInitCX + sin(rotation2D * Math.PI / 180.0) * 0.40 * radius * moveFactor).toFloat()
centerY = (mInitCY - sin(rotation3D * Math.PI / 180.0) * 0.50 * radius * moveFactor).toFloat()
progress = (progress + interval) % duration
computeRadius(duration, progress)
}
private fun computeRadius(duration: Long, progress: Long) {
radius = if (progress < 0.5 * duration) {
(initRadius * (1 + (scaleRatio - 1) * progress / 0.5 / duration)).toFloat()
} else {
(initRadius * (scaleRatio - (scaleRatio - 1) * (progress - 0.5 * duration) / 0.5 / duration)).toFloat()
}
}
}
private class Star(
val centerX: Float,
val centerY: Float,
radius: Float,
@field:ColorInt @param:ColorInt val color: Int,
val duration: Long,
val animate: Boolean,
) {
var radius: Float
var alpha = 0f
var progress: Long = 0
init {
this.radius = (radius * (0.7 + 0.3 * Random().nextFloat())).toFloat()
if (!animate) {
alpha = Random().nextFloat()
} else {
computeAlpha(duration, progress)
}
}
fun shine(interval: Long) {
if (!animate) return
progress = (progress + interval) % duration
computeAlpha(duration, progress)
}
private fun computeAlpha(duration: Long, progress: Long) {
alpha = if (progress < 0.5 * duration) {
(progress / 0.5 / duration).toFloat()
} else {
(1 - (progress - 0.5 * duration) / 0.5 / duration).toFloat()
}
}
}
private class Thunder {
var r = 81
var g = 67
var b = 168
var alpha = 0f
private var progress: Long = 0
private var duration: Long = 0
private var delay: Long = 0
init {
init()
computeFrame()
}
private fun init() {
progress = 0
duration = 300
delay = (Random().nextInt(5000) + 2000).toLong()
}
private fun computeFrame() {
alpha = if (progress < duration) {
if (progress < 0.25 * duration) {
(progress / 0.25 / duration).toFloat()
} else if (progress < 0.5 * duration) {
(1 - (progress - 0.25 * duration) / 0.25 / duration).toFloat()
} else if (progress < 0.75 * duration) {
((progress - 0.5 * duration) / 0.25 / duration).toFloat()
} else {
(1 - (progress - 0.75 * duration) / 0.25 / duration).toFloat()
}
} else {
0f
}
}
fun shine(interval: Long) {
progress += interval
if (progress > duration + delay) {
init()
}
computeFrame()
}
}
init {
val viewWidth = canvasSizes[0]
val viewHeight = canvasSizes[1]
mRandom = Random()
if (type == TYPE_FOG || type == TYPE_HAZE) {
val cloudColors = if (type == TYPE_FOG) {
if (daylight && !darkMode) {
intArrayOf(
"#93B9FF".toColorInt(), // -0x8e8260,
"#93B9FF".toColorInt(), // -0x8e8260,
"#93B9FF".toColorInt() // -0x8e8260
)
} else {
intArrayOf(
"#2A5476".toColorInt(), // Color.rgb(85, 99, 110),
"#2A5476".toColorInt(), // Color.rgb(91, 104, 114),
"#2A5476".toColorInt() // Color.rgb(99, 113, 123)
)
}
} else {
if (daylight && !darkMode) {
intArrayOf(
"#FFDDA1".toColorInt(), // -0x53627e
"#FFDDA1".toColorInt(), // -0x53627e
"#FFDDA1".toColorInt() // -0x53627e
)
} else {
intArrayOf(
"#4D3314".toColorInt(), // Color.rgb(179, 158, 132)
"#4D3314".toColorInt(), // Color.rgb(179, 158, 132)
"#4D3314".toColorInt() // Color.rgb(179, 158, 132)
)
}
}
val cloudAlphas = floatArrayOf(0.3f, 0.3f, 0.3f)
val clouds = arrayOf(
Cloud(
viewWidth * 1.0699f,
viewWidth * (1.1900f * 0.2286f + 0.11f),
viewWidth * (0.4694f * 0.9f),
1.1f,
getRandomFactor(1.3f, 1.8f),
cloudColors[0],
cloudAlphas[0],
9000,
0
),
Cloud(
viewWidth * 0.4866f,
viewWidth * (0.4866f * 0.6064f + 0.085f),
viewWidth * (0.3946f * 0.9f),
1.1f,
getRandomFactor(1.3f, 1.8f),
cloudColors[0],
cloudAlphas[0],
10500,
0
),
Cloud(
viewWidth * 0.0351f,
viewWidth * (0.1701f * 1.4327f + 0.11f),
viewWidth * (0.4627f * 0.9f),
1.1f,
getRandomFactor(1.3f, 1.8f),
cloudColors[0],
cloudAlphas[0],
9000,
0
),
Cloud(
viewWidth * 0.8831f,
viewWidth * (1.0270f * 0.1671f + 0.07f),
viewWidth * (0.3238f * 0.9f),
1.15f,
getRandomFactor(1.6f, 2f),
cloudColors[1],
cloudAlphas[1],
7000,
0
),
Cloud(
viewWidth * 0.4663f,
viewWidth * (0.4663f * 0.3520f + 0.050f),
viewWidth * (0.2906f * 0.9f),
1.15f,
getRandomFactor(1.6f, 2f),
cloudColors[1],
cloudAlphas[1],
8500,
0
),
Cloud(
viewWidth * 0.1229f,
viewWidth * (0.0234f * 5.7648f + 0.07f),
viewWidth * (0.2972f * 0.9f),
1.15f,
getRandomFactor(1.6f, 2f),
cloudColors[1],
cloudAlphas[1],
7000,
0
),
Cloud(
viewWidth * 0.9250f,
viewWidth * (0.9250f * 0.0249f + 0.1500f),
viewWidth * 0.3166f,
1.15f,
getRandomFactor(1.8f, 2.2f),
cloudColors[2],
cloudAlphas[2],
7000,
0
),
Cloud(
viewWidth * 0.4694f,
viewWidth * (0.4694f * 0.0489f + 0.1500f),
viewWidth * 0.3166f,
1.15f,
getRandomFactor(1.8f, 2.2f),
cloudColors[2],
cloudAlphas[2],
8200,
0
),
Cloud(
viewWidth * 0.0250f,
viewWidth * (0.0250f * 0.6820f + 0.1500f),
viewWidth * 0.3166f,
1.15f,
getRandomFactor(1.8f, 2.2f),
cloudColors[2],
cloudAlphas[2],
7700,
0
)
)
initialize(clouds)
} else if (type == TYPE_CLOUDY || type == TYPE_THUNDER) {
var cloudColors = IntArray(2)
var cloudAlphas = FloatArray(2)
when (type) {
TYPE_CLOUDY -> {
cloudColors = if (daylight && !darkMode) {
intArrayOf(
Color.rgb(160, 179, 191),
Color.rgb(160, 179, 191)
)
} else {
intArrayOf(
Color.rgb(95, 104, 108),
Color.rgb(95, 104, 108)
)
}
cloudAlphas = floatArrayOf(0.3f, 0.3f)
}
TYPE_THUNDER -> {
cloudColors = if (daylight && !darkMode) {
intArrayOf(
"#AB90DB".toColorInt(), // -0x43523f,
"#AB90DB".toColorInt() // -0x43523f
)
} else {
intArrayOf(
"#2C1C4D".toColorInt(), // Color.rgb(43, 30, 66),
"#2C1C4D".toColorInt() // Color.rgb(43, 30, 66)
)
}
cloudAlphas = floatArrayOf(0.3f, 0.3f)
// cloudAlphas = if (daylight && !darkMode) floatArrayOf(0.2f, 0.3f) else floatArrayOf(0.8f, 0.8f)
}
}
val clouds = arrayOf(
Cloud(
viewWidth * 1.0699f,
viewWidth * (1.1900f * 0.2286f + 0.11f),
viewWidth * (0.4694f * 0.9f),
1.1f,
getRandomFactor(1.3f, 1.8f),
cloudColors[0],
cloudAlphas[0],
9000,
0
),
Cloud(
viewWidth * 0.4866f,
viewWidth * (0.4866f * 0.6064f + 0.085f),
viewWidth * (0.3946f * 0.9f),
1.1f,
getRandomFactor(1.3f, 1.8f),
cloudColors[0],
cloudAlphas[0],
10500,
0
),
Cloud(
viewWidth * 0.0351f,
viewWidth * (0.1701f * 1.4327f + 0.11f),
viewWidth * (0.4627f * 0.9f),
1.1f,
getRandomFactor(1.3f, 1.8f),
cloudColors[0],
cloudAlphas[0],
9000,
0
),
Cloud(
viewWidth * 0.8831f,
viewWidth * (1.0270f * 0.1671f + 0.07f),
viewWidth * (0.3238f * 0.9f),
1.15f,
getRandomFactor(1.6f, 2f),
cloudColors[1],
cloudAlphas[1],
7000,
0
),
Cloud(
viewWidth * 0.4663f,
viewWidth * (0.4663f * 0.3520f + 0.050f),
viewWidth * (0.2906f * 0.9f),
1.15f,
getRandomFactor(1.6f, 2f),
cloudColors[1],
cloudAlphas[1],
8500,
0
),
Cloud(
viewWidth * 0.1229f,
viewWidth * (0.0234f * 5.7648f + 0.07f),
viewWidth * (0.2972f * 0.9f),
1.15f,
getRandomFactor(1.6f, 2f),
cloudColors[1],
cloudAlphas[1],
7000,
0
)
)
initialize(clouds)
} else {
val cloudColor = if (daylight && !darkMode) {
Color.rgb(203, 245, 255)
} else {
Color.rgb(151, 168, 202)
}
val cloudAlphas: FloatArray = floatArrayOf(0.40f, 0.10f)
val clouds = arrayOf(
Cloud(
(viewWidth * 0.1529).toFloat(),
(viewWidth * 0.1529 * 0.5568 + viewWidth * 0.050).toFloat(),
(viewWidth * 0.2649).toFloat(),
1.20f,
getRandomFactor(1.5f, 1.8f),
cloudColor,
cloudAlphas[0],
7000,
0
),
Cloud(
(viewWidth * 0.4793).toFloat(),
(viewWidth * 0.4793 * 0.2185 + viewWidth * 0.050).toFloat(),
(viewWidth * 0.2426).toFloat(),
1.20f,
getRandomFactor(1.5f, 1.8f),
cloudColor,
cloudAlphas[0],
8500,
0
),
Cloud(
(viewWidth * 0.8531).toFloat(),
(viewWidth * 0.8531 * 0.1286 + viewWidth * 0.050).toFloat(),
(viewWidth * 0.2970).toFloat(),
1.20f,
getRandomFactor(1.5f, 1.8f),
cloudColor,
cloudAlphas[0],
7050,
0
),
Cloud(
(viewWidth * 0.0551).toFloat(),
(viewWidth * 0.0551 * 2.8600 + viewWidth * 0.050).toFloat(),
(viewWidth * 0.4125).toFloat(),
1.15f,
getRandomFactor(1.3f, 1.5f),
cloudColor,
cloudAlphas[1],
9500,
0
),
Cloud(
(viewWidth * 0.4928).toFloat(),
(viewWidth * 0.4928 * 0.3897 + viewWidth * 0.050).toFloat(),
(viewWidth * 0.3521).toFloat(),
1.15f,
getRandomFactor(1.3f, 1.5f),
cloudColor,
cloudAlphas[1],
10500,
0
),
Cloud(
(viewWidth * 1.0499).toFloat(),
(viewWidth * 1.0499 * 0.1875 + viewWidth * 0.050).toFloat(),
(viewWidth * 0.4186).toFloat(),
1.15f,
getRandomFactor(1.3f, 1.5f),
cloudColor,
cloudAlphas[1],
9000,
0
)
)
if (daylight) {
initialize(clouds)
} else {
val colors = intArrayOf(
Color.rgb(210, 247, 255),
Color.rgb(208, 233, 255),
Color.rgb(175, 201, 228),
Color.rgb(164, 194, 220),
Color.rgb(97, 171, 220),
Color.rgb(74, 141, 193),
Color.rgb(54, 66, 119),
Color.rgb(34, 48, 74),
Color.rgb(236, 234, 213),
Color.rgb(240, 220, 151)
)
val r = Random()
val canvasSize = sqrt(viewWidth.toDouble().pow(2.0) + viewHeight.toDouble().pow(2.0)).toInt()
val width = (1.0 * canvasSize).toInt()
val height = ((canvasSize - viewHeight) * 0.5 + viewWidth * 1.1111).toInt()
val radius = (0.00125 * canvasSize * (0.5 + r.nextFloat())).toFloat()
val stars = Array(50) { i ->
val x = (r.nextInt(width) - 0.5 * (canvasSize - viewWidth)).toInt()
val y = (r.nextInt(height) - 0.5 * (canvasSize - viewHeight)).toInt()
val duration = (2500 + r.nextFloat() * 2500).toLong()
Star(
x.toFloat(),
y.toFloat(),
radius,
colors[i % colors.size],
duration,
mAnimate
)
}
initialize(clouds, stars)
}
}
mThunder = if (type == TYPE_THUNDER) Thunder() else null
}
private fun initialize(clouds: Array<Cloud>, stars: Array<Star> = emptyArray()) {
mPaint = Paint().apply {
style = Paint.Style.FILL
isAntiAlias = true
}
mClouds = clouds
mStars = stars
}
private fun getRandomFactor(from: Float, to: Float): Float {
return from + mRandom.nextFloat() % (to - from)
}
override fun updateData(
@Size(2) canvasSizes: IntArray,
interval: Long,
rotation2D: Float,
rotation3D: Float,
) {
for (c in mClouds) {
c.move(interval, rotation2D, rotation3D)
}
for (s in mStars) {
s.shine(interval)
}
mThunder?.shine(interval)
}
override fun draw(
@Size(2) canvasSizes: IntArray,
canvas: Canvas,
scrollRate: Float,
rotation2D: Float,
rotation3D: Float,
) {
if (scrollRate < 1) {
mThunder?.let {
canvas.drawColor(
Color.argb(
((1 - scrollRate) * it.alpha * 255 * 0.66).toInt(),
it.r,
it.g,
it.b
)
)
}
for (s in mStars) {
mPaint.color = s.color
mPaint.alpha = ((1 - scrollRate) * s.alpha * 255).toInt()
canvas.drawCircle(s.centerX, s.centerY, s.radius, mPaint)
}
for (c in mClouds) {
mPaint.color = c.color
mPaint.alpha = ((1 - scrollRate) * c.alpha * 255).toInt()
canvas.drawCircle(c.centerX, c.centerY, c.radius, mPaint)
}
}
}
companion object {
const val TYPE_CLOUD = 1
const val TYPE_CLOUDY = 3
const val TYPE_THUNDER = 5
const val TYPE_FOG = 6
const val TYPE_HAZE = 7
@ColorInt
fun getThemeColor(@TypeRule type: Int, daylight: Boolean): Int {
return when (type) {
TYPE_CLOUDY -> if (daylight) -0x62503f else -0xd9cdc8
TYPE_THUNDER -> if (daylight) -0x4d6943 else -0xdce8c7
TYPE_FOG -> if (daylight) -0x5c513e else -0xb0a298
TYPE_HAZE -> if (daylight) -0x1e3767 else -0x93a3b7
// TYPE_CLOUD:
else -> if (daylight) -0xff5a27 else -0xddd2bd
}
}
}
}

View file

@ -0,0 +1,175 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.ui.theme.weatherView.materialWeatherView.implementor
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import androidx.annotation.ColorInt
import androidx.annotation.Size
import org.breezyweather.ui.theme.weatherView.materialWeatherView.MaterialWeatherView.WeatherAnimationImplementor
import java.util.Random
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.sin
/**
* Hail implementor.
*/
class HailImplementor(
@Size(2) canvasSizes: IntArray,
animate: Boolean,
daylight: Boolean,
) : WeatherAnimationImplementor() {
private val mAnimate = animate
private val mPaint = Paint().apply {
style = Paint.Style.FILL
isAntiAlias = true
}
private val mHails: Array<Hail>
private var mLastRotation3D: Float
private class Hail(
private val mViewWidth: Int,
private val mViewHeight: Int,
@field:ColorInt @param:ColorInt val color: Int,
val scale: Float,
) {
var cx = 0f
var cy = 0f
var centerX = 0f
var centerY = 0f
var size: Float
var rotation = 0f
var speedY: Float
var speedX = 0f
var speedRotation = 0f
var rectF = RectF()
private val mCanvasSize: Int
init {
mCanvasSize = (mViewWidth * mViewWidth + mViewHeight * mViewHeight).toDouble().pow(0.5).toInt()
size = (0.0324 * min(mViewWidth, mViewHeight)).toFloat() * 0.8f
speedY = min(mViewWidth, mViewHeight) / 150f
init(true)
}
private fun init(firstTime: Boolean) {
val r = Random()
cx = r.nextInt(mCanvasSize).toFloat()
cy = if (firstTime) {
(r.nextInt((mCanvasSize - size).toInt()) - mCanvasSize).toFloat()
} else {
-size
}
rotation = 360 * r.nextFloat()
speedRotation = 360f / 500f * r.nextFloat()
speedX = 0.75f * (r.nextFloat() * speedY * if (r.nextBoolean()) 1 else -1)
computeCenterPosition()
}
private fun computeCenterPosition() {
centerX = (cx - (mCanvasSize - mViewWidth) * 0.5).toFloat()
centerY = (cy - (mCanvasSize - mViewHeight) * 0.5).toFloat()
}
fun move(interval: Long, deltaRotation3D: Float) {
cx += (speedX * interval * scale.toDouble().pow(1.5)).toFloat()
cy +=
(speedY * interval * (scale.toDouble().pow(1.5) - 5 * sin(deltaRotation3D * Math.PI / 180.0))).toFloat()
rotation = (rotation + speedRotation * interval) % 360
if (cy - size >= mCanvasSize) {
init(false)
} else {
computeCenterPosition()
}
rectF.set(cx - size * scale, cy - size * scale, cx + size * scale, cy + size * scale)
}
}
init {
val colors: IntArray = if (daylight) {
intArrayOf(
Color.rgb(128, 197, 255),
Color.rgb(185, 222, 255),
Color.rgb(255, 255, 255)
)
} else {
intArrayOf(
Color.rgb(40, 102, 155),
Color.rgb(99, 144, 182),
Color.rgb(255, 255, 255)
)
}
val scales = floatArrayOf(0.6f, 0.8f, 1f)
mHails = Array(HAIL_COUNT) { i ->
Hail(
canvasSizes[0],
canvasSizes[1],
colors[i * 3 / HAIL_COUNT],
scales[i * 3 / HAIL_COUNT]
)
}
mLastRotation3D = INITIAL_ROTATION_3D
}
override fun updateData(
@Size(2) canvasSizes: IntArray,
interval: Long,
rotation2D: Float,
rotation3D: Float,
) {
for (h in mHails) {
h.move(interval, if (mLastRotation3D == INITIAL_ROTATION_3D) 0f else rotation3D - mLastRotation3D)
}
mLastRotation3D = rotation3D
}
override fun draw(
@Size(2) canvasSizes: IntArray,
canvas: Canvas,
scrollRate: Float,
rotation2D: Float,
rotation3D: Float,
) {
if (scrollRate < 1) {
canvas.rotate(
rotation2D,
canvasSizes[0] * 0.5f,
canvasSizes[1] * 0.5f
)
for (h in mHails) {
mPaint.color = h.color
mPaint.alpha = ((1 - scrollRate) * 255).toInt()
canvas.rotate(h.rotation, h.cx, h.cy)
canvas.drawRect(h.rectF, mPaint)
canvas.rotate(-h.rotation, h.cx, h.cy)
}
}
}
companion object {
private const val INITIAL_ROTATION_3D = 1000f
private const val HAIL_COUNT = 51
@ColorInt
fun getThemeColor(daylight: Boolean): Int {
return if (daylight) -0x974501 else -0xe5a46e
}
}
}

View file

@ -0,0 +1,269 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.ui.theme.weatherView.materialWeatherView.implementor
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import androidx.annotation.ColorInt
import androidx.annotation.Size
import org.breezyweather.ui.theme.weatherView.materialWeatherView.MaterialWeatherView.WeatherAnimationImplementor
import java.util.Random
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
import kotlin.time.Duration.Companion.seconds
/**
* Meteor shower implementor.
*/
class MeteorShowerImplementor(
@Size(2) canvasSizes: IntArray,
animate: Boolean,
) : WeatherAnimationImplementor() {
private val mAnimate = animate
private val mPaint = Paint().apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
isAntiAlias = true
}
private val mMeteors: Array<Meteor>
private val mStars: Array<Star>
private var mLastRotation3D: Float
private class Meteor(
private val mViewWidth: Int,
private val mViewHeight: Int,
@ColorInt val color: Int,
val scale: Float,
) {
var x = 0f
var y = 0f
var width: Float
var height = 0f
var rectF: RectF = RectF()
var speed: Float
private var progress: Long = 0
private var delay: Long = 0
private val random: Random = Random()
private val mCanvasSize: Int
private val maxHeight: Float
private val minHeight: Float
init { // 1, 0.7, 0.4
mCanvasSize = (mViewWidth * mViewWidth + mViewHeight * mViewHeight).toDouble().pow(0.5).toInt()
width = (mViewWidth * 0.0088 * scale).toFloat()
speed = mViewWidth / 200f
maxHeight = (1.1 * mViewWidth / cos(60.0 * Math.PI / 180.0)).toFloat()
minHeight = (maxHeight * 0.7).toFloat()
init(true)
}
private fun init(firstTime: Boolean) {
progress = 0
delay = (random.nextInt(METEOR_REVIVE_SEC_MAX - METEOR_REVIVE_SEC_MIN) + METEOR_REVIVE_SEC_MIN)
.seconds.inWholeMilliseconds
x = random.nextInt(mCanvasSize).toFloat()
y = if (!firstTime) {
random.nextInt(mCanvasSize) - maxHeight - mCanvasSize
} else {
// prevents spawning all at once
mCanvasSize.toFloat() * 2
}
height = minHeight + random.nextFloat() * (maxHeight - minHeight)
buildRectF()
}
private fun buildRectF() {
val x = (x - (mCanvasSize - mViewWidth) * 0.5).toFloat()
val y = (y - (mCanvasSize - mViewHeight) * 0.5).toFloat()
rectF.set(x, y, x + width, y + height)
}
fun update(interval: Long, deltaRotation3D: Float) {
if (y > mCanvasSize) {
progress += interval
if (progress > delay) init(false)
return
}
move(interval, deltaRotation3D)
buildRectF()
}
private fun move(interval: Long, deltaRotation3D: Float) {
x -= (speed * interval * 5 * sin(deltaRotation3D * Math.PI / 180.0) * cos(60 * Math.PI / 180.0))
.toFloat()
y += speed
.times(interval)
.times(
scale.toDouble().pow(0.5) - 5 * sin(deltaRotation3D * Math.PI / 180.0) * sin(60 * Math.PI / 180.0)
).toFloat()
}
}
private class Star(
var centerX: Float,
var centerY: Float,
radius: Float,
@field:ColorInt @param:ColorInt var color: Int,
var duration: Long,
) {
var radius: Float
var alpha = 0f
var progress: Long = 0
init {
this.radius = (radius * (0.7 + 0.3 * Random().nextFloat())).toFloat()
computeAlpha(duration, progress)
}
fun shine(interval: Long) {
progress = (progress + interval) % duration
computeAlpha(duration, progress)
}
private fun computeAlpha(duration: Long, progress: Long) {
alpha = if (progress < 0.5 * duration) {
(progress / 0.5 / duration).toFloat()
} else {
(1 - (progress - 0.5 * duration) / 0.5 / duration).toFloat()
}
alpha = alpha * 0.66f + 0.33f
}
}
init {
val random = Random()
val viewWidth = canvasSizes[0]
val viewHeight = canvasSizes[1]
val colors = intArrayOf(
Color.rgb(210, 247, 255),
Color.rgb(208, 233, 255),
Color.rgb(175, 201, 228),
Color.rgb(164, 194, 220),
Color.rgb(97, 171, 220),
Color.rgb(74, 141, 193),
Color.rgb(54, 66, 119),
Color.rgb(34, 48, 74),
Color.rgb(236, 234, 213),
Color.rgb(240, 220, 151)
)
mMeteors = Array(if (mAnimate) 10 else 0) {
Meteor(
viewWidth,
viewHeight,
colors[random.nextInt(colors.size)],
random.nextFloat()
)
}
val canvasSize = (viewWidth.toDouble().pow(2.0) + viewHeight.toDouble().pow(2.0)).pow(0.5).toInt()
val width = (1.0 * canvasSize).toInt()
val height = ((canvasSize - viewHeight) * 0.5 + viewWidth * 1.1111).toInt()
val radius = (0.00125 * canvasSize * (0.5 + random.nextFloat())).toFloat()
mStars = Array(70) { i ->
val x = (random.nextInt(width) - 0.5 * (canvasSize - viewWidth)).toInt()
val y = (random.nextInt(height) - 0.5 * (canvasSize - viewHeight)).toInt()
val duration = (2500 + random.nextFloat() * 2500).toLong()
Star(
x.toFloat(),
y.toFloat(),
radius,
colors[i % colors.size],
duration
)
}
mLastRotation3D = INITIAL_ROTATION_3D
}
override fun updateData(
@Size(2) canvasSizes: IntArray,
interval: Long,
rotation2D: Float,
rotation3D: Float,
) {
for (m in mMeteors) {
m.update(
interval,
if (mLastRotation3D == INITIAL_ROTATION_3D) 0f else rotation3D - mLastRotation3D
)
}
for (s in mStars) {
s.shine(interval)
}
mLastRotation3D = rotation3D
}
override fun draw(
@Size(2) canvasSizes: IntArray,
canvas: Canvas,
scrollRate: Float,
rotation2D: Float,
rotation3D: Float,
) {
if (scrollRate < 1) {
canvas.rotate(
rotation2D,
canvasSizes[0] * 0.5f,
canvasSizes[1] * 0.5f
)
for (s in mStars) {
mPaint.apply {
color = s.color
alpha = ((1 - scrollRate) * s.alpha * 255).toInt()
strokeWidth = s.radius * 2
}
canvas.drawPoint(s.centerX, s.centerY, mPaint)
}
canvas.rotate(
60f,
canvasSizes[0] * 0.5f,
canvasSizes[1] * 0.5f
)
for (m in mMeteors) {
mPaint.apply {
color = m.color
strokeWidth = m.rectF.width()
alpha = ((1 - scrollRate) * 255).toInt()
}
canvas.drawLine(
m.rectF.centerX(),
m.rectF.top,
m.rectF.centerX(),
m.rectF.bottom,
mPaint
)
}
}
}
companion object {
private const val INITIAL_ROTATION_3D = 1000f
private const val METEOR_REVIVE_SEC_MIN = 5
private const val METEOR_REVIVE_SEC_MAX = 25
@get:ColorInt
val themeColor: Int
get() = Color.rgb(20, 28, 44)
}
}

View file

@ -0,0 +1,308 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.ui.theme.weatherView.materialWeatherView.implementor
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import androidx.annotation.ColorInt
import androidx.annotation.IntDef
import androidx.annotation.Size
import org.breezyweather.ui.theme.weatherView.materialWeatherView.MaterialWeatherView.WeatherAnimationImplementor
import java.util.Random
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
/**
* Rain implementor.
*/
class RainImplementor(
@Size(2) canvasSizes: IntArray,
animate: Boolean,
@TypeRule type: Int,
daylight: Boolean,
) : WeatherAnimationImplementor() {
private val mAnimate = animate
private val mPaint = Paint().apply {
style = Paint.Style.FILL
isAntiAlias = true
}
private val mRains: Array<Rain>
private var mThunder: Thunder? = null
private var mLastRotation3D: Float
@IntDef(TYPE_RAIN, TYPE_THUNDERSTORM, TYPE_SLEET)
internal annotation class TypeRule
private class Rain(
private val mViewWidth: Int,
private val mViewHeight: Int,
@ColorInt val color: Int,
val scale: Float,
@TypeRule type: Int,
) {
var x = 0f
var y = 0f
var width = 0f
var height = 0f
var rectF: RectF = RectF()
var speed: Float
private val mCanvasSize: Int
private val maxWidth: Float
private val minWidth: Float
private val maxHeight: Float
private val minHeight: Float
init {
mCanvasSize = (mViewWidth * mViewWidth + mViewHeight * mViewHeight).toDouble().pow(0.5).toInt()
val velocity = if (type == TYPE_SLEET) 3.0 else 5.0
speed = (mCanvasSize / (1000.0 * (1.75 + Random().nextDouble())) * velocity).toFloat()
maxWidth = ((if (type == TYPE_SLEET) 0.006 else 0.003) * mCanvasSize).toFloat()
minWidth = ((if (type == TYPE_SLEET) 0.004 else 0.002) * mCanvasSize).toFloat()
maxHeight = maxWidth * 10
minHeight = minWidth * 6
init(true)
}
private fun init(firstTime: Boolean) {
val r = Random()
x = r.nextInt(mCanvasSize).toFloat()
y = if (firstTime) {
(r.nextInt((mCanvasSize - maxHeight).toInt()) - mCanvasSize).toFloat()
} else {
-maxHeight * (1 + 2 * r.nextFloat())
}
width = minWidth + r.nextFloat() * (maxWidth - minWidth)
height = minHeight + r.nextFloat() * (maxHeight - minHeight)
buildRectF()
}
private fun buildRectF() {
val x = (x - (mCanvasSize - mViewWidth) * 0.5).toFloat()
val y = (y - (mCanvasSize - mViewHeight) * 0.5).toFloat()
rectF.set(x, y, x + width * scale, y + height * scale)
}
fun move(interval: Long, deltaRotation3D: Float) {
y += speed
.times(interval)
.times(
scale.toDouble().pow(1.5) - 5 * sin(deltaRotation3D * Math.PI / 180.0) * cos(8 * Math.PI / 180.0)
).toFloat()
x -= (speed * interval * 5 * sin(deltaRotation3D * Math.PI / 180.0) * sin(8 * Math.PI / 180.0))
.toFloat()
if (y >= mCanvasSize) {
init(false)
} else {
buildRectF()
}
}
}
private class Thunder {
var r = 81
var g = 67
var b = 168
var alpha = 0f
private var progress: Long = 0
private var duration: Long = 0
private var delay: Long = 0
init {
init()
computeFrame()
}
private fun init() {
progress = 0
duration = 300
delay = (Random().nextInt(5000) + 3000).toLong()
}
private fun computeFrame() {
alpha = if (progress < duration) {
if (progress < 0.25 * duration) {
(progress / 0.25 / duration).toFloat()
} else if (progress < 0.5 * duration) {
(1 - (progress - 0.25 * duration) / 0.25 / duration).toFloat()
} else if (progress < 0.75 * duration) {
((progress - 0.5 * duration) / 0.25 / duration).toFloat()
} else {
(1 - (progress - 0.75 * duration) / 0.25 / duration).toFloat()
}
} else {
0f
}
}
fun shine(interval: Long) {
progress += interval
if (progress > duration + delay) {
init()
}
computeFrame()
}
}
init {
if (mAnimate) {
var colors = IntArray(3)
var rainCount = RAIN_COUNT
when (type) {
TYPE_RAIN -> if (daylight) {
rainCount = RAIN_COUNT
mThunder = null
colors = intArrayOf(
Color.rgb(223, 179, 114),
Color.rgb(152, 175, 222),
Color.rgb(255, 255, 255)
)
} else {
rainCount = RAIN_COUNT
mThunder = null
colors = intArrayOf(
Color.rgb(182, 142, 82),
Color.rgb(88, 92, 113),
Color.rgb(255, 255, 255)
)
}
TYPE_THUNDERSTORM -> if (daylight) {
rainCount = RAIN_COUNT
mThunder = Thunder()
colors = intArrayOf(
Color.rgb(182, 142, 82),
-0x93aa6e,
Color.rgb(255, 255, 255)
)
} else {
rainCount = RAIN_COUNT
mThunder = Thunder()
colors = intArrayOf(
Color.rgb(182, 142, 82),
Color.rgb(88, 92, 113),
Color.rgb(255, 255, 255)
)
}
TYPE_SLEET -> if (daylight) {
rainCount = SLEET_COUNT
mThunder = null
colors = intArrayOf(
Color.rgb(128, 197, 255),
Color.rgb(185, 222, 255),
Color.rgb(255, 255, 255)
)
} else {
rainCount = SLEET_COUNT
mThunder = null
colors = intArrayOf(
Color.rgb(40, 102, 155),
Color.rgb(99, 144, 182),
Color.rgb(255, 255, 255)
)
}
}
val scales = floatArrayOf(0.6f, 0.8f, 1f)
mRains = Array(rainCount) { i ->
Rain(
canvasSizes[0],
canvasSizes[1],
colors[i * 3 / rainCount],
scales[i * 3 / rainCount],
type
)
}
} else {
mRains = emptyArray()
}
mLastRotation3D = INITIAL_ROTATION_3D
}
override fun updateData(
@Size(2) canvasSizes: IntArray,
interval: Long,
rotation2D: Float,
rotation3D: Float,
) {
// do not display any rain effects if animations are turned off
if (!mAnimate) return
for (r in mRains) {
r.move(
interval,
if (mLastRotation3D == INITIAL_ROTATION_3D) 0f else rotation3D - mLastRotation3D
)
}
mThunder?.shine(interval)
mLastRotation3D = rotation3D
}
override fun draw(
@Size(2) canvasSizes: IntArray,
canvas: Canvas,
scrollRate: Float,
rotation2D: Float,
rotation3D: Float,
) {
var rotation2Dc = rotation2D
if (scrollRate < 1) {
rotation2Dc += 8f
canvas.rotate(
rotation2Dc,
canvasSizes[0] * 0.5f,
canvasSizes[1] * 0.5f
)
for (r in mRains) {
mPaint.color = r.color
mPaint.alpha = ((1 - scrollRate) * 255).toInt()
canvas.drawRoundRect(r.rectF, r.width / 2f, r.width / 2f, mPaint)
}
mThunder?.let {
canvas.drawColor(
Color.argb(
((1 - scrollRate) * it.alpha * 255 * 0.66).toInt(),
it.r,
it.g,
it.b
)
)
}
}
}
companion object {
private const val INITIAL_ROTATION_3D = 1000f
const val TYPE_RAIN = 1
const val TYPE_THUNDERSTORM = 3
const val TYPE_SLEET = 4
private const val RAIN_COUNT = 75
private const val SLEET_COUNT = 45
@ColorInt
fun getThemeColor(@TypeRule type: Int, daylight: Boolean): Int {
return when (type) {
TYPE_SLEET -> if (daylight) -0x974501 else -0xe5a46e
TYPE_THUNDERSTORM -> if (daylight) -0x4d6943 else -0xdce8c7
// TYPE_RAIN:
else -> if (daylight) -0xbd6819 else -0xd9b171
}
}
}
}

View file

@ -0,0 +1,165 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.ui.theme.weatherView.materialWeatherView.implementor
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import androidx.annotation.ColorInt
import androidx.annotation.Size
import org.breezyweather.ui.theme.weatherView.materialWeatherView.MaterialWeatherView.WeatherAnimationImplementor
import java.util.Random
import kotlin.math.pow
import kotlin.math.sin
/**
* Snow implementor.
*/
class SnowImplementor(
@Size(2) canvasSizes: IntArray,
animate: Boolean,
daylight: Boolean,
) : WeatherAnimationImplementor() {
private val mAnimate = animate
private val mPaint = Paint().apply {
style = Paint.Style.FILL
isAntiAlias = true
}
private val mSnows: Array<Snow>
private var mLastRotation3D: Float
private class Snow(
private val mViewWidth: Int,
private val mViewHeight: Int,
@field:ColorInt @param:ColorInt val color: Int,
val scale: Float,
) {
private var mCX = 0f
private var mCY = 0f
var centerX = 0f
var centerY = 0f
var radius: Float
var speedX = 0f
var speedY: Float
private val mCanvasSize: Int
init {
mCanvasSize = (mViewWidth * mViewWidth + mViewHeight * mViewHeight).toDouble().pow(0.5).toInt()
radius = (mCanvasSize * (0.005 + Random().nextDouble() * 0.007) * scale).toFloat()
speedY = (mCanvasSize / (1000.0 * (2.5 + Random().nextDouble())) * SNOW_SPEED).toFloat()
init(true)
}
private fun init(firstTime: Boolean) {
val r = Random()
mCX = r.nextInt(mCanvasSize).toFloat()
mCY = if (firstTime) {
(r.nextInt((mCanvasSize - radius).toInt()) - mCanvasSize).toFloat()
} else {
-radius
}
speedX = r.nextInt((2 * speedY).toInt()) - speedY
computeCenterPosition()
}
private fun computeCenterPosition() {
centerX = (mCX - (mCanvasSize - mViewWidth) * 0.5).toInt().toFloat()
centerY = (mCY - (mCanvasSize - mViewHeight) * 0.5).toInt().toFloat()
}
fun move(interval: Long, deltaRotation3D: Float) {
mCX += (speedX * interval * scale.toDouble().pow(1.5)).toFloat()
mCY +=
(speedY * interval * (scale.toDouble().pow(1.5) - 5 * sin(deltaRotation3D * Math.PI / 180.0))).toFloat()
if (centerY >= mCanvasSize) {
init(false)
} else {
computeCenterPosition()
}
}
}
init {
val colors = if (daylight) {
intArrayOf(
Color.rgb(190, 225, 255),
Color.rgb(211, 233, 255),
Color.rgb(255, 255, 255)
)
} else {
intArrayOf(
Color.rgb(111, 133, 155),
Color.rgb(140, 161, 182),
Color.rgb(255, 255, 255)
)
}
val scales = floatArrayOf(0.6f, 0.8f, 1f)
mSnows = Array(SNOW_COUNT) { i ->
Snow(
canvasSizes[0],
canvasSizes[1],
colors[i * 3 / SNOW_COUNT],
scales[i * 3 / SNOW_COUNT]
)
}
mLastRotation3D = INITIAL_ROTATION_3D
}
override fun updateData(
@Size(2) canvasSizes: IntArray,
interval: Long,
rotation2D: Float,
rotation3D: Float,
) {
for (s in mSnows) {
s.move(interval, if (mLastRotation3D == INITIAL_ROTATION_3D) 0f else rotation3D - mLastRotation3D)
}
mLastRotation3D = rotation3D
}
override fun draw(
@Size(2) canvasSizes: IntArray,
canvas: Canvas,
scrollRate: Float,
rotation2D: Float,
rotation3D: Float,
) {
if (scrollRate < 1) {
canvas.rotate(
rotation2D,
canvasSizes[0] * 0.5f,
canvasSizes[1] * 0.5f
)
for (s in mSnows) {
mPaint.color = s.color
mPaint.alpha = ((1 - scrollRate) * 255).toInt()
canvas.drawCircle(s.centerX, s.centerY, s.radius, mPaint)
}
}
}
companion object {
private const val INITIAL_ROTATION_3D = 1000f
private const val SNOW_COUNT = 50
private const val SNOW_SPEED = 1.5
@ColorInt
fun getThemeColor(daylight: Boolean): Int {
return if (daylight) -0x974501 else -0xe5a46e
}
}
}

View file

@ -0,0 +1,98 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.ui.theme.weatherView.materialWeatherView.implementor
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import androidx.annotation.ColorInt
import androidx.annotation.Size
import org.breezyweather.ui.theme.weatherView.materialWeatherView.MaterialWeatherView.WeatherAnimationImplementor
import kotlin.math.sin
/**
* Clear day implementor.
*/
class SunImplementor(
@Size(2) canvasSizes: IntArray,
animate: Boolean,
) : WeatherAnimationImplementor() {
private val mAnimate = animate
private val mPaint = Paint().apply {
style = Paint.Style.FILL
isAntiAlias = true
color = Color.WHITE
}
private val mAngles = FloatArray(3)
private val mUnitSizes: FloatArray = floatArrayOf(
(0.5 * 0.47 * canvasSizes[0]).toFloat(),
(1.7794 * 0.5 * 0.47 * canvasSizes[0]).toFloat(),
(3.0594 * 0.5 * 0.47 * canvasSizes[0]).toFloat()
)
override fun updateData(
@Size(2) canvasSizes: IntArray,
interval: Long,
rotation2D: Float,
rotation3D: Float,
) {
for (i in mAngles.indices) {
mAngles[i] = ((mAngles[i] + 90.0 / (3000 + 1000 * i) * interval) % 90).toFloat()
}
}
override fun draw(
@Size(2) canvasSizes: IntArray,
canvas: Canvas,
scrollRate: Float,
rotation2D: Float,
rotation3D: Float,
) {
if (scrollRate < 1) {
val deltaX = (sin(rotation2D * Math.PI / 180.0) * 0.3 * canvasSizes[0]).toFloat()
val deltaY = (sin(rotation3D * Math.PI / 180.0) * -0.3 * canvasSizes[0]).toFloat()
canvas.translate(canvasSizes[0] + deltaX, (SUN_POSITION * canvasSizes[0] + deltaY).toFloat())
arrayOf(SMALL_SUN_ALPHA, MEDIUM_SUN_ALPHA, LARGE_SUN_ALPHA).forEachIndexed { index, alpha ->
mPaint.alpha = ((1 - scrollRate) * 255 * alpha).toInt()
canvas.rotate(mAngles[index])
for (i in 0..3) {
canvas.drawRect(
-mUnitSizes[index],
-mUnitSizes[index],
mUnitSizes[index],
mUnitSizes[index],
mPaint
)
canvas.rotate(22.5f)
}
canvas.rotate(-90 - mAngles[index])
}
}
}
companion object {
const val SUN_POSITION = 0.5 // Old: 0.0333. Moved lower to avoid overlap with top icons
const val SMALL_SUN_ALPHA = 0.16
const val MEDIUM_SUN_ALPHA = 0.08
const val LARGE_SUN_ALPHA = 0.04
@get:ColorInt
val themeColor: Int
get() = Color.rgb(253, 188, 76)
}
}

View file

@ -0,0 +1,179 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.ui.theme.weatherView.materialWeatherView.implementor
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import androidx.annotation.ColorInt
import androidx.annotation.Size
import androidx.core.graphics.toColorInt
import org.breezyweather.ui.theme.weatherView.materialWeatherView.MaterialWeatherView.WeatherAnimationImplementor
import java.util.Random
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
class WindImplementor(
@Size(2) canvasSizes: IntArray,
animate: Boolean,
daylight: Boolean,
) : WeatherAnimationImplementor() {
private val mAnimate = animate
private val mPaint = Paint().apply {
style = Paint.Style.FILL
isAntiAlias = true
alpha = ((if (daylight) 1f else 0.33f) * 255).toInt()
}
private val mWinds: Array<Wind>
private var mLastRotation3D: Float
private class Wind(
private val mViewWidth: Int,
private val mViewHeight: Int,
@ColorInt val color: Int,
val scale: Float,
) {
var x = 0f
var y = 0f
var width = 0f
var height = 0f
var rectF: RectF = RectF()
var speed: Float
private val mCanvasSize: Int
private val maxWidth: Float
private val minWidth: Float
private val maxHeight: Float
private val minHeight: Float
init {
mCanvasSize = (mViewWidth * mViewWidth + mViewHeight * mViewHeight).toDouble().pow(0.5).toInt()
speed = (mCanvasSize / (1000.0 * (0.5 + Random().nextDouble())) * 6.0).toFloat()
maxHeight = 0.007f * mCanvasSize
minHeight = 0.005f * mCanvasSize
maxWidth = maxHeight * 10
minWidth = minHeight * 6
init(true)
}
private fun init(firstTime: Boolean) {
val r = Random()
y = r.nextInt(mCanvasSize).toFloat()
x = if (firstTime) {
(r.nextInt((mCanvasSize - maxHeight).toInt()) - mCanvasSize).toFloat()
} else {
-maxHeight
}
width = minWidth + r.nextFloat() * (maxWidth - minWidth)
height = minHeight + r.nextFloat() * (maxHeight - minHeight)
buildRectF()
}
private fun buildRectF() {
val x = (x - (mCanvasSize - mViewWidth) * 0.5).toFloat()
val y = (y - (mCanvasSize - mViewHeight) * 0.5).toFloat()
rectF.set(x, y, x + width * scale, y + height * scale)
}
fun move(interval: Long, deltaRotation3D: Float) {
x += speed
.times(interval)
.times(
scale.toDouble().pow(1.5) + 5 * sin(deltaRotation3D * Math.PI / 180.0) * cos(16 * Math.PI / 180.0)
).toFloat()
y -= (speed * interval * 5 * sin(deltaRotation3D * Math.PI / 180.0) * sin(16 * Math.PI / 180.0))
.toFloat()
if (x >= mCanvasSize) {
init(false)
} else {
buildRectF()
}
}
}
init {
val colors = if (daylight) {
intArrayOf(
"#C2E4CA".toColorInt(), // Color.rgb(240, 200, 148),
"#B2E0BA".toColorInt(), // Color.rgb(237, 178, 100),
"#D2F0DA".toColorInt() // Color.rgb(209, 142, 54)
)
} else {
intArrayOf(
"#313E3A".toColorInt(), // Color.rgb(240, 200, 148),
"#529B73".toColorInt(), // Color.rgb(237, 178, 100),
"#638170".toColorInt() // Color.rgb(209, 142, 54)
)
}
val scales = floatArrayOf(0.6f, 0.8f, 1f)
mWinds = Array(WIND_COUNT) { i ->
Wind(
canvasSizes[0],
canvasSizes[1],
colors[i * 3 / WIND_COUNT],
scales[i * 3 / WIND_COUNT]
)
}
mLastRotation3D = INITIAL_ROTATION_3D
}
override fun updateData(
@Size(2) canvasSizes: IntArray,
interval: Long,
rotation2D: Float,
rotation3D: Float,
) {
for (w in mWinds) {
w.move(interval, if (mLastRotation3D == INITIAL_ROTATION_3D) 0f else rotation3D - mLastRotation3D)
}
mLastRotation3D = rotation3D
}
override fun draw(
@Size(2) canvasSizes: IntArray,
canvas: Canvas,
scrollRate: Float,
rotation2D: Float,
rotation3D: Float,
) {
var rotation2Dc = rotation2D
if (scrollRate < 1) {
rotation2Dc -= 16f
canvas.rotate(
rotation2D,
canvasSizes[0] * 0.5f,
canvasSizes[1] * 0.5f
)
for (w in mWinds) {
mPaint.color = w.color
mPaint.alpha = ((1 - scrollRate) * 255).toInt()
canvas.drawRect(w.rectF, mPaint)
}
}
}
companion object {
private const val INITIAL_ROTATION_3D = 1000f
private const val WIND_COUNT = 160
@ColorInt
fun getThemeColor(daylight: Boolean): Int {
return if (daylight) -0x15325d else -0x6a798b
}
}
}

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<gradient
android:angle="270"
android:startColor="#592B19"
android:centerColor="#CC6143"
android:endColor="#D18268" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<gradient
android:angle="270"
android:startColor="#23262C"
android:centerColor="#525D66"
android:endColor="#6B8394" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<gradient
android:angle="270"
android:startColor="#131B45"
android:centerColor="#2A5476"
android:endColor="#918EAF" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<gradient
android:angle="270"
android:startColor="#335A7E"
android:centerColor="#435A6F"
android:endColor="#303742" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<gradient
android:angle="270"
android:startColor="#4D3314"
android:centerColor="#755125"
android:endColor="#C5AF9D" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<gradient
android:angle="270"
android:startColor="#1C2F75"
android:centerColor="#585D6D"
android:endColor="#747B99" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<gradient
android:angle="270"
android:startColor="#233361"
android:centerColor="#2F4997"
android:endColor="#252731" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<gradient
android:angle="270"
android:startColor="#23306B"
android:centerColor="#3549A4"
android:endColor="#23262F" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<gradient
android:angle="270"
android:startColor="#353A47"
android:centerColor="#455762"
android:endColor="#414F83" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<gradient
android:angle="270"
android:startColor="#2F2B38"
android:centerColor="#50406D"
android:endColor="#2C1C4D" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<gradient
android:angle="270"
android:startColor="#313E3A"
android:centerColor="#529B73"
android:endColor="#638170" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<gradient
android:angle="270"
android:startColor="#FFBA5E"
android:centerColor="#FFC67E"
android:endColor="#FFF0E2" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<gradient
android:angle="270"
android:startColor="#171D52"
android:centerColor="#3F4DBA"
android:endColor="#5E68BD" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<gradient
android:angle="270"
android:startColor="#AFBCC7"
android:centerColor="#D2D6DB"
android:endColor="#EBEFF8" />
</shape>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<solid android:color="@android:color/transparent" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<gradient
android:angle="270"
android:startColor="#A2C3FF"
android:centerColor="#B3AFD1"
android:endColor="#E7BEB0" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<gradient
android:angle="270"
android:startColor="#DDF7FF"
android:centerColor="#E1E5E9"
android:endColor="#A0AFC1" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<gradient
android:angle="270"
android:startColor="#FFDDA1"
android:centerColor="#D1C3AF"
android:endColor="#B5E8EF" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<gradient
android:angle="270"
android:startColor="#64DBFF"
android:centerColor="#D6DEE0"
android:endColor="#E2F0F6" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<gradient
android:angle="270"
android:startColor="#97B6D1"
android:centerColor="#A6BBCE"
android:endColor="#97A8D7" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<gradient
android:angle="270"
android:startColor="#B4D3ED"
android:centerColor="#C5D2DC"
android:endColor="#ADC3DE" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<gradient
android:angle="270"
android:startColor="#93A2AD"
android:centerColor="#E3E7EB"
android:endColor="#EDF2F6" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<gradient
android:angle="270"
android:startColor="#C697D7"
android:centerColor="#C1A3CE"
android:endColor="#AB90DB" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:visible="true">
<gradient
android:angle="270"
android:startColor="#96D0A3"
android:centerColor="#DFFAE7"
android:endColor="#E8F4EE" />
</shape>