Skip to content
11 changes: 10 additions & 1 deletion lib/components/viewers/json_viewer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1423,7 +1423,16 @@ class _AsyncJsonParserState extends State<AsyncJsonParser> {
}
if (raw is String) {
final trimmed = raw.trim();
if (trimmed.isEmpty || (trimmed[0] != '{' && trimmed[0] != '[')) {
// Normalise empty / whitespace-only strings to `null` so consumers
// can treat them uniformly as "no data" (renders an EmptyState)
// instead of falling through to a blank JSON viewer.
if (trimmed.isEmpty) {
_parsedData = null;
_isJson = false;
_ready = true;
return true;
}
if (trimmed[0] != '{' && trimmed[0] != '[') {
_parsedData = raw;
_isJson = false;
_ready = true;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';

/// 30×30 hover-fade icon button used in the network detail's mini-header
/// ("Close", "Screenshot", …). Background tweens to a tinted fill on hover.
class HeaderIconButton extends StatefulWidget {
final IconData icon;
final String tooltip;
final bool isDark;
final VoidCallback onTap;

const HeaderIconButton({
super.key,
required this.icon,
required this.tooltip,
required this.isDark,
required this.onTap,
});

@override
State<HeaderIconButton> createState() => _HeaderIconButtonState();
}

class _HeaderIconButtonState extends State<HeaderIconButton> {
bool _hovered = false;

@override
Widget build(BuildContext context) {
final hoverColor = widget.isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.05);
final iconColor = widget.isDark
? const Color(0xFF9A9A9A)
: const Color(0xFF6B6B6B);
return Tooltip(
message: widget.tooltip,
child: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: widget.onTap,
behavior: HitTestBehavior.opaque,
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
curve: const Cubic(0.16, 1, 0.3, 1),
width: 30,
height: 30,
decoration: BoxDecoration(
color: _hovered ? hoverColor : Colors.transparent,
borderRadius: BorderRadius.circular(7),
),
child: Icon(widget.icon, size: 14, color: iconColor),
),
),
),
);
}
}
170 changes: 170 additions & 0 deletions lib/features/all_events/presentation/buttons/icon_btn.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';

import '../../../../core/theme/color_tokens.dart';

/// Square 28×28 icon button for the All Events toolbar. Three visual states:
///
/// - **active** (primary tint) — for sort/auto-scroll toggles that are on.
/// - **danger-on-hover** (error tint) — for the clear-all trash icon.
/// - **muted-on-hover** — neutral hover for inactive icons.
class IconBtn extends StatefulWidget {
final IconData icon;
final String tooltip;
final bool isActive;
final bool isDanger;
final VoidCallback onTap;

const IconBtn({
super.key,
required this.icon,
required this.tooltip,
this.isActive = false,
this.isDanger = false,
required this.onTap,
});

@override
State<IconBtn> createState() => _IconBtnState();
}

class _IconBtnState extends State<IconBtn> {
bool _hovered = false;

@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;

Color iconColor;
Color bgColor;

if (widget.isActive) {
iconColor = ColorTokens.primary;
bgColor = ColorTokens.primary.withValues(alpha: 0.15);
} else if (widget.isDanger && _hovered) {
iconColor = ColorTokens.error;
bgColor = ColorTokens.error.withValues(alpha: 0.12);
} else if (_hovered) {
iconColor = isDark ? Colors.grey[300]! : Colors.grey[700]!;
bgColor = isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.06);
} else {
iconColor = isDark ? Colors.grey[500]! : Colors.grey[500]!;
bgColor = Colors.transparent;
}

return Tooltip(
message: widget.tooltip,
child: GestureDetector(
onTap: widget.onTap,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 28,
height: 28,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(7),
),
child: Icon(
widget.icon,
size: 14,
color: iconColor,
),
),
),
),
);
}
}

