style: ui perfection
This commit is contained in:
@@ -0,0 +1,473 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:random_avatar/random_avatar.dart';
|
||||
|
||||
import '../../../../core/network/ws_client.dart';
|
||||
import '../../domain/entities/game_entities.dart';
|
||||
|
||||
/// اوورلیِ روی صحنهی Flame: آواتارِ بازیکنان با قابِ طلایی، نام، نشانِ رتبه،
|
||||
/// شمارندهی نوبت، تاجِ حاکم و تعدادِ کارت — بهعلاوهی پنلِ امتیاز/حکم (بالا-چپ)،
|
||||
/// نشانگرِ اتصال و دکمهی خروج (بالا-راست) و دکمهی گفتگو (پایین-راست).
|
||||
/// همهی چیدمان نسبت به اندازهی صفحه است تا با کارتهای Flame همتراز بماند.
|
||||
class TableHud extends StatelessWidget {
|
||||
final GameState s;
|
||||
final WsStatus connection;
|
||||
final VoidCallback onExit;
|
||||
final VoidCallback onChat;
|
||||
const TableHud({
|
||||
super.key,
|
||||
required this.s,
|
||||
required this.connection,
|
||||
required this.onExit,
|
||||
required this.onChat,
|
||||
});
|
||||
|
||||
// مکانِ آواتارِ هر جایگاهِ نسبی (۰=پایین/شما، ۱=چپ، ۲=بالا، ۳=راست).
|
||||
static const _spots = {
|
||||
0: Alignment(0, 1.0),
|
||||
1: Alignment(-0.94, -0.08),
|
||||
2: Alignment(0, -0.92),
|
||||
3: Alignment(0.94, -0.08),
|
||||
};
|
||||
|
||||
int _rel(int seat) => (seat - s.yourSeat + 4) % 4;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(builder: (context, c) {
|
||||
final w = c.maxWidth;
|
||||
final avatarD = (w * 0.17).clamp(48.0, 84.0);
|
||||
return Stack(
|
||||
children: [
|
||||
for (final p in s.players)
|
||||
Align(
|
||||
alignment: _spots[_rel(p.seat)]!,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: _rel(p.seat) == 0 ? 2 : 0,
|
||||
top: _rel(p.seat) == 2 ? 6 : 0,
|
||||
),
|
||||
child: _PlayerSeat(
|
||||
player: p,
|
||||
diameter: avatarD,
|
||||
isTurn: s.turn == p.seat,
|
||||
isHakem: s.hakem == p.seat,
|
||||
nameAbove: _rel(p.seat) == 2,
|
||||
cardCount:
|
||||
p.seat < s.handCounts.length ? s.handCounts[p.seat] : 0,
|
||||
turnToken: '${s.turn}|${s.trick.length}|${s.phase}',
|
||||
turnSeconds: s.turnTimeoutMs > 0 ? s.turnTimeoutMs / 1000 : 30,
|
||||
showRing: s.phase == 'playing' || s.phase == 'choose_trump',
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(top: 8, left: 8, child: SafeArea(child: _scorePanel())),
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: SafeArea(child: _topRight(context)),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
right: 12,
|
||||
child: _RoundBtn(
|
||||
icon: Icons.chat_bubble,
|
||||
color: const Color(0xFF1E5FA8),
|
||||
onTap: onChat,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// پنلِ امتیاز (ما/حریف) + چیپِ حکم. امتیازِ تیمِ شما = scores[yourSeat%2].
|
||||
Widget _scorePanel() {
|
||||
final myTeam = s.yourSeat % 2;
|
||||
final mine = myTeam < s.scores.length ? s.scores[myTeam] : 0;
|
||||
final opp = (1 - myTeam) < s.scores.length ? s.scores[1 - myTeam] : 0;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: _panelDeco(),
|
||||
child: Row(
|
||||
children: [
|
||||
_scoreCol('ما', mine),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 30,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
color: const Color(0x33E9B949),
|
||||
),
|
||||
_scoreCol('حریف', opp),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_hokmChip(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _scoreCol(String label, int v) => Column(
|
||||
children: [
|
||||
Text(label,
|
||||
style: const TextStyle(color: Color(0xFFE9B949), fontSize: 12)),
|
||||
Text('$v',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold)),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _hokmChip() {
|
||||
final (glyph, color) = _trumpGlyph(s.trump);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||
decoration: _panelDeco(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('حکم',
|
||||
style: TextStyle(
|
||||
color: Color(0xFFE9B949),
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold)),
|
||||
const SizedBox(width: 6),
|
||||
Text(glyph, style: TextStyle(color: color, fontSize: 18)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _topRight(BuildContext context) {
|
||||
final ok = connection == WsStatus.connected;
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: _panelDeco(),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(ok ? Icons.wifi : Icons.wifi_off,
|
||||
color: ok ? const Color(0xFF4CD964) : Colors.redAccent,
|
||||
size: 16),
|
||||
const SizedBox(width: 5),
|
||||
Text(ok ? 'آنلاین' : 'قطع',
|
||||
style: TextStyle(
|
||||
color: ok ? const Color(0xFF4CD964) : Colors.redAccent,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_RoundBtn(
|
||||
icon: Icons.logout,
|
||||
color: const Color(0xFFB81D2A),
|
||||
onTap: onExit,
|
||||
size: 40,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static BoxDecoration _panelDeco() => BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF2A2018), Color(0xFF14100B)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFFB8860B), width: 1.4),
|
||||
boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 6)],
|
||||
);
|
||||
|
||||
(String, Color) _trumpGlyph(String? suit) {
|
||||
switch (suit) {
|
||||
case 'hearts':
|
||||
return ('♥', const Color(0xFFE53935));
|
||||
case 'diamonds':
|
||||
return ('♦', const Color(0xFFE53935));
|
||||
case 'clubs':
|
||||
return ('♣', Colors.white);
|
||||
case 'spades':
|
||||
return ('♠', Colors.white);
|
||||
default:
|
||||
return ('—', Colors.white54);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayerSeat extends StatelessWidget {
|
||||
final GamePlayer player;
|
||||
final double diameter;
|
||||
final bool isTurn;
|
||||
final bool isHakem;
|
||||
final bool nameAbove;
|
||||
final int cardCount;
|
||||
final String turnToken;
|
||||
final double turnSeconds;
|
||||
final bool showRing;
|
||||
const _PlayerSeat({
|
||||
required this.player,
|
||||
required this.diameter,
|
||||
required this.isTurn,
|
||||
required this.isHakem,
|
||||
required this.nameAbove,
|
||||
required this.cardCount,
|
||||
required this.turnToken,
|
||||
required this.turnSeconds,
|
||||
required this.showRing,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ringW = diameter * 0.08;
|
||||
final name = Container(
|
||||
constraints: BoxConstraints(maxWidth: diameter * 1.7),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xCC14100B),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: isTurn ? const Color(0xFFE9B949) : const Color(0x55B8860B)),
|
||||
),
|
||||
child: Text(
|
||||
player.bot ? '${player.name} (ربات)' : player.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: isTurn ? const Color(0xFFE9B949) : Colors.white,
|
||||
fontSize: diameter * 0.18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final avatar = SizedBox(
|
||||
width: diameter,
|
||||
height: diameter,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// قابِ طلایی + آواتار.
|
||||
Container(
|
||||
width: diameter,
|
||||
height: diameter,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFF3D27A), Color(0xFFB8860B)],
|
||||
),
|
||||
boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 6)],
|
||||
),
|
||||
padding: EdgeInsets.all(ringW),
|
||||
child: ClipOval(
|
||||
child: Container(
|
||||
color: const Color(0xFF1A2740),
|
||||
child: player.bot
|
||||
? Icon(Icons.smart_toy,
|
||||
color: Colors.white70, size: diameter * 0.5)
|
||||
: RandomAvatar(player.name.isEmpty ? '?' : player.name),
|
||||
),
|
||||
),
|
||||
),
|
||||
// حلقهی شمارشِ نوبت (روی قاب).
|
||||
if (isTurn && showRing)
|
||||
Positioned.fill(
|
||||
child: _TurnRing(
|
||||
token: turnToken,
|
||||
seconds: turnSeconds,
|
||||
stroke: ringW * 1.1,
|
||||
),
|
||||
),
|
||||
// تاجِ حاکم بالای آواتار.
|
||||
if (isHakem)
|
||||
Positioned(
|
||||
top: -diameter * 0.32,
|
||||
child: Icon(Icons.workspace_premium,
|
||||
color: const Color(0xFFFFC107), size: diameter * 0.42),
|
||||
),
|
||||
// حبابِ تعدادِ کارت (پایین-راست).
|
||||
if (cardCount > 0)
|
||||
Positioned(
|
||||
right: -diameter * 0.04,
|
||||
bottom: -diameter * 0.04,
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(diameter * 0.06),
|
||||
constraints: BoxConstraints(
|
||||
minWidth: diameter * 0.34, minHeight: diameter * 0.34),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0E2A12),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: const Color(0xFFE9B949), width: 1.4),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text('$cardCount',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: diameter * 0.18,
|
||||
fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// نشانِ رتبهی فشرده (تصویر) کنارِ آواتار.
|
||||
final badge = (!player.bot && player.rankTier.isNotEmpty)
|
||||
? Image.asset(
|
||||
'assets/images/badge/${player.rankTier}.png',
|
||||
width: diameter * 0.34,
|
||||
height: diameter * 0.34,
|
||||
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
|
||||
final nameRow = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
badge,
|
||||
if (!player.bot && player.rankTier.isNotEmpty) const SizedBox(width: 4),
|
||||
Flexible(child: name),
|
||||
if (!player.bot && player.coins > 0) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text('${player.coins}',
|
||||
style: TextStyle(
|
||||
color: const Color(0xFFE9B949), fontSize: diameter * 0.16)),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: nameAbove
|
||||
? [nameRow, SizedBox(height: diameter * 0.06), avatar]
|
||||
: [avatar, SizedBox(height: diameter * 0.06), nameRow],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// حلقهی شمارشِ معکوسِ نوبت: از پر به خالی طیِ `seconds`؛ نزدیکِ پایان قرمز
|
||||
/// میشود. با تغییرِ `token` (نوبتِ جدید) از نو شروع میشود.
|
||||
class _TurnRing extends StatefulWidget {
|
||||
final String token;
|
||||
final double seconds;
|
||||
final double stroke;
|
||||
const _TurnRing(
|
||||
{required this.token, required this.seconds, required this.stroke});
|
||||
|
||||
@override
|
||||
State<_TurnRing> createState() => _TurnRingState();
|
||||
}
|
||||
|
||||
class _TurnRingState extends State<_TurnRing>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _c;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_c = AnimationController(
|
||||
vsync: this, duration: Duration(milliseconds: (widget.seconds * 1000).round()))
|
||||
..forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _TurnRing old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.token != widget.token) {
|
||||
_c.duration = Duration(milliseconds: (widget.seconds * 1000).round());
|
||||
_c
|
||||
..reset()
|
||||
..forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_c.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _c,
|
||||
builder: (_, __) {
|
||||
final left = (1 - _c.value).clamp(0.0, 1.0);
|
||||
return CustomPaint(
|
||||
painter: _RingPainter(left: left, stroke: widget.stroke),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RingPainter extends CustomPainter {
|
||||
final double left; // ۱..۰
|
||||
final double stroke;
|
||||
_RingPainter({required this.left, required this.stroke});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final rect = Offset.zero & size;
|
||||
final center = rect.center;
|
||||
final radius = (size.shortestSide - stroke) / 2;
|
||||
const start = -1.5708; // بالا
|
||||
final sweep = 6.28318 * left;
|
||||
final color = left < 0.3 ? const Color(0xFFE53935) : const Color(0xFF4CD964);
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: center, radius: radius),
|
||||
start,
|
||||
sweep,
|
||||
false,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeWidth = stroke
|
||||
..color = color,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _RingPainter old) =>
|
||||
old.left != left || old.stroke != stroke;
|
||||
}
|
||||
|
||||
class _RoundBtn extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
final double size;
|
||||
const _RoundBtn(
|
||||
{required this.icon,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
this.size = 46});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: const Color(0xFFE9B949), width: 1.6),
|
||||
boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 6)],
|
||||
),
|
||||
child: Icon(icon, color: Colors.white, size: size * 0.5),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user