Files
MookNote/lib/pages/movies/movie_share_page.dart
2026-08-12 22:28:33 +08:00

720 lines
35 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import '../../widgets/app_overlay.dart';
class MovieSharePage extends StatefulWidget {
final Movie movie;
const MovieSharePage({super.key, required this.movie});
@override
State<MovieSharePage> createState() => _MovieSharePageState();
}
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) {
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;
const icons = [Icons.image_outlined, Icons.confirmation_num_outlined, Icons.local_movies_outlined, Icons.card_giftcard_outlined];
const subtitles = ['简约海报风格', '经典复古票根', '电影票票根样式', '高端收藏版票根'];
appModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(width: 36, height: 4, decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))),
const SizedBox(height: 20),
Align(alignment: Alignment.centerLeft, child: Text('选择样式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface))),
const SizedBox(height: 12),
for (int i = 0; i < _styleNames.length; i++) ...[
if (i > 0) Divider(height: 0.5, color: colors.outlineVariant),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)),
child: Icon(icons[i], size: 20, color: _currentStyle == i ? colors.primary : colors.onSurface.withValues(alpha: 0.6))),
title: Text(_styleNames[i], style: TextStyle(fontSize: 13, fontWeight: _currentStyle == i ? FontWeight.w600 : FontWeight.w500, color: colors.onSurface)),
subtitle: Text(subtitles[i], style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
trailing: _currentStyle == i
? Icon(Icons.check_circle, size: 20, color: colors.primary)
: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)),
onTap: () { setState(() => _currentStyle = i); Navigator.pop(ctx); },
),
],
const SizedBox(height: 12),
]),
),
);
}
// ─── 底部编辑栏 ───
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;
appModalBottomSheet(
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 = <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 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;
}