import 'dart:math'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:just_audio/just_audio.dart'; import '../widgets/animated_gradient_background.dart'; import '../widgets/ios_toast.dart'; import '../widgets/gradient_button.dart'; import '../theme/app_colors.dart' as appclr; // ============================================================ // 音乐创作页面 — 水豚灵感电台 // 精确还原 music-creation.html 的所有视觉细节 // ============================================================ /// Playlist track data class _Track { final int id; final String title; final String lyrics; String audioAsset; _Track({ required this.id, required this.title, required this.lyrics, required this.audioAsset, }); } class MusicCreationPage extends StatefulWidget { /// Whether this page is embedded as a tab (hides back button) final bool isTab; const MusicCreationPage({super.key, this.isTab = true}); @override State createState() => _MusicCreationPageState(); } class _MusicCreationPageState extends State with TickerProviderStateMixin { // ── State ── bool _isPlaying = false; bool _isGenerating = false; bool _isFlipped = false; int? _selectedMoodIndex; double _progress = 0.0; String _currentTime = '0:00'; String _totalTime = '3:24'; int _currentTrackIndex = 0; bool _isDragging = false; // True while user drags the slider // Audio player (just_audio) late AudioPlayer _audioPlayer; // Speech bubble String? _speechText; bool _speechVisible = false; // ── Animation Controllers ── late AnimationController _vinylSpinController; late AnimationController _tonearmController; late Animation _tonearmAngle; late AnimationController _flipController; late Animation _flipAnimation; late AnimationController _genRingController; late AnimationController _mysteryShimmerController; // ── Playlist Data (matching HTML) ── final List<_Track> _playlist = [ _Track( id: 1, title: '卡皮巴拉蹦蹦蹦', audioAsset: 'assets/www/music/卡皮巴拉蹦蹦蹦.mp3', lyrics: '卡皮巴拉\n啦啦啦啦\n卡皮巴拉\n啦啦啦啦\n\n' '卡皮巴拉 蹦蹦蹦\n一整天都 在发疯\n卡皮巴拉 转一圈\n左一脚 右一脚 (嘿)\n\n' '卡皮巴拉 蹦蹦蹦\n洗脑节奏 响空中\n卡皮巴拉 不要停\n跟着我 一起疯\n\n' '一口菜叶 卡一巴\n两口草莓 巴一拉\n三口西瓜 啦一啦\n嘴巴圆圆 哈哈哈 (哦耶)', ), _Track( id: 2, title: '卡皮巴拉快乐水', audioAsset: 'assets/www/music/卡皮巴拉快乐水.mp3', lyrics: '卡皮巴拉\n卡皮巴拉\n卡皮巴拉\n啦啦啦啦\n\n' '卡皮巴拉趴地上\n一动不动好嚣张\n心里其实在上网\n刷到我就笑出响 (哈哈哈)\n\n' '卡皮巴拉 巴拉巴拉\n压力来啦 它说算啦\n一点不慌 就是躺啦\n世界太吵 它在发呆呀', ), _Track( id: 3, title: '卡皮巴拉快乐营业', audioAsset: 'assets/www/music/卡皮巴拉快乐营业.mp3', lyrics: '早八打工人\n心却躺平人\n桌面壁纸换上\n卡皮巴拉一整屏 (嘿)\n\n' '它坐在河边\n像个退休中年\n我卷生卷死\n它只发呆发呆再发呆\n\n' '卡皮巴拉 卡皮巴拉 拉\n看你就把压力清空啦 (啊对对对)\n谁骂我韭菜我就回他\n我已经转职水豚啦', ), _Track( id: 4, title: '卡皮巴拉快乐趴', audioAsset: 'assets/www/music/卡皮巴拉快乐趴.mp3', lyrics: '今天不上班\n卡皮巴拉躺平在沙滩\n小小太阳帽\n草帽底下梦见一整片菜园 (好香哦)\n\n' '卡皮巴拉啦啦啦\n快乐像病毒一样传染呀\n你一笑 它一哈\n全场都在哈哈哈', ), ]; // ── Mood cards ── static const List> _moods = [ {'icon': Icons.spa_outlined, 'color': 0xFFB8D4E3, 'title': 'Chill Lofi', 'desc': '慵懒 · 治愈 · 水声'}, {'icon': Icons.directions_run, 'color': 0xFFF5C6A5, 'title': 'Happy Funk', 'desc': '活力 · 奔跑 · 阳光'}, {'icon': Icons.nights_stay_outlined, 'color': 0xFFCBB8E0, 'title': 'Deep Sleep', 'desc': '白噪音 · 助眠 · 梦境'}, {'icon': Icons.psychology_outlined, 'color': 0xFFA8D8C8, 'title': 'Focus Flow', 'desc': '心流 · 专注 · 效率'}, {'icon': Icons.redeem_outlined, 'color': 0xFFD4A0E8, 'title': '盲盒惊喜', 'desc': 'AI 随机生成神曲'}, {'icon': Icons.auto_awesome, 'color': 0xFFECCFA8, 'title': '自由创作', 'desc': '输入灵感 · 生成音乐'}, ]; @override void initState() { super.initState(); // Vinyl spin: continuous 6s rotation (HTML: animation: spin 6s linear infinite) _vinylSpinController = AnimationController( duration: const Duration(seconds: 6), vsync: this, ); // Tonearm: -55deg (rest) → -25deg (playing) // HTML: transition: transform 0.6s cubic-bezier(0.4, 0.0, 0.2, 1) _tonearmController = AnimationController( duration: const Duration(milliseconds: 600), vsync: this, ); _tonearmAngle = Tween(begin: -55, end: -25).animate( CurvedAnimation( parent: _tonearmController, curve: const Cubic(0.4, 0.0, 0.2, 1.0), ), ); // Flip: 0 → π (HTML: transition: transform 0.6s cubic-bezier(0.4, 0.0, 0.2, 1)) _flipController = AnimationController( duration: const Duration(milliseconds: 600), vsync: this, ); _flipAnimation = Tween(begin: 0, end: pi).animate( CurvedAnimation( parent: _flipController, curve: const Cubic(0.4, 0.0, 0.2, 1.0), ), ); // Generation progress ring _genRingController = AnimationController( duration: const Duration(milliseconds: 600), vsync: this, ); // Mystery box diagonal shimmer — 3s loop _mysteryShimmerController = AnimationController( duration: const Duration(milliseconds: 3000), vsync: this, )..repeat(); // ── Audio Player Setup ── _audioPlayer = AudioPlayer(); // Listen to position → update progress bar & time label _audioPlayer.positionStream.listen((position) { if (!mounted || _isDragging) return; final duration = _audioPlayer.duration; if (duration != null && duration.inMilliseconds > 0) { setState(() { _progress = (position.inMilliseconds / duration.inMilliseconds).clamp(0.0, 1.0); _currentTime = _formatDuration(position); }); } }); // Listen to duration → update total time label _audioPlayer.durationStream.listen((duration) { if (!mounted || duration == null) return; setState(() { _totalTime = _formatDuration(duration); }); }); // Listen to player state → detect track completion _audioPlayer.playerStateStream.listen((state) { if (!mounted) return; if (state.processingState == ProcessingState.completed) { _onTrackComplete(); } }); // Pre-load the first track (don't auto-play) _loadTrack(_currentTrackIndex); } // ── Duration formatter ── String _formatDuration(Duration d) { final minutes = d.inMinutes; final seconds = d.inSeconds % 60; return '$minutes:${seconds.toString().padLeft(2, '0')}'; } // ── Load a track into the audio player (without playing) ── Future _loadTrack(int index) async { try { final track = _playlist[index]; await _audioPlayer.setAsset(track.audioAsset); } catch (e) { debugPrint('Error loading track: $e'); if (mounted) { _showSpeech('音频加载失败,请重试'); } } } // ── When a track finishes, play the next one ── void _onTrackComplete() { final nextIndex = (_currentTrackIndex + 1) % _playlist.length; _playTrack(nextIndex); } @override void dispose() { _audioPlayer.dispose(); _vinylSpinController.dispose(); _tonearmController.dispose(); _flipController.dispose(); _genRingController.dispose(); _mysteryShimmerController.dispose(); super.dispose(); } // ── Playback Controls ── void _togglePlay() async { setState(() { _isPlaying = !_isPlaying; if (_isPlaying) { if (!_isFlipped) _vinylSpinController.repeat(); _tonearmController.forward(); } else { _vinylSpinController.stop(); _tonearmController.reverse(); } }); // Actually play or pause audio try { if (_isPlaying) { await _audioPlayer.play(); } else { await _audioPlayer.pause(); } } catch (e) { debugPrint('Playback error: $e'); if (mounted) { _showSpeech('播放出错了,请重试'); // Revert UI state on error setState(() { _isPlaying = false; _vinylSpinController.stop(); _tonearmController.reverse(); }); } } } void _flipVinyl() { setState(() { _isFlipped = !_isFlipped; if (_isFlipped) { _flipController.forward(); _vinylSpinController.stop(); // Pause spin while flipped (HTML behavior) } else { _flipController.reverse(); if (_isPlaying) _vinylSpinController.repeat(); } }); } void _playTrack(int index) async { setState(() { _currentTrackIndex = index; _progress = 0; _currentTime = '0:00'; }); // Flip back to front if flipped if (_isFlipped) _flipVinyl(); // Load the new track await _loadTrack(index); // Start playing if (!_isPlaying) { _togglePlay(); } else { // Already playing — seek to start & play try { await _audioPlayer.seek(Duration.zero); await _audioPlayer.play(); } catch (e) { debugPrint('Play track error: $e'); } } _showSpeech('正在播放: ${_playlist[index].title}'); } // ── Mood Selection ── void _selectMood(int index) { if (_isGenerating) { _showSpeech('音乐正在生成中,请稍等哦~'); return; } // Last card = custom input if (index == 5) { _showInputModal(); return; } setState(() => _selectedMoodIndex = index); _mockGenerate(_moods[index]['title'] ?? ''); } // ── Mock Generation (matches HTML network-error fallback) ── void _mockGenerate(String title) async { setState(() => _isGenerating = true); _showSpeech('🎼 正在连接 AI...', duration: 0); await Future.delayed(const Duration(milliseconds: 800)); if (!mounted) return; _showSpeech('🎵 正在生成音乐...', duration: 0); await Future.delayed(const Duration(milliseconds: 1200)); if (!mounted) return; _showSpeech('✨ (演示模式) 新歌出炉!'); await Future.delayed(const Duration(milliseconds: 500)); if (!mounted) return; setState(() { _isGenerating = false; _selectedMoodIndex = null; // 生成完成,取消选中态 }); // If already playing, show confirm dialog; otherwise auto-play if (_isPlaying) { _showConfirmDialog(title); } else { if (!_isPlaying) _togglePlay(); } } // ── Speech Bubble ── void _showSpeech(String text, {int duration = 3000}) { setState(() { _speechText = text; _speechVisible = true; }); if (duration > 0) { Future.delayed(Duration(milliseconds: duration), () { if (mounted && _speechText == text) { setState(() => _speechVisible = false); } }); } } // ══════════════════════════════════════════════════════════════ // BUILD // ══════════════════════════════════════════════════════════════ @override Widget build(BuildContext context) { return Stack( children: [ // Background - reuse the global animated gradient const AnimatedGradientBackground(), // Content SafeArea( bottom: false, child: Column( children: [ _buildHeader(), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 0, 16, 56), child: Column( children: [ const SizedBox(height: 2), _buildPlayerArea(), const SizedBox(height: 6), // HTML: gap 6px _buildProgressBar(), const SizedBox(height: 6), _buildMoodSection(), ], ), ), ), ], ), ), ], ); } // ══════════════════════════════════════════════════════════════ // HEADER — matches HTML .page-header // ══════════════════════════════════════════════════════════════ Widget _buildHeader() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), child: Row( children: [ // Back button (hidden when used as tab) if (!widget.isTab) GestureDetector( onTap: () => Navigator.of(context).pop(), child: _headerIconButton( const Icon(Icons.arrow_back_ios_new, size: 18, color: Color(0xFF4B5563)), ), ) else const SizedBox(width: 40), // Spacer to center title // Title — HTML: .page-title { font-size: 17px; font-weight: 600 } Expanded( child: Text( '灵感电台', textAlign: TextAlign.center, style: GoogleFonts.outfit( fontSize: 17, fontWeight: FontWeight.w600, color: const Color(0xFF1F2937), letterSpacing: -0.17, // HTML: -0.01em ), ), ), // Playlist button — HTML: .icon-btn with grid SVG GestureDetector( onTap: _showPlaylistModal, child: _headerIconButton( const Icon(Icons.grid_view_rounded, size: 22, color: Color(0xFF4B5563)), ), ), ], ), ); } Widget _headerIconButton(Widget child) { // HTML: .icon-btn { background: rgba(255,255,255,0.6); backdrop-filter: blur(8px); // width: 40px; height: 40px; border-radius: 12px; } return ClipRRect( borderRadius: BorderRadius.circular(12), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), child: Container( width: 40, height: 40, decoration: BoxDecoration( color: Colors.white.withOpacity(0.6), borderRadius: BorderRadius.circular(12), ), alignment: Alignment.center, child: child, ), ), ); } // ══════════════════════════════════════════════════════════════ // PLAYER AREA — vinyl + speech bubble // ══════════════════════════════════════════════════════════════ Widget _buildPlayerArea() { // HTML: .player-area { width: 210px; margin: 0 auto; } return SizedBox( width: 260, // Extra space for bubble overflow height: 228, child: Stack( clipBehavior: Clip.none, alignment: Alignment.center, children: [ // Vinyl player — centered Positioned( left: 25, top: 8, child: _buildVinylWrapper(), ), // Speech bubble — positioned top-right if (_speechVisible && _speechText != null) Positioned( top: 0, right: -24, // HTML: right: -24px child: _buildSpeechBubble(), ), ], ), ); } // ── Vinyl Wrapper with 3D flip ── Widget _buildVinylWrapper() { // HTML: .player-visual-wrapper { perspective: 800px; width: 210px; height: 210px; // filter: drop-shadow(0 20px 40px rgba(0,0,0,0.2)); } return GestureDetector( onTap: _flipVinyl, child: Container( width: 210, height: 210, decoration: BoxDecoration( shape: BoxShape.circle, boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.2), offset: const Offset(0, 20), blurRadius: 40, ), ], ), child: AnimatedBuilder( animation: _flipAnimation, builder: (context, child) { final angle = _flipAnimation.value; final showBack = angle > pi / 2; return Transform( alignment: Alignment.center, transform: Matrix4.identity() ..setEntry(3, 2, 0.00125) // perspective ≈ 1/800 ..rotateY(angle), child: showBack ? Transform( alignment: Alignment.center, transform: Matrix4.identity()..rotateY(pi), child: _buildVinylBack(), ) : _buildVinylFront(), ); }, ), ), ); } // ── Vinyl Front: spinning disc + album cover + tonearm + loading ── Widget _buildVinylFront() { return SizedBox( width: 210, height: 210, child: Stack( clipBehavior: Clip.none, children: [ // Spinning disc AnimatedBuilder( animation: _vinylSpinController, builder: (context, child) { return Transform.rotate( angle: _vinylSpinController.value * 2 * pi, child: child, ); }, child: Container( width: 210, height: 210, decoration: const BoxDecoration( shape: BoxShape.circle, color: Color(0xFF18181B), // zinc-900 ), child: CustomPaint( painter: _VinylDiscPainter(), ), ), ), // Album cover (static — HTML: no rotation on cover) // HTML: .album-cover { width: 130px; height: 130px; border-radius: 50%; // border: 2px solid rgba(236,207,168,0.6); } Center( child: Container( width: 130, height: 130, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: const Color(0xFFECCFA8).withOpacity(0.6), width: 2, ), boxShadow: [ const BoxShadow( color: Color(0x1A000000), // rgba(0,0,0,0.1) blurRadius: 20, spreadRadius: 4, ), BoxShadow( color: Colors.black.withOpacity(0.5), blurRadius: 20, // inset shadow approximation ), ], ), clipBehavior: Clip.antiAlias, child: Image.asset( 'assets/www/Capybara.png', fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container( color: const Color(0xFF27272A), child: const Icon(Icons.music_note, color: Colors.white54, size: 40), ), ), ), ), // Tonearm // HTML: .tonearm { position: absolute; top: -8px; right: 18px; // transform-origin: 62px 12px; transform: rotate(-55deg); } Positioned( top: -8, right: 18, child: AnimatedBuilder( animation: _tonearmAngle, builder: (context, child) { return Transform( alignment: Alignment( (62 - 40) / 40, // Convert 62px from left of 80px width → alignment (12 - 50) / 50, // Convert 12px from top of 100px height ), transform: Matrix4.identity() ..rotateZ(_tonearmAngle.value * pi / 180), child: child, ); }, child: SizedBox( width: 80, height: 100, child: CustomPaint( painter: _TonearmPainter(), ), ), ), ), // Loading overlay // HTML: .loading-overlay { background: rgba(0,0,0,0.3); } if (_isGenerating) Container( width: 210, height: 210, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.black.withOpacity(0.3), ), child: const Center( child: SizedBox( width: 40, height: 40, child: CircularProgressIndicator( color: Colors.white, strokeWidth: 3, ), ), ), ), ], ), ); } // ── Vinyl Back: lyrics ── Widget _buildVinylBack() { final track = _playlist[_currentTrackIndex]; // HTML: .vinyl-back { background: #18181B; border: 3px solid rgba(236,207,168,0.25); } return Container( width: 210, height: 210, decoration: BoxDecoration( shape: BoxShape.circle, color: const Color(0xFF18181B), border: Border.all( color: const Color(0xFFECCFA8).withOpacity(0.25), width: 3, ), ), child: CustomPaint( painter: _VinylBackGroovesPainter(), child: Center( child: Container( width: 150, height: 150, decoration: BoxDecoration( // Dark overlay to cover groove lines behind text color: const Color(0xFF18181B).withOpacity(0.75), shape: BoxShape.circle, ), child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(10), child: Text( track.lyrics.isNotEmpty ? track.lyrics : '生成音乐后\n点我看歌词', style: GoogleFonts.dmSans( fontSize: 12, height: 1.6, color: track.lyrics.isNotEmpty ? Colors.white.withOpacity(0.92) : Colors.white.withOpacity(0.4), fontStyle: track.lyrics.isEmpty ? FontStyle.italic : FontStyle.normal, ), textAlign: TextAlign.center, ), ), ), ), ), ), ); } // ── Speech Bubble ── Widget _buildSpeechBubble() { // HTML: .capy-speech-bubble { background: rgba(253,247,237,0.93); // font-size: 12.5px; font-weight: 500; color: #6B4423; } return AnimatedOpacity( duration: const Duration(milliseconds: 200), opacity: _speechVisible ? 1.0 : 0.0, child: AnimatedScale( duration: const Duration(milliseconds: 350), scale: _speechVisible ? 1.0 : 0.7, curve: const Cubic(0.34, 1.56, 0.64, 1.0), // HTML bouncy curve alignment: Alignment.bottomLeft, child: Container( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), decoration: BoxDecoration( color: const Color(0xFFFDF7ED).withOpacity(0.93), borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: const Color(0xFFECCFA8).withOpacity(0.45), blurRadius: 0.5, ), BoxShadow( color: const Color(0xFF8B5E3C).withOpacity(0.10), offset: const Offset(0, 3), blurRadius: 12, ), ], ), child: Text( _speechText ?? '', style: GoogleFonts.dmSans( fontSize: 12.5, fontWeight: FontWeight.w500, color: const Color(0xFF6B4423), ), ), ), ), ); } // ══════════════════════════════════════════════════════════════ // PROGRESS BAR — matches HTML .progress-section // ══════════════════════════════════════════════════════════════ Widget _buildProgressBar() { return Container( height: 56, child: Stack( clipBehavior: Clip.none, alignment: Alignment.center, children: [ // Background bar — flush with mood cards Positioned( left: 0, right: 0, top: 4, bottom: 4, child: ClipRRect( borderRadius: BorderRadius.circular(24), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), child: Container( padding: const EdgeInsets.only(left: 52, right: 16), decoration: BoxDecoration( color: Colors.white.withOpacity(0.6), borderRadius: BorderRadius.circular(24), ), child: Row( children: [ // Current time SizedBox( width: 36, child: Text( _currentTime, textAlign: TextAlign.center, style: GoogleFonts.dmSans( fontSize: 12, color: const Color(0xFF6B7280), fontFeatures: const [FontFeature.tabularFigures()], ), ), ), // Slider Expanded( child: SliderTheme( data: SliderTheme.of(context).copyWith( activeTrackColor: const Color(0xFFE8C9A8), inactiveTrackColor: const Color(0xFFE5E5EA), thumbColor: Colors.white, trackHeight: 6, thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 9), overlayShape: const RoundSliderOverlayShape(overlayRadius: 16), ), child: Slider( value: _progress, onChangeStart: (_) => _isDragging = true, onChanged: (v) { setState(() => _progress = v); // Update the time label while dragging final duration = _audioPlayer.duration; if (duration != null) { final seekPos = Duration( milliseconds: (v * duration.inMilliseconds).toInt(), ); setState(() => _currentTime = _formatDuration(seekPos)); } }, onChangeEnd: (v) { _isDragging = false; final duration = _audioPlayer.duration; if (duration != null) { _audioPlayer.seek(Duration( milliseconds: (v * duration.inMilliseconds).toInt(), )); } }, ), ), ), // Total time SizedBox( width: 36, child: Text( _totalTime, textAlign: TextAlign.center, style: GoogleFonts.dmSans( fontSize: 12, color: const Color(0xFF6B7280), fontFeatures: const [FontFeature.tabularFigures()], ), ), ), ], ), ), ), ), ), // Play/Pause button (sits on top, aligned with capsule left edge) Positioned( left: 0, child: GestureDetector( onTap: _togglePlay, child: Container( width: 48, height: 48, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.white.withOpacity(0.9), border: Border.all(color: Colors.black.withOpacity(0.08)), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.1), offset: const Offset(0, 2), blurRadius: 8, ), ], ), child: Icon( _isPlaying ? Icons.pause : Icons.play_arrow, size: 24, color: const Color(0xFF6B7280), ), ), ), ), ], ), ); } // ══════════════════════════════════════════════════════════════ // MOOD SECTION — matches HTML .inspiration-section + .mood-grid // ══════════════════════════════════════════════════════════════ Widget _buildMoodSection() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // HTML: .section-label { font-size: 13px; font-weight: 600; color: var(--text-muted); // margin-bottom: 4px; padding-left: 4px; letter-spacing: 0.02em; } // HTML: .mood-grid { grid-template-columns: repeat(2, 1fr); gap: 8px; } GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, crossAxisSpacing: 8, mainAxisSpacing: 8, childAspectRatio: 1.85, ), itemCount: _moods.length, itemBuilder: (context, index) => _buildMoodCard(index), ), ], ); } Widget _buildMoodCard(int index) { final mood = _moods[index]; final isActive = _selectedMoodIndex == index; final themeColor = Color(mood['color'] as int); final isMystery = index == 4; // 盲盒惊喜 final isCustom = index == 5; // 自由创作 // ── Card background color logic ── Color cardColor; if (isCustom) { // 自由创作: white glass morphism cardColor = isActive ? Colors.white : Colors.white.withOpacity(0.65); } else if (isMystery) { // 盲盒惊喜: richer purple tint, more eye-catching cardColor = isActive ? Color.lerp(Colors.white, themeColor, 0.40)! : Color.lerp(Colors.white.withOpacity(0.50), themeColor, 0.30)!; } else { // Normal mood cards: themed tint cardColor = isActive ? Color.lerp(Colors.white, themeColor, 0.30)! : Color.lerp(Colors.white.withOpacity(0.55), themeColor, 0.20)!; } // ── Border color logic ── Color borderColor; if (isCustom) { borderColor = isActive ? const Color(0xFFECCFA8) : Colors.white.withOpacity(0.4); } else { borderColor = isActive ? themeColor.withOpacity(0.55) : themeColor.withOpacity(0.18); } final cardBody = AnimatedContainer( duration: const Duration(milliseconds: 300), curve: const Cubic(0.25, 0.46, 0.45, 0.94), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: cardColor, borderRadius: BorderRadius.circular(14), border: Border.all( color: borderColor, width: isActive ? 1.5 : 1, ), boxShadow: isActive ? [ BoxShadow( color: (isCustom ? const Color(0xFFECCFA8) : themeColor) .withOpacity(0.30), offset: const Offset(0, 6), blurRadius: 18, spreadRadius: -4, ), ] : [ BoxShadow( color: Colors.black.withOpacity(0.03), offset: const Offset(0, 2), blurRadius: 8, spreadRadius: -1, ), ], ), child: Row( children: [ Icon( mood['icon'] as IconData, size: 24, color: isActive ? (isCustom ? const Color(0xFFECCFA8) : themeColor) : themeColor, ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( mood['title'] as String, style: GoogleFonts.outfit( fontSize: 14, fontWeight: isActive ? FontWeight.w700 : FontWeight.w600, color: isActive ? const Color(0xFF1F2937) : const Color(0xFF374151), ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( mood['desc'] as String, style: GoogleFonts.dmSans( fontSize: 11, color: isActive ? const Color(0xFF6B7280) : const Color(0xFF9CA3AF), height: 1.3, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), ], ), ); return GestureDetector( onTap: () => _selectMood(index), child: Stack( children: [ // Main card body cardBody, // ── Mystery box: soft diagonal gleam ── if (isMystery) Positioned.fill( child: ClipRRect( borderRadius: BorderRadius.circular(14), child: IgnorePointer( child: AnimatedBuilder( animation: _mysteryShimmerController, builder: (context, _) { final t = _mysteryShimmerController.value; // Wide, soft sweep — barely visible glow final sweep = -2.0 + t * 5.0; return Opacity( opacity: 0.35, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment(sweep - 1.2, sweep - 1.2), end: Alignment(sweep + 1.2, sweep + 1.2), colors: [ Colors.white.withOpacity(0.0), Colors.white.withOpacity(0.08), Colors.white.withOpacity(0.18), Colors.white.withOpacity(0.08), Colors.white.withOpacity(0.0), ], stops: const [0.0, 0.25, 0.5, 0.75, 1.0], ), ), ), ); }, ), ), ), ), // Active indicator dot — top-right if (isActive) Positioned( top: 8, right: 8, child: Container( width: 8, height: 8, decoration: BoxDecoration( color: isCustom ? const Color(0xFFECCFA8) : themeColor, shape: BoxShape.circle, ), ), ), ], ), ); } // ══════════════════════════════════════════════════════════════ // MODALS // ══════════════════════════════════════════════════════════════ // ── Custom Input Modal ── void _showInputModal() { final controller = TextEditingController(); showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (ctx) => _InputModalContent( controller: controller, onSubmit: (text) { Navigator.pop(ctx); setState(() => _selectedMoodIndex = 5); _mockGenerate(text); }, ), ); } // ── Playlist Modal ── void _showPlaylistModal() { showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (ctx) => _PlaylistModalContent( tracks: _playlist, currentIndex: _currentTrackIndex, onSelect: (index) { Navigator.pop(ctx); _playTrack(index); }, ), ); } // ── Confirm Dialog (new song ready) ── void _showConfirmDialog(String title) { showDialog( context: context, barrierColor: Colors.black.withOpacity(0.4), builder: (ctx) => _ConfirmDialogContent( title: title, onListen: () { Navigator.pop(ctx); _showSpeech('正在播放: $title'); }, onLater: () { Navigator.pop(ctx); _showSpeech('已加入唱片架,随时可以听'); }, ), ); } } // ══════════════════════════════════════════════════════════════ // CUSTOM PAINTERS // ══════════════════════════════════════════════════════════════ /// Vinyl disc grooves + conic shine /// HTML: repeating-radial-gradient(#18181B 0, #18181B 3px, #27272A 4px) /// + conic-gradient shine overlay class _VinylDiscPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); // Groove rings final groovePaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 0.5; for (double r = 20; r < size.width / 2; r += 4) { groovePaint.color = r % 8 == 0 ? Colors.white.withOpacity(0.06) : Colors.white.withOpacity(0.03); canvas.drawCircle(center, r, groovePaint); } // Conic shine (subtle light reflection) // HTML: conic-gradient from 30deg with subtle white bands final shinePaint = Paint() ..shader = SweepGradient( startAngle: 30 * pi / 180, endAngle: 30 * pi / 180 + 2 * pi, colors: const [ Color(0x00FFFFFF), // transparent Color(0x0DFFFFFF), // 0.05 Color(0x1CFFFFFF), // 0.11 Color(0x0DFFFFFF), // 0.05 Color(0x00FFFFFF), // transparent Color(0x00FFFFFF), Color(0x0DFFFFFF), Color(0x1CFFFFFF), Color(0x0DFFFFFF), Color(0x00FFFFFF), ], stops: const [ 0.0, 0.033, 0.069, 0.106, 0.139, 0.5, 0.533, 0.569, 0.606, 0.639, ], ).createShader(Rect.fromCircle(center: center, radius: size.width / 2)); canvas.drawCircle(center, size.width / 2, shinePaint); // Center hole canvas.drawCircle( center, 4, Paint()..color = const Color(0xFF27272A), ); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } /// Vinyl back grooves (subtler pattern) class _VinylBackGroovesPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); final groovePaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 0.3 ..color = const Color(0xFF1F1F23).withOpacity(0.4); for (double r = 10; r < size.width / 2 - 3; r += 3) { canvas.drawCircle(center, r, groovePaint); } } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } /// Tonearm — matches HTML structure /// HTML: .tonearm-base (18x18 radial gradient circle) /// .tonearm-arm (3px wide, 70px tall, rotated 25deg) /// .tonearm-head (9x10 rectangle at end of arm) class _TonearmPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { // Base knob — HTML: .tonearm-base { top: 4px; right: 8px; width: 18px; height: 18px; // background: radial-gradient(circle at 40% 40%, #D0D0D0, #909090); } final baseCenter = Offset(size.width - 17, 13); final basePaint = Paint() ..shader = RadialGradient( center: const Alignment(-0.2, -0.2), // at 40% 40% colors: const [Color(0xFFD0D0D0), Color(0xFF909090)], ).createShader( Rect.fromCircle(center: baseCenter, radius: 9)); canvas.drawCircle(baseCenter, 9, basePaint); // Inner knob — HTML: .tonearm-base::after { width: 6px; height: 6px; // background: radial-gradient(circle at 40% 40%, #E8E8E8, #B0B0B0); } final innerPaint = Paint() ..shader = RadialGradient( center: const Alignment(-0.2, -0.2), colors: const [Color(0xFFE8E8E8), Color(0xFFB0B0B0)], ).createShader( Rect.fromCircle(center: baseCenter, radius: 3)); canvas.drawCircle(baseCenter, 3, innerPaint); // Arm — HTML: .tonearm-arm { top: 12px; right: 16px; width: 3px; height: 70px; // background: linear-gradient(180deg, #A0A0A0, #C0C0C0); transform: rotate(25deg); } canvas.save(); canvas.translate(size.width - 17, 18); canvas.rotate(25 * pi / 180); final armPaint = Paint() ..shader = const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFFA0A0A0), Color(0xFFC0C0C0)], ).createShader(const Rect.fromLTWH(-1.5, 0, 3, 70)) ..strokeWidth = 3 ..strokeCap = StrokeCap.round; canvas.drawLine(const Offset(0, 0), const Offset(0, 70), armPaint); // Head — HTML: .tonearm-head { bottom: -6px; left: -3px; width: 9px; height: 10px; // background: linear-gradient(180deg, #888, #666); } final headPaint = Paint() ..shader = const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFF888888), Color(0xFF666666)], ).createShader(const Rect.fromLTWH(-4.5, 70, 9, 10)); canvas.drawRRect( RRect.fromRectAndRadius( const Rect.fromLTWH(-4.5, 70, 9, 10), const Radius.circular(1), ), headPaint, ); canvas.restore(); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } // ══════════════════════════════════════════════════════════════ // MODAL WIDGETS (Extracted as StatelessWidgets for cleanliness) // ══════════════════════════════════════════════════════════════ /// Custom Input Modal — HTML: .input-modal-container class _InputModalContent extends StatelessWidget { final TextEditingController controller; final ValueChanged onSubmit; const _InputModalContent({ required this.controller, required this.onSubmit, }); @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), child: Container( padding: EdgeInsets.fromLTRB( 20, 16, 20, 16 + MediaQuery.of(context).padding.bottom, ), decoration: BoxDecoration( color: Colors.white.withOpacity(0.95), borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.06), offset: const Offset(0, -2), blurRadius: 16, ), ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ // Handle bar Container( width: 36, height: 4, margin: const EdgeInsets.only(bottom: 12), decoration: BoxDecoration( color: const Color(0xFFE8C9A8), borderRadius: BorderRadius.circular(2), ), ), // Header Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '自由创作', style: GoogleFonts.outfit( fontSize: 16, fontWeight: FontWeight.w600, color: const Color(0xFF374151), ), ), GestureDetector( onTap: () => Navigator.pop(context), child: Container( width: 28, height: 28, decoration: BoxDecoration( color: Colors.black.withOpacity(0.05), shape: BoxShape.circle, ), child: const Icon(Icons.close, size: 16, color: Color(0xFF4B5563)), ), ), ], ), const SizedBox(height: 4), // Subtitle hint Align( alignment: Alignment.centerLeft, child: Text( '描述你想要的音乐氛围、场景或情绪', style: GoogleFonts.dmSans( fontSize: 12, color: const Color(0xFF9CA3AF), ), ), ), const SizedBox(height: 12), // Textarea - increased minLines for taller input ConstrainedBox( constraints: const BoxConstraints(minHeight: 100), child: TextField( controller: controller, minLines: 4, maxLines: 6, style: GoogleFonts.dmSans( fontSize: 14, color: const Color(0xFF374151)), decoration: InputDecoration( hintText: '例如:水豚在雨中等公交,心情却很平静...', hintStyle: GoogleFonts.dmSans( fontSize: 14, color: const Color(0xFF9CA3AF)), filled: true, fillColor: Colors.black.withOpacity(0.03), border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: Colors.black.withOpacity(0.06)), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: Colors.black.withOpacity(0.06)), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), borderSide: const BorderSide( color: Color(0xFFECCFA8), width: 1.5, ), ), contentPadding: const EdgeInsets.all(16), ), ), ), const SizedBox(height: 14), // Submit button GradientButton( text: '生成音乐 🎵', height: 48, gradient: appclr.AppColors.btnPlushGradient, onPressed: () { final text = controller.text.trim(); if (text.isEmpty) { AppToast.show(context, '请输入一点灵感吧 ✨', isError: true); return; } onSubmit(text); }, ), ], ), ), ); } } /// Playlist Modal — HTML: .playlist-container class _PlaylistModalContent extends StatelessWidget { final List<_Track> tracks; final int currentIndex; final ValueChanged onSelect; const _PlaylistModalContent({ required this.tracks, required this.currentIndex, required this.onSelect, }); @override Widget build(BuildContext context) { return Container( constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.88, ), padding: EdgeInsets.fromLTRB( 20, 16, 20, 24 + MediaQuery.of(context).padding.bottom, ), decoration: BoxDecoration( color: Colors.white.withOpacity(0.95), borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.06), offset: const Offset(0, -2), blurRadius: 16, ), ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ // Header Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '我的唱片架', style: GoogleFonts.outfit( fontSize: 15, fontWeight: FontWeight.w600, color: const Color(0xFF374151), ), ), GestureDetector( onTap: () => Navigator.pop(context), child: Container( width: 28, height: 28, decoration: BoxDecoration( color: Colors.black.withOpacity(0.05), shape: BoxShape.circle, ), child: const Icon(Icons.close, size: 16, color: Color(0xFF4B5563)), ), ), ], ), const SizedBox(height: 16), // Record grid — HTML: .record-grid { grid-template-columns: repeat(3, 1fr); gap: 8px; } Flexible( child: GridView.builder( shrinkWrap: true, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, crossAxisSpacing: 8, mainAxisSpacing: 8, childAspectRatio: 0.75, ), itemCount: tracks.length, itemBuilder: (context, index) { final track = tracks[index]; final isPlaying = index == currentIndex; // HTML: .record-slot { background: rgba(0,0,0,0.03); border-radius: 12px; // padding: 10px 4px; border: 1px solid rgba(0,0,0,0.02); } return GestureDetector( onTap: () => onSelect(index), child: Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 10), decoration: BoxDecoration( color: Colors.black.withOpacity(0.03), borderRadius: BorderRadius.circular(12), border: Border.all( color: Colors.black.withOpacity(0.02)), ), child: Column( children: [ // Mini vinyl cover Expanded( child: AspectRatio( aspectRatio: 1, child: Container( decoration: BoxDecoration( shape: BoxShape.circle, color: const Color(0xFF18181B), // HTML: .record-item.playing .record-cover-wrapper // { box-shadow: 0 0 0 2px #ECCFA8, ... } boxShadow: [ if (isPlaying) const BoxShadow( color: Color(0xFFECCFA8), spreadRadius: 2, ), BoxShadow( color: Colors.black.withOpacity(0.15), offset: const Offset(0, 8), blurRadius: 16, ), ], ), child: Stack( children: [ // Groove pattern CustomPaint( painter: _VinylBackGroovesPainter(), size: Size.infinite, ), // Inner cover image Center( child: FractionallySizedBox( widthFactor: 0.55, heightFactor: 0.55, child: Container( decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: Colors.white .withOpacity(0.2), width: 1, ), ), clipBehavior: Clip.antiAlias, child: Image.asset( 'assets/www/Capybara.png', fit: BoxFit.cover, errorBuilder: (_, __, ___) => const Icon( Icons.music_note, color: Colors.white54, ), ), ), ), ), ], ), ), ), ), const SizedBox(height: 8), // HTML: .record-title { font-size: 12px; font-weight: 500; } Text( track.title, style: GoogleFonts.dmSans( fontSize: 12, fontWeight: FontWeight.w500, color: const Color(0xFF374151), ), textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), ); }, ), ), ], ), ); } } /// Confirm Dialog — HTML: .confirm-container class _ConfirmDialogContent extends StatelessWidget { final String title; final VoidCallback onListen; final VoidCallback onLater; const _ConfirmDialogContent({ required this.title, required this.onListen, required this.onLater, }); @override Widget build(BuildContext context) { return Center( child: Container( width: MediaQuery.of(context).size.width - 48, constraints: const BoxConstraints(maxWidth: 320), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), decoration: BoxDecoration( // HTML: background: rgba(255,255,255,0.95); backdrop-filter: blur(20px); // border-radius: 20px; box-shadow: 0 8px 32px rgba(0,0,0,0.12); color: Colors.white.withOpacity(0.95), borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.12), offset: const Offset(0, 8), blurRadius: 32, ), ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ // HTML: .confirm-text { font-size: 15px; font-weight: 600; line-height: 1.5; } Text( '新歌已生成,是否立即试听?', style: GoogleFonts.outfit( fontSize: 15, fontWeight: FontWeight.w600, color: const Color(0xFF374151), height: 1.5, ), textAlign: TextAlign.center, ), const SizedBox(height: 18), // Buttons Row( children: [ // "稍后再听" — HTML: .confirm-btn.secondary Expanded( child: GestureDetector( onTap: onLater, child: Container( height: 40, decoration: BoxDecoration( color: Colors.black.withOpacity(0.06), borderRadius: BorderRadius.circular(20), ), alignment: Alignment.center, child: Text( '稍后再听', style: GoogleFonts.dmSans( fontSize: 14, fontWeight: FontWeight.w600, color: const Color(0xFF4B5563), ), ), ), ), ), const SizedBox(width: 10), // "立即试听" — HTML: .confirm-btn.primary Expanded( child: GradientButton( text: '立即试听', height: 40, gradient: appclr.AppColors.btnPlushGradient, onPressed: onListen, ), ), ], ), ], ), ), ); } }