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, + ); +} diff --git a/lib/models/interaction/pill_focus.dart b/lib/models/interaction/pill_focus.dart new file mode 100644 index 00000000..e7c291f7 --- /dev/null +++ b/lib/models/interaction/pill_focus.dart @@ -0,0 +1,29 @@ +import 'package:counter_spell/models/game/counter.dart'; + +sealed class PillFocus { + const PillFocus(); +} + +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; +} + +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..bda0b198 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,16 @@ class _ArenaPlayerCellState extends State<_ArenaPlayerCell> @override Reactive cachedAttackerIndex = Reactive(null); + /// which count pill has taken over the cell (null in the normal life view) + @override + Reactive focusedPill = Reactive(null); + @override void dispose() { increment.dispose(); advanced.dispose(); cachedAttackerIndex.dispose(); + focusedPill.dispose(); super.dispose(); } @@ -143,6 +149,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..0dfa7288 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,94 @@ 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) { + final bool editing = focus != null; + return Stack( + children: [ + Positioned.fill( + child: AnimatedOpacity( + // same timing as the pill editor's entrance/exit + 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( + 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_cast_page.dart b/lib/widgets/arena/player_cell/components/player_cell_cast_page.dart index 9020a859..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 @@ -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,18 @@ 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, + ), + ); + 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..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 @@ -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,24 @@ 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, + ), + ); + if (!isBoolean) { + context.arenaPlayerController.focusedPill.update( + CounterPillFocus(counter), + ); + } + }, child: PlayerCellIconValue( icon: counter.bigIcon, highlightIcon: isBoolean && value > 0, 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..cbabe04c --- /dev/null +++ b/lib/widgets/arena/player_cell/components/player_cell_pill_editor.dart @@ -0,0 +1,323 @@ +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'; + +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; + + 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; + 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; + final Color behindColor = colorScheme.primaryContainerDim; + + 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, + ); + 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: [ + Material( + color: behindColor, + borderRadius: BorderRadius.circular(behindHeight / 2), + clipBehavior: Clip.antiAlias, + child: SizedBox( + height: behindHeight, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: buttonZone + centerWidth / 2, + child: _StepButton( + icon: Icons.remove, + color: colorScheme.onPrimaryContainer, + sign: -1, + onEdit: _edit, + iconAlignment: Alignment.centerLeft, + iconZone: buttonZone, + ), + ), + SizedBox( + width: buttonZone + centerWidth / 2, + child: _StepButton( + icon: Icons.add, + color: colorScheme.onPrimaryContainer, + sign: 1, + onEdit: _edit, + iconAlignment: Alignment.centerRight, + iconZone: buttonZone, + ), + ), + ], + ), + ), + ), + 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, + ), + ), + ], + ); + + return AnimatedBuilder( + animation: _t, + builder: (context, _) { + final double t = _t.value; + return Opacity( + opacity: t.clamp(0.0, 1.0), + child: Stack( + children: [ + 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, + ), + ), + Positioned( + top: side * 0.06, + right: side * 0.06, + child: _CloseButton( + size: centerHeight * 0.5, + iconColor: colorScheme.onPrimaryContainer, + onTap: _close, + ), + ), + ], + ), + ); + }, + ); + }, + ); + } +} + +class _StepButton extends StatelessWidget { + const _StepButton({ + required this.icon, + required this.color, + 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 SizedBox.square( + dimension: size, + child: InkResponse( + onTap: onTap, + radius: size, + child: Center( + child: Icon(Icons.close, color: iconColor, size: size * 0.6), + ), + ), + ); + } +} + +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 5ec504df..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 @@ -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,11 @@ class _PlayerCellQuickInfo extends StatelessWidget { icon: counter.filledIcon, result: amount, boolean: counter.isBoolean, + onTap: counter.isBoolean + ? null + : () => context.arenaPlayerController.focusedPill.update( + CounterPillFocus(counter), + ), ), if (thisPlayerState.commanderCasts.partnerA case int castsA) if (castsA != 0) @@ -172,6 +179,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 +189,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 ], @@ -230,7 +243,18 @@ 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, + onTap: () { + final interactionLogic = context.counterSpell.interactionLogic; + interactionLogic.selectAttackingPlayer( + playerIndex: attackerIndex, + ); + interactionLogic.updatePartnerA(attackerIndex, fromPartnerA); + }, + ), ); }, ); 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..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 @@ -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,27 @@ extension on Axis { }; } +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, diff --git a/lib/widgets/components/project/delta_chip.dart b/lib/widgets/components/project/delta_chip.dart index 752ebd09..5595deff 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,8 @@ class DeltaChip extends StatelessWidget { final String? note; final bool boolean; + final VoidCallback? onTap; + @override Widget build(BuildContext context) { final theme = context.theme; @@ -38,41 +42,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, + ), ), ), ), - ), - ], + ], + ), ), ), );