/// Slightly larger icon button used in the header next to the port/device
/// counters (the server restart "Reload" button).
class HeaderActionIcon extends StatefulWidget {
final IconData icon;
final String tooltip;
final bool spinning;
final VoidCallback onTap;

const HeaderActionIcon({
super.key,
required this.icon,
required this.tooltip,
required this.spinning,
required this.onTap,
});

@override
State<HeaderActionIcon> createState() => _HeaderActionIconState();
}

class _HeaderActionIconState extends State<HeaderActionIcon>
with SingleTickerProviderStateMixin {
late final AnimationController _spinCtrl;
bool _hovered = false;

@override
void initState() {
super.initState();
_spinCtrl = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
);
if (widget.spinning) _spinCtrl.repeat();
}

@override
void didUpdateWidget(covariant HeaderActionIcon old) {
super.didUpdateWidget(old);
if (widget.spinning && !_spinCtrl.isAnimating) {
_spinCtrl.repeat();
} else if (!widget.spinning && _spinCtrl.isAnimating) {
_spinCtrl.reset();
}
}

@override
void dispose() {
_spinCtrl.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final iconColor = _hovered || widget.spinning
? ColorTokens.primary
: (isDark ? Colors.grey[400]! : Colors.grey[600]!);
final bgColor = _hovered || widget.spinning
? ColorTokens.primary.withValues(alpha: 0.12)
: Colors.transparent;

return Tooltip(
message: widget.tooltip,
child: GestureDetector(
onTap: widget.spinning ? null : widget.onTap,
child: MouseRegion(
cursor:
widget.spinning ? SystemMouseCursors.basic : SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(6),
),
child: RotationTransition(
turns: _spinCtrl,
child: Icon(widget.icon, size: 12, color: iconColor),
),
),
),
),
);
}
}
38 changes: 38 additions & 0 deletions lib/features/all_events/presentation/buttons/mini_icon_button.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';

/// 24×24 neutral square icon button. Used inside dense detail-panel
/// sections where the 28×28 [IconBtn] would feel too large.
class MiniIconButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback onTap;

const MiniIconButton({
super.key,
required this.icon,
required this.tooltip,
required this.onTap,
});

@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: GestureDetector(
onTap: onTap,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: Colors.grey.withValues(alpha: 0.06),
),
child: Icon(icon, size: 11, color: Colors.grey[500]),
),
),
),
);
}
}
92 changes: 92 additions & 0 deletions lib/features/all_events/presentation/buttons/pressable_button.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';

import '../../../../components/text/text_component.dart';

/// Wraps [child] in a press-feedback that scales to 0.92 and dips opacity
/// while the pointer is held. Used as the chassis for [CopyButton],
/// [FormatToggleButton], and [ActionButton] in the detail panel.
class PressableButton extends StatefulWidget {
final VoidCallback onTap;
final Widget child;

const PressableButton({
super.key,
required this.onTap,
required this.child,
});

@override
State<PressableButton> createState() => _PressableButtonState();
}

class _PressableButtonState extends State<PressableButton> {
bool _pressed = false;

@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => setState(() => _pressed = true),
onTapUp: (_) {
setState(() => _pressed = false);
widget.onTap();
},
onTapCancel: () => setState(() => _pressed = false),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: AnimatedScale(
scale: _pressed ? 0.92 : 1.0,
duration: const Duration(milliseconds: 100),
child: AnimatedOpacity(
opacity: _pressed ? 0.7 : 1.0,
duration: const Duration(milliseconds: 100),
child: widget.child,
),
),
),
);
}
}

/// Compact icon-and-label button (e.g. "Format", "Reload") rendered with
/// the shared [PressableButton] press feedback.
class ActionButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;

const ActionButton({
super.key,
required this.icon,
required this.label,
required this.onTap,
});

@override
Widget build(BuildContext context) {
return PressableButton(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
color: Colors.grey.withValues(alpha: 0.08),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: Colors.grey[500]),
const SizedBox(width: 4),
TextComponent(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: Colors.grey[500],
),
),
],
),
),
);
}
}
Loading
Loading