diff --git a/Makefile b/Makefile index 0cad865..685f927 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: unit integration test build-scoreboards test-scoreboards build-ios build-android release +.PHONY: unit integration test serve-web build-scoreboards test-scoreboards build-ios build-android release # Run unit and widget tests only (fast, no device required) unit: @@ -11,6 +11,10 @@ integration: # Run all tests test: unit integration +# Serve the static support website locally at http://localhost:8080 +serve-web: + python3 -m http.server 8080 --directory support-page + # Build Docker images for each scoreboard version (run once, before test-scoreboards) build-scoreboards: ./scripts/build-scoreboard-images.sh diff --git a/docs/JAM_TIMER_PROCEDURES.md b/docs/JAM_TIMER_PROCEDURES.md new file mode 100644 index 0000000..68ac900 --- /dev/null +++ b/docs/JAM_TIMER_PROCEDURES.md @@ -0,0 +1,49 @@ +# WFTDA Jam Timer Responsibilities & Signals + +- **Initial lineup / teams report to track** + - Responsibility: Use a rolling whistle to call teams to the track and begin lineup procedures. + - Source: “Whistles” section (“[Rolling Whistle]”) in the officiating cues document. + +- **Five seconds before a jam begins** + - Responsibility: Verbally call “Five Seconds” while raising one hand with all five fingers extended toward the track. + - Source: “Jam Start and Ending” → “Five Seconds” + +- **Jam starts** + - Responsibility: Blow 1 short whistle blast while lowering the raised hand and pointing toward the track in front of the foremost blocker. + - Source: “Jam Start” + +- **Jam ends (called off or time expires)** + - Responsibility: Blow 4 rapid short whistle blasts while repeatedly lifting hands off hips with elbows pointed outward. + - Source: “Jam Ending” + +- **Team Timeout (TTO)** + - Responsibility: Verbally announce “Timeout, [Team Color]” while forming a clear “T” shape with the hands. + - Source: “Play Stoppages” → “Team Timeout” + +- **Official Timeout (OTO)** + - Responsibility: Verbally announce “Official Timeout” while tapping the tops of the shoulders with fingertips, elbows extended at shoulder height. + - Source: “Play Stoppages” → “Official Timeout” + +- **Official Review (OR)** + - Responsibility: Verbally announce “Official Review, [Team Color]” while forming a round “O” shape with fingers and thumbs. + - Source: “Official Review” + +- **Timeout / Official Timeout / Official Review begins** + - Responsibility: Use 4 rapid short whistle blasts to stop the period clock and signal time stoppage. + - Source: “Whistles” → “[Four Rapid, Short Blasts]” + +- **Timeout or period officially ends** + - Responsibility: Use a rolling whistle. + - Source: “Whistles” → “[Rolling Whistle]” + +- **End of period/game after final jam and review window** + - Responsibility: Wait until reviews are resolved or 30 seconds after the final jam ends, then use the rolling whistle to officially end the period. + - Source: “Whistles” → “LEARN MORE” under “[Rolling Whistle]” + +# Sources + +- WFTDA Officiating Cues, Codes and Signals (December 2018) + - https://static.wftda.com/officiating/wftda-officiating-cues-codes-and-signals.pdf + +- WFTDA Rules of Flat Track Roller Derby + - https://rules.wftda.com/01_params.html \ No newline at end of file diff --git a/lib/screens/jam_timer/jam_timer_layout.dart b/lib/screens/jam_timer/jam_timer_layout.dart index 14ddd29..be67448 100644 --- a/lib/screens/jam_timer/jam_timer_layout.dart +++ b/lib/screens/jam_timer/jam_timer_layout.dart @@ -62,6 +62,11 @@ extension _JamTimerLayout on _JamTimerScreenState { onPressed: () => _navigateToHome(context), ), actions: [ + IconButton( + tooltip: 'Jam timer signals', + onPressed: () => _openProcedureHelper(context), + icon: const _WhistleHelpIcon(), + ), if (_isLocalMode) Padding( padding: const EdgeInsets.only(right: 12), @@ -279,13 +284,15 @@ extension _JamTimerLayout on _JamTimerScreenState { // Disable controls when the game is officially over, during active // halftime intermission, or during unofficial score with no remaining // jams (noMoreJam=false means overtime is still possible, so keep live). - final controlsEnabled = isEnabled && + final controlsEnabled = + isEnabled && !_isGameOver(state) && !_isHalftimeIntermission(state) && !(_isUnofficialScore(state) && state.noMoreJam); final timeoutRunning = state.clocks['Timeout']?.running ?? false; final lineupRunning = state.clocks['Lineup']?.running ?? false; - final inOverlapTimeoutState = timeoutRunning && (state.inJam || lineupRunning); + final inOverlapTimeoutState = + timeoutRunning && (state.inJam || lineupRunning); final showTimeoutOnly = timeoutRunning && !inOverlapTimeoutState; return Container( @@ -347,3 +354,148 @@ extension _JamTimerLayout on _JamTimerScreenState { ); } } + +class _WhistleHelpIcon extends StatelessWidget { + const _WhistleHelpIcon(); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 32, + height: 32, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: CustomPaint( + painter: _WhistleIconPainter(color: Colors.white70), + ), + ), + Positioned( + right: -2, + top: -2, + child: Container( + width: 15, + height: 15, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.orange.shade400, + shape: BoxShape.circle, + border: Border.all(color: const Color(0xFF121212), width: 1.5), + ), + child: const Text( + '?', + style: TextStyle( + color: Colors.black, + fontSize: 11, + height: 1, + fontWeight: FontWeight.w900, + ), + ), + ), + ), + ], + ), + ); + } +} + +class _WhistleIconPainter extends CustomPainter { + final Color color; + + const _WhistleIconPainter({required this.color}); + + @override + void paint(Canvas canvas, Size size) { + // GiWhistle from react-icons/game-icons.net, CC Attribution. + final icon = _giWhistlePath(); + final bounds = icon.getBounds(); + final scale = + (size.shortestSide * 0.82) / + (bounds.width > bounds.height ? bounds.width : bounds.height); + final dx = (size.width - bounds.width * scale) / 2 - bounds.left * scale; + final dy = (size.height - bounds.height * scale) / 2 - bounds.top * scale; + + canvas.save(); + canvas.translate(dx, dy); + canvas.scale(scale); + canvas.drawPath( + icon, + Paint() + ..color = color + ..style = PaintingStyle.fill, + ); + canvas.restore(); + } + + Path _giWhistlePath() { + return Path() + ..moveTo(93.75, 81.443) + ..relativeCubicTo(-5.38, 0, -12.368, 2.49, -22.358, 8.967) + ..relativeCubicTo(3.966, 4.682, 8.167, 9.687, 16.47, 19.256) + ..relativeCubicTo(5.782, 6.663, 11.618, 13.29, 16.026, 18.088) + ..relativeCubicTo(0.038, 0.042, 0.055, 0.055, 0.092, 0.096) + ..relativeLineTo(30.894, -17.932) + ..relativeLineTo(-14.652, -14.148) + ..relativeCubicTo(-11.292, -9.404, -18.644, -13.866, -25.418, -14.293) + ..relativeCubicTo(-0.345, -0.022, -0.696, -0.034, -1.055, -0.034) + ..close() + ..moveTo(213.83, 96.525) + ..relativeCubicTo(-0.885, -0.01, -1.767, -0.006, -2.643, 0.01) + ..relativeCubicTo(-10.46, 0.193, -20.2, 2.23, -26.742, 5.424) + ..relativeLineTo(-67.262, 39.038) + ..relativeCubicTo(2.45, 0.544, 4.885, 1.196, 7.287, 2.02) + ..relativeCubicTo(17.275, 5.923, 33.093, 18.223, 49.568, 34.7) + ..relativeLineTo(216.44, 213.5) + ..relativeLineTo(80.978, -44.433) + ..lineTo(258.54, 111.38) + ..relativeCubicTo(-8.656, -7.84, -22.49, -12.908, -36.693, -14.394) + ..relativeCubicTo(-2.677, -0.28, -5.363, -0.43, -8.018, -0.46) + ..close() + ..moveTo(58.192, 102.74) + ..relativeCubicTo(-17.543, 20.723, -20.57, 37.186, -15.326, 57.004) + ..relativeCubicTo(0.692, 2.618, 3.057, 6.357, 6.373, 10.47) + ..relativeCubicTo(2.195, -3.144, 4.55, -6.304, 7.086, -9.478) + ..relativeCubicTo(3.99, -4.995, 8.385, -9.183, 13.085, -12.558) + ..relativeLineTo(-0.106, -0.2) + ..relativeLineTo(2.768, -1.61) + ..relativeCubicTo(1.354, -0.862, 2.73, -1.66, 4.13, -2.393) + ..relativeLineTo(11.868, -6.89) + ..relativeCubicTo(-4.175, -4.618, -8.94, -10.017, -13.803, -15.622) + ..relativeCubicTo(-5.956, -6.864, -11.732, -13.62, -16.074, -18.723) + ..close() + ..moveTo(242.285, 116.178) + ..relativeLineTo(58.415, 61.67) + ..relativeCubicTo(-46.086, -5.037, -56.79, 13.2, -69.027, 34.2) + ..relativeLineTo(-57.334, -59.304) + ..relativeLineTo(67.946, -36.566) + ..close() + ..moveTo(103.702, 157.23) + ..relativeCubicTo(-0.714, -0.016, -1.43, -0.016, -2.15, 0.002) + ..relativeCubicTo(-6.976, 0.18, -14.207, 2.058, -22.252, 5.885) + ..relativeCubicTo(-3.035, 2.29, -5.99, 5.196, -8.91, 8.852) + ..relativeCubicTo(-25.77, 32.264, -30.45, 59.135, -25.484, 83.477) + ..relativeCubicTo(4.965, 24.343, 20.536, 46.656, 37.916, 66.455) + ..relativeCubicTo(13.314, 15.168, 28.86, 23.992, 48.472, 27.93) + ..relativeCubicTo(19.614, 3.94, 43.438, 2.708, 71.98, -3.475) + ..relativeCubicTo(33.246, -7.2, 66.01, 8.42, 95.81, 27.665) + ..relativeCubicTo(26.118, 16.868, 50.676, 37.09, 70.98, 49.95) + ..relativeLineTo(8.79, -18.935) + ..relativeLineTo(-217.52, -214.57) + ..relativeLineTo(-0.022, -0.022) + ..relativeCubicTo(-15.524, -15.524, -29.565, -25.905, -42.682, -30.402) + ..relativeCubicTo(-5.02, -1.722, -9.925, -2.695, -14.928, -2.813) + ..close() + ..moveTo(470.782, 367.686) + ..relativeLineTo(-73.45, 40.304) + ..relativeLineTo(-10.48, 22.567) + ..relativeLineTo(70.833, -38.41) + ..relativeLineTo(13.096, -24.46) + ..close(); + } + + @override + bool shouldRepaint(covariant _WhistleIconPainter oldDelegate) { + return oldDelegate.color != color; + } +} diff --git a/lib/screens/jam_timer_procedure_helper_screen.dart b/lib/screens/jam_timer_procedure_helper_screen.dart new file mode 100644 index 0000000..afa9805 --- /dev/null +++ b/lib/screens/jam_timer_procedure_helper_screen.dart @@ -0,0 +1,330 @@ +import 'package:flutter/material.dart'; +import 'package:jam_ready/styles/background.dart'; + +class JamTimerProcedureHelperScreen extends StatelessWidget { + const JamTimerProcedureHelperScreen({super.key}); + + static const List<_ProcedureGroup> _groups = [ + _ProcedureGroup( + title: 'Jam Flow', + items: [ + _ProcedureItem( + cue: 'Initial lineup', + signal: 'Rolling whistle', + detail: 'Call teams to the track and begin lineup procedures.', + ), + _ProcedureItem( + cue: '5 seconds before jam start', + signal: 'Verbal call plus raised hand', + detail: + 'Call "Five Seconds" with one hand raised and all five fingers extended toward the track.', + ), + _ProcedureItem( + cue: 'Jam starts', + signal: '1 short whistle blast', + detail: + 'Lower the raised hand and point toward the track in front of the foremost blocker.', + ), + _ProcedureItem( + cue: 'Jam ends', + signal: '4 rapid short blasts', + detail: + 'Use the jam ending signal with hands repeatedly lifting off hips, elbows outward.', + ), + ], + ), + _ProcedureGroup( + title: 'Timeouts and Reviews', + items: [ + _ProcedureItem( + cue: 'Timeout, OTO, or review begins', + signal: '4 rapid short blasts', + detail: 'Stop the period clock and signal the time stoppage.', + ), + _ProcedureItem( + cue: 'Team Timeout', + signal: 'Announce "Timeout, [Team Color/Name]"', + detail: 'Form a clear T shape with the hands.', + ), + _ProcedureItem( + cue: 'Official Timeout', + signal: 'Announce "Official Timeout"', + detail: + 'Tap the tops of the shoulders with fingertips, elbows extended at shoulder height.', + ), + _ProcedureItem( + cue: 'Official Review', + signal: 'Announce "Official Review, [Team Color/Name]"', + detail: 'Form a round O shape with fingers and thumbs.', + ), + _ProcedureItem( + cue: 'Timeout ends', + signal: 'Rolling whistle', + detail: + 'Use to close the timeout. Recommended to verbally indicate to players this whistle ends timeout only.', + ), + ], + ), + _ProcedureGroup( + title: 'Period End and Other Whistles', + items: [ + _ProcedureItem( + cue: 'End of period or game', + signal: 'Rolling whistle after review window', + detail: + 'Wait until reviews are resolved or 30 seconds after the final jam ends.', + ), + ], + ), + ]; + + @override + Widget build(BuildContext context) { + return DynamicBackground( + accentColor: Colors.orange.shade400, + child: Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + title: const Text('Jam Timer Signals'), + backgroundColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + foregroundColor: Colors.white, + ), + body: SafeArea(child: _ScrollableProcedureList(groups: _groups)), + ), + ); + } +} + +class _ScrollableProcedureList extends StatefulWidget { + final List<_ProcedureGroup> groups; + + const _ScrollableProcedureList({required this.groups}); + + @override + State<_ScrollableProcedureList> createState() => + _ScrollableProcedureListState(); +} + +class _ScrollableProcedureListState extends State<_ScrollableProcedureList> { + final ScrollController _scrollController = ScrollController(); + bool _hasMoreBelow = false; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_updateScrollHint); + WidgetsBinding.instance.addPostFrameCallback((_) => _updateScrollHint()); + } + + @override + void dispose() { + _scrollController.removeListener(_updateScrollHint); + _scrollController.dispose(); + super.dispose(); + } + + void _updateScrollHint() { + if (!_scrollController.hasClients) return; + + final hasMoreBelow = + _scrollController.position.extentAfter > 20 && + _scrollController.position.maxScrollExtent > 0; + if (hasMoreBelow == _hasMoreBelow) return; + + setState(() { + _hasMoreBelow = hasMoreBelow; + }); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + ListView( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 44), + children: [ + for (final group in widget.groups) ...[ + _ProcedureGroupView(group: group), + const SizedBox(height: 14), + ], + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + 'Reference: WFTDA Officiating Cues, Codes and Signals, December 2018.', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.52), + fontSize: 12, + ), + ), + ), + ], + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: IgnorePointer( + child: AnimatedOpacity( + opacity: _hasMoreBelow ? 1 : 0, + duration: const Duration(milliseconds: 160), + child: const _ScrollHint(), + ), + ), + ), + ], + ); + } +} + +class _ScrollHint extends StatelessWidget { + const _ScrollHint(); + + @override + Widget build(BuildContext context) { + return Container( + height: 54, + alignment: Alignment.bottomCenter, + padding: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + const Color(0xFF0E0F12).withValues(alpha: 0.86), + ], + ), + ), + child: Container( + width: 34, + height: 24, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.46), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: Colors.white.withValues(alpha: 0.14)), + ), + child: Icon( + Icons.keyboard_arrow_down_rounded, + color: Colors.orange.shade200, + size: 22, + ), + ), + ); + } +} + +class _ProcedureGroupView extends StatelessWidget { + final _ProcedureGroup group; + + const _ProcedureGroupView({required this.group}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + group.title.toUpperCase(), + style: TextStyle( + color: Colors.orange.shade300, + fontSize: 12, + fontWeight: FontWeight.w900, + letterSpacing: 0.9, + ), + ), + ), + SizedBox( + width: double.infinity, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.045), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.white.withValues(alpha: 0.1)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < group.items.length; i++) ...[ + _ProcedureItemView(item: group.items[i]), + if (i != group.items.length - 1) + Divider( + height: 1, + color: Colors.white.withValues(alpha: 0.09), + ), + ], + ], + ), + ), + ), + ], + ); + } +} + +class _ProcedureItemView extends StatelessWidget { + final _ProcedureItem item; + + const _ProcedureItemView({required this.item}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(14, 13, 14, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.cue, + style: const TextStyle( + color: Colors.white, + fontSize: 17, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 6), + Text( + item.signal, + style: TextStyle( + color: Colors.orange.shade200, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + item.detail, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.72), + fontSize: 14, + height: 1.32, + ), + ), + ], + ), + ); + } +} + +class _ProcedureGroup { + final String title; + final List<_ProcedureItem> items; + + const _ProcedureGroup({required this.title, required this.items}); +} + +class _ProcedureItem { + final String cue; + final String signal; + final String detail; + + const _ProcedureItem({ + required this.cue, + required this.signal, + required this.detail, + }); +} diff --git a/lib/screens/jam_timer_screen.dart b/lib/screens/jam_timer_screen.dart index e210980..4143c5e 100644 --- a/lib/screens/jam_timer_screen.dart +++ b/lib/screens/jam_timer_screen.dart @@ -14,6 +14,7 @@ import '../widgets/prominent_period_clock.dart'; import '../widgets/swipe_button.dart'; import '../widgets/team_panel.dart'; import 'home_screen.dart'; +import 'jam_timer_procedure_helper_screen.dart'; part 'jam_timer/jam_timer_layout.dart'; part 'jam_timer/jam_timer_controls.dart'; @@ -150,6 +151,14 @@ class _JamTimerScreenState extends State } } + void _openProcedureHelper(BuildContext context) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const JamTimerProcedureHelperScreen(), + ), + ); + } + @override Widget build(BuildContext context) { return Consumer( diff --git a/pubspec.yaml b/pubspec.yaml index e40fb50..fc14350 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+2 +version: 1.1.0+4 environment: sdk: ^3.11.0 diff --git a/support-page/index.html b/support-page/index.html index c8fdea5..68305a8 100644 --- a/support-page/index.html +++ b/support-page/index.html @@ -25,7 +25,10 @@ JamReady icon JamReady - Contact +
@@ -70,7 +73,8 @@

