新增影视分享的几种样式

This commit is contained in:
DelLevin-Home
2026-06-19 00:28:13 +08:00
parent 56dee60782
commit 67b77f14e0
12 changed files with 2076 additions and 557 deletions

View File

@@ -1,5 +1,4 @@
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
@@ -9,10 +8,8 @@ import '../../models/data_models.dart';
import '../../utils/toast_util.dart';
import '../../widgets/fade_in_local_image.dart';
/// 影视分享海报页面
class MovieSharePage extends StatefulWidget {
final Movie movie;
const MovieSharePage({super.key, required this.movie});
@override
@@ -22,6 +19,72 @@ class MovieSharePage extends StatefulWidget {
class _MovieSharePageState extends State<MovieSharePage> {
final GlobalKey _posterKey = GlobalKey();
bool _isGenerating = false;
int _currentStyle = 0;
static const _styleNames = ['海报', '经典票根', '喵眼电影票根', '收藏版票根'];
// 影院信息(可编辑)
late TextEditingController _cinemaCtrl;
late TextEditingController _hallCtrl;
late TextEditingController _seatRowCtrl;
late TextEditingController _seatNumCtrl;
late TextEditingController _screeningDateCtrl;
late TextEditingController _screeningTimeCtrl;
late TextEditingController _priceCtrl;
late TextEditingController _serviceFeeCtrl;
late TextEditingController _issueTimeCtrl;
late TextEditingController _platformCtrl;
late TextEditingController _ticketCodeCtrl;
@override
void initState() {
super.initState();
final now = DateTime.now();
final dateStr = '${now.year}${now.month}${now.day}';
final timeStr = '${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
final issueStr = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
final codeStr = 'MK${now.millisecondsSinceEpoch.toString().substring(1)}';
_cinemaCtrl = TextEditingController(text: '万达影城');
_hallCtrl = TextEditingController(text: '3号厅');
_seatRowCtrl = TextEditingController(text: '8');
_seatNumCtrl = TextEditingController(text: '12');
_screeningDateCtrl = TextEditingController(text: dateStr);
_screeningTimeCtrl = TextEditingController(text: timeStr);
_priceCtrl = TextEditingController(text: '39.90');
_serviceFeeCtrl = TextEditingController(text: '5.00');
_issueTimeCtrl = TextEditingController(text: issueStr);
_platformCtrl = TextEditingController(text: '喵眼电影');
_ticketCodeCtrl = TextEditingController(text: codeStr);
}
@override
void dispose() {
_cinemaCtrl.dispose();
_hallCtrl.dispose();
_seatRowCtrl.dispose();
_seatNumCtrl.dispose();
_screeningDateCtrl.dispose();
_screeningTimeCtrl.dispose();
_priceCtrl.dispose();
_serviceFeeCtrl.dispose();
_issueTimeCtrl.dispose();
_platformCtrl.dispose();
_ticketCodeCtrl.dispose();
super.dispose();
}
bool get _isCinemaStyle => _currentStyle >= 2;
Widget _buildBody() {
switch (_currentStyle) {
case 0: return _buildPosterWidget();
case 1: return _buildTicketClassic();
case 2: return _buildTicketCinema();
case 3: return _buildTicketCollectible();
default: return _buildPosterWidget();
}
}
@override
Widget build(BuildContext context) {
@@ -31,53 +94,232 @@ class _MovieSharePageState extends State<MovieSharePage> {
appBar: AppBar(
backgroundColor: colors.surface,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.close, color: colors.onSurface),
onPressed: () => Navigator.pop(context),
),
title: Text(
'分享海报',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: colors.onSurface,
),
),
leading: IconButton(icon: Icon(Icons.close, color: colors.onSurface), onPressed: () => Navigator.pop(context)),
title: Text('分享海报', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
centerTitle: true,
actions: [
IconButton(icon: Icon(Icons.palette_outlined, color: colors.onSurface, size: 22), tooltip: '选择样式', onPressed: _showStylePicker),
TextButton(
onPressed: _isGenerating ? null : _generateAndShare,
child: _isGenerating
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(
'分享',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: colors.onSurface,
),
),
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: Text('分享', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)),
),
const SizedBox(width: 8),
const SizedBox(width: 4),
],
),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: RepaintBoundary(
key: _posterKey,
child: _buildPosterWidget(),
body: Column(children: [
// 预览
Expanded(child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: RepaintBoundary(key: _posterKey, child: _buildBody()),
),
)),
// 底部编辑按钮(仅票根样式)
if (_isCinemaStyle) _buildBottomEditBar(colors),
]),
);
}
// ─── 样式选择 ───
void _showStylePicker() {
final colors = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('选择样式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(height: 16),
Wrap(spacing: 10, runSpacing: 10, children: List.generate(_styleNames.length, (i) {
final selected = _currentStyle == i;
return GestureDetector(
onTap: () { setState(() => _currentStyle = i); Navigator.pop(ctx); },
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
decoration: BoxDecoration(
color: selected ? colors.primary : colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: selected ? colors.primary : colors.outline, width: 0.5),
),
child: Text(_styleNames[i], style: TextStyle(fontSize: 14, fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6))),
),
);
})),
]),
),
);
}
// ─── 底部编辑栏 ───
Widget _buildBottomEditBar(ColorScheme colors) {
return Container(
padding: EdgeInsets.only(left: 24, right: 24, bottom: MediaQuery.of(context).padding.bottom + 12, top: 8),
color: colors.surface,
child: SizedBox(
width: double.infinity,
height: 44,
child: ElevatedButton.icon(
onPressed: _showEditSheet,
icon: const Icon(Icons.edit_outlined, size: 18),
label: const Text('编辑票根信息'),
style: ElevatedButton.styleFrom(
backgroundColor: colors.primary,
foregroundColor: colors.onPrimary,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
),
);
}
/// 构建海报 Widget
void _showEditSheet() {
final colors = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => Padding(
padding: EdgeInsets.fromLTRB(20, 16, 20, MediaQuery.of(ctx).viewInsets.bottom + 20),
child: SingleChildScrollView(child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('编辑票根信息', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(height: 16),
_sheetField('影院名称', _cinemaCtrl),
_sheetField('影厅', _hallCtrl),
Row(children: [
Expanded(child: _sheetField('', _seatRowCtrl)),
const SizedBox(width: 12),
Expanded(child: _sheetField('', _seatNumCtrl)),
]),
Row(children: [
Expanded(child: _sheetField('票价 (¥)', _priceCtrl)),
const SizedBox(width: 12),
Expanded(child: _sheetField('服务费 (¥)', _serviceFeeCtrl)),
]),
_sheetDatePicker('出票日期', _issueTimeCtrl, ctx),
_sheetCodeField(),
const SizedBox(height: 16),
SizedBox(
width: double.infinity, height: 44,
child: ElevatedButton(
onPressed: () { setState(() {}); Navigator.pop(ctx); },
style: ElevatedButton.styleFrom(
backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('完成'),
),
),
],
)),
),
);
}
Widget _sheetField(String label, TextEditingController ctrl) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: ctrl,
style: TextStyle(fontSize: 14, color: colors.onSurface),
decoration: InputDecoration(
labelText: label,
labelStyle: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5)),
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
filled: true, fillColor: colors.surfaceContainerHighest,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: colors.primary, width: 1)),
),
),
);
}
Widget _sheetDatePicker(String label, TextEditingController ctrl, BuildContext sheetCtx) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: GestureDetector(
onTap: () async {
final now = DateTime.now();
final picked = await showDatePicker(
context: sheetCtx,
initialDate: now,
firstDate: DateTime(1900),
lastDate: DateTime(now.year + 10),
locale: const Locale('zh'),
);
if (picked != null) {
ctrl.text = '${picked.year}-${picked.month.toString().padLeft(2, '0')}-${picked.day.toString().padLeft(2, '0')}';
}
},
child: AbsorbPointer(child: TextField(
controller: ctrl,
style: TextStyle(fontSize: 14, color: colors.onSurface),
decoration: InputDecoration(
labelText: label,
labelStyle: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5)),
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
filled: true, fillColor: colors.surfaceContainerHighest,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: colors.primary, width: 1)),
suffixIcon: Icon(Icons.calendar_today_outlined, size: 18, color: colors.onSurface.withValues(alpha: 0.4)),
),
)),
),
);
}
Widget _sheetCodeField() {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(children: [
Expanded(child: TextField(
controller: _ticketCodeCtrl,
style: TextStyle(fontSize: 14, color: colors.onSurface, fontFamily: 'monospace'),
decoration: InputDecoration(
labelText: '编码',
labelStyle: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5)),
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
filled: true, fillColor: colors.surfaceContainerHighest,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: colors.primary, width: 1)),
),
)),
const SizedBox(width: 8),
GestureDetector(
onTap: () {
_ticketCodeCtrl.text = 'MK${DateTime.now().millisecondsSinceEpoch.toString().substring(1)}';
},
child: Container(
width: 40, height: 40,
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)),
child: Icon(Icons.casino_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.6)),
),
),
]),
);
}
// ═══════════════════════════════════════════════════════════
// 样式 0海报
// ═══════════════════════════════════════════════════════════
Widget _buildPosterWidget() {
final colors = Theme.of(context).colorScheme;
final movie = widget.movie;
@@ -85,240 +327,389 @@ class _MovieSharePageState extends State<MovieSharePage> {
return Container(
width: 320,
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(16),
decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10))]),
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
if (hasPoster)
ClipRRect(borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
child: FadeInLocalImage(path: movie.posterPath, width: 320, height: 200, fit: BoxFit.cover)),
Padding(padding: const EdgeInsets.all(20), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(movie.title, style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: colors.onSurface)),
if (movie.alternateTitles.isNotEmpty) ...[
const SizedBox(height: 8),
Text(movie.alternateTitles.join(' / '), style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))),
],
const SizedBox(height: 16),
if (movie.rating != null && movie.rating! > 0) ...[
Row(children: [
const Icon(Icons.star, size: 18, color: Color(0xFFFFB800)),
const SizedBox(width: 4),
Text(movie.rating!.toStringAsFixed(1), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFFFFB800))),
const SizedBox(width: 4),
Text('/ 10', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
]),
const SizedBox(height: 12),
],
if (movie.directors.isNotEmpty) _infoRow('导演', movie.directors.join(' / '), colors),
if (movie.writers.isNotEmpty) _infoRow('编剧', movie.writers.join(' / '), colors),
if (movie.actors.isNotEmpty) _infoRow('主演', movie.actors.take(3).join(' / '), colors),
if (movie.genres.isNotEmpty) _infoRow('类型', movie.genres.join(' / '), colors),
if (movie.releaseDate != null) _infoRow('上映', _fmtDate(movie.releaseDate!), colors),
if (movie.summary != null && movie.summary!.isNotEmpty) ...[
const SizedBox(height: 16),
Text('简介', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 8),
Text(movie.summary!, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6), height: 1.6), maxLines: 5, overflow: TextOverflow.ellipsis),
],
const SizedBox(height: 20),
Divider(height: 1, color: colors.outline),
const SizedBox(height: 12),
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.movie_outlined, size: 14, color: colors.onSurface.withValues(alpha: 0.5)),
const SizedBox(width: 6),
Text('来自 MookNote', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))),
]),
])),
]),
);
}
Widget _infoRow(String label, String value, ColorScheme colors) {
return Padding(padding: const EdgeInsets.only(bottom: 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('$label', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))),
Expanded(child: Text(value, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.75)))),
]));
}
// ═══════════════════════════════════════════════════════════
// 样式 1经典票根
// ═══════════════════════════════════════════════════════════
Widget _buildTicketClassic() {
final movie = widget.movie;
final hasPoster = movie.posterPath != null && movie.posterPath!.isNotEmpty;
const c = Color(0xFF2D2D2D);
return Container(
width: 300,
decoration: BoxDecoration(color: const Color(0xFFFFFBF5), borderRadius: BorderRadius.circular(8),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.08), blurRadius: 16, offset: const Offset(0, 6))]),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Padding(padding: const EdgeInsets.all(16), child: Column(children: [
if (hasPoster) ClipRRect(borderRadius: BorderRadius.circular(4),
child: FadeInLocalImage(path: movie.posterPath, width: 268, height: 160, fit: BoxFit.cover)),
const SizedBox(height: 12),
Text(movie.title, textAlign: TextAlign.center, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: c, letterSpacing: 1)),
if (movie.alternateTitles.isNotEmpty) ...[
const SizedBox(height: 4),
Text(movie.alternateTitles.join(' / '), textAlign: TextAlign.center, style: TextStyle(fontSize: 11, color: c.withValues(alpha: 0.4))),
],
])),
_dashedLine(c.withValues(alpha: 0.15)),
Padding(padding: const EdgeInsets.fromLTRB(20, 14, 20, 16), child: Column(children: [
_classicRow('DIRECTED BY', movie.directors.isNotEmpty ? movie.directors.join(', ') : '--'),
const SizedBox(height: 10),
_classicRow('STARRING', movie.actors.isNotEmpty ? movie.actors.take(3).join(', ') : '--'),
const SizedBox(height: 10),
_classicRow('GENRE', movie.genres.isNotEmpty ? movie.genres.join(' / ') : '--'),
const SizedBox(height: 10),
_classicRow('RELEASE', movie.releaseDate != null ? _fmtDate(movie.releaseDate!) : '--'),
if (movie.rating != null && movie.rating! > 0) ...[
const SizedBox(height: 10),
_classicRow('RATING', '${movie.rating!.toStringAsFixed(1)} / 10'),
],
const SizedBox(height: 14),
Row(children: [
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(border: Border.all(color: c.withValues(alpha: 0.2), width: 0.5)),
child: Text(_statusEN(movie.status), style: TextStyle(fontSize: 9, fontWeight: FontWeight.w600, letterSpacing: 2, color: c.withValues(alpha: 0.5)))),
const Spacer(),
Icon(Icons.movie_outlined, size: 12, color: c.withValues(alpha: 0.3)),
const SizedBox(width: 4),
Text('MookNote', style: TextStyle(fontSize: 9, letterSpacing: 1, color: c.withValues(alpha: 0.3))),
]),
])),
]),
);
}
Widget _classicRow(String label, String value) {
return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(width: 80, child: Text(label, style: TextStyle(fontSize: 9, fontWeight: FontWeight.w600, letterSpacing: 1.5, color: const Color(0xFF2D2D2D).withValues(alpha: 0.35)))),
Expanded(child: Text(value, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: Color(0xFF2D2D2D), height: 1.4))),
]);
}
// ═══════════════════════════════════════════════════════════
// 样式 2电影票参考票根.html
// ═══════════════════════════════════════════════════════════
Widget _buildTicketCinema() {
final movie = widget.movie;
const bg = Color(0xFFFFFDF7);
const text = Color(0xFF1A1A1A);
const muted = Color(0xFF999999);
return Container(
width: 340,
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.12), blurRadius: 24, offset: const Offset(0, 6))]),
child: Column(mainAxisSize: MainAxisSize.min, children: [
// Header
Padding(padding: const EdgeInsets.fromLTRB(20, 16, 20, 14), child: Row(children: [
Image.asset('assets/images/ticket/maoyan.png', width: 36, height: 36, errorBuilder: (_, __, ___) => Icon(Icons.local_movies, size: 36, color: text)),
const SizedBox(width: 10),
Text(_platformCtrl.text.isNotEmpty ? _platformCtrl.text : 'MookNote', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w900, color: text, letterSpacing: 0.5)),
])),
Container(height: 0.5, color: const Color(0xFFF0EDE5)),
// Body
Padding(padding: const EdgeInsets.fromLTRB(20, 18, 20, 14), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// 影院
Text(_cinemaCtrl.text, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: text, letterSpacing: 0.5)),
const SizedBox(height: 14),
// 片名
Text(movie.title, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: text, letterSpacing: 0.5, height: 1.3)),
const SizedBox(height: 4),
Text(_subtitleText(movie), style: const TextStyle(fontSize: 12, color: Color(0xFF666666), letterSpacing: 0.5)),
const SizedBox(height: 16),
// 场次
Row(children: [
Icon(Icons.desktop_windows_outlined, size: 15, color: text.withValues(alpha: 0.6)),
const SizedBox(width: 8),
Text(_hallCtrl.text, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: text.withValues(alpha: 0.8))),
]),
const SizedBox(height: 8),
Row(children: [
Text('${_seatRowCtrl.text}${_seatNumCtrl.text}', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: text.withValues(alpha: 0.8))),
Padding(padding: const EdgeInsets.symmetric(horizontal: 8), child: Text('|', style: TextStyle(color: const Color(0xFFD0CCC0), fontWeight: FontWeight.w300))),
Text(widget.movie.releaseDate != null ? '${widget.movie.releaseDate!.year}${widget.movie.releaseDate!.month}${widget.movie.releaseDate!.day}' : '待定', style: TextStyle(fontSize: 14, color: text.withValues(alpha: 0.6))),
]),
])),
// 撕票线
_tearScissors(bg),
// Footer
Padding(padding: const EdgeInsets.fromLTRB(20, 10, 20, 16), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
Container(width: 100, height: 100, decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE8E4DA), width: 0.5)),
child: Image.asset('assets/images/ticket/qr.png', fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const SizedBox.shrink())),
const SizedBox(width: 16),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_ticketDetail('票价', _priceCtrl.text, isPrice: true),
_ticketDetail('服务费', _serviceFeeCtrl.text),
_ticketDetail('出票日期', _issueTimeCtrl.text),
_ticketDetail(_platformCtrl.text.isNotEmpty ? _platformCtrl.text : '平台', '已出票'),
const SizedBox(height: 4),
Text(_ticketCodeCtrl.text, style: TextStyle(fontFamily: 'monospace', fontSize: 9, letterSpacing: 0.8, color: muted)),
])),
])),
]),
);
}
Widget _ticketDetail(String label, String value, {bool isPrice = false}) {
return Padding(padding: const EdgeInsets.only(bottom: 4), child: Row(children: [
Text(label, style: const TextStyle(fontSize: 11, color: Color(0xFF999999))),
const Spacer(),
Text(value, style: TextStyle(
fontSize: isPrice ? 16 : 12,
fontWeight: isPrice ? FontWeight.w700 : FontWeight.w500,
color: isPrice ? const Color(0xFFE5342F) : const Color(0xFF1A1A1A),
)),
]));
}
// ═══════════════════════════════════════════════════════════
// 样式 3收藏版票根参考票根收藏版.html
// ═══════════════════════════════════════════════════════════
Widget _buildTicketCollectible() {
final movie = widget.movie;
const red = Color(0xFFC0392B);
const text = Color(0xFF1A1A1A);
const light = Color(0xFF8C8C8C);
const line = Color(0xFFE8E2D8);
return Container(
width: 360,
decoration: BoxDecoration(color: const Color(0xFFFFFFFB),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 20,
offset: const Offset(0, 10),
BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 12, offset: const Offset(0, 2)),
BoxShadow(color: Colors.black.withValues(alpha: 0.10), blurRadius: 40, offset: const Offset(0, 8)),
]),
child: Column(mainAxisSize: MainAxisSize.min, children: [
// 红色头部
Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
color: red,
child: Row(children: [
const Text('MookNote', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Colors.white, letterSpacing: 0.5)),
const Spacer(),
Text('收藏纪念\nCOLLECTIBLE', textAlign: TextAlign.right,
style: TextStyle(fontSize: 8, color: Colors.white.withValues(alpha: 0.7), letterSpacing: 0.5, height: 1.5)),
]),
),
// 主体
Padding(padding: const EdgeInsets.fromLTRB(24, 22, 24, 0), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// 影院
Row(children: [
Icon(Icons.location_on_outlined, size: 14, color: red),
const SizedBox(width: 4),
Text(_cinemaCtrl.text, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: Color(0xFF6B6B6B), letterSpacing: 0.3)),
]),
const SizedBox(height: 16),
// 标题
Text(movie.title, style: const TextStyle(fontSize: 32, fontWeight: FontWeight.w700, color: text, letterSpacing: 0.5, height: 1.15)),
const SizedBox(height: 6),
// 标签
Wrap(spacing: 8, runSpacing: 6, children: [
if (movie.genres.isNotEmpty) ...movie.genres.take(3).map((g) => _tagChip(g)),
if (movie.releaseDate != null) _tagChip(_fmtDate(movie.releaseDate!)),
]),
const SizedBox(height: 20),
// 三列网格
Container(
decoration: BoxDecoration(border: Border(top: BorderSide(color: line, width: 0.5), bottom: BorderSide(color: line, width: 0.5))),
padding: const EdgeInsets.symmetric(vertical: 14),
child: Row(children: [
_gridCol('影厅', _hallCtrl.text, red),
Container(width: 0.5, height: 36, color: const Color(0xFFF2EDE5)),
_gridCol('座位', '${_seatRowCtrl.text}${_seatNumCtrl.text}', text),
Container(width: 0.5, height: 36, color: const Color(0xFFF2EDE5)),
_gridCol('上映', movie.releaseDate != null ? _fmtDate(movie.releaseDate!) : '--', red),
]),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 海报图片
if (hasPoster)
ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
child: FadeInLocalImage(
path: movie.posterPath,
width: 320,
height: 200,
fit: BoxFit.cover,
),
),
])),
// 内容区域
Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题
Text(
movie.title,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: colors.onSurface,
),
),
// 撕票线(圆形缺口)
_tearCircles(),
// 别名
if (movie.alternateTitles.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
movie.alternateTitles.join(' / '),
style: TextStyle(
fontSize: 14,
color: colors.onSurface.withValues(alpha: 0.6),
),
),
],
// 底部信息
Padding(padding: const EdgeInsets.fromLTRB(24, 10, 24, 0), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
Container(width: 120, height: 120, decoration: BoxDecoration(borderRadius: BorderRadius.circular(6), border: Border.all(color: const Color(0xFFE8E2D8), width: 0.5)),
child: Image.asset('assets/images/ticket/qr.png', fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const SizedBox.shrink())),
const SizedBox(width: 16),
Expanded(child: Column(children: [
_collectInfo('票价', '¥${_priceCtrl.text}', isPrice: true),
_collectInfo('服务费', '¥${_serviceFeeCtrl.text}'),
_collectInfo('时间', _issueTimeCtrl.text),
_collectInfo('平台', 'MookNote'),
_collectInfo('编码', _ticketCodeCtrl.text, isCode: true),
])),
])),
const SizedBox(height: 16),
// 评分
if (movie.rating != null && movie.rating! > 0) ...[
Row(
children: [
const Icon(
Icons.star,
size: 18,
color: Color(0xFFFFB800),
),
const SizedBox(width: 4),
Text(
movie.rating!.toStringAsFixed(1),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFFFFB800),
),
),
const SizedBox(width: 4),
Text(
'/ 10',
style: TextStyle(
fontSize: 12,
color: colors.onSurface.withValues(alpha: 0.4),
),
),
],
),
const SizedBox(height: 12),
],
// 导演
if (movie.directors.isNotEmpty)
_buildInfoRow('导演', movie.directors.join(' / '), colors),
// 编剧
if (movie.writers.isNotEmpty)
_buildInfoRow('编剧', movie.writers.join(' / '), colors),
// 主演
if (movie.actors.isNotEmpty)
_buildInfoRow('主演', movie.actors.take(3).join(' / '), colors),
// 类型
if (movie.genres.isNotEmpty)
_buildInfoRow('类型', movie.genres.join(' / '), colors),
// 上映日期
if (movie.releaseDate != null)
_buildInfoRow(
'上映',
'${movie.releaseDate!.year}.${movie.releaseDate!.month.toString().padLeft(2, '0')}.${movie.releaseDate!.day.toString().padLeft(2, '0')}',
colors,
),
const SizedBox(height: 16),
// 简介
if (movie.summary != null && movie.summary!.isNotEmpty) ...[
Text(
'简介',
style: TextStyle(
fontSize: 12,
color: colors.onSurface.withValues(alpha: 0.4),
),
),
const SizedBox(height: 8),
Text(
movie.summary!,
style: TextStyle(
fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.6),
height: 1.6,
),
maxLines: 5,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 20),
// 底部标识
Divider(height: 1, color: colors.outline),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.movie_outlined,
size: 14,
color: colors.onSurface.withValues(alpha: 0.5),
),
const SizedBox(width: 6),
Text(
'来自 MookNote',
style: TextStyle(
fontSize: 12,
color: colors.onSurface.withValues(alpha: 0.5),
),
),
],
),
],
),
),
],
),
// 底部条
Padding(padding: const EdgeInsets.fromLTRB(24, 12, 24, 14), child: Row(children: [
Text('MOOKNOTE MOVIE · TICKET', style: TextStyle(fontSize: 9, letterSpacing: 1, color: light.withValues(alpha: 0.7))),
const Spacer(),
Text(_ticketCodeCtrl.text, style: TextStyle(fontSize: 9, letterSpacing: 0.5, color: light.withValues(alpha: 0.7))),
])),
]),
);
}
/// 构建信息行
Widget _buildInfoRow(String label, String value, ColorScheme colors) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$label',
style: TextStyle(
fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.4),
),
),
Expanded(
child: Text(
value,
style: TextStyle(
fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.75),
),
),
),
],
),
);
Widget _tagChip(String text) {
return Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(border: Border.all(color: const Color(0xFFEBE5D9), width: 0.5), borderRadius: BorderRadius.circular(3)),
child: Text(text, style: const TextStyle(fontSize: 10, color: Color(0xFF6B6B6B), letterSpacing: 0.5)));
}
/// 生成并分享海报
Widget _gridCol(String label, String value, Color valueColor) {
return Expanded(child: Column(children: [
Text(label, style: const TextStyle(fontSize: 9, color: Color(0xFF8C8C8C), letterSpacing: 1)),
const SizedBox(height: 4),
Text(value, textAlign: TextAlign.center, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: valueColor, letterSpacing: 0.5)),
]));
}
Widget _collectInfo(String label, String value, {bool isPrice = false, bool isCode = false}) {
return Padding(padding: const EdgeInsets.only(bottom: 3), child: Row(children: [
SizedBox(width: 32, child: Text(label, style: const TextStyle(fontSize: 9, color: Color(0xFF8C8C8C), letterSpacing: 0.5))),
Expanded(child: Text(value, textAlign: TextAlign.right, style: TextStyle(
fontSize: isPrice ? 15 : 10,
fontWeight: isPrice ? FontWeight.w700 : FontWeight.w500,
color: isPrice ? const Color(0xFFC0392B) : const Color(0xFF1A1A1A).withValues(alpha: 0.8),
fontFamily: isCode ? 'monospace' : null,
letterSpacing: isCode ? 0.5 : 0,
))),
]));
}
// ─── 通用组件 ───
Widget _dashedLine(Color color) {
return Padding(padding: const EdgeInsets.symmetric(horizontal: 8),
child: CustomPaint(size: const Size(double.infinity, 1), painter: _DashedLinePainter(color: color)));
}
Widget _tearScissors(Color bg) {
return SizedBox(height: 28, child: Stack(children: [
Positioned(left: 20, right: 20, top: 13, child: CustomPaint(
size: const Size(double.infinity, 1), painter: _DashedLinePainter(color: const Color(0xFFC8C4B8), dashWidth: 6, dashSpace: 8))),
Positioned(left: 2, top: 4, child: Icon(Icons.content_cut, size: 16, color: const Color(0xFF999999))),
]));
}
Widget _tearCircles() {
return Padding(padding: const EdgeInsets.symmetric(horizontal: 24), child: SizedBox(height: 24, child: Stack(children: [
Positioned(left: 0, right: 0, top: 11, child: CustomPaint(
size: const Size(double.infinity, 1), painter: _DashedLinePainter(color: const Color(0xFFD8D0C0), dashWidth: 9, dashSpace: 9))),
Positioned(left: -30, top: 5.5, child: Container(width: 13, height: 13, decoration: BoxDecoration(color: const Color(0xFFEBE5DB), shape: BoxShape.circle))),
Positioned(right: -30, top: 5.5, child: Container(width: 13, height: 13, decoration: BoxDecoration(color: const Color(0xFFEBE5DB), shape: BoxShape.circle))),
])));
}
String _fmtDate(DateTime d) => '${d.year}.${d.month.toString().padLeft(2, '0')}.${d.day.toString().padLeft(2, '0')}';
String _subtitleText(Movie m) {
final parts = <String>[];
if (m.genres.isNotEmpty) parts.add(m.genres.join(' / '));
return parts.join(' · ');
}
String _statusEN(String s) {
switch (s) { case 'watched': return 'ADMITTED'; case 'watching': return 'NOW PLAYING'; default: return 'RESERVED'; }
}
// ─── 生成分享 ───
Future<void> _generateAndShare() async {
setState(() => _isGenerating = true);
try {
// 捕获海报图片
final boundary = _posterKey.currentContext?.findRenderObject()
as RenderRepaintBoundary?;
if (boundary == null) {
throw Exception('无法获取海报边界');
}
final boundary = _posterKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
if (boundary == null) throw Exception('无法获取海报边界');
final image = await boundary.toImage(pixelRatio: 3.0);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData == null) {
throw Exception('无法生成图片数据');
}
final bytes = byteData.buffer.asUint8List();
// 保存到临时文件
if (byteData == null) throw Exception('无法生成图片数据');
final tempDir = await getTemporaryDirectory();
final fileName = 'movie_poster_${DateTime.now().millisecondsSinceEpoch}.png';
final file = File('${tempDir.path}/$fileName');
await file.writeAsBytes(bytes);
// 分享
await Share.shareXFiles(
[XFile(file.path)],
text: '分享影视:${widget.movie.title}',
);
final file = File('${tempDir.path}/movie_poster_${DateTime.now().millisecondsSinceEpoch}.png');
await file.writeAsBytes(byteData.buffer.asUint8List());
await Share.shareXFiles([XFile(file.path)], text: '分享影视:${widget.movie.title}');
} catch (e) {
if (mounted) {
ToastUtil.show(context, '生成海报失败:$e');
}
if (mounted) ToastUtil.show(context, '生成海报失败:$e');
} finally {
if (mounted) {
setState(() => _isGenerating = false);
}
if (mounted) setState(() => _isGenerating = false);
}
}
}
class _DashedLinePainter extends CustomPainter {
final Color color;
final double dashWidth;
final double dashSpace;
_DashedLinePainter({required this.color, this.dashWidth = 4, this.dashSpace = 4});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = color..strokeWidth = 1..style = PaintingStyle.stroke;
double x = 0;
while (x < size.width) { canvas.drawLine(Offset(x, 0), Offset(x + dashWidth, 0), paint); x += dashWidth + dashSpace; }
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_provider.dart';
/// 阅读状态选择栏 - 水滴滑动动画
/// 阅读状态选择栏 - 交叉渐变动画
class BookStatusBar extends StatelessWidget {
const BookStatusBar({super.key});
@@ -15,9 +15,7 @@ class BookStatusBar extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: colors.surface,
border: Border(
bottom: BorderSide(color: colors.outline, width: 0.5),
),
border: Border(bottom: BorderSide(color: colors.outline, width: 0.5)),
),
child: Container(
padding: const EdgeInsets.all(4),
@@ -25,66 +23,13 @@ class BookStatusBar extends StatelessWidget {
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(24),
),
child: LayoutBuilder(
builder: (context, constraints) {
final tabWidth = constraints.maxWidth / 3;
return SizedBox(
height: 40,
child: Stack(
children: [
AnimatedPositioned(
duration: const Duration(milliseconds: 800),
curve: Curves.elasticOut,
left: provider.bookStatusIndex * tabWidth,
top: 0,
bottom: 0,
width: tabWidth,
child: Padding(
padding: const EdgeInsets.all(3),
child: Container(
decoration: BoxDecoration(
color: colors.primary,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
),
),
),
Row(
children: [
_buildTab(
colors: colors,
label: '已读',
icon: Icons.check_circle_outline,
isSelected: provider.bookStatusIndex == 0,
onTap: () => provider.setBookStatusIndex(0),
),
_buildTab(
colors: colors,
label: '在读',
icon: Icons.menu_book_outlined,
isSelected: provider.bookStatusIndex == 1,
onTap: () => provider.setBookStatusIndex(1),
),
_buildTab(
colors: colors,
label: '想读',
icon: Icons.bookmark_outlined,
isSelected: provider.bookStatusIndex == 2,
onTap: () => provider.setBookStatusIndex(2),
),
],
),
],
),
);
},
child: SizedBox(
height: 40,
child: Row(children: [
_buildTab(colors, '已读', Icons.check_circle_outline, provider.bookStatusIndex == 0, () => provider.setBookStatusIndex(0)),
_buildTab(colors, '在读', Icons.menu_book_outlined, provider.bookStatusIndex == 1, () => provider.setBookStatusIndex(1)),
_buildTab(colors, '想读', Icons.bookmark_outlined, provider.bookStatusIndex == 2, () => provider.setBookStatusIndex(2)),
]),
),
),
);
@@ -92,39 +37,45 @@ class BookStatusBar extends StatelessWidget {
);
}
Widget _buildTab({
required ColorScheme colors,
required String label,
required IconData icon,
required bool isSelected,
required VoidCallback onTap,
}) {
Widget _buildTab(ColorScheme colors, String label, IconData icon, bool isSelected, VoidCallback onTap) {
return Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
height: double.infinity,
child: AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
margin: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: isSelected ? colors.primary : Colors.transparent,
borderRadius: BorderRadius.circular(20),
boxShadow: isSelected ? [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 8, offset: const Offset(0, 2))] : null,
),
alignment: Alignment.center,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: isSelected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6),
),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6),
child: AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
style: TextStyle(
fontSize: 13,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 350),
child: Icon(icon, key: ValueKey(isSelected), size: 16,
color: isSelected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6)),
),
),
],
const SizedBox(width: 6),
AnimatedSwitcher(
duration: const Duration(milliseconds: 350),
child: Text(label, key: ValueKey('$label$isSelected')),
),
],
),
),
),
),

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_provider.dart';
/// 观影状态选择栏 - 水滴滑动动画
/// 观影状态选择栏 - 交叉渐变动画
class MovieStatusBar extends StatelessWidget {
const MovieStatusBar({super.key});
@@ -15,9 +15,7 @@ class MovieStatusBar extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: colors.surface,
border: Border(
bottom: BorderSide(color: colors.outline, width: 0.5),
),
border: Border(bottom: BorderSide(color: colors.outline, width: 0.5)),
),
child: Container(
padding: const EdgeInsets.all(4),
@@ -25,66 +23,13 @@ class MovieStatusBar extends StatelessWidget {
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(24),
),
child: LayoutBuilder(
builder: (context, constraints) {
final tabWidth = constraints.maxWidth / 3;
return SizedBox(
height: 40,
child: Stack(
children: [
AnimatedPositioned(
duration: const Duration(milliseconds: 800),
curve: Curves.elasticOut,
left: provider.movieStatusIndex * tabWidth,
top: 0,
bottom: 0,
width: tabWidth,
child: Padding(
padding: const EdgeInsets.all(3),
child: Container(
decoration: BoxDecoration(
color: colors.primary,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
),
),
),
Row(
children: [
_buildTab(
colors: colors,
label: '已看',
icon: Icons.check_circle_outline,
isSelected: provider.movieStatusIndex == 0,
onTap: () => provider.setMovieStatusIndex(0),
),
_buildTab(
colors: colors,
label: '在看',
icon: Icons.play_circle_outline,
isSelected: provider.movieStatusIndex == 1,
onTap: () => provider.setMovieStatusIndex(1),
),
_buildTab(
colors: colors,
label: '想看',
icon: Icons.bookmark_outline,
isSelected: provider.movieStatusIndex == 2,
onTap: () => provider.setMovieStatusIndex(2),
),
],
),
],
),
);
},
child: SizedBox(
height: 40,
child: Row(children: [
_buildTab(colors, '已看', Icons.check_circle_outline, provider.movieStatusIndex == 0, () => provider.setMovieStatusIndex(0)),
_buildTab(colors, '在看', Icons.play_circle_outline, provider.movieStatusIndex == 1, () => provider.setMovieStatusIndex(1)),
_buildTab(colors, '想看', Icons.bookmark_outline, provider.movieStatusIndex == 2, () => provider.setMovieStatusIndex(2)),
]),
),
),
);
@@ -92,39 +37,45 @@ class MovieStatusBar extends StatelessWidget {
);
}
Widget _buildTab({
required ColorScheme colors,
required String label,
required IconData icon,
required bool isSelected,
required VoidCallback onTap,
}) {
Widget _buildTab(ColorScheme colors, String label, IconData icon, bool isSelected, VoidCallback onTap) {
return Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
height: double.infinity,
child: AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
margin: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: isSelected ? colors.primary : Colors.transparent,
borderRadius: BorderRadius.circular(20),
boxShadow: isSelected ? [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 8, offset: const Offset(0, 2))] : null,
),
alignment: Alignment.center,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: isSelected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6),
),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6),
child: AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
style: TextStyle(
fontSize: 13,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 350),
child: Icon(icon, key: ValueKey(isSelected), size: 16,
color: isSelected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6)),
),
),
],
const SizedBox(width: 6),
AnimatedSwitcher(
duration: const Duration(milliseconds: 350),
child: Text(label, key: ValueKey('$label$isSelected')),
),
],
),
),
),
),