feat(calls): polish native incoming ring — axis-locked slide-to-answer (up only), color-morph + 3-rung haptics, breathing avatar halo, decline tap-only, reduced-motion

This commit is contained in:
heaven 2026-06-24 02:07:12 +03:00
parent 501ed9ac96
commit aa83b05a31
8 changed files with 245 additions and 120 deletions

View file

@ -1,10 +1,12 @@
package chat.vojo.app;
import android.animation.ArgbEvaluator;
import android.animation.ValueAnimator;
import android.app.Activity;
import android.app.KeyguardManager;
import android.content.Context;
import android.content.Intent;
import android.content.res.ColorStateList;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
@ -17,8 +19,9 @@ import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.WindowManager;
import android.view.animation.AccelerateDecelerateInterpolator;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.LinearInterpolator;
import android.view.animation.OvershootInterpolator;
import android.widget.ImageButton;
import android.widget.TextView;
@ -71,6 +74,18 @@ public class IncomingCallActivity extends Activity {
private ValueAnimator hintAnimator;
private volatile boolean dragHintSuppressed;
// Slow "breathing" pulse behind the caller avatar (the native mirror of the
// web css.CallerPulse) so the lockscreen ring's avatar isn't dead-static.
private ValueAnimator haloAnimator;
// Drag-to-answer: the answer disc ripens Success-green go-green as the slide
// nears commit (bound to the colors.xml call_answer / call_answer_confirm
// tokens), and a 3-rung ascending haptic ladder fires along the way.
private static final int ANSWER_TINT_BASE = 0xFF7DD3A8;
private static final int ANSWER_TINT_CONFIRM = 0xFF34D399;
private final ArgbEvaluator argbEval = new ArgbEvaluator();
private int lastHapticRung = -1;
@Override
protected void attachBaseContext(Context base) {
// Render the ring screen in the in-app language (matches CallStyle / strip /
@ -108,16 +123,18 @@ public class IncomingCallActivity extends Activity {
View declineBtn = findViewById(R.id.call_decline);
answerBtn.setOnClickListener(v -> onAnswer());
declineBtn.setOnClickListener(v -> onDecline());
// Drag-to-activate: a plain tap fires the action immediately; the moment a
// drag begins, the button follows the finger, a radial glow charges up
// with the drag progress, and the action commits once dragged past its own
// area (releasing short of that springs back, cancelling). Both buttons get
// their own listener instance (independent per-gesture state safe under
// multitouch). performClick() routes the commit through the click listener
// above, so the keyboard/TalkBack click path stays live.
answerBtn.setOnTouchListener(dragToCommit(findViewById(R.id.answer_glow)));
declineBtn.setOnTouchListener(dragToCommit(findViewById(R.id.decline_glow)));
// Slide-up-to-answer: a plain tap fires immediately; the moment a drag
// begins, the disc tracks the finger UP its rail (the direction the chevron
// hint points), ripens toward go-green, drives a radial glow + an ascending
// haptic ladder, and commits once dragged past the threshold (or flicked
// up). Releasing short springs back, cancelling. DECLINE is tap-only only
// Answer carries the slide, so a stray drag can never reject a call.
// performClick() routes the commit through the click listener above, so the
// keyboard/TalkBack path stays live.
answerBtn.setOnTouchListener(
dragToCommit(answerBtn, findViewById(R.id.answer_glow), declineBtn));
startDragHint(findViewById(R.id.answer_hint));
startAvatarHalo(findViewById(R.id.call_avatar_halo));
acquireRingWakeLock();
current = this;
@ -151,7 +168,12 @@ public class IncomingCallActivity extends Activity {
return false;
}
String callerName = intent.getStringExtra("caller_name");
if (callerName == null || callerName.trim().isEmpty()) callerName = "Vojo";
if (callerName == null || callerName.trim().isEmpty()) {
// No caller name in the ring payload name the state, not the app
// ("Vojo" read as if the app itself were calling). Matches the web
// strip's Call.unknown_caller fallback.
callerName = getString(R.string.call_unknown_caller);
}
((TextView) findViewById(R.id.call_name)).setText(callerName);
((TextView) findViewById(R.id.call_subtitle)).setText(R.string.call_incoming);
@ -211,30 +233,27 @@ public class IncomingCallActivity extends Activity {
return km != null && km.isKeyguardLocked();
}
// Drag-to-activate gesture (slide a ring button to commit)
// Slide-up-to-answer gesture (drag the answer disc up to commit)
// A fresh stateful OnTouchListener per call so two buttons never share
// per-gesture state. Tap (no drag past touch-slop) performClick() on UP.
// Drag the disc tracks the finger 1:1 while a radial [glow] behind it
// charges up (alpha + scale) CONTINUOUSLY with the drag progress (01 over
// the commit distance), and the disc scales up with it. Crossing the commit
// threshold fires a one-shot haptic; releasing past it performs the click,
// releasing short of it springs everything back and does nothing. Values are
// set directly (not via animate()) during the drag so the glow stays glued to
// the finger with zero latency; animate() is used only for the press lift and
// the spring-back.
private View.OnTouchListener dragToCommit(final View glow) {
// Tap (no drag past touch-slop) performClick() on UP. Drag the disc tracks
// the finger UP its rail (the axis the chevron hint points; sideways/downward
// drags never commit, so the affordance can't lie), and CONTINUOUSLY along the
// slide the disc scales + tilts, ripens Success-green go-green, drives the
// radial [glow], recedes the [decline] disc, and steps a 3-rung haptic ladder.
// Dragging (or flicking) past the commit distance performs the click; releasing
// short springs everything back. Progress visuals are set directly (zero
// latency) during the drag; animate() is used only for the press-lift and the
// spring-back. DECLINE is tap-only only the answer disc gets this listener.
private View.OnTouchListener dragToCommit(final View disc, final View glow, final View decline) {
final int touchSlop = ViewConfiguration.get(this).getScaledTouchSlop();
// Deliberate upward travel NOT the old ~40dp (half a disc radius), which
// made "slide to answer" indistinguishable from a twitch. dp(88) a disc
// diameter of climb. needsDevice: tune one-handed reach on a tall phone.
final float commitDistance = Math.max(touchSlop * 4f, dp(88f));
return new View.OnTouchListener() {
private float downX;
private float downY;
private boolean dragging;
private boolean pastThreshold;
private float commitThreshold(View v) {
// Past roughly the button's own radius+ "dragged out of its area".
return Math.max(touchSlop * 4f, v.getWidth() * 0.55f);
}
@Override
public boolean onTouch(View v, MotionEvent e) {
@ -243,54 +262,46 @@ public class IncomingCallActivity extends Activity {
downX = e.getRawX();
downY = e.getRawY();
dragging = false;
pastThreshold = false;
v.animate().scaleX(1.06f).scaleY(1.06f).setDuration(120).start();
lastHapticRung = -1;
disc.animate().scaleX(1.06f).scaleY(1.06f).setDuration(120).start();
return true;
case MotionEvent.ACTION_MOVE: {
float dx = e.getRawX() - downX;
float dy = e.getRawY() - downY;
double dist = Math.hypot(dx, dy);
if (!dragging && dist > touchSlop) {
if (!dragging && Math.hypot(dx, dy) > touchSlop) {
dragging = true;
dragHintSuppressed = true;
// Cancel the in-flight press-lift so it stops fighting the
// direct scale-sets below (the old code let both run for
// ~120ms a scale jitter at the start of every drag).
disc.animate().cancel();
}
if (dragging) {
float threshold = commitThreshold(v);
float p = (float) Math.min(1.0, dist / threshold);
v.setTranslationX(dx);
v.setTranslationY(dy);
v.setScaleX(1.06f + 0.16f * p);
v.setScaleY(1.06f + 0.16f * p);
if (glow != null) {
glow.setAlpha(0.18f + 0.82f * p);
glow.setScaleX(0.58f + 0.52f * p);
glow.setScaleY(0.58f + 0.52f * p);
}
boolean past = p >= 1f;
if (past != pastThreshold) {
pastThreshold = past;
if (past) {
v.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
}
}
float up = -dy; // upward = positive progress
float p = (float) Math.min(1.0, Math.max(0.0, up / commitDistance));
// Track the finger UP its rail: a little lateral follow,
// but the disc never travels downward (down = no commit).
disc.setTranslationX(0.15f * dx);
disc.setTranslationY(Math.min(0f, dy));
applyProgressVisuals(disc, glow, decline, p);
hapticLadder(disc, p);
}
return true;
}
case MotionEvent.ACTION_UP: {
float dx = e.getRawX() - downX;
float dy = e.getRawY() - downY;
boolean commit = !dragging || Math.hypot(dx, dy) >= commitThreshold(v);
float up = -(e.getRawY() - downY);
boolean commit = !dragging || up >= commitDistance;
if (commit) {
// Tap or a committed drag fire. The activity finishes
// Tap or a committed slide fire. The activity finishes
// immediately, so no need to settle the visuals.
v.performClick();
disc.performClick();
} else {
springBackDragButton(v, glow);
springBackDragButton(disc, glow, decline, up / commitDistance);
}
return true;
}
case MotionEvent.ACTION_CANCEL:
springBackDragButton(v, glow);
springBackDragButton(disc, glow, decline, 0f);
return true;
default:
return false;
@ -299,35 +310,105 @@ public class IncomingCallActivity extends Activity {
};
}
// Spring the disc back to rest (overshoot) and fade the glow out. Re-enables
// the idle drag hint.
private void springBackDragButton(View v, View glow) {
v.animate()
// Maps drag progress p (01) onto every live visual, so the disc, glow, tint,
// tilt and the receding decline disc move as one body. Driven directly from the
// finger during a drag and from the spring-back on release.
private void applyProgressVisuals(View disc, View glow, View decline, float p) {
disc.setScaleX(1.06f + 0.16f * p);
disc.setScaleY(1.06f + 0.16f * p);
disc.setRotation(-6f * p); // subtle "lifting the handset" tilt
// Ripen toward go-green only over the last 40% so a short release leaves the
// disc at its base tint (no colour snap on spring-back).
float cp = Math.min(1f, Math.max(0f, (p - 0.6f) / 0.4f));
disc.setBackgroundTintList(ColorStateList.valueOf(
(Integer) argbEval.evaluate(cp, ANSWER_TINT_BASE, ANSWER_TINT_CONFIRM)));
if (glow != null) {
glow.setAlpha(0.18f + 0.82f * p);
float gs = 0.58f + 0.42f * p; // peak 1.0 (the 124dp glow already covers the disc)
glow.setScaleX(gs);
glow.setScaleY(gs);
}
if (decline != null) {
// The screen "takes a side": the decline disc recedes as answer climbs.
decline.setAlpha(1f - 0.45f * p);
decline.setScaleX(1f - 0.12f * p);
decline.setScaleY(1f - 0.12f * p);
}
}
// Three ascending rungs along the slide (engage / near / commit), each once per
// drag. performHapticFeedback honours the system haptic toggle (no Vibrator
// permission / no manual settings check needed). needsDevice: the felt crescendo
// wants a richer VibrationEffect.Composition ladder (see drag spec) on real HW.
private void hapticLadder(View v, float p) {
int rung = p >= 1f ? 3 : p >= 0.85f ? 2 : p >= 0.45f ? 1 : 0;
if (rung <= lastHapticRung) return;
lastHapticRung = rung;
if (rung == 0) return;
v.performHapticFeedback(rung >= 3
? HapticFeedbackConstants.LONG_PRESS : HapticFeedbackConstants.CLOCK_TICK);
}
// Spring the disc back to rest and fade the glow out (slightly trailing, so the
// halo is the last energy to leave). A gentle, distance-aware decelerate settle
// replaces the old OvershootInterpolator(2.2f) "boing". Under reduced motion the
// framework zeroes these durations, so they snap to rest. Re-enables the idle
// hint only once the disc has actually landed (withEndAction).
private void springBackDragButton(View disc, View glow, View decline, float pRelease) {
float p = Math.min(1f, Math.max(0f, pRelease));
long dur = (long) (200 + 140 * p);
disc.animate()
.translationX(0)
.translationY(0)
.scaleX(1f)
.scaleY(1f)
.setInterpolator(new OvershootInterpolator(2.2f))
.setDuration(340)
.rotation(0f)
.setInterpolator(new DecelerateInterpolator(1.6f))
.setDuration(dur)
.withEndAction(() -> dragHintSuppressed = false)
.start();
if (glow != null) {
glow.animate().alpha(0f).scaleX(0.58f).scaleY(0.58f).setDuration(220).start();
// Animate the tint back so a top-zone release doesn't snap greengreen.
float cp = Math.min(1f, Math.max(0f, (p - 0.6f) / 0.4f));
if (cp > 0f) {
Integer fromColor = (Integer) argbEval.evaluate(cp, ANSWER_TINT_BASE, ANSWER_TINT_CONFIRM);
ValueAnimator tint = ValueAnimator.ofObject(argbEval, fromColor, ANSWER_TINT_BASE);
tint.setDuration(dur);
tint.addUpdateListener(a -> disc.setBackgroundTintList(
ColorStateList.valueOf((Integer) a.getAnimatedValue())));
tint.start();
} else {
disc.setBackgroundTintList(ColorStateList.valueOf(ANSWER_TINT_BASE));
}
if (glow != null) {
glow.animate().alpha(0f).scaleX(0.58f).scaleY(0.58f)
.setInterpolator(new DecelerateInterpolator())
.setDuration(dur + 60)
.start();
}
if (decline != null) {
decline.animate().alpha(1f).scaleX(1f).scaleY(1f).setDuration(dur).start();
}
dragHintSuppressed = false;
}
// Looping "slide up to answer" affordance: the chevrons rise and fade in a
// 1.5s cycle. Suppressed (held invisible) while a drag is active so the hint
// never fights the live gesture.
// 1.5s eased cycle. Suppressed (held invisible) while a drag is active so the
// hint never fights the live gesture. Reduced motion no loop (held static),
// since an infinite looping affordance is exactly what motion-sensitive users
// disable.
private void startDragHint(final View hint) {
if (hint == null) return;
if (!ValueAnimator.areAnimatorsEnabled()) {
hint.setTranslationY(0f);
hint.setAlpha(0.85f);
return;
}
hint.setAlpha(0f);
final float from = dp(10f);
final float to = dp(-8f);
ValueAnimator a = ValueAnimator.ofFloat(0f, 1f);
a.setDuration(1500);
a.setRepeatCount(ValueAnimator.INFINITE);
a.setInterpolator(new LinearInterpolator());
a.setInterpolator(new AccelerateDecelerateInterpolator());
a.addUpdateListener(an -> {
if (dragHintSuppressed) {
hint.setAlpha(0f);
@ -349,6 +430,29 @@ public class IncomingCallActivity extends Activity {
hintAnimator = a;
}
// Slow "breathing" halo behind the caller avatar the native mirror of the
// web css.CallerPulse so the most-stared-at call surface isn't a dead static
// disc. The halo scales out while fading (a ring expanding + dissolving), green
// (#7dd3a8) = "answerable call ringing". Reduced motion no pulse (held clear).
private void startAvatarHalo(final View halo) {
if (halo == null) return;
halo.setAlpha(0f);
if (!ValueAnimator.areAnimatorsEnabled()) return;
ValueAnimator a = ValueAnimator.ofFloat(0f, 1f);
a.setDuration(1800);
a.setRepeatCount(ValueAnimator.INFINITE);
a.setInterpolator(new LinearInterpolator());
a.addUpdateListener(an -> {
float f = (float) an.getAnimatedValue();
float s = 0.9f + 0.25f * f;
halo.setScaleX(s);
halo.setScaleY(s);
halo.setAlpha(0.5f * (1f - f));
});
a.start();
haloAnimator = a;
}
private float dp(float value) {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, value, getResources().getDisplayMetrics());
@ -393,6 +497,10 @@ public class IncomingCallActivity extends Activity {
hintAnimator.cancel();
hintAnimator = null;
}
if (haloAnimator != null) {
haloAnimator.cancel();
haloAnimator = null;
}
if (current == this) current = null;
releaseRingWakeLock();
super.onDestroy();

View file

@ -0,0 +1,14 @@
<!-- Soft radial halo behind the caller avatar on the full-screen ring. Animated
in code (scale out + fade) into a slow "breathing" pulse — the native mirror
of the web css.CallerPulse, so the lockscreen ring no longer has a dead,
static avatar. Success/green tint (#7dd3a8) = "an answerable call is ringing",
matching the answer disc, glow and chevrons. Suppressed under reduced motion. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<gradient
android:type="radial"
android:gradientRadius="80dp"
android:startColor="#807DD3A8"
android:centerColor="#337DD3A8"
android:endColor="#007DD3A8" />
</shape>

View file

@ -1,11 +0,0 @@
<!-- Soft radial halo behind the Decline disc (Critical/red tint to match
call_decline). Same code-driven alpha + scale as the answer glow. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<gradient
android:type="radial"
android:gradientRadius="62dp"
android:startColor="#A6C08E7B"
android:centerColor="#4DC08E7B"
android:endColor="#00C08E7B" />
</shape>

View file

@ -1,8 +1,8 @@
<!-- Material "call" handset. White fill; each button tints it (answer/decline)
via android:tint. The decline button rotates this 135° in the layout. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:width="38dp"
android:height="38dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path

View file

@ -18,23 +18,39 @@
android:layout_height="0dp"
android:layout_weight="1.1" />
<!-- Fallback avatar disc with the caller's initial (set in code). -->
<TextView
android:id="@+id/call_avatar"
android:layout_width="112dp"
android:layout_height="112dp"
android:background="@drawable/bg_call_avatar"
android:gravity="center"
android:textColor="@color/call_avatar_on"
android:textSize="46sp"
android:textStyle="bold"
android:includeFontPadding="false" />
<!-- Avatar disc (caller initial, set in code) behind a breathing halo. Sized
124dp to match the over-lock hero avatar so answer-from-lock doesn't pop. -->
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<View
android:id="@+id/call_avatar_halo"
android:layout_width="160dp"
android:layout_height="160dp"
android:layout_gravity="center"
android:alpha="0"
android:importantForAccessibility="no"
android:background="@drawable/bg_call_avatar_halo" />
<TextView
android:id="@+id/call_avatar"
android:layout_width="124dp"
android:layout_height="124dp"
android:layout_gravity="center"
android:background="@drawable/bg_call_avatar"
android:gravity="center"
android:textColor="@color/call_avatar_on"
android:textSize="51sp"
android:textStyle="bold"
android:includeFontPadding="false" />
</FrameLayout>
<TextView
android:id="@+id/call_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:layout_marginTop="16dp"
android:textColor="@color/call_text"
android:textSize="28sp"
android:textStyle="bold"
@ -80,31 +96,20 @@
android:layout_marginBottom="4dp"
android:visibility="invisible" />
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<View
android:id="@+id/decline_glow"
android:layout_width="124dp"
android:layout_height="124dp"
android:layout_gravity="center"
android:alpha="0"
android:background="@drawable/bg_call_glow_decline" />
<ImageButton
android:id="@+id/call_decline"
android:layout_width="72dp"
android:layout_height="72dp"
android:layout_gravity="center"
android:background="@drawable/bg_call_circle"
android:backgroundTint="@color/call_decline"
android:src="@drawable/ic_call_handset"
android:tint="@color/call_decline_on"
android:scaleType="center"
android:rotation="135"
android:contentDescription="@string/call_decline" />
</FrameLayout>
<!-- Decline is tap-only (no drag / no glow) — only Answer carries the
slide affordance, so a stray drag can never reject a call. -->
<ImageButton
android:id="@+id/call_decline"
android:layout_width="72dp"
android:layout_height="72dp"
android:background="@drawable/bg_call_circle"
android:backgroundTint="@color/call_decline"
android:elevation="6dp"
android:src="@drawable/ic_call_handset"
android:tint="@color/call_decline_on"
android:scaleType="center"
android:rotation="135"
android:contentDescription="@string/call_decline" />
<TextView
android:layout_width="wrap_content"
@ -130,6 +135,7 @@
android:layout_height="40dp"
android:layout_marginBottom="4dp"
android:src="@drawable/ic_call_drag_chevrons"
android:importantForAccessibility="no"
android:contentDescription="@null" />
<FrameLayout
@ -142,6 +148,7 @@
android:layout_height="124dp"
android:layout_gravity="center"
android:alpha="0"
android:importantForAccessibility="no"
android:background="@drawable/bg_call_glow_answer" />
<ImageButton
@ -151,6 +158,7 @@
android:layout_gravity="center"
android:background="@drawable/bg_call_circle"
android:backgroundTint="@color/call_answer"
android:elevation="8dp"
android:src="@drawable/ic_call_handset"
android:tint="@color/call_answer_on"
android:scaleType="center"

View file

@ -3,4 +3,5 @@
<string name="call_answer">Ответить</string>
<string name="call_decline">Отклонить</string>
<string name="call_incoming">Входящий звонок</string>
<string name="call_unknown_caller">Неизвестный абонент</string>
</resources>

View file

@ -21,6 +21,10 @@
<color name="call_avatar_on">#0c0c0e</color>
<color name="call_answer">#7dd3a8</color>
<color name="call_answer_on">#0e2e1f</color>
<!-- Drag-to-answer "ripen" target: the answer disc morphs Success #7dd3a8 →
this saturated go-green as the slide nears the commit threshold, so the
disc visibly says "release now". Stays within the green family. -->
<color name="call_answer_confirm">#34d399</color>
<color name="call_decline">#c08e7b</color>
<color name="call_decline_on">#3a1f17</color>
</resources>

View file

@ -8,4 +8,5 @@
<string name="call_answer">Answer</string>
<string name="call_decline">Decline</string>
<string name="call_incoming">Incoming call</string>
<string name="call_unknown_caller">Unknown caller</string>
</resources>