Smartwatch Companion
4league ships a companion app for Wear OS and watchOS. Referees, Team Managers, and organizers can start matches, log goals, cards, fouls, and substitutions directly from the wrist — and fans can glance at a ranking-position widget to see where their followed teams sit across every competition they play in.
The watch app is a companion to the 4league phone app — all authentication, API calls, and data writes flow through the phone. If the phone is out of Bluetooth range or the 4league app is force-quit, the watch will show a connecting/waiting state until it reconnects.
Overview
The smartwatch companion has two main capabilities:
- Live match control — available to users with match-editing rights (organizers, referees, Team Managers). Start a match, add goals / cards / fouls / substitutions, and end the match — all from the watch.
- Ranking-position widget — available to every user. Glanceable list of every competition your followed teams play in, showing current rank, points, and a daily ▲/▼ arrow vs. the previous day's snapshot.
The watch does not hold its own session. On launch it waits for the phone to push the signed-in user's session over — until that handshake arrives, the companion renders a waiting screen.
Platforms
| Platform | Tech stack | Target | Package / Bundle |
|---|---|---|---|
| Wear OS | Kotlin, Jetpack Compose for Wear, Material3 for Wear, Wearable Data Layer API | Wear OS 3+ (API 30+) | com.fourleague.watch |
| watchOS | Swift, SwiftUI, WatchConnectivity | watchOS 9+ | com.nativeuplab.fourleague.watchkitapp |
Wear OS currently has the only complete feature set. On watchOS, only the match list is wired into the navigation graph today — tapping a match lands on a placeholder Text destination, and the live-match, action-menu, foul, goal, card, and substitution views in the watchOS source tree are not yet attached. See the feature-parity table below for the per-feature breakdown.
Feature parity
Both watch apps share the same backend, the same session-handoff model, and the same paired-phone-as-source-of-truth pattern. The live-match action set is fully built and wired on Wear OS; on watchOS, only the match list is reachable today — every screen past it lands on a Text placeholder.
| Feature | Wear OS | watchOS |
|---|---|---|
| Match list | ✓ | ✓ |
| Live match (score / timer / pause+resume) | ✓ | ⚠ View exists but not wired |
| Start match / End match | ✓ | ⚠ View exists but not wired |
| Goal entry (scorer + assist) | ✓ | ⚠ Placeholder |
| Card entry (yellow / red) | ✓ | ⚠ Placeholder (referenced view undefined) |
| Foul entry | ✓ | ⚠ View exists but not wired |
| Substitution | ✓ | ⚠ Placeholder (referenced view undefined) |
| Rankings widget | ✓ | ✗ |
Two flavours of "not done" on watchOS today:
- ⚠ View exists but not wired — the SwiftUI
Viewstruct is present inwatches/watchos/FourLeagueWatch/Presentation/Screens/, but noNavigationLinkornavigationDestinationreaches it. The tap target currently resolves to a placeholderText. This is the case forLiveMatchView,ActionMenuView, andFoulView. - ⚠ Placeholder (referenced view undefined) —
ActionMenuView.swiftreferencesCardEntryView()andSubstitutionView(), but nostruct CardEntryVieworstruct SubstitutionViewexists in the watchOS source tree. The file would fail to compile the momentActionMenuViewwere actually wired into navigation.
Pairing & Session Sync
Installing the 4league watch app from the Play Store (Wear OS) or App Store (watchOS) is enough — there is no separate login on the watch. The phone app detects a paired watch with the companion installed and pushes a session envelope via the Wearable Data Layer / WatchConnectivity.
Install the watch app
From the phone's companion store, or directly on the watch if the user browses for "4league" on a Wear OS device.
Open the 4league phone app
Log in on the phone as usual. As soon as the phone detects a reachable watch, it pushes the current session over to the companion automatically.
Open the watch app
The waiting screen resolves as soon as the session envelope has been received. The main menu then offers Matches and Rankings.
Live Match Screens
When the signed-in user has match-editing rights (organizer, referee, or Team Manager) the watch exposes the full live-match flow:
Wear OS navigation
Wear OS ├── WaitingScreen — pre-session ├── MatchListScreen — upcoming/in-progress matches │ └── LiveMatchScreen — running clock, score, team crests │ ├── GoalEntryScreen — tap a side to add a goal │ ├── ActionMenuScreen — long-press to open │ ├── CardEntryScreen — yellow / red card │ ├── FoulScreen │ └── SubstitutionScreen └── RankingsScreen — ranking-position widget (all users) └── RankingDetailScreen — Top 5 of the tapped competition
watchOS navigation
watchOS (reachable today) ├── WaitingView — pre-session └── MatchListView — upcoming/in-progress matches └── Text("Live Match: {id}") — placeholder destination
Note: the watchOS source tree also ships LiveMatchView, ActionMenuView, and FoulView, but none of them are attached to the navigation graph — WaitingView's only navigationDestination for .liveMatch renders a placeholder Text. Goal Entry, Card Entry, and Substitution are placeholders as well (and the references inside ActionMenuView point at view types that don't exist yet). The Rankings widget is Wear OS only today and does not appear in the watchOS navigation graph.
The match list contains only matches the signed-in user has editing rights on. The phone app is the source of truth for that filtering — it computes the list against the live user session and pushes the resulting payload to the watch, so the watch itself does no permission checks and does not query the backend for match data.
Role gating
The watch app contains no role-check logic — it is a thin client. The phone (and backend) decide what to push: fans and followers see only the Rankings widget (Wear OS only today — watchOS has no rankings screen); only users with match-editing rights for a given match (organizer, referee, or Team Manager) receive that match in the watch's match list. The rankings endpoint additionally filters out competitions where visibility === 0 or !onboardingDone server-side (see backend/controllers/Competition.js), so private or unpublished competitions never reach the wrist.
Ranking-Position Widget
The rankings widget is a glanceable tile for every competition that a followed team plays in — visible to every plan (Free, PRO Player, PRO, Enterprise). It's designed so a fan who follows three teams across five competitions can see all five positions in one scroll without opening the phone.
What the user sees
Each tile on the Rankings screen shows:
- The organization name (if the competition belongs to one) as a small header.
- The team name — large, bold.
- The current rank (e.g. "#4") and points in that competition.
- A delta arrow on the right side:
- ▲ green — team moved up since the previous day's snapshot.
- ▼ red — team moved down.
- — grey — rank unchanged since yesterday.
- · dim — no prior snapshot yet (first-run or newly published competition). Rendered as a no-arrow state on purpose; zero and null are different states.
The backend emits delta = priorRank − currentRank. Positive means the team climbed positions (e.g. 5 → 3 gives delta = +2 → green ▲). This matches the sign convention the rankingPositionChange push notification uses, so the arrow the user saw in the notification matches the arrow on the watch tile.
Stale-data hint
If the last successful fetch is more than 5 minutes old while the Rankings screen is open, a small yellow "Updated >5m ago" line appears above the list. The screen re-evaluates staleness every 30 seconds while visible — the ticker only runs after the first response, so it doesn't burn battery before the user has any data.
Empty state
If the user follows no teams, or every followed team's competition is private/unpublished, the screen shows "No rankings available" plus a manual Refresh button. Private competitions are never shown on the watch to users who can't see them.
Backend endpoint
The Rankings screen is backed by a single endpoint — GET /competition/watch-rankings (also accepts POST for clients that prefer to send the team list in a body). It takes the followed team IDs as a repeated teamIds query param or a body array, walks each followed team's competitions, drops anything private (visibility === 0) or with onboardingDone === false, and returns the tile payload:
{
"tiles": [
{
"rankingId": "...",
"orgId": "...",
"orgName": "...",
"orgLogo": "...",
"teamId": "...",
"teamName": "...",
"teamCrest": "...",
"rank": 4,
"points": 27,
"delta": 2,
"top5": [ ... ]
}
]
}
The same pipeline that Competition.getStandings uses computes rank and points, so a tile's number always matches the standings screen on the phone. The top5 array is what powers RankingDetailScreen's Top 5 view.
Rankings Screens
RankingsScreen — the tile list
Vertical ScalingLazyColumn of RankingTileCards, one per (team, competition) pair. The screen requests fresh data on navigation and on manual refresh; the list renders whatever the repository last observed and updates reactively when fresh data arrives.
RankingDetailScreen — Top 5 of one competition
Tapping a tile opens a detail view that renders the top 5 rows of that competition's standings — the same ranking the user sees on the phone's standings screen. The followed team is highlighted with a green left-edge rule and bold text.
If the followed team is not in the top 5 (say they sit 9th), the screen shows the top 5, a ··· separator, and then one highlighted row for the followed team's current position.
Alex follows two teams — his son's U14 club and his local Sunday-league team. Between matches he raises his wrist, sees the U14 side has moved up one place (▲ +1) and the Sunday team held steady (—). He taps the U14 tile and confirms the club is now 2nd with the leader just 1 point ahead.
Related: the rankingPositionChange push
Complementing the glanceable widget is an on-demand push notification (rankingPositionChange) that fires when a followed team's position moves relative to the organization-level ranking system. Tapping the notification deep-links into the phone app's RankingTable screen for the relevant organization.
Because the notification's positionChange uses the same positive-means-moved-up convention as the watch tile's delta, the ▲/▼ the user saw in the push matches the ▲/▼ they see when they raise their wrist a moment later.
Notification thresholds (how many positions a team must move before a push is fired) are configurable per organization from the organization profile — see that page's admin settings for the audit-logged threshold control.