_addNewBookWithAnimation 新增 coverUrl 参数,POST /stories/ 时携带 cover_url 字段,两处调用均从 saveResult 传入生成好的封面 URL。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1145 lines
36 KiB
Dart
1145 lines
36 KiB
Dart
import 'dart:math';
|
|
import 'dart:ui';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
import 'package:flutter_svg/flutter_svg.dart';
|
|
import '../core/network/api_client.dart';
|
|
import 'story_detail_page.dart';
|
|
import 'product_selection_page.dart';
|
|
import 'settings_page.dart';
|
|
import '../widgets/glass_dialog.dart';
|
|
import '../widgets/story_generator_modal.dart';
|
|
import 'story_loading_page.dart';
|
|
import 'profile/profile_page.dart';
|
|
import 'music_creation_page.dart';
|
|
import '../theme/design_tokens.dart';
|
|
import '../widgets/dashed_rect.dart';
|
|
import '../widgets/ios_toast.dart';
|
|
import '../widgets/animated_gradient_background.dart';
|
|
import '../widgets/gradient_button.dart';
|
|
import '../features/device/presentation/controllers/device_controller.dart';
|
|
|
|
class DeviceControlPage extends ConsumerStatefulWidget {
|
|
const DeviceControlPage({super.key});
|
|
|
|
@override
|
|
ConsumerState<DeviceControlPage> createState() => _DeviceControlPageState();
|
|
}
|
|
|
|
class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|
with SingleTickerProviderStateMixin {
|
|
int _currentIndex = 0; // 0: Home, 1: Story, 2: Music, 3: User
|
|
|
|
// Animation for mascot
|
|
late AnimationController _mascotAnimController;
|
|
|
|
// PageController for bookshelf scroll tracking
|
|
late PageController _bookshelfController;
|
|
double _bookshelfScrollOffset = 0.0;
|
|
|
|
// Animation for new book
|
|
int? _newBookIndex;
|
|
|
|
// Shelves loaded from backend: [{id, name, capacity, story_count, stories: [...]}]
|
|
List<Map<String, dynamic>> _shelves = [];
|
|
bool _shelvesLoading = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_mascotAnimController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(seconds: 4),
|
|
)..repeat(reverse: true);
|
|
|
|
// Initialize bookshelf PageController
|
|
_bookshelfController = PageController(viewportFraction: 0.85);
|
|
_bookshelfController.addListener(() {
|
|
setState(() {
|
|
_bookshelfScrollOffset = _bookshelfController.page ?? 0.0;
|
|
});
|
|
});
|
|
|
|
// Load shelves and stories from backend
|
|
_loadShelves();
|
|
}
|
|
|
|
/// Fetch shelves and their stories from backend
|
|
Future<void> _loadShelves() async {
|
|
try {
|
|
final api = ref.read(apiClientProvider);
|
|
|
|
// Load shelves
|
|
final shelvesData = await api.get('/stories/shelves/') as List;
|
|
final shelves = <Map<String, dynamic>>[];
|
|
|
|
for (final shelf in shelvesData) {
|
|
final shelfId = shelf['id'];
|
|
// Load stories for this shelf
|
|
final storiesData = await api.get(
|
|
'/stories/',
|
|
queryParameters: {'shelf_id': shelfId, 'page_size': 10},
|
|
);
|
|
final stories = (storiesData['items'] as List?)
|
|
?.map((s) => Map<String, dynamic>.from(s))
|
|
.toList() ?? [];
|
|
|
|
shelves.add({
|
|
...Map<String, dynamic>.from(shelf),
|
|
'stories': stories,
|
|
});
|
|
}
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_shelves = shelves;
|
|
_shelvesLoading = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Failed to load shelves: $e');
|
|
if (mounted) {
|
|
setState(() {
|
|
_shelvesLoading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_mascotAnimController.dispose();
|
|
_bookshelfController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onTabTapped(int index) {
|
|
setState(() {
|
|
_currentIndex = index;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: Colors.white,
|
|
body: Stack(
|
|
children: [
|
|
// Global Gradient Background
|
|
_buildGradientBackground(),
|
|
|
|
// Main Content Area
|
|
// Main Content Area
|
|
IndexedStack(
|
|
index: _currentIndex,
|
|
children: [
|
|
SafeArea(bottom: false, child: _buildHomeView()),
|
|
SafeArea(bottom: false, child: _buildStoryView()),
|
|
MusicCreationPage(isTab: true, isVisible: _currentIndex == 2),
|
|
const ProfilePage(), // No SafeArea here to allow full background
|
|
],
|
|
),
|
|
|
|
// Header (Visible on Home and Story tabs, but maybe different style?)
|
|
// For now, keep it fixed on top for both, as per design.
|
|
// Note: In story view, header might overlay content.
|
|
// Header (Only visible on Home tab)
|
|
if (_currentIndex == 0)
|
|
Positioned(top: 0, left: 0, right: 0, child: _buildHeader()),
|
|
|
|
// Custom Bottom Navigation Bar
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: MediaQuery.of(context).padding.bottom + 12,
|
|
child: _buildBottomNavBar(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildGradientBackground() {
|
|
return const AnimatedGradientBackground();
|
|
}
|
|
|
|
// --- Header --- HTML: padding-top: calc(env(safe-area-inset-top) + 48px)
|
|
Widget _buildHeader() {
|
|
return Container(
|
|
padding: EdgeInsets.fromLTRB(
|
|
20,
|
|
MediaQuery.of(context).padding.top + 8, // Reduced from +48 to sit closer to top
|
|
20,
|
|
10,
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
// Switch Device Button
|
|
_buildIconBtn(
|
|
'assets/www/icons/icon-switch.svg',
|
|
onTap: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => const ProductSelectionPage(),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
|
|
// Add Animation Trigger Logic for testing or real use
|
|
// We'll hook this up to the Generator Modal return value.
|
|
|
|
// Status Pill — dynamic from device detail
|
|
_buildStatusPill(),
|
|
|
|
// Settings Button
|
|
_buildIconBtn(
|
|
'assets/www/icons/icon-settings-pixel.svg',
|
|
onTap: () {
|
|
Navigator.of(context).push(
|
|
PageRouteBuilder(
|
|
pageBuilder: (context, animation, secondaryAnimation) =>
|
|
const SettingsPage(),
|
|
transitionsBuilder:
|
|
(context, animation, secondaryAnimation, child) {
|
|
const begin = Offset(0.0, 1.0);
|
|
const end = Offset.zero;
|
|
const curve = Cubic(0.2, 0.8, 0.2, 1.0);
|
|
var tween = Tween(
|
|
begin: begin,
|
|
end: end,
|
|
).chain(CurveTween(curve: curve));
|
|
return SlideTransition(
|
|
position: animation.drive(tween),
|
|
child: child,
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildIconBtn(String iconPath, {VoidCallback? onTap}) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
width: 44,
|
|
height: 44,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.25),
|
|
borderRadius: BorderRadius.circular(22),
|
|
border: Border.all(color: Colors.white.withOpacity(0.4)),
|
|
),
|
|
alignment: Alignment.center,
|
|
child: SvgPicture.asset(
|
|
iconPath,
|
|
width: 20,
|
|
height: 20,
|
|
colorFilter: const ColorFilter.mode(
|
|
Color(0xFF4B5563),
|
|
BlendMode.srcIn,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStatusPill() {
|
|
final devicesAsync = ref.watch(deviceControllerProvider);
|
|
final devices = devicesAsync.value ?? [];
|
|
final firstDevice = devices.isNotEmpty ? devices.first : null;
|
|
|
|
// If we have a device, try to load its detail for status/battery
|
|
String statusText = '离线';
|
|
Color dotColor = const Color(0xFF9CA3AF);
|
|
String batteryText = '--';
|
|
|
|
if (firstDevice != null) {
|
|
final detailAsync = ref.watch(
|
|
deviceDetailControllerProvider(firstDevice.id),
|
|
);
|
|
final detail = detailAsync.value;
|
|
if (detail != null) {
|
|
final isOnline = detail.status == 'online';
|
|
statusText = isOnline ? '在线' : '离线';
|
|
dotColor = isOnline ? const Color(0xFF22C55E) : const Color(0xFF9CA3AF);
|
|
batteryText = '${detail.battery}%';
|
|
}
|
|
}
|
|
|
|
return Container(
|
|
height: 44,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.25),
|
|
borderRadius: BorderRadius.circular(24),
|
|
border: Border.all(color: Colors.white.withOpacity(0.4)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(
|
|
color: dotColor,
|
|
shape: BoxShape.circle,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: dotColor.withOpacity(0.2),
|
|
blurRadius: 0,
|
|
spreadRadius: 2,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
statusText,
|
|
style: GoogleFonts.dmSans(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: const Color(0xFF4B5563),
|
|
),
|
|
),
|
|
Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 12),
|
|
width: 1,
|
|
height: 16,
|
|
color: Colors.black.withOpacity(0.1),
|
|
),
|
|
SvgPicture.asset(
|
|
'assets/www/icons/icon-battery-full.svg',
|
|
width: 18,
|
|
height: 18,
|
|
colorFilter: const ColorFilter.mode(
|
|
Color(0xFF4B5563),
|
|
BlendMode.srcIn,
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
batteryText,
|
|
style: GoogleFonts.dmSans(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: const Color(0xFF4B5563),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// --- Home View ---
|
|
Widget _buildHomeView() {
|
|
return Center(
|
|
child: AnimatedBuilder(
|
|
animation: _mascotAnimController,
|
|
builder: (context, child) {
|
|
return Transform.translate(
|
|
offset: Offset(
|
|
0,
|
|
10 * _mascotAnimController.value - 25,
|
|
), // Float +/- 5
|
|
child: child,
|
|
);
|
|
},
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
// Mascot Image
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: const Color(0xFFF9A8D4).withOpacity(0.25),
|
|
blurRadius: 50,
|
|
spreadRadius: 10,
|
|
),
|
|
],
|
|
),
|
|
child: Image.asset(
|
|
'assets/www/Capybara.png',
|
|
width: 250,
|
|
fit: BoxFit.contain,
|
|
errorBuilder: (_, __, ___) =>
|
|
const Icon(Icons.smart_toy, size: 150, color: Colors.amber),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// --- Story View ---
|
|
Widget _buildStoryView() {
|
|
return Stack(
|
|
children: [
|
|
// Main Content Column
|
|
Column(
|
|
children: [
|
|
// Top Spacer - HTML: .story-header-spacer { height: 20px }
|
|
const SizedBox(height: 20),
|
|
|
|
// Bookshelf PageView - constrained height for proper proportions
|
|
SizedBox(
|
|
height: MediaQuery.of(context).size.height * 0.64,
|
|
child: _shelvesLoading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: PageView.builder(
|
|
controller: _bookshelfController,
|
|
clipBehavior: Clip.none,
|
|
padEnds: false,
|
|
itemCount: _shelves.length + 1, // shelves + 1 locked placeholder
|
|
itemBuilder: (context, index) {
|
|
if (index < _shelves.length) {
|
|
final shelf = _shelves[index];
|
|
final stories = (shelf['stories'] as List?)
|
|
?.cast<Map<String, dynamic>>() ?? [];
|
|
final capacity = shelf['capacity'] as int? ?? 10;
|
|
final count = '${stories.length}/$capacity';
|
|
return Padding(
|
|
padding: const EdgeInsets.only(left: 16, right: 6),
|
|
child: _buildBookshelfSlide(
|
|
shelf['name'] as String? ?? '书架',
|
|
count,
|
|
stories,
|
|
shelfId: shelf['id'] as int,
|
|
),
|
|
);
|
|
} else {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(left: 6, right: 16),
|
|
child: _buildLockedShelf(),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
|
|
// Flexible bottom space
|
|
const Spacer(),
|
|
],
|
|
),
|
|
|
|
// Create Story Button (.story-actions-wrapper)
|
|
Positioned(
|
|
bottom: MediaQuery.of(context).padding.bottom + 120,
|
|
left: 0,
|
|
right: 0,
|
|
child: Center(child: _buildCreateStoryButton()),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// Create Story Button per PRD (.create-story-btn)
|
|
Widget _buildCreateStoryButton() {
|
|
return GradientButton(
|
|
text: '+ 创作新故事',
|
|
width: 220,
|
|
height: 52,
|
|
gradient: const LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: AppColors.btnCapybaraGradient,
|
|
),
|
|
onPressed: () async {
|
|
final result = await showModalBottomSheet<Map<String, dynamic>>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (context) => const StoryGeneratorModal(),
|
|
);
|
|
|
|
if (result != null && result['action'] == 'start_generation') {
|
|
final saveResult = await Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => StoryLoadingPage(
|
|
characters: List<String>.from(result['characters'] ?? []),
|
|
scenes: List<String>.from(result['scenes'] ?? []),
|
|
props: List<String>.from(result['props'] ?? []),
|
|
),
|
|
),
|
|
);
|
|
if (saveResult is Map && saveResult['action'] == 'saved') {
|
|
await _addNewBookWithAnimation(
|
|
title: saveResult['title'] as String? ?? '新故事',
|
|
content: saveResult['content'] as String? ?? '',
|
|
coverUrl: saveResult['cover_url'] as String? ?? '',
|
|
);
|
|
}
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildBookshelfSlide(
|
|
String title,
|
|
String count,
|
|
List<Map<String, dynamic>> stories, {
|
|
required int shelfId,
|
|
}) {
|
|
// PRD: .bookshelf-container height: 600px, .story-book height: 100%
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.bookshelfBg, // .story-book bg rgba(255,255,255,0.55)
|
|
borderRadius: BorderRadius.circular(24), // 24px
|
|
border: Border.all(
|
|
color: AppColors.bookshelfBorder,
|
|
), // 1px solid rgba(255,255,255,0.6)
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: Color(0x08000000), // rgba(0,0,0,0.03)
|
|
blurRadius: 40,
|
|
offset: Offset(0, 10),
|
|
),
|
|
],
|
|
),
|
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16), // Tighter padding for better proportions
|
|
child: Column(
|
|
children: [
|
|
// Header (.book-cover)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 14),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(title, style: AppTextStyles.bookTitle),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.bookCountBg,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(count, style: AppTextStyles.bookCount),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Grid (.story-grid) 2 cols, 5 rows
|
|
// PRD: grid-template-rows: repeat(5, minmax(0, 1fr))
|
|
Expanded(
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
// Calculate aspect ratio based on available space
|
|
// 5 rows with 12px gaps (4 gaps total = 48px)
|
|
final gridHeight = constraints.maxHeight;
|
|
final gridWidth = constraints.maxWidth;
|
|
final rowHeight = (gridHeight - 48) / 5; // 5 rows, 4 gaps
|
|
final colWidth = (gridWidth - 12) / 2; // 2 cols, 1 gap
|
|
final aspectRatio = colWidth / rowHeight;
|
|
|
|
return GridView.builder(
|
|
padding: EdgeInsets.zero,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 2,
|
|
childAspectRatio: aspectRatio,
|
|
crossAxisSpacing: 12,
|
|
mainAxisSpacing: 12,
|
|
),
|
|
itemCount: 10, // Fixed 10 slots per book (2x5)
|
|
itemBuilder: (context, index) {
|
|
if (index < stories.length) {
|
|
// Check if this is a newly added book
|
|
final isNewBook = _newBookIndex == index;
|
|
return _buildStorySlot(stories[index], isNew: isNewBook);
|
|
} else {
|
|
// Empty clickable slot with +
|
|
return _buildStorySlot({'type': 'empty_slot'});
|
|
}
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStorySlot(Map<String, dynamic> story, {bool isNew = false}) {
|
|
final coverUrl = story['cover_url'] as String? ?? story['cover'] as String? ?? '';
|
|
final bool hasCover = coverUrl.isNotEmpty;
|
|
final bool hasContent = story['content'] != null && (story['content'] as String).isNotEmpty;
|
|
|
|
// Empty/Clickable Slot — no content, just a "+" to create new story
|
|
if (!hasContent && !hasCover) {
|
|
return GestureDetector(
|
|
onTap: () async {
|
|
final result = await showModalBottomSheet<Map<String, dynamic>>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (context) => const StoryGeneratorModal(),
|
|
);
|
|
|
|
if (result != null && result['action'] == 'start_generation') {
|
|
final saveResult = await Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => StoryLoadingPage(
|
|
characters: List<String>.from(result['characters'] ?? []),
|
|
scenes: List<String>.from(result['scenes'] ?? []),
|
|
props: List<String>.from(result['props'] ?? []),
|
|
),
|
|
),
|
|
);
|
|
if (saveResult is Map && saveResult['action'] == 'saved') {
|
|
_addNewBookWithAnimation(
|
|
title: saveResult['title'] as String? ?? '新故事',
|
|
content: saveResult['content'] as String? ?? '',
|
|
coverUrl: saveResult['cover_url'] as String? ?? '',
|
|
);
|
|
}
|
|
}
|
|
},
|
|
child: DashedRect(
|
|
color: AppColors.slotBorder, // rgba(0, 0, 0, 0.05)
|
|
strokeWidth: 1,
|
|
gap: 4,
|
|
borderRadius: BorderRadius.circular(StoryBookRadius.slot),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: AppColors.slotClickableBg, // rgba(255,255,255,0.4)
|
|
borderRadius: BorderRadius.circular(StoryBookRadius.slot),
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Text('+', style: AppTextStyles.emptyPlus),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Cover widget: real image or "未生成封面" placeholder
|
|
Widget coverWidget;
|
|
if (hasCover) {
|
|
coverWidget = coverUrl.startsWith('http')
|
|
? Image.network(
|
|
coverUrl,
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (_, __, ___) => Container(color: Colors.grey.shade200),
|
|
)
|
|
: Image.asset(
|
|
coverUrl,
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (_, __, ___) => Container(color: Colors.grey.shade200),
|
|
);
|
|
} else {
|
|
// No cover — show soft placeholder
|
|
coverWidget = Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
const Color(0xFFE8E0F0),
|
|
const Color(0xFFD5CBE8),
|
|
],
|
|
),
|
|
),
|
|
alignment: Alignment.center,
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
child: const Text(
|
|
'暂无封面',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Color(0xFF9B8DB8),
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
);
|
|
}
|
|
|
|
// Filled Slot (.story-slot.filled)
|
|
Widget slotContent = GestureDetector(
|
|
onTap: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => StoryDetailPage(story: story),
|
|
),
|
|
);
|
|
},
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(StoryBookRadius.slot),
|
|
boxShadow: const [AppShadows.storySlotFilled],
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Stack(
|
|
children: [
|
|
// Cover Image or Placeholder
|
|
Positioned.fill(child: coverWidget),
|
|
// Title Bar (.story-title-bar)
|
|
Positioned(
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: Container(
|
|
color: AppColors.slotTitleBarBg,
|
|
padding: StoryBookSpacing.titleBarPadding,
|
|
child: Text(
|
|
story['title'] ?? '',
|
|
style: AppTextStyles.slotTitle,
|
|
textAlign: TextAlign.center,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
// Wrap with animation if this is a new book
|
|
// PRD: animation: bookPop 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards
|
|
if (isNew) {
|
|
return _NewBookAnimation(child: slotContent);
|
|
}
|
|
|
|
return slotContent;
|
|
}
|
|
|
|
// Locked Bookshelf Placeholder per PRD (.add-book-placeholder)
|
|
// 解锁新书架 — 单组内容丝滑从 peek 位置滑到正中
|
|
Widget _buildLockedShelf() {
|
|
final t = Curves.easeOut.transform(
|
|
_bookshelfScrollOffset.clamp(0.0, 1.0),
|
|
);
|
|
|
|
// --- 对齐 ---
|
|
// t=0 时内容在可见 peek 区域中央 (alignX ≈ -0.7)
|
|
// t=1 时内容在卡片正中 (alignX = 0)
|
|
final alignX = lerpDouble(-0.92, 0.0, t)!;
|
|
|
|
// --- 图标尺寸:从 20 → 32 ---
|
|
final iconSize = lerpDouble(20, 32, t)!;
|
|
|
|
// --- 文字大小:从 11 → 14 ---
|
|
final fontSize = lerpDouble(11, 14, t)!;
|
|
|
|
// --- 文字内容:竖排 → 横排 ---
|
|
final isHorizontal = t > 0.5;
|
|
|
|
return GestureDetector(
|
|
onTap: _showUnlockDialog,
|
|
child: Container(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
child: DashedRect(
|
|
color: const Color(0x80C99672),
|
|
strokeWidth: 2,
|
|
gap: 6,
|
|
borderRadius: BorderRadius.circular(20),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: const Color(0x66FFFFFF),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Align(
|
|
alignment: Alignment(alignX, 0),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.lock_outline,
|
|
color: const Color(0xFFBBBBBB),
|
|
size: iconSize,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
isHorizontal ? '解锁新书架' : '解锁\n新书架',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: fontSize,
|
|
fontWeight: FontWeight.w600,
|
|
color: const Color(0xFF9CA3AF),
|
|
height: 1.3,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildBottomNavBar() {
|
|
return Center(
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(32),
|
|
child: BackdropFilter(
|
|
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
|
child: Container(
|
|
width: 320, // HTML: max-width 320px
|
|
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.6),
|
|
borderRadius: BorderRadius.circular(32),
|
|
border: Border.all(color: Colors.white.withOpacity(0.8)),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: const Color(0xFF4B5563).withOpacity(0.08),
|
|
offset: const Offset(0, 10),
|
|
blurRadius: 30,
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
_buildNavItem(0, 'home', Icons.home),
|
|
_buildNavItem(1, 'story', Icons.auto_stories),
|
|
_buildNavItem(2, 'music', Icons.music_note),
|
|
_buildNavItem(3, 'user', Icons.person),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildNavItem(int index, String id, IconData defaultIcon) {
|
|
bool isActive = _currentIndex == index;
|
|
String iconPath = 'assets/www/icons/icon-$id-pixel.svg';
|
|
if (id == 'home') iconPath = 'assets/www/icons/icon-home-capybara.svg';
|
|
|
|
return GestureDetector(
|
|
onTap: () => _onTabTapped(index),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 300),
|
|
width: 56,
|
|
height: 56,
|
|
decoration: BoxDecoration(
|
|
color: isActive ? null : Colors.transparent,
|
|
gradient: isActive
|
|
? const LinearGradient(
|
|
colors: [
|
|
Color(0xFFE6B98D),
|
|
Color(0xFFD4A373),
|
|
Color(0xFFB07D5A),
|
|
],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
)
|
|
: null,
|
|
borderRadius: BorderRadius.circular(28),
|
|
boxShadow: isActive
|
|
? [
|
|
BoxShadow(
|
|
color: const Color(0xFFD4A373).withOpacity(0.4),
|
|
offset: const Offset(0, 4),
|
|
blurRadius: 15,
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
alignment: Alignment.center,
|
|
child: SvgPicture.asset(
|
|
iconPath,
|
|
width: isActive ? 30 : 28,
|
|
height: isActive ? 30 : 28,
|
|
colorFilter: ColorFilter.mode(
|
|
isActive ? Colors.white : const Color(0xFF6B7280).withOpacity(0.6),
|
|
BlendMode.srcIn,
|
|
),
|
|
placeholderBuilder: (_) => Icon(
|
|
defaultIcon,
|
|
color: isActive ? Colors.white : const Color(0xFF6B7280),
|
|
size: 24,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showUnlockDialog() {
|
|
showGlassDialog(
|
|
context: context,
|
|
title: '解锁新书架',
|
|
description: '确认消耗 100 积分以永久解锁该书架?',
|
|
confirmText: '确认解锁',
|
|
onConfirm: () async {
|
|
Navigator.pop(context);
|
|
try {
|
|
final api = ref.read(apiClientProvider);
|
|
await api.post('/stories/shelves/unlock/');
|
|
// Reload shelves to get the new one
|
|
await _loadShelves();
|
|
// Auto-scroll to the newly unlocked shelf
|
|
Future.delayed(const Duration(milliseconds: 300), () {
|
|
if (mounted) {
|
|
_bookshelfController.animateToPage(
|
|
_shelves.length - 1,
|
|
duration: const Duration(milliseconds: 500),
|
|
curve: Curves.easeOutCubic,
|
|
);
|
|
}
|
|
});
|
|
if (mounted) AppToast.show(context, '解锁成功!新书架已添加');
|
|
} catch (e) {
|
|
if (mounted) AppToast.show(context, '解锁失败: ${e.toString()}');
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _addNewBookWithAnimation({String title = '新故事', String content = '', String coverUrl = ''}) async {
|
|
// Find the first shelf that has space
|
|
int? targetShelfId;
|
|
for (final shelf in _shelves) {
|
|
final stories = shelf['stories'] as List? ?? [];
|
|
final capacity = shelf['capacity'] as int? ?? 10;
|
|
if (stories.length < capacity) {
|
|
targetShelfId = shelf['id'] as int;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (targetShelfId == null) {
|
|
if (mounted) AppToast.show(context, '所有书架已满,请解锁新书架');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final api = ref.read(apiClientProvider);
|
|
await api.post('/stories/', data: {
|
|
'title': title,
|
|
'content': content,
|
|
'shelf_id': targetShelfId,
|
|
if (coverUrl.isNotEmpty) 'cover_url': coverUrl,
|
|
});
|
|
// Reload to get the new story
|
|
await _loadShelves();
|
|
|
|
if (mounted) {
|
|
// Find the shelf index and trigger animation
|
|
for (int i = 0; i < _shelves.length; i++) {
|
|
if (_shelves[i]['id'] == targetShelfId) {
|
|
final stories = _shelves[i]['stories'] as List? ?? [];
|
|
setState(() {
|
|
_newBookIndex = stories.length - 1;
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
Future.delayed(const Duration(milliseconds: 800), () {
|
|
if (mounted) setState(() => _newBookIndex = null);
|
|
});
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Save story failed: $e');
|
|
if (mounted) AppToast.show(context, '保存失败: ${e.toString()}');
|
|
}
|
|
}
|
|
}
|
|
|
|
/// New Book Animation Widget matching PRD
|
|
/// PRD: animation: bookPop 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards
|
|
/// Plus magic particle effect with sparkleFloat animation
|
|
class _NewBookAnimation extends StatefulWidget {
|
|
final Widget child;
|
|
|
|
const _NewBookAnimation({required this.child});
|
|
|
|
@override
|
|
State<_NewBookAnimation> createState() => _NewBookAnimationState();
|
|
}
|
|
|
|
class _NewBookAnimationState extends State<_NewBookAnimation>
|
|
with TickerProviderStateMixin {
|
|
late AnimationController _popController;
|
|
late AnimationController _particleController;
|
|
late Animation<double> _scaleAnimation;
|
|
late Animation<double> _opacityAnimation;
|
|
|
|
// PRD: 20 particles with random angles/distances
|
|
final List<_Particle> _particles = [];
|
|
|
|
// PRD particle colors: [#FFD700, #FF6B6B, #4ECDC4, #A78BFA, #FCD34D]
|
|
static const List<Color> _particleColors = [
|
|
Color(0xFFFFD700), // Gold
|
|
Color(0xFFFF6B6B), // Coral
|
|
Color(0xFF4ECDC4), // Teal
|
|
Color(0xFFA78BFA), // Purple
|
|
Color(0xFFFCD34D), // Yellow
|
|
];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
// PRD: bookPop 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275)
|
|
_popController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 600),
|
|
);
|
|
|
|
// PRD: sparkleFloat 0.8s
|
|
_particleController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 800),
|
|
);
|
|
|
|
// PRD cubic-bezier(0.175, 0.885, 0.32, 1.275) - overshoot curve
|
|
const prdCurve = Cubic(0.175, 0.885, 0.32, 1.275);
|
|
|
|
_scaleAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
|
CurvedAnimation(parent: _popController, curve: prdCurve),
|
|
);
|
|
|
|
_opacityAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
|
CurvedAnimation(
|
|
parent: _popController,
|
|
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
|
|
),
|
|
);
|
|
|
|
// Generate 20 particles with random properties
|
|
_generateParticles();
|
|
|
|
// Start animations
|
|
_popController.forward();
|
|
_particleController.forward();
|
|
}
|
|
|
|
void _generateParticles() {
|
|
final random = Random();
|
|
for (int i = 0; i < 20; i++) {
|
|
// PRD: random angle 0-360, distance 50-100px, size 5-10px
|
|
final angle = random.nextDouble() * 2 * pi; // 0-360 degrees in radians
|
|
final distance = 50.0 + random.nextDouble() * 50; // 50-100px
|
|
final size = 5.0 + random.nextDouble() * 5; // 5-10px
|
|
final colorIndex = random.nextInt(_particleColors.length);
|
|
final delay = random.nextDouble() * 0.3; // 0-0.3s delay
|
|
|
|
_particles.add(_Particle(
|
|
angle: angle,
|
|
distance: distance,
|
|
size: size,
|
|
color: _particleColors[colorIndex],
|
|
delay: delay,
|
|
));
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_popController.dispose();
|
|
_particleController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedBuilder(
|
|
animation: Listenable.merge([_popController, _particleController]),
|
|
builder: (context, child) {
|
|
return Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
// Main book with pop animation
|
|
Transform.scale(
|
|
scale: _scaleAnimation.value,
|
|
child: Opacity(
|
|
opacity: _opacityAnimation.value.clamp(0.0, 1.0),
|
|
child: widget.child,
|
|
),
|
|
),
|
|
|
|
// Magic particles overlay
|
|
..._particles.map((particle) {
|
|
// PRD sparkleFloat: 0% scale(0) opacity(0), 50% opacity(1), 100% scale(0) opacity(0)
|
|
final progress = _particleController.value;
|
|
final adjustedProgress =
|
|
((progress - particle.delay) / (1 - particle.delay))
|
|
.clamp(0.0, 1.0);
|
|
|
|
// Calculate opacity: 0 -> 1 -> 0
|
|
double opacity;
|
|
if (adjustedProgress < 0.5) {
|
|
opacity = adjustedProgress * 2;
|
|
} else {
|
|
opacity = (1 - adjustedProgress) * 2;
|
|
}
|
|
|
|
// Calculate scale: 0 -> 1 -> 0
|
|
double scale;
|
|
if (adjustedProgress < 0.5) {
|
|
scale = adjustedProgress * 2;
|
|
} else {
|
|
scale = (1 - adjustedProgress) * 2;
|
|
}
|
|
|
|
// Calculate position using proper trigonometry
|
|
// Particles radiate outward from center
|
|
final dx = cos(particle.angle) * particle.distance * adjustedProgress;
|
|
final dy = sin(particle.angle) * particle.distance * adjustedProgress;
|
|
|
|
return Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
child: Center(
|
|
child: Transform.translate(
|
|
offset: Offset(dx, dy),
|
|
child: Transform.scale(
|
|
scale: scale,
|
|
child: Opacity(
|
|
opacity: opacity.clamp(0.0, 1.0),
|
|
child: Container(
|
|
width: particle.size,
|
|
height: particle.size,
|
|
decoration: BoxDecoration(
|
|
color: particle.color,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Particle {
|
|
final double angle;
|
|
final double distance;
|
|
final double size;
|
|
final Color color;
|
|
final double delay;
|
|
|
|
_Particle({
|
|
required this.angle,
|
|
required this.distance,
|
|
required this.size,
|
|
required this.color,
|
|
required this.delay,
|
|
});
|
|
}
|