From 18af872e380c44dae76194070c86456067925986 Mon Sep 17 00:00:00 2001 From: Jonathan Espinosa <65873243+JonathanEspinosaLong@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:18:34 -0600 Subject: [PATCH 1/7] Enter commander-damage mode by tapping a commander-damage pill Add an optional onTap to DeltaChip (ink ripple over the pill) and wire the commander-damage pill so tapping it selects the commander that dealt the damage as the attacker and sets that attacker's active partner to match the pill, entering commander-damage mode. --- .../components/player_cell_quick_info.dart | 15 +++- .../components/project/delta_chip.dart | 69 +++++++++++-------- 2 files changed, 53 insertions(+), 31 deletions(-) diff --git a/lib/widgets/arena/player_cell/components/player_cell_quick_info.dart b/lib/widgets/arena/player_cell/components/player_cell_quick_info.dart index 5ec504df..beac7263 100644 --- a/lib/widgets/arena/player_cell/components/player_cell_quick_info.dart +++ b/lib/widgets/arena/player_cell/components/player_cell_quick_info.dart @@ -230,7 +230,20 @@ class _CommanderDamageChip extends StatelessWidget { builder: (context, cardA, cardB, themeA, themeB, child) { return Theme( data: fromPartnerA ? themeA : themeB, - child: DeltaChip.result(icon: icon, result: result, note: note), + child: DeltaChip.result( + icon: icon, + result: result, + note: note, + // Tapping the pill enters commander-damage mode with the + // commander that dealt this damage selected as the attacker. + onTap: () { + final interactionLogic = context.counterSpell.interactionLogic; + interactionLogic.selectAttackingPlayer( + playerIndex: attackerIndex, + ); + interactionLogic.updatePartnerA(attackerIndex, fromPartnerA); + }, + ), ); }, ); diff --git a/lib/widgets/components/project/delta_chip.dart b/lib/widgets/components/project/delta_chip.dart index 752ebd09..51a51d03 100644 --- a/lib/widgets/components/project/delta_chip.dart +++ b/lib/widgets/components/project/delta_chip.dart @@ -8,6 +8,7 @@ class DeltaChip extends StatelessWidget { this.increment, required this.result, this.note, + this.onTap, }) : boolean = false; const DeltaChip.result({ super.key, @@ -15,6 +16,7 @@ class DeltaChip extends StatelessWidget { required this.result, this.boolean = false, this.note, + this.onTap, }) : increment = null; final IconData icon; @@ -23,6 +25,9 @@ class DeltaChip extends StatelessWidget { final String? note; final bool boolean; + /// When set, the pill becomes tappable (with an ink ripple over the pill). + final VoidCallback? onTap; + @override Widget build(BuildContext context) { final theme = context.theme; @@ -38,41 +43,45 @@ class DeltaChip extends StatelessWidget { final mainChip = Material( borderRadius: BorderRadius.circular(layout.radius.small), color: background, - child: Pad( - vertical: layout.padding.tiny, - horizontal: increment == null - ? layout.padding.smaller - : layout.padding.small, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: increment == null ? 18 : 20, color: foreground), - if (increment case int increment) - Pad( - left: layout.spacing.tiny, - child: Text( - '${increment > 0 ? '+' : ''}$increment', - style: theme.textTheme.labelLarge!.copyWith( - fontSize: theme.textTheme.bodyLarge!.fontSize, - color: foreground, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(layout.radius.small), + child: Pad( + vertical: layout.padding.tiny, + horizontal: increment == null + ? layout.padding.smaller + : layout.padding.small, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: increment == null ? 18 : 20, color: foreground), + if (increment case int increment) + Pad( + left: layout.spacing.tiny, + child: Text( + '${increment > 0 ? '+' : ''}$increment', + style: theme.textTheme.labelLarge!.copyWith( + fontSize: theme.textTheme.bodyLarge!.fontSize, + color: foreground, + ), ), ), - ), - if ((!boolean) || increment != null) - Pad( - left: increment == null - ? layout.spacing.tiny - : layout.spacing.small, - child: Text( - increment == null ? '$result' : '= $result', - style: theme.textTheme.bodySmall!.copyWith( - color: foreground.withValues( - alpha: increment == null ? 1 : 0.65, + if ((!boolean) || increment != null) + Pad( + left: increment == null + ? layout.spacing.tiny + : layout.spacing.small, + child: Text( + increment == null ? '$result' : '= $result', + style: theme.textTheme.bodySmall!.copyWith( + color: foreground.withValues( + alpha: increment == null ? 1 : 0.65, + ), ), ), ), - ), - ], + ], + ), ), ), ); From 6ba9c131e93f6b0a47a5f81a7c9fe45dede1e9c2 Mon Sep 17 00:00:00 2001 From: Jonathan Espinosa <65873243+JonathanEspinosaLong@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:18:00 -0600 Subject: [PATCH 2/7] Add a focused count-pill editor that takes over the arena cell Tapping a numeric counter or commander-cast quick-info pill now takes over the cell with a centered +/- editor floating over the commander art: the rest of the cell UI fades out and the control animates in as if the pill repositions itself from its corner. - PillFocus model + a focusedPill reactive on ArenaPlayerController. - PlayerCellPillEditor: horizontal control (+ / icon+value capsule / -) with self-managed enter/exit animation; edits reuse addCounters/castCommander. - Closes on tap-outside or, once interacted with, after the confirmation delay of inactivity; closing always returns to the basic cell view. --- lib/models/interaction/pill_focus.dart | 33 ++ .../arena/player_cell/arena_player_cell.dart | 9 + .../components/player_cell_body.dart | 144 +++++---- .../components/player_cell_pill_editor.dart | 287 ++++++++++++++++++ .../components/player_cell_quick_info.dart | 15 + 5 files changed, 434 insertions(+), 54 deletions(-) create mode 100644 lib/models/interaction/pill_focus.dart create mode 100644 lib/widgets/arena/player_cell/components/player_cell_pill_editor.dart diff --git a/lib/models/interaction/pill_focus.dart b/lib/models/interaction/pill_focus.dart new file mode 100644 index 00000000..6458f664 --- /dev/null +++ b/lib/models/interaction/pill_focus.dart @@ -0,0 +1,33 @@ +import 'package:counter_spell/models/game/counter.dart'; + +/// Identifies which count-bearing quick-info pill has taken over an arena cell +/// for focused +/- editing. Boolean statuses have no count and are excluded. +sealed class PillFocus { + const PillFocus(); +} + +/// A numeric counter pill (poison, energy, rad, experience, storm, ...). +class CounterPillFocus extends PillFocus { + const CounterPillFocus(this.counter); + final Counter counter; + + @override + bool operator ==(Object other) => + other is CounterPillFocus && other.counter == counter; + + @override + int get hashCode => counter.hashCode; +} + +/// A commander-cast pill for partner A or B. +class CastPillFocus extends PillFocus { + const CastPillFocus({required this.partnerA}); + final bool partnerA; + + @override + bool operator ==(Object other) => + other is CastPillFocus && other.partnerA == partnerA; + + @override + int get hashCode => partnerA.hashCode; +} diff --git a/lib/widgets/arena/player_cell/arena_player_cell.dart b/lib/widgets/arena/player_cell/arena_player_cell.dart index af8e363a..78a90a63 100644 --- a/lib/widgets/arena/player_cell/arena_player_cell.dart +++ b/lib/widgets/arena/player_cell/arena_player_cell.dart @@ -1,6 +1,7 @@ import 'package:counter_spell/main.dart'; import 'package:counter_spell/models/game/counter.dart'; import 'package:counter_spell/models/interaction/interaction_mode.dart'; +import 'package:counter_spell/models/interaction/pill_focus.dart'; import 'package:counter_spell/widgets/arena/player_cell/components/player_cell_body.dart'; import 'package:counter_spell/widgets/arena/player_cell/components/player_cell_decoration.dart'; import 'package:counter_spell/widgets/arena/player_cell/components/player_cell_padding.dart'; @@ -61,11 +62,17 @@ class _ArenaPlayerCellState extends State<_ArenaPlayerCell> @override Reactive cachedAttackerIndex = Reactive(null); + /// which count-bearing quick-info pill has taken over the cell for focused + /// +/- editing (null when the normal life view is shown) + @override + Reactive focusedPill = Reactive(null); + @override void dispose() { increment.dispose(); advanced.dispose(); cachedAttackerIndex.dispose(); + focusedPill.dispose(); super.dispose(); } @@ -143,6 +150,8 @@ mixin ArenaPlayerController { Reactive get cachedAttackerIndex; + Reactive get focusedPill; + void nextMultipleOf(int n); void previousMultipleOf(int n); diff --git a/lib/widgets/arena/player_cell/components/player_cell_body.dart b/lib/widgets/arena/player_cell/components/player_cell_body.dart index 532c36bb..fbd8f762 100644 --- a/lib/widgets/arena/player_cell/components/player_cell_body.dart +++ b/lib/widgets/arena/player_cell/components/player_cell_body.dart @@ -2,6 +2,7 @@ import 'package:counter_spell/widgets/arena/player_cell/arena_player_cell.dart'; import 'package:counter_spell/widgets/arena/player_cell/components/player_cell_advanced_body.dart'; import 'package:counter_spell/widgets/arena/player_cell/components/player_cell_basic_body.dart'; import 'package:counter_spell/widgets/arena/player_cell/components/player_cell_more_button.dart'; +import 'package:counter_spell/widgets/arena/player_cell/components/player_cell_pill_editor.dart'; import 'package:counter_spell/widgets/components/project/delay_provider.dart'; import 'package:flutter/material.dart'; import 'package:sid_base/sid_base.dart'; @@ -81,65 +82,100 @@ class _PlayerCellBodyState extends State { alwaysScrollable: true, ); - return Stack( - children: [ - Positioned.fill( - child: Align( - alignment: toTheLeft ? Alignment.centerLeft : Alignment.centerRight, - child: controller.advanced.buildWithStaticChild( - child: PageView( - physics: basicScrollPhysics, - children: [ - PlayerCellBasicBody( - playerIndex: widget.playerIndex, - avoidMenuButton: widget.avoidMenuButton, - open: open, - ), - ], - ), - builder: (context, value, child) => AnimatedListed( - listed: !value, - direction: Axis.horizontal, - axisAlignment: toTheLeft ? 1 : -1, - fadeFirstFraction: 1, - child: child, + return controller.focusedPill.build((context, focus) { + // When a count pill is focused the rest of the cell UI fades out so the + // editor floats over the commander art alone. + final bool editing = focus != null; + return Stack( + children: [ + Positioned.fill( + child: AnimatedOpacity( + // Match the pill editor's entrance/exit speed and curve so the + // fade tracks the pill's motion. + opacity: editing ? 0 : 1, + duration: Durations.medium4, + curve: Curves.easeOutCubic, + child: IgnorePointer( + ignoring: editing, + child: Stack( + children: [ + Positioned.fill( + child: Align( + alignment: toTheLeft + ? Alignment.centerLeft + : Alignment.centerRight, + child: controller.advanced.buildWithStaticChild( + child: PageView( + physics: basicScrollPhysics, + children: [ + PlayerCellBasicBody( + playerIndex: widget.playerIndex, + avoidMenuButton: widget.avoidMenuButton, + open: open, + ), + ], + ), + builder: (context, value, child) => AnimatedListed( + listed: !value, + direction: Axis.horizontal, + axisAlignment: toTheLeft ? 1 : -1, + fadeFirstFraction: 1, + child: child, + ), + ), + ), + ), + Positioned.fill( + child: Align( + alignment: toTheLeft + ? Alignment.centerRight + : Alignment.centerLeft, + child: controller.advanced.buildWithStaticChild( + child: PlayerCellAdvancedBody( + playerIndex: widget.playerIndex, + onChangeOrientation: onChangeOrientation, + pageController: advancedPageController, + physics: advancedScrollPhysics, + ), + builder: (context, value, child) => AnimatedListed( + listed: value, + direction: Axis.horizontal, + fadeFirstFraction: 1, + axisAlignment: toTheLeft ? -1 : 1, + child: child, + ), + ), + ), + ), + Positioned( + bottom: 0, + right: 0, + child: PlayerCellMoreButton( + open: () { + onChangeOrientation(true); + advancedPageController.jumpToPage(0); + open(); + }, + close: close, + ), + ), + ], + ), ), ), ), - ), - Positioned.fill( - child: Align( - alignment: toTheLeft ? Alignment.centerRight : Alignment.centerLeft, - child: controller.advanced.buildWithStaticChild( - child: PlayerCellAdvancedBody( + // Focused single-pill editor: a centered +/- control over the + // commander art, shown when a count pill is tapped. It animates its + // own entrance/exit (repositioning from the pill's origin). + if (focus != null) + Positioned.fill( + child: PlayerCellPillEditor( playerIndex: widget.playerIndex, - onChangeOrientation: onChangeOrientation, - pageController: advancedPageController, - physics: advancedScrollPhysics, - ), - builder: (context, value, child) => AnimatedListed( - listed: value, - direction: Axis.horizontal, - fadeFirstFraction: 1, - axisAlignment: toTheLeft ? -1 : 1, - child: child, + focus: focus, ), ), - ), - ), - Positioned( - bottom: 0, - right: 0, - child: PlayerCellMoreButton( - open: () { - onChangeOrientation(true); - advancedPageController.jumpToPage(0); - open(); - }, - close: close, - ), - ), - ], - ); + ], + ); + }); } } diff --git a/lib/widgets/arena/player_cell/components/player_cell_pill_editor.dart b/lib/widgets/arena/player_cell/components/player_cell_pill_editor.dart new file mode 100644 index 00000000..e3e64cf9 --- /dev/null +++ b/lib/widgets/arena/player_cell/components/player_cell_pill_editor.dart @@ -0,0 +1,287 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:counter_spell/main.dart'; +import 'package:counter_spell/models/game/partner_vectors.dart'; +import 'package:counter_spell/models/interaction/pill_focus.dart'; +import 'package:counter_spell/models/pages.dart'; +import 'package:counter_spell/widgets/arena/player_cell/arena_player_cell.dart'; +import 'package:flutter/material.dart'; +import 'package:sid_base/sid_base.dart'; + +/// Takes over the arena cell to edit a single count-bearing pill (a numeric +/// counter or a commander-cast). Renders as a centered horizontal control — a +/// `+` on the left, the icon + live value in a highlighted capsule, a `−` on +/// the right — floating over the commander art (the rest of the cell UI is +/// hidden by [PlayerCellBody] while this is shown). +/// +/// Tapping outside the control closes it; once the player has interacted it +/// also closes itself after the confirmation-delay of inactivity. +class PlayerCellPillEditor extends StatefulWidget { + const PlayerCellPillEditor({ + super.key, + required this.playerIndex, + required this.focus, + }); + + final int playerIndex; + final PillFocus focus; + + @override + State createState() => _PlayerCellPillEditorState(); +} + +class _PlayerCellPillEditorState extends State + with SingleTickerProviderStateMixin { + Timer? _autoCloseTimer; + late final AnimationController _controller; + late final Animation _t; + bool _closing = false; + + /// Where the control animates from/to — the top-right corner, where the + /// tapped count pills live, so it reads as the pill repositioning itself. + static const Alignment _origin = Alignment.topRight; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: Durations.medium4, + ); + _t = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic); + _controller.forward(); + } + + @override + void dispose() { + _autoCloseTimer?.cancel(); + _controller.dispose(); + super.dispose(); + } + + Future _close() async { + if (_closing) return; + _closing = true; + _autoCloseTimer?.cancel(); + await _controller.reverse(); + if (mounted) { + final controller = context.arenaPlayerController; + // Always return to the normal (basic) cell view — if the editor was + // opened from the advanced page, leave that page too. + controller.advanced.update(false); + controller.focusedPill.update(null); + } + } + + void _scheduleAutoClose() { + _autoCloseTimer?.cancel(); + final delay = context.counterSpell.interactionLogic.confirmationDelay.value; + _autoCloseTimer = Timer(delay, () { + if (mounted) _close(); + }); + } + + void _edit(int amount) { + final gameLogic = context.counterSpell.gameLogic; + switch (widget.focus) { + case CounterPillFocus(:final counter): + gameLogic.editGame( + (game) => game.addCounters( + playerIndex: widget.playerIndex, + counter: counter, + amount: amount, + ), + ); + case CastPillFocus(:final partnerA): + gameLogic.editGame( + (game) => game.castCommander( + playerIndex: widget.playerIndex, + partnerA: partnerA, + times: amount, + ), + ); + } + _scheduleAutoClose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = context.theme.colorScheme; + // The center capsule matches the original quick-info pill + // (primaryContainer); the +/- pill behind is a darker shade of it. + final Color behindColor = Color.alphaBlend( + Colors.black.withValues(alpha: 0.35), + colorScheme.primaryContainer, + ); + + final IconData icon = switch (widget.focus) { + CounterPillFocus(:final counter) => counter.bigIcon, + CastPillFocus() => BodyPage.cast.filledIcon, + }; + + return LayoutBuilder( + builder: (context, constraints) { + final double side = math.min( + constraints.maxWidth, + constraints.maxHeight, + ); + // The whole control is a horizontal pill: [+] [icon value] [-], + // with the center capsule noticeably taller than the +/- pill. + final double centerHeight = side * 0.34; + final double behindHeight = centerHeight / 1.5; + final double buttonZone = behindHeight * 0.85; + final double centerWidth = centerHeight * 1.3; + + final Widget control = Stack( + alignment: Alignment.center, + children: [ + // Pill behind, holding the + and - buttons. + Container( + height: behindHeight, + decoration: BoxDecoration( + color: behindColor, + borderRadius: BorderRadius.circular(behindHeight / 2), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: buttonZone, + child: _StepButton( + icon: Icons.add, + color: colorScheme.onPrimaryContainer, + onTap: () => _edit(1), + ), + ), + SizedBox(width: centerWidth), + SizedBox( + width: buttonZone, + child: _StepButton( + icon: Icons.remove, + color: colorScheme.onPrimaryContainer, + onTap: () => _edit(-1), + ), + ), + ], + ), + ), + // Highlighted center capsule with the icon + live value. + Container( + width: centerWidth, + height: centerHeight, + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(centerHeight / 2), + ), + child: _CenterValue( + icon: icon, + color: colorScheme.onPrimaryContainer, + playerIndex: widget.playerIndex, + focus: widget.focus, + ), + ), + ], + ); + + // Animate the control in/out as if the tapped pill repositions itself + // from its top-right origin to the center, scaling up and fading in. + return AnimatedBuilder( + animation: _t, + builder: (context, _) { + final double t = _t.value; + return Opacity( + opacity: t.clamp(0.0, 1.0), + child: Stack( + children: [ + // Tapping outside the control closes the editor. + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _close, + ), + ), + Align( + alignment: Alignment.lerp(_origin, Alignment.center, t)!, + child: Transform.scale( + scale: 0.7 + 0.3 * t, + child: control, + ), + ), + ], + ), + ); + }, + ); + }, + ); + } +} + +class _StepButton extends StatelessWidget { + const _StepButton({ + required this.icon, + required this.color, + required this.onTap, + }); + + final IconData icon; + final Color color; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkResponse( + onTap: onTap, + radius: 28, + child: Center(child: Icon(icon, color: color, size: 28)), + ); + } +} + +class _CenterValue extends StatelessWidget { + const _CenterValue({ + required this.icon, + required this.color, + required this.playerIndex, + required this.focus, + }); + + final IconData icon; + final Color color; + final int playerIndex; + final PillFocus focus; + + @override + Widget build(BuildContext context) { + final layout = context.theme.layout; + return context.counterSpell.gameLogic.buildWithGame((context, game) { + final state = game.currentState.playerStates[playerIndex]; + final int value = switch (focus) { + CounterPillFocus(:final counter) => state.counters[counter] ?? 0, + CastPillFocus(:final partnerA) => state.commanderCasts.of(partnerA), + }; + return Pad( + all: layout.padding.small, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible(child: Icon(icon, color: color)), + Space.horizontal(layout.spacing.small), + Flexible( + child: FittedBox( + child: DefaultTextStyle( + style: context.theme.textTheme.titleLarge!.copyWith( + color: color, + ), + child: AutoSizeText(value.toString(), maxLines: 1), + ), + ), + ), + ], + ), + ); + }); + } +} diff --git a/lib/widgets/arena/player_cell/components/player_cell_quick_info.dart b/lib/widgets/arena/player_cell/components/player_cell_quick_info.dart index beac7263..e9388059 100644 --- a/lib/widgets/arena/player_cell/components/player_cell_quick_info.dart +++ b/lib/widgets/arena/player_cell/components/player_cell_quick_info.dart @@ -5,6 +5,8 @@ import 'package:counter_spell/models/game/partner_vectors.dart'; import 'package:counter_spell/models/game/player_settings.dart'; import 'package:counter_spell/models/game/player_state.dart'; import 'package:counter_spell/models/interaction/interaction_mode.dart'; +import 'package:counter_spell/models/interaction/pill_focus.dart'; +import 'package:counter_spell/widgets/arena/player_cell/arena_player_cell.dart'; import 'package:counter_spell/widgets/arena/player_cell/builders/cell_mode_builder.dart'; import 'package:counter_spell/widgets/body/players_list_view/player_tile/components/split_theme.dart'; import 'package:counter_spell/widgets/components/builders/cell_mode_and_increment_builder.dart'; @@ -162,6 +164,13 @@ class _PlayerCellQuickInfo extends StatelessWidget { icon: counter.filledIcon, result: amount, boolean: counter.isBoolean, + // Numeric counters take over the cell for +/- editing; + // boolean statuses have no count so stay inert. + onTap: counter.isBoolean + ? null + : () => context.arenaPlayerController.focusedPill.update( + CounterPillFocus(counter), + ), ), if (thisPlayerState.commanderCasts.partnerA case int castsA) if (castsA != 0) @@ -172,6 +181,9 @@ class _PlayerCellQuickInfo extends StatelessWidget { false => null, }, result: castsA, + onTap: () => context.arenaPlayerController.focusedPill.update( + const CastPillFocus(partnerA: true), + ), ), if (thisPlayerState.commanderCasts.partnerB case int castsB) if (castsB != 0) @@ -179,6 +191,9 @@ class _PlayerCellQuickInfo extends StatelessWidget { icon: InteractionMode.cast.filledIcon, note: 'B', result: castsB, + onTap: () => context.arenaPlayerController.focusedPill.update( + const CastPillFocus(partnerA: false), + ), ), // TODO: add is dead chip ], From 6518c7e0afd426d44e5067d64f5c4ce56ab3fde9 Mon Sep 17 00:00:00 2001 From: Jonathan Espinosa <65873243+JonathanEspinosaLong@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:18:16 -0600 Subject: [PATCH 3/7] Open the count editor from advanced-page count icons Tapping a numeric counter or cast icon on the advanced pages now performs its usual increment and then surfaces the focused +/- editor, so both the quick-info pill and the advanced page lead to the same editor. Boolean statuses keep their plain toggle. --- .../components/player_cell_cast_page.dart | 22 +++++++++---- .../components/player_cell_counter_page.dart | 32 ++++++++++++------- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/lib/widgets/arena/player_cell/components/player_cell_cast_page.dart b/lib/widgets/arena/player_cell/components/player_cell_cast_page.dart index 9020a859..9853bef9 100644 --- a/lib/widgets/arena/player_cell/components/player_cell_cast_page.dart +++ b/lib/widgets/arena/player_cell/components/player_cell_cast_page.dart @@ -2,7 +2,9 @@ import 'package:auto_size_text/auto_size_text.dart'; import 'package:counter_spell/main.dart'; import 'package:counter_spell/models/game/partner_vectors.dart'; import 'package:counter_spell/models/game/player_state.dart'; +import 'package:counter_spell/models/interaction/pill_focus.dart'; import 'package:counter_spell/models/pages.dart'; +import 'package:counter_spell/widgets/arena/player_cell/arena_player_cell.dart'; import 'package:counter_spell/widgets/arena/player_cell/components/player_cell_icon_value.dart'; import 'package:flutter/material.dart'; @@ -31,13 +33,19 @@ class PlayerCellCastPage extends StatelessWidget { times: -1, ), ), - onTap: () => counterSpell.gameLogic.editGame( - (game) => game.castCommander( - playerIndex: playerIndex, - partnerA: partnerA, - times: 1, - ), - ), + onTap: () { + counterSpell.gameLogic.editGame( + (game) => game.castCommander( + playerIndex: playerIndex, + partnerA: partnerA, + times: 1, + ), + ); + // Surface the focused +/- editor after the tap. + context.arenaPlayerController.focusedPill.update( + CastPillFocus(partnerA: partnerA), + ); + }, child: PlayerCellIconValue( icon: BodyPage.cast.filledIcon, value: value == 0 diff --git a/lib/widgets/arena/player_cell/components/player_cell_counter_page.dart b/lib/widgets/arena/player_cell/components/player_cell_counter_page.dart index 06a3d98f..02a5bc48 100644 --- a/lib/widgets/arena/player_cell/components/player_cell_counter_page.dart +++ b/lib/widgets/arena/player_cell/components/player_cell_counter_page.dart @@ -2,6 +2,8 @@ import 'package:auto_size_text/auto_size_text.dart'; import 'package:counter_spell/main.dart'; import 'package:counter_spell/models/game/counter.dart'; import 'package:counter_spell/models/game/player_state.dart'; +import 'package:counter_spell/models/interaction/pill_focus.dart'; +import 'package:counter_spell/widgets/arena/player_cell/arena_player_cell.dart'; import 'package:counter_spell/widgets/arena/player_cell/components/player_cell_icon_value.dart'; import 'package:flutter/material.dart'; @@ -32,17 +34,25 @@ class PlayerCellCounterPage extends StatelessWidget { amount: -1, ), ), - onTap: () => counterSpell.gameLogic.editGame( - (game) => game.addCounters( - playerIndex: playerIndex, - counter: counter, - amount: isBoolean - ? value == 0 - ? 1 - : -1 - : 1, - ), - ), + onTap: () { + counterSpell.gameLogic.editGame( + (game) => game.addCounters( + playerIndex: playerIndex, + counter: counter, + amount: isBoolean + ? value == 0 + ? 1 + : -1 + : 1, + ), + ); + // For count values, surface the focused +/- editor after the tap. + if (!isBoolean) { + context.arenaPlayerController.focusedPill.update( + CounterPillFocus(counter), + ); + } + }, child: PlayerCellIconValue( icon: counter.bigIcon, highlightIcon: isBoolean && value > 0, From 1cab924053bcbd2ca4088eb3b82ebe817f34d9e4 Mon Sep 17 00:00:00 2001 From: Jonathan Espinosa <65873243+JonathanEspinosaLong@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:51:41 -0600 Subject: [PATCH 4/7] add primaryContainerDim color scheme extension --- lib/data/color_scheme_extensions.dart | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 lib/data/color_scheme_extensions.dart diff --git a/lib/data/color_scheme_extensions.dart b/lib/data/color_scheme_extensions.dart new file mode 100644 index 00000000..a2f470db --- /dev/null +++ b/lib/data/color_scheme_extensions.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; + +extension CounterSpellColorScheme on ColorScheme { + /// A darker shade of [primaryContainer], for surfaces that sit *behind* a + /// primaryContainer element — e.g. the +/- pill behind the pill editor's + /// highlighted center capsule. + Color get primaryContainerDim => Color.alphaBlend( + Colors.black.withValues(alpha: 0.35), + primaryContainer, + ); +} From 361d57bb55c5812f4336b45ffd68fd47e3339f8e Mon Sep 17 00:00:00 2001 From: Jonathan Espinosa <65873243+JonathanEspinosaLong@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:51:45 -0600 Subject: [PATCH 5/7] extract shared hold-escalation multiplier helper --- .../components/player_cell_side_taps.dart | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/lib/widgets/arena/player_cell/components/player_cell_side_taps.dart b/lib/widgets/arena/player_cell/components/player_cell_side_taps.dart index 6ad0abf5..6264075f 100644 --- a/lib/widgets/arena/player_cell/components/player_cell_side_taps.dart +++ b/lib/widgets/arena/player_cell/components/player_cell_side_taps.dart @@ -42,24 +42,8 @@ class PlayerCellSideTaps extends StatelessWidget { } void onLongPress(Duration duration, Axis direction) { - if (duration < 650.milliseconds) return; - int n = switch (duration.inMilliseconds) { - < 3000 => 5, - < 6000 => 10, - < 10000 => 20, - < 13000 => 50, - < 16000 => 100, - < 18000 => 200, - < 20000 => 500, - < 22500 => 1000, - < 25000 => 2000, - < 27500 => 5000, - < 30000 => 10000, - < 32000 => 20000, - < 34000 => 50000, - < 36000 => 100000, - _ => 200000, - }; + final n = continuedLongPressMultiplier(duration); + if (n == null) return; if (direction == Axis.horizontal) { previousMultipleOf(n); } else { @@ -118,6 +102,30 @@ extension on Axis { }; } +/// Step multiplier for a hold sustained for [duration], escalating the longer +/// a [ContinuedLongPress] is held. Returns null before the hold threshold, when +/// no repeated step should fire yet. +int? continuedLongPressMultiplier(Duration duration) { + if (duration < 650.milliseconds) return null; + return switch (duration.inMilliseconds) { + < 3000 => 5, + < 6000 => 10, + < 10000 => 20, + < 13000 => 50, + < 16000 => 100, + < 18000 => 200, + < 20000 => 500, + < 22500 => 1000, + < 25000 => 2000, + < 27500 => 5000, + < 30000 => 10000, + < 32000 => 20000, + < 34000 => 50000, + < 36000 => 100000, + _ => 200000, + }; +} + class ContinuedLongPress extends StatefulWidget { const ContinuedLongPress({ super.key, From 92377bd4abe385409049001ed340e7260ec80315 Mon Sep 17 00:00:00 2001 From: Jonathan Espinosa <65873243+JonathanEspinosaLong@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:51:49 -0600 Subject: [PATCH 6/7] add +/- editing, hold-repeat and close button to pill editor Swap the +/- sides, widen each step button so its ripple runs behind the center capsule, add tap-and-hold escalation, and a background-less close button pinned to the cell corner. --- .../components/player_cell_pill_editor.dart | 137 +++++++++++++----- 1 file changed, 100 insertions(+), 37 deletions(-) diff --git a/lib/widgets/arena/player_cell/components/player_cell_pill_editor.dart b/lib/widgets/arena/player_cell/components/player_cell_pill_editor.dart index e3e64cf9..1c5955dc 100644 --- a/lib/widgets/arena/player_cell/components/player_cell_pill_editor.dart +++ b/lib/widgets/arena/player_cell/components/player_cell_pill_editor.dart @@ -2,17 +2,19 @@ import 'dart:async'; import 'dart:math' as math; import 'package:auto_size_text/auto_size_text.dart'; +import 'package:counter_spell/data/color_scheme_extensions.dart'; import 'package:counter_spell/main.dart'; import 'package:counter_spell/models/game/partner_vectors.dart'; import 'package:counter_spell/models/interaction/pill_focus.dart'; import 'package:counter_spell/models/pages.dart'; import 'package:counter_spell/widgets/arena/player_cell/arena_player_cell.dart'; +import 'package:counter_spell/widgets/arena/player_cell/components/player_cell_side_taps.dart'; import 'package:flutter/material.dart'; import 'package:sid_base/sid_base.dart'; /// Takes over the arena cell to edit a single count-bearing pill (a numeric /// counter or a commander-cast). Renders as a centered horizontal control — a -/// `+` on the left, the icon + live value in a highlighted capsule, a `−` on +/// `−` on the left, the icon + live value in a highlighted capsule, a `+` on /// the right — floating over the commander art (the rest of the cell UI is /// hidden by [PlayerCellBody] while this is shown). /// @@ -111,10 +113,7 @@ class _PlayerCellPillEditorState extends State final colorScheme = context.theme.colorScheme; // The center capsule matches the original quick-info pill // (primaryContainer); the +/- pill behind is a darker shade of it. - final Color behindColor = Color.alphaBlend( - Colors.black.withValues(alpha: 0.35), - colorScheme.primaryContainer, - ); + final Color behindColor = colorScheme.primaryContainerDim; final IconData icon = switch (widget.focus) { CounterPillFocus(:final counter) => counter.bigIcon, @@ -127,7 +126,7 @@ class _PlayerCellPillEditorState extends State constraints.maxWidth, constraints.maxHeight, ); - // The whole control is a horizontal pill: [+] [icon value] [-], + // The whole control is a horizontal pill: [-] [icon value] [+], // with the center capsule noticeably taller than the +/- pill. final double centerHeight = side * 0.34; final double behindHeight = centerHeight / 1.5; @@ -137,34 +136,46 @@ class _PlayerCellPillEditorState extends State final Widget control = Stack( alignment: Alignment.center, children: [ - // Pill behind, holding the + and - buttons. - Container( - height: behindHeight, - decoration: BoxDecoration( - color: behindColor, - borderRadius: BorderRadius.circular(behindHeight / 2), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: buttonZone, - child: _StepButton( - icon: Icons.add, - color: colorScheme.onPrimaryContainer, - onTap: () => _edit(1), + // Pill behind, holding the − and + buttons. A Material carries the + // background and shape so the ink splashes paint above [behindColor] + // and are clipped to the rounded pill. + Material( + color: behindColor, + borderRadius: BorderRadius.circular(behindHeight / 2), + clipBehavior: Clip.antiAlias, + child: SizedBox( + height: behindHeight, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Each half widens to the pill center so its ripple runs + // behind the center capsule, with the icon pinned to its + // outer end so it still reads as centered. + SizedBox( + width: buttonZone + centerWidth / 2, + child: _StepButton( + icon: Icons.remove, + color: colorScheme.onPrimaryContainer, + sign: -1, + onEdit: _edit, + iconAlignment: Alignment.centerLeft, + iconZone: buttonZone, + ), ), - ), - SizedBox(width: centerWidth), - SizedBox( - width: buttonZone, - child: _StepButton( - icon: Icons.remove, - color: colorScheme.onPrimaryContainer, - onTap: () => _edit(-1), + SizedBox( + width: buttonZone + centerWidth / 2, + child: _StepButton( + icon: Icons.add, + color: colorScheme.onPrimaryContainer, + sign: 1, + onEdit: _edit, + iconAlignment: Alignment.centerRight, + iconZone: buttonZone, + ), ), - ), - ], + ], + ), ), ), // Highlighted center capsule with the icon + live value. @@ -209,6 +220,16 @@ class _PlayerCellPillEditorState extends State child: control, ), ), + // Explicit close, pinned to the cell's top-right corner. + Positioned( + top: side * 0.06, + right: side * 0.06, + child: _CloseButton( + size: centerHeight * 0.5, + iconColor: colorScheme.onPrimaryContainer, + onTap: _close, + ), + ), ], ), ); @@ -223,19 +244,61 @@ class _StepButton extends StatelessWidget { const _StepButton({ required this.icon, required this.color, - required this.onTap, + required this.sign, + required this.onEdit, + required this.iconAlignment, + required this.iconZone, }); final IconData icon; final Color color; + final int sign; + final void Function(int amount) onEdit; + final AlignmentGeometry iconAlignment; + final double iconZone; + + @override + Widget build(BuildContext context) { + return ContinuedLongPress( + onTapDown: () => onEdit(sign), + onTapUp: () {}, + onContinuedLongPress: (duration) { + final n = continuedLongPressMultiplier(duration); + if (n != null) onEdit(sign * n); + }, + child: Align( + alignment: iconAlignment, + child: SizedBox( + width: iconZone, + child: Center(child: Icon(icon, color: color, size: 28)), + ), + ), + ); + } +} + +class _CloseButton extends StatelessWidget { + const _CloseButton({ + required this.size, + required this.iconColor, + required this.onTap, + }); + + final double size; + final Color iconColor; final VoidCallback onTap; @override Widget build(BuildContext context) { - return InkResponse( - onTap: onTap, - radius: 28, - child: Center(child: Icon(icon, color: color, size: 28)), + return SizedBox.square( + dimension: size, + child: InkResponse( + onTap: onTap, + radius: size, + child: Center( + child: Icon(Icons.close, color: iconColor, size: size * 0.6), + ), + ), ); } } From b789a30dc87fc214264191984536241e9357821e Mon Sep 17 00:00:00 2001 From: Jonathan Espinosa <65873243+JonathanEspinosaLong@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:20:37 -0600 Subject: [PATCH 7/7] trim redundant comments from pill editor components Keep the pill editor code self-explanatory by removing verbose doc and inline comments that restate what the widgets already express. --- lib/models/interaction/pill_focus.dart | 4 --- .../arena/player_cell/arena_player_cell.dart | 3 +-- .../components/player_cell_body.dart | 8 +----- .../components/player_cell_cast_page.dart | 1 - .../components/player_cell_counter_page.dart | 1 - .../components/player_cell_pill_editor.dart | 27 ------------------- .../components/player_cell_quick_info.dart | 4 --- .../components/player_cell_side_taps.dart | 3 --- .../components/project/delta_chip.dart | 1 - 9 files changed, 2 insertions(+), 50 deletions(-) diff --git a/lib/models/interaction/pill_focus.dart b/lib/models/interaction/pill_focus.dart index 6458f664..e7c291f7 100644 --- a/lib/models/interaction/pill_focus.dart +++ b/lib/models/interaction/pill_focus.dart @@ -1,12 +1,9 @@ import 'package:counter_spell/models/game/counter.dart'; -/// Identifies which count-bearing quick-info pill has taken over an arena cell -/// for focused +/- editing. Boolean statuses have no count and are excluded. sealed class PillFocus { const PillFocus(); } -/// A numeric counter pill (poison, energy, rad, experience, storm, ...). class CounterPillFocus extends PillFocus { const CounterPillFocus(this.counter); final Counter counter; @@ -19,7 +16,6 @@ class CounterPillFocus extends PillFocus { int get hashCode => counter.hashCode; } -/// A commander-cast pill for partner A or B. class CastPillFocus extends PillFocus { const CastPillFocus({required this.partnerA}); final bool partnerA; diff --git a/lib/widgets/arena/player_cell/arena_player_cell.dart b/lib/widgets/arena/player_cell/arena_player_cell.dart index 78a90a63..bda0b198 100644 --- a/lib/widgets/arena/player_cell/arena_player_cell.dart +++ b/lib/widgets/arena/player_cell/arena_player_cell.dart @@ -62,8 +62,7 @@ class _ArenaPlayerCellState extends State<_ArenaPlayerCell> @override Reactive cachedAttackerIndex = Reactive(null); - /// which count-bearing quick-info pill has taken over the cell for focused - /// +/- editing (null when the normal life view is shown) + /// which count pill has taken over the cell (null in the normal life view) @override Reactive focusedPill = Reactive(null); diff --git a/lib/widgets/arena/player_cell/components/player_cell_body.dart b/lib/widgets/arena/player_cell/components/player_cell_body.dart index fbd8f762..0dfa7288 100644 --- a/lib/widgets/arena/player_cell/components/player_cell_body.dart +++ b/lib/widgets/arena/player_cell/components/player_cell_body.dart @@ -83,15 +83,12 @@ class _PlayerCellBodyState extends State { ); return controller.focusedPill.build((context, focus) { - // When a count pill is focused the rest of the cell UI fades out so the - // editor floats over the commander art alone. final bool editing = focus != null; return Stack( children: [ Positioned.fill( child: AnimatedOpacity( - // Match the pill editor's entrance/exit speed and curve so the - // fade tracks the pill's motion. + // same timing as the pill editor's entrance/exit opacity: editing ? 0 : 1, duration: Durations.medium4, curve: Curves.easeOutCubic, @@ -164,9 +161,6 @@ class _PlayerCellBodyState extends State { ), ), ), - // Focused single-pill editor: a centered +/- control over the - // commander art, shown when a count pill is tapped. It animates its - // own entrance/exit (repositioning from the pill's origin). if (focus != null) Positioned.fill( child: PlayerCellPillEditor( diff --git a/lib/widgets/arena/player_cell/components/player_cell_cast_page.dart b/lib/widgets/arena/player_cell/components/player_cell_cast_page.dart index 9853bef9..b6911d2c 100644 --- a/lib/widgets/arena/player_cell/components/player_cell_cast_page.dart +++ b/lib/widgets/arena/player_cell/components/player_cell_cast_page.dart @@ -41,7 +41,6 @@ class PlayerCellCastPage extends StatelessWidget { times: 1, ), ); - // Surface the focused +/- editor after the tap. context.arenaPlayerController.focusedPill.update( CastPillFocus(partnerA: partnerA), ); diff --git a/lib/widgets/arena/player_cell/components/player_cell_counter_page.dart b/lib/widgets/arena/player_cell/components/player_cell_counter_page.dart index 02a5bc48..b99a7f6c 100644 --- a/lib/widgets/arena/player_cell/components/player_cell_counter_page.dart +++ b/lib/widgets/arena/player_cell/components/player_cell_counter_page.dart @@ -46,7 +46,6 @@ class PlayerCellCounterPage extends StatelessWidget { : 1, ), ); - // For count values, surface the focused +/- editor after the tap. if (!isBoolean) { context.arenaPlayerController.focusedPill.update( CounterPillFocus(counter), diff --git a/lib/widgets/arena/player_cell/components/player_cell_pill_editor.dart b/lib/widgets/arena/player_cell/components/player_cell_pill_editor.dart index 1c5955dc..cbabe04c 100644 --- a/lib/widgets/arena/player_cell/components/player_cell_pill_editor.dart +++ b/lib/widgets/arena/player_cell/components/player_cell_pill_editor.dart @@ -12,14 +12,6 @@ import 'package:counter_spell/widgets/arena/player_cell/components/player_cell_s import 'package:flutter/material.dart'; import 'package:sid_base/sid_base.dart'; -/// Takes over the arena cell to edit a single count-bearing pill (a numeric -/// counter or a commander-cast). Renders as a centered horizontal control — a -/// `−` on the left, the icon + live value in a highlighted capsule, a `+` on -/// the right — floating over the commander art (the rest of the cell UI is -/// hidden by [PlayerCellBody] while this is shown). -/// -/// Tapping outside the control closes it; once the player has interacted it -/// also closes itself after the confirmation-delay of inactivity. class PlayerCellPillEditor extends StatefulWidget { const PlayerCellPillEditor({ super.key, @@ -41,8 +33,6 @@ class _PlayerCellPillEditorState extends State late final Animation _t; bool _closing = false; - /// Where the control animates from/to — the top-right corner, where the - /// tapped count pills live, so it reads as the pill repositioning itself. static const Alignment _origin = Alignment.topRight; @override @@ -70,8 +60,6 @@ class _PlayerCellPillEditorState extends State await _controller.reverse(); if (mounted) { final controller = context.arenaPlayerController; - // Always return to the normal (basic) cell view — if the editor was - // opened from the advanced page, leave that page too. controller.advanced.update(false); controller.focusedPill.update(null); } @@ -111,8 +99,6 @@ class _PlayerCellPillEditorState extends State @override Widget build(BuildContext context) { final colorScheme = context.theme.colorScheme; - // The center capsule matches the original quick-info pill - // (primaryContainer); the +/- pill behind is a darker shade of it. final Color behindColor = colorScheme.primaryContainerDim; final IconData icon = switch (widget.focus) { @@ -126,8 +112,6 @@ class _PlayerCellPillEditorState extends State constraints.maxWidth, constraints.maxHeight, ); - // The whole control is a horizontal pill: [-] [icon value] [+], - // with the center capsule noticeably taller than the +/- pill. final double centerHeight = side * 0.34; final double behindHeight = centerHeight / 1.5; final double buttonZone = behindHeight * 0.85; @@ -136,9 +120,6 @@ class _PlayerCellPillEditorState extends State final Widget control = Stack( alignment: Alignment.center, children: [ - // Pill behind, holding the − and + buttons. A Material carries the - // background and shape so the ink splashes paint above [behindColor] - // and are clipped to the rounded pill. Material( color: behindColor, borderRadius: BorderRadius.circular(behindHeight / 2), @@ -149,9 +130,6 @@ class _PlayerCellPillEditorState extends State mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Each half widens to the pill center so its ripple runs - // behind the center capsule, with the icon pinned to its - // outer end so it still reads as centered. SizedBox( width: buttonZone + centerWidth / 2, child: _StepButton( @@ -178,7 +156,6 @@ class _PlayerCellPillEditorState extends State ), ), ), - // Highlighted center capsule with the icon + live value. Container( width: centerWidth, height: centerHeight, @@ -196,8 +173,6 @@ class _PlayerCellPillEditorState extends State ], ); - // Animate the control in/out as if the tapped pill repositions itself - // from its top-right origin to the center, scaling up and fading in. return AnimatedBuilder( animation: _t, builder: (context, _) { @@ -206,7 +181,6 @@ class _PlayerCellPillEditorState extends State opacity: t.clamp(0.0, 1.0), child: Stack( children: [ - // Tapping outside the control closes the editor. Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, @@ -220,7 +194,6 @@ class _PlayerCellPillEditorState extends State child: control, ), ), - // Explicit close, pinned to the cell's top-right corner. Positioned( top: side * 0.06, right: side * 0.06, diff --git a/lib/widgets/arena/player_cell/components/player_cell_quick_info.dart b/lib/widgets/arena/player_cell/components/player_cell_quick_info.dart index e9388059..f254327b 100644 --- a/lib/widgets/arena/player_cell/components/player_cell_quick_info.dart +++ b/lib/widgets/arena/player_cell/components/player_cell_quick_info.dart @@ -164,8 +164,6 @@ class _PlayerCellQuickInfo extends StatelessWidget { icon: counter.filledIcon, result: amount, boolean: counter.isBoolean, - // Numeric counters take over the cell for +/- editing; - // boolean statuses have no count so stay inert. onTap: counter.isBoolean ? null : () => context.arenaPlayerController.focusedPill.update( @@ -249,8 +247,6 @@ class _CommanderDamageChip extends StatelessWidget { icon: icon, result: result, note: note, - // Tapping the pill enters commander-damage mode with the - // commander that dealt this damage selected as the attacker. onTap: () { final interactionLogic = context.counterSpell.interactionLogic; interactionLogic.selectAttackingPlayer( diff --git a/lib/widgets/arena/player_cell/components/player_cell_side_taps.dart b/lib/widgets/arena/player_cell/components/player_cell_side_taps.dart index 6264075f..9f1e26aa 100644 --- a/lib/widgets/arena/player_cell/components/player_cell_side_taps.dart +++ b/lib/widgets/arena/player_cell/components/player_cell_side_taps.dart @@ -102,9 +102,6 @@ extension on Axis { }; } -/// Step multiplier for a hold sustained for [duration], escalating the longer -/// a [ContinuedLongPress] is held. Returns null before the hold threshold, when -/// no repeated step should fire yet. int? continuedLongPressMultiplier(Duration duration) { if (duration < 650.milliseconds) return null; return switch (duration.inMilliseconds) { diff --git a/lib/widgets/components/project/delta_chip.dart b/lib/widgets/components/project/delta_chip.dart index 51a51d03..5595deff 100644 --- a/lib/widgets/components/project/delta_chip.dart +++ b/lib/widgets/components/project/delta_chip.dart @@ -25,7 +25,6 @@ class DeltaChip extends StatelessWidget { final String? note; final bool boolean; - /// When set, the pill becomes tappable (with an ink ripple over the pill). final VoidCallback? onTap; @override