Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions tennis-watch/.impeccable/design.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -91,6 +91,10 @@
"deletion": {
"role": "error",
"displayName": "Deletion red"
},
"on-deletion": {
"role": "neutral",
"displayName": "Trash charcoal"
}
},
"typographyMeta": {
Expand Down Expand Up @@ -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": "<div class=\"ds-trash-preview\" aria-label=\"Delete match preview\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"48\" height=\"48\" viewBox=\"0 0 48 48\">\n <g fill=\"none\" stroke=\"#FF6B6B\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d=\"M12 15h24M19 15v-4h10v4M15 15l2 24h14l2-24M21 21v12M27 21v12\"/>\n </g>\n</svg></div>",
"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": "<div class=\"ds-delete-face\" aria-label=\"Hold to delete match\"><div class=\"ds-delete-background\"></div><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"48\" height=\"48\" viewBox=\"0 0 48 48\">\n <g fill=\"none\" stroke=\"#111511\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d=\"M12 15h24M19 15v-4h10v4M15 15l2 24h14l2-24M21 21v12M27 21v12\"/>\n </g>\n</svg></div>",
"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": {
Expand All @@ -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": [
Expand Down
10 changes: 8 additions & 2 deletions tennis-watch/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ colors:
separator: "#8B938D"
divider: "#343936"
deletion: "#FF6B6B"
on-deletion: "#111511"
typography:
display:
fontFamily: "sans-serif-condensed"
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion tennis-watch/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion tennis-watch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Binary file modified tennis-watch/docs/screenshots/delete-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 9 additions & 7 deletions tennis-watch/docs/verification.md
Original file line number Diff line number Diff line change
@@ -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 |
| --- | --- |
Expand All @@ -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.
31 changes: 31 additions & 0 deletions tennis-watch/src/TennisWatch.Core/Matches/MatchDeleteHold.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
21 changes: 10 additions & 11 deletions tennis-watch/src/TennisWatch.Core/Matches/MatchSwipe.cs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.type.watch" android:required="true" />
<uses-feature android:name="android.hardware.touchscreen" android:required="true" />
<uses-permission android:name="android.permission.VIBRATE" />
<application android:allowBackup="true" android:supportsRtl="false" android:theme="@style/TennisTheme"
android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round">
<meta-data android:name="com.google.android.wearable.standalone" android:value="true" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion tennis-watch/src/TennisWatch/Resources/Images/trash.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 38 additions & 0 deletions tennis-watch/src/TennisWatch/Scoring/DeletePreview.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading