diff --git a/tennis-watch/.impeccable/design.json b/tennis-watch/.impeccable/design.json index ef8e6ea..6ca0f58 100644 --- a/tennis-watch/.impeccable/design.json +++ b/tennis-watch/.impeccable/design.json @@ -1,6 +1,6 @@ { "schemaVersion": 2, - "generatedAt": "2026-09-12T18:07:24Z", + "generatedAt": "2026-09-13T03:38:19Z", "title": "Design System: Tennis Watch", "extensions": { "colorMeta": { @@ -91,6 +91,10 @@ "deletion": { "role": "error", "displayName": "Deletion red" + }, + "on-deletion": { + "role": "neutral", + "displayName": "Trash charcoal" } }, "typographyMeta": { @@ -147,9 +151,9 @@ { "name": "Delete preview", "kind": "custom", - "description": "Revealed behind an upward-moving score face. Fades from 0.45 to 1 and grows from 0.8 to 1 as the drag reaches the deletion threshold. It is a gesture cue, not a button.", - "html": "
\n \n \n \n
", - "css": ".ds-trash-preview { width: 48px; height: 48px; background: #000000; } .ds-trash-preview svg { display: block; }" + "description": "A red semicircle rises over the score to the screen midpoint. Hold it there for one second to confirm deletion with an 80ms vibration. The deleted score sinks and fades into the trash before the remaining match appears as the semicircle recedes. Releasing early or pulling back cancels.", + "html": "
\n \n \n \n
", + "css": ".ds-delete-face { position: relative; width: 225px; height: 225px; border-radius: 50%; overflow: hidden; background: #000000; } .ds-delete-background { position: absolute; top: 50%; left: 0; width: 225px; height: 225px; border-radius: 50%; background: #FF6B6B; } .ds-delete-face svg { position: absolute; left: 50%; top: 77%; width: 48px; height: 48px; transform: translate(-50%, -50%); }" } ], "narrative": { @@ -173,7 +177,7 @@ { "name": "Swipe feedback", "section": "motion", - "body": "Track the finger when navigating matches or revealing deletion. Release a horizontal swipe to settle in 150ms, or return an incomplete swipe in 160ms. Up reveals a red trash icon; release beyond 45% of the viewport, minimum 96dp, to delete. Respect disabled system animations." + "body": "Track the finger when navigating matches or raising the red deletion semicircle. A horizontal swipe settles in 150ms; an incomplete swipe returns in 160ms. Up raises the semicircle to the screen midpoint. Hold there continuously for one second to delete with an 80ms vibration. The deleted score moves down 30% of the viewport and fades out over 180ms with cubic ease-in. The remaining match then fades in as the semicircle recedes over 220ms with cubic ease-out. Releasing early or pulling back cancels. Respect disabled system animations without shortening the confirmation delay." } ], "dos": [ diff --git a/tennis-watch/DESIGN.md b/tennis-watch/DESIGN.md index e756c1c..dfd7a57 100644 --- a/tennis-watch/DESIGN.md +++ b/tennis-watch/DESIGN.md @@ -9,6 +9,7 @@ colors: separator: "#8B938D" divider: "#343936" deletion: "#FF6B6B" + on-deletion: "#111511" typography: display: fontFamily: "sans-serif-condensed" @@ -82,7 +83,8 @@ The palette uses off-white and green-tinted grays on an OLED black ground. | `secondary` | Completed sets and undo stroke | | `separator` | Small multiplication sign between point scores | | `divider` | Thin line between points and games | -| `deletion` | Trash icon revealed by an upward drag | +| `deletion` | Semicircle raised by an upward drag | +| `on-deletion` | Trash icon on the red semicircle | ## Typography @@ -123,7 +125,11 @@ Each plus sits on a filled rounded rectangle, 38dp wide and 42dp high, centered Horizontal dragging moves the entire score face with the finger and reveals the adjacent match. Left advances through history and previews a fresh match at the newest end. Right shows previous matches. At the oldest match, a rightward drag moves the face at 18% of the finger distance and springs back. A completed horizontal swipe settles in 150ms; an incomplete swipe returns in 160ms. -Upward dragging reveals a red, 48dp trash icon centered at 80% height. Its opacity rises from 0.45 to 1 and its scale from 0.8 to 1 as the drag approaches the deletion threshold. Release beyond 45% of the viewport height, with a minimum of 96dp, deletes the match. The score exits upward in 130ms, and the selected remaining match enters from below in 180ms. A short swipe or a return below the threshold cancels deletion. All settling uses cubic ease-out and respects Android's animator-duration setting, including disabled animations. +Upward dragging raises a solid red semicircle over the stationary score. Its curved top stops at the vertical midpoint. The circle diameter matches the shorter display dimension; its top rests at half the viewport height when fully raised. A dark, 48dp trash icon is centered horizontally, 27% of the circle diameter below that top. The shape and icon translate together without fading or scaling. + +Holding a deliberate upward drag at half the display height, with a minimum of 96dp, for one continuous second deletes the match and produces an 80ms vibration. Releasing, pulling back, adding another finger, or leaving the app before confirmation cancels the hold. One gesture can delete at most one match. Cancellation returns the semicircle in 160ms with cubic ease-out. + +At confirmation, the deleted score sinks toward the trash, moving down 30% of the viewport height and fading out over 180ms with cubic ease-in. The raised red semicircle covers the departing score. Once it has disappeared, the remaining match fades in at its resting position while the semicircle recedes, both over 220ms with cubic ease-out. Interrupting this transition shows the remaining match at full opacity. Android's animator-duration setting scales all motion; disabled animations show the result immediately. The confirmation delay always remains one second. ## Launcher icon diff --git a/tennis-watch/PRODUCT.md b/tennis-watch/PRODUCT.md index 7d6902f..3371ff8 100644 --- a/tennis-watch/PRODUCT.md +++ b/tennis-watch/PRODUCT.md @@ -8,7 +8,7 @@ Native Android app for Wear OS. Target device: Samsung Galaxy Watch 5 Pro. A tennis player records points during a match by tapping the left or right plus button on their watch. ## Scope -The single screen contains a match number and creation date, two point scores, one plus button per side, a divider, a small games/set score, and undo beneath. Swipe left opens the next match and creates one at the end of history. Swipe right opens the previous match. Swipe up reveals a red trash icon; completing the upward swipe deletes the current match. Match scores, dates, numbers, selection, and undo history persist locally. Player names, settings, and match completion screens are outside this app's scope. +The single screen contains a match number and creation date, two point scores, one plus button per side, a divider, a small games/set score, and undo beneath. Swipe left opens the next match and creates one at the end of history. Swipe right opens the previous match. Swipe up raises a red semicircle with a dark trash icon to the middle of the screen. A continuous one-second hold there confirms deletion with a short vibration. Releasing early or pulling back cancels. Match scores, dates, numbers, selection, and undo history persist locally. Player names, settings, and match completion screens are outside this app's scope. ## Appearance Preserve the sketch’s composition with a minimal black-and-white finish. Points and the plus controls lead; match metadata, the games row, and undo have less visual emphasis. Red identifies the deletion gesture. The approved yellow-green tennis-ball icon identifies the app in the launcher. The scoring screen must remain legible during a quick glance on court. diff --git a/tennis-watch/README.md b/tennis-watch/README.md index 15caa96..298f47b 100644 --- a/tennis-watch/README.md +++ b/tennis-watch/README.md @@ -4,7 +4,7 @@ A single-screen tennis scorer for Samsung Galaxy Watch 5 Pro, built with .NET MA Tap either **+** to award that side a point. The arrow undoes the last point, including a point that ended a game or set. Swipe left to open the next match, or create one when viewing the newest match. Swipe right to open the previous match. The score follows the drag and slides into place when the swipe completes. -Swipe up to reveal the red trash icon. Release after dragging at least 45% of the display height, with a minimum of 96dp, to delete the current match. Shorter swipes snap back. Deletion opens the previous match, or the next when deleting the first. Deleting the only match creates a fresh one. +Swipe up to raise the red semicircle and dark trash icon to the middle of the screen. Hold there for one second until the watch vibrates and deletes the current match. Releasing early or pulling back cancels the hold. Deletion opens the previous match, or the next when deleting the first. Deleting the only match creates a fresh one. The header shows the match number and creation date. Numbers remain stable after deletion. Matches saved by the first version retain their scores and show "Date unavailable", because that version did not record dates. diff --git a/tennis-watch/docs/screenshots/delete-preview.png b/tennis-watch/docs/screenshots/delete-preview.png index b2c09b2..9502533 100644 Binary files a/tennis-watch/docs/screenshots/delete-preview.png and b/tennis-watch/docs/screenshots/delete-preview.png differ diff --git a/tennis-watch/docs/verification.md b/tennis-watch/docs/verification.md index de90c98..f2dca9d 100644 --- a/tennis-watch/docs/verification.md +++ b/tennis-watch/docs/verification.md @@ -1,6 +1,6 @@ # Verification -`./build.sh` builds the development AppHost, runs 91 data-driven tests and packages the signed Release APK. The APK was installed in a Wear OS 5 emulator with a round 450 × 450 display at 320 dpi. These screenshots and the recording use controlled match data and come directly from the installed Release app. +`./build.sh` builds the development AppHost, runs 104 data-driven tests and packages the signed Release APK. The APK was installed in a Wear OS 5 emulator with a round 450 × 450 display at 320 dpi. The screenshots and recording use controlled match data from installed Release builds. | State | Capture | | --- | --- | @@ -10,14 +10,16 @@ | New match, undo disabled | [Empty](screenshots/empty.png) | | Legacy match without a recorded creation date | [Migrated match](screenshots/legacy.png) | | Held leftward drag revealing the next match | [Navigation](screenshots/swipe-next.png) | -| Held upward drag revealing the red trash icon | [Delete preview](screenshots/delete-preview.png) | +| Red semicircle at the screen midpoint with a dark trash icon | [Delete preview](screenshots/delete-preview.png) | | Approved tennis-ball icon in the Wear OS app grid | [Launcher](screenshots/launcher.png) | -| Left/right navigation, new match, canceled deletion and full deletion | [Gesture recording](https://github.com/user-attachments/assets/da0060f7-0c49-4ba7-82e4-47b74640ffe5) | +| Early release cancels; confirmation sinks the deleted score into the trash; navigation afterward | [Deletion recording](https://github.com/user-attachments/assets/3f532ac4-aa7a-4e5c-9e88-fa0092b3798e) | -ADB touch input verified navigation through existing matches, creation at the newest end, resistance at the oldest end, and point/undo controls after switching the score views. Short horizontal swipes starting on a plus button did not award points. Partial upward swipes, downward and diagonal gestures, and crossing the deletion threshold then returning below it left the match unchanged. Backgrounding the app during a held deletion canceled the gesture and restored the controls. +ADB touch input verified that a fast full upward swipe and a partial drag held for more than one second leave the match unchanged. No deletion occurs before the full second in the deletion zone. Pulling back resets the deadline, and reentering requires a fresh one-second hold. Releasing early or backgrounding the app cancels the pending deletion and restores the controls. -Full upward swipes deleted only the selected match. Tests covered the first, middle, last, and only remaining match, stable match numbers after deletion, restored selection and undo after restart, and navigation/deletion with system animations disabled. Release smoke checks repeated point, undo, navigation, fresh-match creation, canceled/full deletion, restart, and legacy migration. +A completed hold deletes while the finger is still down. Continuing contact for another 1.4 seconds and then releasing does not delete a second match. A new gesture can delete again. Checks also covered left/right navigation after confirmation, point/undo, deletion of the only match, persistence after restart, and the full confirmation delay with system animations disabled. Android's vibrator service recorded the app's 80ms confirmation request. -The unit tests cover advantage scoring, set boundaries, extended tiebreaks, continued scoring, undo across boundaries, independent match histories, metadata validation, stable numbering, deletion selection, nonmutating previews, gesture axes and thresholds, file persistence, legacy migration, and an interrupted temporary write. Coverage excludes generated JSON serialization code. +The recording shows the deleted score moving downward and fading behind the raised semicircle before the remaining match appears. Backgrounding during either the exit or reveal restored the correct match at full opacity and its resting position, with point and undo controls working. Completed and interrupted transitions matched a reference capture of the remaining match pixel for pixel. The same comparison passed with animations disabled and with font scale 1.3 in dark theme. The emulator's crash log was empty after the checks. -The APK contains `armeabi-v7a`, `arm64-v8a`, and `x86_64` native libraries. Physical Galaxy Watch 5 Pro testing remains to be done. +The unit tests cover advantage scoring, set boundaries, extended tiebreaks, continued scoring, undo across boundaries, independent match histories, metadata validation, stable numbering, deletion selection, nonmutating previews, gesture axes and thresholds, the one-second deadline, cancellation and reentry, one confirmation per gesture, file persistence, legacy migration, and an interrupted temporary write. Coverage excludes generated JSON serialization code. The CRAP gate's maximum improves from 12 on main to 10. + +The APK contains `armeabi-v7a`, `arm64-v8a`, and `x86_64` native libraries. Physical Galaxy Watch 5 Pro testing, including the feel of the vibration, remains to be done. diff --git a/tennis-watch/src/TennisWatch.Core/Matches/MatchDeleteHold.cs b/tennis-watch/src/TennisWatch.Core/Matches/MatchDeleteHold.cs new file mode 100644 index 0000000..6593047 --- /dev/null +++ b/tennis-watch/src/TennisWatch.Core/Matches/MatchDeleteHold.cs @@ -0,0 +1,31 @@ +namespace TennisWatch.Core.Matches; + +public sealed class MatchDeleteHold +{ + public static readonly TimeSpan Duration = TimeSpan.FromSeconds(1); + private TimeSpan? startedAt; + + public bool IsConfirmed { get; private set; } + public bool IsWaiting => startedAt.HasValue && !IsConfirmed; + + public void Update(bool inDeleteZone, TimeSpan now) + { + if (IsConfirmed) return; + startedAt = inDeleteZone ? startedAt ?? now : null; + } + + public bool TryConfirm(TimeSpan now) + { + if (IsConfirmed || startedAt is not { } start || now - start < Duration) return false; + IsConfirmed = true; + return true; + } + + public void Cancel() => startedAt = null; + + public void Reset() + { + Cancel(); + IsConfirmed = false; + } +} diff --git a/tennis-watch/src/TennisWatch.Core/Matches/MatchSwipe.cs b/tennis-watch/src/TennisWatch.Core/Matches/MatchSwipe.cs index 99998de..f993c78 100644 --- a/tennis-watch/src/TennisWatch.Core/Matches/MatchSwipe.cs +++ b/tennis-watch/src/TennisWatch.Core/Matches/MatchSwipe.cs @@ -1,6 +1,6 @@ namespace TennisWatch.Core.Matches; -public enum SwipeAction { None, NextMatch, PreviousMatch, DeleteMatch } +public enum SwipeAction { None, NextMatch, PreviousMatch } public enum SwipeAxis { None, Horizontal, Vertical } public static class MatchSwipe @@ -15,17 +15,16 @@ public static SwipeAxis Axis(float deltaX, float deltaY) return SwipeAxis.None; } - public static float DeleteDistance(float height) => Math.Max(96, height * 0.45f); + public static float DeleteDistance(float height) => Math.Max(96, height * 0.5f); - public static SwipeAction Recognize(SwipeAxis axis, float deltaX, float deltaY, float height) + public static bool IsDeleteZone(SwipeAxis axis, float deltaX, float deltaY, float height) => + axis == SwipeAxis.Vertical && Axis(deltaX, deltaY) == SwipeAxis.Vertical && + -deltaY >= DeleteDistance(height); + + public static SwipeAction Recognize(SwipeAxis axis, float deltaX, float deltaY) { - if (axis != Axis(deltaX, deltaY)) return SwipeAction.None; - return axis switch - { - SwipeAxis.Horizontal when Math.Abs(deltaX) >= 48 => - deltaX < 0 ? SwipeAction.NextMatch : SwipeAction.PreviousMatch, - SwipeAxis.Vertical when -deltaY >= DeleteDistance(height) => SwipeAction.DeleteMatch, - _ => SwipeAction.None - }; + if (axis != SwipeAxis.Horizontal || Axis(deltaX, deltaY) != SwipeAxis.Horizontal || + Math.Abs(deltaX) < 48) return SwipeAction.None; + return deltaX < 0 ? SwipeAction.NextMatch : SwipeAction.PreviousMatch; } } diff --git a/tennis-watch/src/TennisWatch/Platforms/Android/AndroidManifest.xml b/tennis-watch/src/TennisWatch/Platforms/Android/AndroidManifest.xml index 54890a0..c4aba6f 100644 --- a/tennis-watch/src/TennisWatch/Platforms/Android/AndroidManifest.xml +++ b/tennis-watch/src/TennisWatch/Platforms/Android/AndroidManifest.xml @@ -2,6 +2,7 @@ + diff --git a/tennis-watch/src/TennisWatch/Platforms/Android/MainActivity.cs b/tennis-watch/src/TennisWatch/Platforms/Android/MainActivity.cs index 8a5f6b1..c49fdc1 100644 --- a/tennis-watch/src/TennisWatch/Platforms/Android/MainActivity.cs +++ b/tennis-watch/src/TennisWatch/Platforms/Android/MainActivity.cs @@ -51,6 +51,7 @@ public override bool DispatchTouchEvent(MotionEvent? e) moved = false; axis = SwipeAxis.None; suppressed = page.IsAnimating; + if (!suppressed) page.BeginSwipe(); return suppressed || base.DispatchTouchEvent(e); case MotionEventActions.PointerDown: CancelChildTouch(e); diff --git a/tennis-watch/src/TennisWatch/Resources/Images/trash.svg b/tennis-watch/src/TennisWatch/Resources/Images/trash.svg index 3b703c6..f8cb14a 100644 --- a/tennis-watch/src/TennisWatch/Resources/Images/trash.svg +++ b/tennis-watch/src/TennisWatch/Resources/Images/trash.svg @@ -1,5 +1,5 @@ - + diff --git a/tennis-watch/src/TennisWatch/Scoring/DeletePreview.cs b/tennis-watch/src/TennisWatch/Scoring/DeletePreview.cs new file mode 100644 index 0000000..968f5eb --- /dev/null +++ b/tennis-watch/src/TennisWatch/Scoring/DeletePreview.cs @@ -0,0 +1,38 @@ +using Microsoft.Maui.Controls.Shapes; + +namespace TennisWatch.Scoring; + +internal sealed class DeletePreview : AbsoluteLayout +{ + private readonly Ellipse background = new() { Fill = new SolidColorBrush(Color.FromArgb("#FF6B6B")), StrokeThickness = 0 }; + private readonly Image trash = new() { Source = "trash.png", Aspect = Aspect.AspectFit, AutomationId = "delete-preview" }; + private double progress; + + public DeletePreview() + { + InputTransparent = true; + IsVisible = false; + this.Add(background); + this.Add(trash); + SemanticProperties.SetDescription(trash, "Hold to delete match"); + SizeChanged += (_, _) => ArrangePreview(); + } + + public double HiddenOffset => Height / 2; + + public void Reveal(double value) + { + progress = Math.Clamp(value, 0, 1); + IsVisible = progress > 0; + TranslationY = HiddenOffset * (1 - progress); + } + + private void ArrangePreview() + { + var size = Math.Min(Width, Height); + if (size <= 0) return; + AbsoluteLayout.SetLayoutBounds(background, new Rect((Width - size) / 2, Height / 2, size, size)); + AbsoluteLayout.SetLayoutBounds(trash, new Rect((Width - 48) / 2, Height / 2 + size * 0.27 - 24, 48, 48)); + TranslationY = HiddenOffset * (1 - progress); + } +} diff --git a/tennis-watch/src/TennisWatch/Scoring/ScorePage.cs b/tennis-watch/src/TennisWatch/Scoring/ScorePage.cs index 0d39539..641e333 100644 --- a/tennis-watch/src/TennisWatch/Scoring/ScorePage.cs +++ b/tennis-watch/src/TennisWatch/Scoring/ScorePage.cs @@ -8,8 +8,9 @@ public sealed class ScorePage : ContentPage private readonly MatchFile storage = new(Path.Combine(FileSystem.AppDataDirectory, "matches.json")); private readonly MatchBook book; private readonly Grid viewport = new() { BackgroundColor = Colors.Black, IsClippedToBounds = true }; - private readonly AbsoluteLayout deleteLayer = new() { InputTransparent = true, IsVisible = false }; - private readonly Image trash = new() { Source = "trash.png", Aspect = Aspect.AspectFit, AutomationId = "delete-preview" }; + private readonly DeletePreview deletePreview = new(); + private readonly MatchDeleteHold deleteHold = new(); + private readonly IDispatcherTimer holdTimer; private ScoreFace current; private ScoreFace incoming; private SwipeAction previewAction; @@ -29,17 +30,26 @@ public ScorePage() incoming = CreateFace(); incoming.InputTransparent = true; incoming.IsVisible = false; - deleteLayer.Add(trash); - viewport.Add(deleteLayer); viewport.Add(incoming); viewport.Add(current); - viewport.SizeChanged += (_, _) => ArrangeTrash(); + viewport.Add(deletePreview); Content = viewport; current.Render(book.Current); + holdTimer = Dispatcher.CreateTimer(); + holdTimer.Interval = TimeSpan.FromMilliseconds(25); + holdTimer.Tick += async (_, _) => await ConfirmDeletion(); } public bool IsAnimating { get; private set; } + public void BeginSwipe() + { + CancelSwipe(); + deleteHold.Reset(); + } + + private static TimeSpan GestureTime => TimeSpan.FromMilliseconds(Android.OS.SystemClock.UptimeMillis()); + private ScoreFace CreateFace() => new(side => Change(() => book.Award(side)), () => Change(book.Undo)); private void Change(Action action) @@ -52,7 +62,7 @@ private void Change(Action action) public void Drag(SwipeAxis axis, float x, float y) { - if (IsAnimating || axis == SwipeAxis.None) return; + if (IsAnimating || deleteHold.IsConfirmed || axis == SwipeAxis.None) return; if (!dragging) { dragging = true; @@ -75,19 +85,19 @@ public void Drag(SwipeAxis axis, float x, float y) } else { - current.TranslationY = Math.Clamp(y, -viewport.Height, 0); - var progress = Math.Clamp(-y / MatchSwipe.DeleteDistance((float)viewport.Height), 0, 1); - deleteLayer.IsVisible = y < 0; - trash.Opacity = 0.45 + 0.55 * progress; - trash.Scale = 0.8 + 0.2 * progress; + deletePreview.Reveal(-y / MatchSwipe.DeleteDistance((float)viewport.Height)); + deleteHold.Update(MatchSwipe.IsDeleteZone(axis, x, y, (float)viewport.Height), GestureTime); + if (!deleteHold.IsWaiting) holdTimer.Stop(); + else if (!holdTimer.IsRunning) holdTimer.Start(); } } public async void FinishSwipe(SwipeAxis axis, float x, float y) { - if (IsAnimating) return; + if (IsAnimating || deleteHold.IsConfirmed) return; Drag(axis, x, y); - var action = MatchSwipe.Recognize(axis, x, y, (float)viewport.Height); + StopDeleteHold(); + var action = MatchSwipe.Recognize(axis, x, y); if (action == SwipeAction.PreviousMatch && !book.HasPrevious) action = SwipeAction.None; var generation = ++animationGeneration; IsAnimating = true; @@ -108,19 +118,10 @@ public async void FinishSwipe(SwipeAxis axis, float x, float y) incoming.InputTransparent = true; current.Render(book.Current); break; - case SwipeAction.DeleteMatch: - await Move(current, 0, -viewport.Height, 130); - if (generation != animationGeneration) return; - book.DeleteCurrent(DateTimeOffset.Now); - storage.Save(book); - current.Render(book.Current); - deleteLayer.IsVisible = false; - current.TranslationY = viewport.Height; - await Move(current, 0, 0, 180); - break; default: var restingX = previewAction == SwipeAction.NextMatch ? viewport.Width : -viewport.Width; - await Task.WhenAll(Move(current, 0, 0, 160), Move(incoming, restingX, 0, 160)); + await Task.WhenAll(Move(current, 0, 0, 160), Move(incoming, restingX, 0, 160), + Move(deletePreview, 0, deletePreview.HiddenOffset, 160)); break; } } @@ -133,39 +134,84 @@ public async void FinishSwipe(SwipeAxis axis, float x, float y) public void CancelSwipe() { animationGeneration++; + StopDeleteHold(); current.CancelAnimations(); incoming.CancelAnimations(); + deletePreview.CancelAnimations(); + if (deleteHold.IsConfirmed) current.Render(book.Current); ResetMotion(); } + private void StopDeleteHold() + { + holdTimer.Stop(); + deleteHold.Cancel(); + } + + private async Task ConfirmDeletion() + { + if (!deleteHold.TryConfirm(GestureTime)) return; + holdTimer.Stop(); + IsAnimating = true; + var generation = ++animationGeneration; + try + { + book.DeleteCurrent(DateTimeOffset.Now); + storage.Save(book); + if (Vibration.Default.IsSupported) Vibration.Default.Vibrate(TimeSpan.FromMilliseconds(80)); + await Task.WhenAll(Move(current, 0, viewport.Height * 0.3, 180, Easing.CubicIn), + Fade(current, 0, 180, Easing.CubicIn)); + if (generation != animationGeneration) return; + current.Render(book.Current); + current.TranslationY = 0; + await Task.WhenAll(Fade(current, 1, 220, Easing.CubicOut), + Move(deletePreview, 0, deletePreview.HiddenOffset, 220)); + } + finally + { + if (generation == animationGeneration) ResetMotion(); + } + } + private void ResetMotion() { current.TranslationX = current.TranslationY = 0; incoming.TranslationX = incoming.TranslationY = 0; + current.Opacity = incoming.Opacity = 1; incoming.IsVisible = false; - deleteLayer.IsVisible = false; + deletePreview.Reveal(0); previewAction = SwipeAction.None; dragging = IsAnimating = false; current.ResetControls(); } - private Task Move(View view, double x, double y, uint milliseconds) + private static Task Move(View view, double x, double y, uint milliseconds, Easing? easing = null) { - var scale = Android.Provider.Settings.Global.GetFloat(Android.App.Application.Context.ContentResolver, - Android.Provider.Settings.Global.AnimatorDurationScale, 1); - if (scale <= 0) + var duration = MotionDuration(milliseconds); + if (duration == 0) { view.TranslationX = x; view.TranslationY = y; return Task.CompletedTask; } - return view.TranslateToAsync(x, y, (uint)(milliseconds * Math.Min(scale, 2)), Easing.CubicOut); + return view.TranslateToAsync(x, y, duration, easing ?? Easing.CubicOut); } - private void ArrangeTrash() + private static Task Fade(View view, double opacity, uint milliseconds, Easing easing) { - var size = Math.Min(viewport.Width, viewport.Height); - AbsoluteLayout.SetLayoutBounds(trash, new Rect((viewport.Width - 48) / 2, - (viewport.Height - size) / 2 + size * 0.8 - 24, 48, 48)); + var duration = MotionDuration(milliseconds); + if (duration == 0) + { + view.Opacity = opacity; + return Task.CompletedTask; + } + return view.FadeToAsync(opacity, duration, easing); + } + + private static uint MotionDuration(uint milliseconds) + { + var scale = Android.Provider.Settings.Global.GetFloat(Android.App.Application.Context.ContentResolver, + Android.Provider.Settings.Global.AnimatorDurationScale, 1); + return (uint)(milliseconds * Math.Clamp(scale, 0, 2)); } } diff --git a/tennis-watch/tests/TennisWatch.Tests/DeleteHoldTests.cs b/tennis-watch/tests/TennisWatch.Tests/DeleteHoldTests.cs new file mode 100644 index 0000000..ce9c436 --- /dev/null +++ b/tennis-watch/tests/TennisWatch.Tests/DeleteHoldTests.cs @@ -0,0 +1,70 @@ +using TennisWatch.Core.Matches; + +namespace TennisWatch.Tests; + +public sealed class DeleteHoldTests +{ + [Theory] + [InlineData(0, false)] + [InlineData(999, false)] + [InlineData(1000, true)] + [InlineData(1500, true)] + public void Confirmation_requires_a_full_second_in_the_delete_zone(int milliseconds, bool expected) + { + var hold = new MatchDeleteHold(); + hold.Update(true, TimeSpan.FromSeconds(5)); + Assert.True(hold.IsWaiting); + Assert.Equal(expected, hold.TryConfirm(TimeSpan.FromMilliseconds(5000 + milliseconds))); + Assert.Equal(expected, hold.IsConfirmed); + } + + [Theory] + [InlineData(100)] + [InlineData(900)] + public void Movement_inside_the_zone_keeps_the_original_deadline(int milliseconds) + { + var hold = new MatchDeleteHold(); + hold.Update(true, TimeSpan.Zero); + hold.Update(true, TimeSpan.FromMilliseconds(milliseconds)); + Assert.True(hold.TryConfirm(TimeSpan.FromSeconds(1))); + Assert.False(hold.IsWaiting); + hold.Update(true, TimeSpan.FromSeconds(2)); + Assert.False(hold.TryConfirm(TimeSpan.FromSeconds(4))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Leaving_or_canceling_the_zone_requires_a_new_full_hold(bool cancel) + { + var hold = new MatchDeleteHold(); + hold.Update(true, TimeSpan.Zero); + if (cancel) hold.Cancel(); + else hold.Update(false, TimeSpan.FromMilliseconds(900)); + Assert.False(hold.IsWaiting); + Assert.False(hold.TryConfirm(TimeSpan.FromSeconds(2))); + hold.Update(true, TimeSpan.FromSeconds(3)); + Assert.False(hold.TryConfirm(TimeSpan.FromMilliseconds(3999))); + Assert.True(hold.TryConfirm(TimeSpan.FromSeconds(4))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void A_gesture_can_confirm_only_once_until_reset(bool cancelAfterConfirmation) + { + var hold = new MatchDeleteHold(); + Assert.False(hold.TryConfirm(TimeSpan.FromSeconds(10))); + hold.Update(true, TimeSpan.FromSeconds(10)); + Assert.True(hold.TryConfirm(TimeSpan.FromSeconds(11))); + if (cancelAfterConfirmation) hold.Cancel(); + hold.Update(false, TimeSpan.FromSeconds(12)); + hold.Update(true, TimeSpan.FromSeconds(13)); + Assert.False(hold.TryConfirm(TimeSpan.FromSeconds(20))); + hold.Reset(); + Assert.False(hold.IsConfirmed); + Assert.False(hold.TryConfirm(TimeSpan.FromSeconds(21))); + hold.Update(true, TimeSpan.FromSeconds(21)); + Assert.True(hold.TryConfirm(TimeSpan.FromSeconds(22))); + } +} diff --git a/tennis-watch/tests/TennisWatch.Tests/MatchBookTests.cs b/tennis-watch/tests/TennisWatch.Tests/MatchBookTests.cs index 3562e8c..5029190 100644 --- a/tennis-watch/tests/TennisWatch.Tests/MatchBookTests.cs +++ b/tennis-watch/tests/TennisWatch.Tests/MatchBookTests.cs @@ -69,7 +69,7 @@ public void Match_navigation_keeps_each_history(int extraPreviousSwipes) [InlineData(100, 80, SwipeAction.None)] [InlineData(5, 100, SwipeAction.None)] public void Only_deliberate_horizontal_swipes_navigate(float dx, float dy, SwipeAction expected) => - Assert.Equal(expected, MatchSwipe.Recognize(SwipeAxis.Horizontal, dx, dy, 225)); + Assert.Equal(expected, MatchSwipe.Recognize(SwipeAxis.Horizontal, dx, dy)); [Theory] [InlineData(-1, 0)] diff --git a/tennis-watch/tests/TennisWatch.Tests/SwipeTests.cs b/tennis-watch/tests/TennisWatch.Tests/SwipeTests.cs index df76a6d..8043006 100644 --- a/tennis-watch/tests/TennisWatch.Tests/SwipeTests.cs +++ b/tennis-watch/tests/TennisWatch.Tests/SwipeTests.cs @@ -15,18 +15,25 @@ public void Gesture_axis_requires_clear_direction(float x, float y, SwipeAxis ex Assert.Equal(expected, MatchSwipe.Axis(x, y)); [Theory] - [InlineData(SwipeAxis.Vertical, 0, -102, 225, SwipeAction.DeleteMatch)] - [InlineData(SwipeAxis.Vertical, 0, -101, 225, SwipeAction.None)] - [InlineData(SwipeAxis.Vertical, 0, -96, 192, SwipeAction.DeleteMatch)] - [InlineData(SwipeAxis.Vertical, 0, -95, 192, SwipeAction.None)] - [InlineData(SwipeAxis.Vertical, 0, -110, 250, SwipeAction.None)] - [InlineData(SwipeAxis.Vertical, 0, -113, 250, SwipeAction.DeleteMatch)] - [InlineData(SwipeAxis.Vertical, 0, 200, 225, SwipeAction.None)] - [InlineData(SwipeAxis.Vertical, 100, -150, 225, SwipeAction.None)] - [InlineData(SwipeAxis.Vertical, 80, -150, 225, SwipeAction.DeleteMatch)] - [InlineData(SwipeAxis.Horizontal, 0, -200, 225, SwipeAction.None)] - [InlineData(SwipeAxis.None, 0, -200, 225, SwipeAction.None)] - public void Delete_requires_a_completed_upward_swipe_on_the_locked_axis( - SwipeAxis axis, float x, float y, float height, SwipeAction expected) => - Assert.Equal(expected, MatchSwipe.Recognize(axis, x, y, height)); + [InlineData(SwipeAxis.Vertical, 0, -112.5f, 225, true)] + [InlineData(SwipeAxis.Vertical, 0, -112, 225, false)] + [InlineData(SwipeAxis.Vertical, 0, -96, 192, true)] + [InlineData(SwipeAxis.Vertical, 0, -95, 192, false)] + [InlineData(SwipeAxis.Vertical, 0, -124, 250, false)] + [InlineData(SwipeAxis.Vertical, 0, -125, 250, true)] + [InlineData(SwipeAxis.Vertical, 0, 200, 225, false)] + [InlineData(SwipeAxis.Vertical, 100, -150, 225, false)] + [InlineData(SwipeAxis.Vertical, 80, -150, 225, true)] + [InlineData(SwipeAxis.Horizontal, 0, -200, 225, false)] + [InlineData(SwipeAxis.None, 0, -200, 225, false)] + public void Hold_starts_only_after_a_deliberate_upward_drag_reaches_the_midpoint( + SwipeAxis axis, float x, float y, float height, bool expected) => + Assert.Equal(expected, MatchSwipe.IsDeleteZone(axis, x, y, height)); + + [Theory] + [InlineData(-120)] + [InlineData(-300)] + [InlineData(300)] + public void Releasing_a_vertical_swipe_never_deletes(float y) => + Assert.Equal(SwipeAction.None, MatchSwipe.Recognize(SwipeAxis.Vertical, 0, y)); }