124 lines
3.8 KiB
Dart
124 lines
3.8 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../models/models.dart';
|
|
|
|
/// Twin messages use a dashed border + badge (PRD §3.1 분신 뱃지).
|
|
class MessageBubble extends StatelessWidget {
|
|
const MessageBubble({
|
|
super.key,
|
|
required this.message,
|
|
required this.isMine,
|
|
this.onRetract,
|
|
});
|
|
|
|
final ChatMessage message;
|
|
final bool isMine;
|
|
final VoidCallback? onRetract;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final twin = message.isTwin;
|
|
final bg = isMine
|
|
? theme.colorScheme.primaryContainer
|
|
: theme.colorScheme.surfaceContainerHighest;
|
|
|
|
final bubble = Container(
|
|
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.78),
|
|
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
|
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
|
decoration: BoxDecoration(
|
|
color: bg,
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: twin
|
|
? Border.all(color: theme.colorScheme.tertiary, width: 1.5, strokeAlign: BorderSide.strokeAlignOutside)
|
|
: null,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (twin)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 4),
|
|
child: Text(
|
|
'분신',
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.tertiary,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
message.retracted ? '(되돌린 메시지)' : message.text,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontStyle: message.retracted ? FontStyle.italic : FontStyle.normal,
|
|
color: message.retracted ? theme.disabledColor : null,
|
|
),
|
|
),
|
|
if (twin && isMine && !message.retracted && onRetract != null)
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: TextButton(
|
|
onPressed: onRetract,
|
|
child: const Text('되돌리기'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
// Dashed look for twin: overlay a custom painter border when twin.
|
|
if (!twin) {
|
|
return Align(
|
|
alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
|
|
child: bubble,
|
|
);
|
|
}
|
|
|
|
return Align(
|
|
alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
|
|
child: CustomPaint(
|
|
painter: _DashedRRectPainter(color: theme.colorScheme.tertiary),
|
|
child: bubble,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DashedRRectPainter extends CustomPainter {
|
|
_DashedRRectPainter({required this.color});
|
|
final Color color;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final paint = Paint()
|
|
..color = color
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 1.5;
|
|
final rrect = RRect.fromRectAndRadius(
|
|
Rect.fromLTWH(1, 1, size.width - 2, size.height - 2),
|
|
const Radius.circular(14),
|
|
);
|
|
final path = Path()..addRRect(rrect);
|
|
final dashed = _dashPath(path, dashLength: 5, gapLength: 4);
|
|
canvas.drawPath(dashed, paint);
|
|
}
|
|
|
|
Path _dashPath(Path source, {required double dashLength, required double gapLength}) {
|
|
final metrics = source.computeMetrics();
|
|
final out = Path();
|
|
for (final metric in metrics) {
|
|
var distance = 0.0;
|
|
while (distance < metric.length) {
|
|
final next = distance + dashLength;
|
|
out.addPath(metric.extractPath(distance, next.clamp(0, metric.length)), Offset.zero);
|
|
distance = next + gapLength;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _DashedRRectPainter oldDelegate) => oldDelegate.color != color;
|
|
}
|