diff --git a/android/app/src/main/java/chat/vojo/app/IncomingCallActivity.java b/android/app/src/main/java/chat/vojo/app/IncomingCallActivity.java index 76e45800..137f2d24 100644 --- a/android/app/src/main/java/chat/vojo/app/IncomingCallActivity.java +++ b/android/app/src/main/java/chat/vojo/app/IncomingCallActivity.java @@ -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 (0→1 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 (0→1) 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 green→green. + 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(); diff --git a/android/app/src/main/res/drawable/bg_call_avatar_halo.xml b/android/app/src/main/res/drawable/bg_call_avatar_halo.xml new file mode 100644 index 00000000..c4b6ad37 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_call_avatar_halo.xml @@ -0,0 +1,14 @@ + + + + diff --git a/android/app/src/main/res/drawable/bg_call_glow_decline.xml b/android/app/src/main/res/drawable/bg_call_glow_decline.xml deleted file mode 100644 index a59b488c..00000000 --- a/android/app/src/main/res/drawable/bg_call_glow_decline.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - diff --git a/android/app/src/main/res/drawable/ic_call_handset.xml b/android/app/src/main/res/drawable/ic_call_handset.xml index 0abd65bf..271ab81e 100644 --- a/android/app/src/main/res/drawable/ic_call_handset.xml +++ b/android/app/src/main/res/drawable/ic_call_handset.xml @@ -1,8 +1,8 @@ - - + + + + + + + - - - - - - + + Ответить Отклонить Входящий звонок + Неизвестный абонент diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index fe311bfd..be48fa14 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -21,6 +21,10 @@ #0c0c0e #7dd3a8 #0e2e1f + + #34d399 #c08e7b #3a1f17 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 31a74623..973066f7 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -8,4 +8,5 @@ Answer Decline Incoming call + Unknown caller