Saba-dart/lib/widgets/message_bubble.dart
2026-04-13 23:41:27 +03:30

440 lines
17 KiB
Dart

import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import '../models/chat_model.dart';
class MessageBubble extends StatefulWidget {
final String body;
final String? rawBody;
final String? statusLabel;
final int date;
final bool isMe;
final MessageStatus status;
final bool isSecure;
final bool canRetryDecryption;
final VoidCallback? onRetryDecryption;
final String? packetMode;
final bool isPendingMultipart;
const MessageBubble({
super.key,
required this.body,
this.rawBody,
this.statusLabel,
required this.date,
required this.isMe,
required this.status,
this.isSecure = false,
this.canRetryDecryption = false,
this.onRetryDecryption,
this.packetMode,
this.isPendingMultipart = false,
});
@override
State<MessageBubble> createState() => _MessageBubbleState();
}
class _MessageBubbleState extends State<MessageBubble>
with SingleTickerProviderStateMixin {
bool _showRaw = false;
late AnimationController _animationController;
late Animation<Offset> _slideAnimation;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 400),
);
_fadeAnimation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeIn,
);
// Directional Slide: Received from left, Sent from right
final double startOffsetX = widget.isMe ? 0.3 : -0.3;
_slideAnimation = Tween<Offset>(
begin: Offset(startOffsetX, 0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.elasticOut, // Added premium bounce effect
));
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
bool get _hasRawView =>
widget.rawBody != null &&
widget.rawBody!.isNotEmpty &&
widget.rawBody != widget.body;
String get _displayBody {
if (_showRaw && widget.rawBody != null) {
return widget.rawBody!;
}
// Handle the payload separator
if (widget.body.contains(' ::PAYLOAD::')) {
return widget.body.split(' ::PAYLOAD::')[0];
}
if (widget.body.contains(' | ')) {
return widget.body.split(' | ')[0];
}
return widget.body;
}
@override
Widget build(BuildContext context) {
final bool isLocked = widget.canRetryDecryption && !widget.isMe;
// Premium Gradient logic for Sent messages
final List<Color> sentGradients = widget.status == MessageStatus.failed
? [Colors.redAccent, Colors.red.shade900]
: (widget.isSecure
? [const Color(0xFF6A11CB), const Color(0xFF2575FC), const Color(0xFF00D2FF)] // Secure: Violet to Cyan
: [const Color(0xFF7000FF), const Color(0xFF5C6BC0)]); // Normal: Deep Purple to Indigo
// Received background colors
final Color receivedBg = isLocked
? const Color(0xFF0A192F) // Deep Blue Glass for locked
: (widget.isSecure ? const Color(0xFF0D0D15) : const Color(0xFF0F0F0F));
return FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: ScaleTransition(
scale: CurvedAnimation(
parent: _animationController,
curve: const Interval(0.0, 0.7, curve: Curves.easeOutBack),
),
child: Row(
textDirection: TextDirection.ltr,
mainAxisAlignment:
widget.isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.only(
left: widget.isMe ? 50 : 8,
right: widget.isMe ? 8 : 50,
top: 6,
bottom: 6,
),
child: CustomPaint(
painter: BubblePainter(
color: widget.isMe ? null : receivedBg,
gradient: widget.isMe
? LinearGradient(
colors: sentGradients,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
stops: sentGradients.length == 3 ? [0.0, 0.5, 1.0] : null,
)
: (!widget.isMe && widget.isSecure && !isLocked
? const LinearGradient(
colors: [Color(0xFF1A1A2E), Color(0xFF16213E)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
)
: null),
isMe: widget.isMe,
isSecure: widget.isSecure,
),
child: Container(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.78),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.isSecure)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isLocked
? Icons.security_rounded
: Icons.verified_user_rounded,
size: 14,
color: widget.isMe
? Colors.white70
: (isLocked
? const Color(0xFF00E5FF)
: const Color(0xFF00D2FF)),
),
const SizedBox(width: 6),
Expanded(
child: Text(
isLocked
? "پیام رمزگذاری شده (نیاز به کلید)"
: (widget.isPendingMultipart
? "در حال دریافت قطعات پیام..."
: (widget.packetMode == 'SYM'
? "رمزنگاری متقارن (AES-256)"
: (widget.packetMode == 'AE'
? "رمزنگاری نامتقارن (ECC)"
: "پیام امن تایید شده"))),
style: TextStyle(
fontWeight: FontWeight.bold,
color: widget.isMe
? Colors.white.withValues(alpha: 0.8)
: (isLocked
? const Color(0xFF00E5FF)
: const Color(0xFF00D2FF)),
fontSize: 10,
letterSpacing: 0.2,
),
),
),
],
),
),
if (widget.isPendingMultipart)
Shimmer.fromColors(
baseColor: Colors.white.withValues(alpha: 0.1),
highlightColor: Colors.white.withValues(alpha: 0.3),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(height: 14, width: double.infinity, decoration: BoxDecoration(color: Colors.white10, borderRadius: BorderRadius.circular(4))),
const SizedBox(height: 6),
Container(height: 14, width: 150, decoration: BoxDecoration(color: Colors.white10, borderRadius: BorderRadius.circular(4))),
],
),
)
else
Directionality(
textDirection: TextDirection.rtl,
child: Text(
_displayBody,
style: TextStyle(
color: widget.isMe
? Colors.white
: Colors.white.withValues(alpha: 0.95),
fontSize: 15.5,
fontWeight: isLocked ? FontWeight.w700 : FontWeight.w400,
height: 1.5,
fontFamily: _showRaw ? "monospace" : null,
),
),
),
if (isLocked) ...[
const SizedBox(height: 12),
InkWell(
onTap: widget.onRetryDecryption,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFF00E5FF).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF00E5FF).withValues(alpha: 0.3), width: 1),
),
child: const Row(
children: [
Icon(Icons.vpn_key_rounded, size: 18, color: Color(0xFF00E5FF)),
SizedBox(width: 10),
Expanded(
child: Text(
"برای بازگشایی ضربه بزنید",
style: TextStyle(fontSize: 12, color: Color(0xFF00E5FF), fontWeight: FontWeight.bold),
),
),
Icon(Icons.chevron_right_rounded, color: Color(0xFF00E5FF)),
],
),
),
),
],
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (widget.isSecure && _hasRawView)
Padding(
padding: const EdgeInsets.only(right: 8),
child: InkWell(
onTap: () => setState(() => _showRaw = !_showRaw),
child: Text(
_showRaw ? "متن اصلی" : "دیتای خام",
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
color: widget.isMe ? Colors.white70 : const Color(0xFF00D2FF),
),
),
),
),
if (widget.statusLabel != null)
Flexible(
child: Text(
widget.statusLabel!,
style: TextStyle(fontSize: 9, color: widget.isMe ? Colors.white60 : Colors.white38),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
Text(
_formatTime(widget.date),
style: TextStyle(fontSize: 10, color: widget.isMe ? Colors.white60 : Colors.white38),
),
if (widget.isMe) ...[
const SizedBox(width: 4),
_buildStatusIcon(),
]
],
),
],
),
),
),
),
],
),
),
),
);
}
Widget _buildStatusIcon() {
switch (widget.status) {
case MessageStatus.sending:
return const SizedBox(
width: 8,
height: 8,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 1,
),
);
case MessageStatus.sent:
return const Icon(Icons.done_all, size: 12, color: Colors.white70);
case MessageStatus.failed:
return const Icon(Icons.error_outline, size: 12, color: Colors.white);
default:
return const SizedBox();
}
}
String _formatTime(int millis) {
final d = DateTime.fromMillisecondsSinceEpoch(millis);
return "${d.hour}:${d.minute.toString().padLeft(2, '0')}";
}
}
class BubblePainter extends CustomPainter {
final Color? color;
final Gradient? gradient;
final bool isMe;
final bool isSecure;
BubblePainter({
this.color,
this.gradient,
required this.isMe,
this.isSecure = false,
});
@override
void paint(Canvas canvas, Size size) {
final Paint paint = Paint()..style = PaintingStyle.fill;
const double tailWidth = 10.0;
const double tailHeight = 12.0;
if (gradient != null) {
paint.shader = gradient!.createShader(Rect.fromLTWH(0, 0, size.width, size.height));
} else if (color != null) {
paint.color = color!;
}
const double R = 22.0; // Standard radius
final Path path = Path();
if (isMe) {
// Sent Message: Tail on bottom-right
path.moveTo(R, 0);
path.lineTo(size.width - R, 0);
path.quadraticBezierTo(size.width, 0, size.width, R);
path.lineTo(size.width, size.height - tailHeight - R);
path.quadraticBezierTo(size.width, size.height - tailHeight, size.width - tailWidth, size.height - tailHeight);
path.lineTo(size.width, size.height); // Pointy tail tip
path.lineTo(size.width - tailWidth - R, size.height - tailHeight);
path.lineTo(R, size.height - tailHeight);
path.quadraticBezierTo(0, size.height - tailHeight, 0, size.height - tailHeight - R);
path.lineTo(0, R);
path.quadraticBezierTo(0, 0, R, 0);
path.close();
} else {
// Received Message: Tail on bottom-left
path.moveTo(tailWidth + R, 0);
path.lineTo(size.width - R, 0);
path.quadraticBezierTo(size.width, 0, size.width, R);
path.lineTo(size.width, size.height - tailHeight - R);
path.quadraticBezierTo(size.width, size.height - tailHeight, size.width - R, size.height - tailHeight);
path.lineTo(tailWidth + R, size.height - tailHeight);
path.lineTo(0, size.height); // Pointy tail tip
path.lineTo(tailWidth, size.height - tailHeight);
path.lineTo(tailWidth, R);
path.quadraticBezierTo(tailWidth, 0, tailWidth + R, 0);
path.close();
}
// --- Enhanced Glowing Shadows ---
if (isSecure) {
final Color shadowColor = isMe ? const Color(0xFF7000FF) : const Color(0xFF00D2FF);
// Layered glow effect
for (int i = 1; i <= 3; i++) {
canvas.drawShadow(
path.shift(Offset(0, 1.0 * i)),
shadowColor.withValues(alpha: 0.15 / i),
4.0 * i,
false,
);
}
} else {
canvas.drawShadow(
path.shift(const Offset(0, 3)),
Colors.black.withValues(alpha: 0.4),
6.0,
false,
);
}
canvas.drawPath(path, paint);
// --- Subtle Inner Border for Glass Effect ---
if (!isMe) {
final Paint borderPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0.5
..color = Colors.white.withValues(alpha: 0.15);
canvas.drawPath(path, borderPaint);
}
}
@override
bool shouldRepaint(covariant BubblePainter oldDelegate) =>
oldDelegate.color != color ||
oldDelegate.gradient != gradient ||
oldDelegate.isMe != isMe ||
oldDelegate.isSecure != isSecure;
}
// Removed incorrect ColorExt