import 'dart:io'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; 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 State createState() => _MovieSharePageState(); } class _MovieSharePageState extends State { 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) { final colors = Theme.of(context).colorScheme; return Scaffold( backgroundColor: colors.surfaceContainerHighest, 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)), 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: 4), ], ), 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)), ), ), ), ); } 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; final hasPoster = movie.posterPath != null && movie.posterPath!.isNotEmpty; return Container( width: 320, 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.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), ]), ), ])), // 撕票线(圆形缺口) _tearCircles(), // 底部信息 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), ])), ])), // 底部条 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 _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 = []; 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 _generateAndShare() async { setState(() => _isGenerating = true); try { 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 tempDir = await getTemporaryDirectory(); 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'); } finally { 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; }