Files
buzz/mobile/lib/features/channels/message_swipe_reply.dart
klopez4212 5fefec604f fix(mobile): restore channel icons and align chat colors with desktop (#8478)
## Summary
- Restores `#` and lock icons for public and private channels on Home,
with matching loading placeholders.
- Updates the dark-mode navigation colors to the desktop design-system
tokens (read and unread text, section headings).
- Replaces the chat color picker with the seven desktop accents (Gray,
Chartreuse, Flame, Magenta, Iris, Cobalt, Jade). Saved legacy colors map
to the closest one.
- Uses white text in your own bubbles, and black where white falls below
4.5:1 (Chartreuse, dark-mode Gray). Links (underlined), quotes, tables
and code use the same text color.
- Insets code blocks in bubbles evenly, with corners concentric to the
bubble and even inner padding.
- The swipe-to-reply indicator uses the chosen chat color.

## Testing
- `flutter analyze` and `dart format` are clean. The full mobile suite
passes (3,705 tests).
- Installed and checked on a Pixel 10 and an iPhone Air against a real
community.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Queue <874bf2d815c2c4b459b90438c669c57f2702f3a76bb6e16fa97184259aac3e9e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:48:12 -04:00

234 lines
8.8 KiB
Dart

import 'dart:async';
import 'dart:math' show sin, pi;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/theme/buzz_icons.dart';
import '../../shared/theme/theme.dart';
import 'thread_detail_page.dart';
import 'timeline_message.dart';
/// Reveals the existing reply action as a message is dragged to the right.
class MessageSwipeReply extends HookConsumerWidget {
final TimelineMessage message;
final List<TimelineMessage>? allMessages;
final String channelId;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
final Widget child;
/// The visible content, excluding author metadata and reply/reaction footers.
final GlobalKey contentKey;
const MessageSwipeReply({
super.key,
required this.message,
required this.allMessages,
required this.channelId,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
required this.child,
required this.contentKey,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final offset = useAnimationController(upperBound: double.infinity);
final pulse = useAnimationController(
duration: const Duration(milliseconds: 180),
);
final dragDistance = useRef(0.0);
final layoutKey = useMemoized(GlobalKey.new);
final contentCenter = useState<double?>(null);
final didHaptic = useRef(false);
final cancelled = useRef(false);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final messages = allMessages;
if (messages == null || message.isSystem) return child;
void reset() {
pulse.stop();
pulse.value = 0;
if (reducedMotion) {
offset.value = 0;
} else {
unawaited(
offset.animateWith(
ScrollSpringSimulation(
const SpringDescription(mass: 1, stiffness: 500, damping: 36),
offset.value,
0,
0,
),
),
);
}
}
// Flutter ends an accepted drag on PointerCancel too. Preserve the raw
// cancellation signal so an interrupted gesture cannot open a reply.
return Listener(
onPointerCancel: (_) => cancelled.value = true,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
excludeFromSemantics: true,
onHorizontalDragStart: (_) {
final contentBox = contentKey.currentContext?.findRenderObject();
final layoutBox = layoutKey.currentContext?.findRenderObject();
if (contentBox is RenderBox && layoutBox is RenderBox) {
contentCenter.value = layoutBox
.globalToLocal(
contentBox.localToGlobal(contentBox.size.center(Offset.zero)),
)
.dy;
}
offset.stop();
pulse.stop();
pulse.value = 0;
offset.value = 0;
dragDistance.value = 0;
didHaptic.value = false;
cancelled.value = false;
},
onHorizontalDragUpdate: (details) {
dragDistance.value = (dragDistance.value + details.delta.dx).clamp(
0.0,
double.infinity,
);
// Keep pulling beyond readiness, with increasing rubber-band resistance.
final excess = dragDistance.value - 72;
offset.value = excess <= 0
? dragDistance.value
: 72 + 40 * excess / (excess + 80);
if (offset.value >= 72 && !didHaptic.value) {
didHaptic.value = true;
unawaited(HapticFeedback.mediumImpact());
if (!reducedMotion) unawaited(pulse.forward(from: 0));
}
if (offset.value < 72) {
didHaptic.value = false;
pulse.stop();
pulse.value = 0;
}
},
onHorizontalDragCancel: reset,
onHorizontalDragEnd: (_) {
final reply = !cancelled.value && offset.value >= 72;
reset();
if (!reply) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: messages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
child: AnimatedBuilder(
animation: Listenable.merge([offset, pulse]),
child: child,
builder: (context, child) {
final progress = (offset.value / 72).clamp(0.0, 1.0);
// Follows the chosen message color, like the user's own bubbles.
final chatColors = ChatColors.of(context);
final accent = chatColors.outgoing;
final filledForeground = chatColors.foreground;
return Stack(
key: layoutKey,
clipBehavior: Clip.none,
alignment: Alignment.centerLeft,
children: [
if (progress > 0)
Positioned(
left: 0,
top: contentCenter.value ?? 0,
width: Grid.lg,
child: FractionalTranslation(
translation: const Offset(0, -0.5),
child: ExcludeSemantics(
child: Opacity(
opacity: progress,
child: Transform.scale(
scale: reducedMotion
? 1
: (0.5 + 0.5 * progress) *
(1 + 0.12 * sin(pi * pulse.value)),
alignment: Alignment.center,
child: SizedBox(
key: const ValueKey('swipe-reply-feedback'),
width: Grid.lg,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox.square(
dimension: Grid.lg,
child: DecoratedBox(
key: const ValueKey('swipe-reply-circle'),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: progress == 1
? accent
: Colors.transparent,
),
child: Stack(
fit: StackFit.expand,
children: [
CircularProgressIndicator(
value: progress,
strokeWidth: 2,
strokeAlign: -1,
color: accent,
backgroundColor: Colors.transparent,
),
Icon(
BuzzIcons.messageSquareReply,
size: 22,
color: progress == 1
? filledForeground
: context.colors.primary,
),
],
),
),
),
const SizedBox(height: Grid.quarter),
Text(
'Reply',
textAlign: TextAlign.center,
style: context.textTheme.labelSmall
?.copyWith(
color:
context.colors.onSurfaceVariant,
),
),
],
),
),
),
),
),
),
),
Transform.translate(
offset: Offset(offset.value, 0),
child: child,
),
],
);
},
),
),
);
}
}