rtc_prd/airhub_app/lib/pages/music_creation_page.dart
seaislee1209 4898d46f9c feat: music page mood cards UI redesign + mystery shimmer + lyrics readability
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 21:10:55 +08:00

1738 lines
60 KiB
Dart

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<MusicCreationPage> createState() => _MusicCreationPageState();
}
class _MusicCreationPageState extends State<MusicCreationPage>
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<double> _tonearmAngle;
late AnimationController _flipController;
late Animation<double> _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<Map<String, dynamic>> _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<double>(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<double>(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<void> _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<String> 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<int> 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,
),
),
],
),
],
),
),
);
}
}