Compatibility

Questions?

- For help, use the support email listed below. + For help, use the support email below or open the + jam timer signal reference.

diff --git a/support-page/jam-timer-procedures/index.html b/support-page/jam-timer-procedures/index.html new file mode 100644 index 0000000..8088542 --- /dev/null +++ b/support-page/jam-timer-procedures/index.html @@ -0,0 +1,286 @@ + + + + + + + JamReady Jam Timer Signals + + + +
+
+ ? + JamReady Reference +
+

Jam timer responsibilities and signals

+

A quick reference for the jam timer's verbal cues, whistles, and hand signals during lineup, jam starts and endings, stoppages, reviews, and period endings.

+
+ +
+
+
RollingTeams report or official end
+
5 secVerbal cue and raised hand
+
1Short blast starts the jam
+
4Rapid blasts stop time
+
+ +
+

Jam Flow

+
+
+

Initial lineup

+

Rolling whistle

+

Call teams to the track and begin lineup procedures.

+
+
+

5 seconds before a jam begins

+

Verbal call plus raised hand

+

Call "Five Seconds" while raising one hand with all five fingers extended toward the track.

+
+
+

Jam starts

+

1 short whistle blast

+

Lower the raised hand and point toward the track in front of the foremost blocker.

+
+
+

Jam ends

