1738 lines
60 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|