+

4 rapid short whistle blasts

+

Use the jam ending signal with hands repeatedly lifting off hips, elbows pointed outward.

+
+
+
+ +
+

Timeouts and Reviews

+
+
+

Team Timeout

+

Announce "Timeout, [Team Color/Name]"

+

Form a clear T shape with the hands.

+
+
+

Official Timeout

+

Announce "Official Timeout"

+

Tap the tops of the shoulders with fingertips, elbows extended at shoulder height.

+
+
+

Official Review

+

Announce "Official Review, [Team Color/Name]"

+

Form a round O shape with fingers and thumbs.

+
+
+

Timeout, official timeout, or review begins

+

4 rapid short whistle blasts

+

Stop the period clock and signal the time stoppage.

+
+
+
+ +
+

Period End and Other Whistles

+
+
+

Timeout or period officially ends

+

Rolling whistle

+

Use the rolling whistle to officially close the timeout or period.

+
+
+

End of period or game after final jam

+

Rolling whistle after review window

+

Wait until reviews are resolved or 30 seconds after the final jam ends.

+
+
+
+
+ + + + diff --git a/support-page/styles.css b/support-page/styles.css index e29abc5..3a4ac5b 100644 --- a/support-page/styles.css +++ b/support-page/styles.css @@ -75,7 +75,13 @@ body { border-radius: 0.6rem; } -.support-link { +.site-nav { + display: flex; + align-items: center; + gap: 0.55rem; +} + +.site-nav a { color: var(--text); text-decoration: none; border: 1px solid var(--line); @@ -341,8 +347,13 @@ a { padding: 0.7rem 0; } - .support-link { + .site-nav { + gap: 0.35rem; + } + + .site-nav a { font-size: 0.86rem; + padding: 0.4rem 0.62rem; } .shot-grid {