feat: music-creation page + MiniMax API integration + Flutter dev setup
Music Creation Page: - Vinyl 3D flip to view lyrics, tonearm animation, glow rotation effect - Circular SVG progress ring, speech bubble feedback, confirm dialog - Playlist modal, free creation input, lyrics formatting optimization - MiniMax API real music generation with SSE streaming progress Backend: - FastAPI proxy server.py for MiniMax API calls - Music + lyrics file persistence to Capybara music/ directory - GET /api/playlist endpoint for auto-building playlist from files UI/UX Refinements: - frontend-design skill compliance across all pages - Glassmorphism effects, modal interactions, scroll tap prevention - iPhone 12 Pro responsive layout (390x844) Flutter Development Preparation: - Installed flutter-expert skill with 6 reference docs - Added 5 Cursor Rules: official Flutter, clean architecture, UI performance, testing, Dart standards Assets: - 9 Capybara music MP3 files + lyrics TXT files - MiniMax API documentation Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
03ab89a8f2
commit
066eb8f820
82
.agents/skills/flutter-expert/SKILL.md
Normal file
82
.agents/skills/flutter-expert/SKILL.md
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
name: flutter-expert
|
||||||
|
description: Use when building cross-platform applications with Flutter 3+ and Dart. Invoke for widget development, Riverpod/Bloc state management, GoRouter navigation, platform-specific implementations, performance optimization.
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: https://github.com/Jeffallan
|
||||||
|
version: "1.0.0"
|
||||||
|
domain: frontend
|
||||||
|
triggers: Flutter, Dart, widget, Riverpod, Bloc, GoRouter, cross-platform
|
||||||
|
role: specialist
|
||||||
|
scope: implementation
|
||||||
|
output-format: code
|
||||||
|
related-skills: react-native-expert, test-master, fullstack-guardian
|
||||||
|
---
|
||||||
|
|
||||||
|
# Flutter Expert
|
||||||
|
|
||||||
|
Senior mobile engineer building high-performance cross-platform applications with Flutter 3 and Dart.
|
||||||
|
|
||||||
|
## Role Definition
|
||||||
|
|
||||||
|
You are a senior Flutter developer with 6+ years of experience. You specialize in Flutter 3.19+, Riverpod 2.0, GoRouter, and building apps for iOS, Android, Web, and Desktop. You write performant, maintainable Dart code with proper state management.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
- Building cross-platform Flutter applications
|
||||||
|
- Implementing state management (Riverpod, Bloc)
|
||||||
|
- Setting up navigation with GoRouter
|
||||||
|
- Creating custom widgets and animations
|
||||||
|
- Optimizing Flutter performance
|
||||||
|
- Platform-specific implementations
|
||||||
|
|
||||||
|
## Core Workflow
|
||||||
|
|
||||||
|
1. **Setup** - Project structure, dependencies, routing
|
||||||
|
2. **State** - Riverpod providers or Bloc setup
|
||||||
|
3. **Widgets** - Reusable, const-optimized components
|
||||||
|
4. **Test** - Widget tests, integration tests
|
||||||
|
5. **Optimize** - Profile, reduce rebuilds
|
||||||
|
|
||||||
|
## Reference Guide
|
||||||
|
|
||||||
|
Load detailed guidance based on context:
|
||||||
|
|
||||||
|
| Topic | Reference | Load When |
|
||||||
|
|-------|-----------|-----------|
|
||||||
|
| Riverpod | `references/riverpod-state.md` | State management, providers, notifiers |
|
||||||
|
| Bloc | `references/bloc-state.md` | Bloc, Cubit, event-driven state, complex business logic |
|
||||||
|
| GoRouter | `references/gorouter-navigation.md` | Navigation, routing, deep linking |
|
||||||
|
| Widgets | `references/widget-patterns.md` | Building UI components, const optimization |
|
||||||
|
| Structure | `references/project-structure.md` | Setting up project, architecture |
|
||||||
|
| Performance | `references/performance.md` | Optimization, profiling, jank fixes |
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
### MUST DO
|
||||||
|
- Use const constructors wherever possible
|
||||||
|
- Implement proper keys for lists
|
||||||
|
- Use Consumer/ConsumerWidget for state (not StatefulWidget)
|
||||||
|
- Follow Material/Cupertino design guidelines
|
||||||
|
- Profile with DevTools, fix jank
|
||||||
|
- Test widgets with flutter_test
|
||||||
|
|
||||||
|
### MUST NOT DO
|
||||||
|
- Build widgets inside build() method
|
||||||
|
- Mutate state directly (always create new instances)
|
||||||
|
- Use setState for app-wide state
|
||||||
|
- Skip const on static widgets
|
||||||
|
- Ignore platform-specific behavior
|
||||||
|
- Block UI thread with heavy computation (use compute())
|
||||||
|
|
||||||
|
## Output Templates
|
||||||
|
|
||||||
|
When implementing Flutter features, provide:
|
||||||
|
1. Widget code with proper const usage
|
||||||
|
2. Provider/Bloc definitions
|
||||||
|
3. Route configuration if needed
|
||||||
|
4. Test file structure
|
||||||
|
|
||||||
|
## Knowledge Reference
|
||||||
|
|
||||||
|
Flutter 3.19+, Dart 3.3+, Riverpod 2.0, Bloc 8.x, GoRouter, freezed, json_serializable, Dio, flutter_hooks
|
||||||
259
.agents/skills/flutter-expert/references/bloc-state.md
Normal file
259
.agents/skills/flutter-expert/references/bloc-state.md
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
# Bloc State Management
|
||||||
|
|
||||||
|
## When to Use Bloc
|
||||||
|
|
||||||
|
Use **Bloc/Cubit** when you need:
|
||||||
|
|
||||||
|
* Explicit event → state transitions
|
||||||
|
* Complex business logic
|
||||||
|
* Predictable, testable flows
|
||||||
|
* Clear separation between UI and logic
|
||||||
|
|
||||||
|
| Use Case | Recommended |
|
||||||
|
| ---------------------- | ----------- |
|
||||||
|
| Simple mutable state | Riverpod |
|
||||||
|
| Event-driven workflows | Bloc |
|
||||||
|
| Forms, auth, wizards | Bloc |
|
||||||
|
| Feature modules | Bloc |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
| Concept | Description |
|
||||||
|
| ------- | ---------------------- |
|
||||||
|
| Event | User/system input |
|
||||||
|
| State | Immutable UI state |
|
||||||
|
| Bloc | Event → State mapper |
|
||||||
|
| Cubit | State-only (no events) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Basic Bloc Setup
|
||||||
|
|
||||||
|
### Event
|
||||||
|
|
||||||
|
```dart
|
||||||
|
sealed class CounterEvent {}
|
||||||
|
|
||||||
|
final class CounterIncremented extends CounterEvent {}
|
||||||
|
|
||||||
|
final class CounterDecremented extends CounterEvent {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### State
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class CounterState {
|
||||||
|
final int value;
|
||||||
|
|
||||||
|
const CounterState({required this.value});
|
||||||
|
|
||||||
|
CounterState copyWith({int? value}) {
|
||||||
|
return CounterState(value: value ?? this.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bloc
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
class CounterBloc extends Bloc<CounterEvent, CounterState> {
|
||||||
|
CounterBloc() : super(const CounterState(value: 0)) {
|
||||||
|
on<CounterIncremented>((event, emit) {
|
||||||
|
emit(state.copyWith(value: state.value + 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
on<CounterDecremented>((event, emit) {
|
||||||
|
emit(state.copyWith(value: state.value - 1));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cubit (Recommended for Simpler Logic)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class CounterCubit extends Cubit<int> {
|
||||||
|
CounterCubit() : super(0);
|
||||||
|
|
||||||
|
void increment() => emit(state + 1);
|
||||||
|
void decrement() => emit(state - 1);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Providing Bloc to the Widget Tree
|
||||||
|
|
||||||
|
```dart
|
||||||
|
BlocProvider(
|
||||||
|
create: (_) => CounterBloc(),
|
||||||
|
child: const CounterScreen(),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple blocs:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
MultiBlocProvider(
|
||||||
|
providers: [
|
||||||
|
BlocProvider(create: (_) => AuthBloc()),
|
||||||
|
BlocProvider(create: (_) => ProfileBloc()),
|
||||||
|
],
|
||||||
|
child: const AppRoot(),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using Bloc in Widgets
|
||||||
|
|
||||||
|
### BlocBuilder (UI rebuilds)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class CounterScreen extends StatelessWidget {
|
||||||
|
const CounterScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocBuilder<CounterBloc, CounterState>(
|
||||||
|
buildWhen: (prev, curr) => prev.value != curr.value,
|
||||||
|
builder: (context, state) {
|
||||||
|
return Text(
|
||||||
|
state.value.toString(),
|
||||||
|
style: Theme.of(context).textTheme.displayLarge,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BlocListener (Side Effects)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
BlocListener<AuthBloc, AuthState>(
|
||||||
|
listenWhen: (prev, curr) => curr is AuthFailure,
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state is AuthFailure) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text(state.message)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const LoginForm(),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BlocConsumer (Builder + Listener)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
BlocConsumer<FormBloc, FormState>(
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state.status == FormStatus.success) {
|
||||||
|
context.pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
builder: (context, state) {
|
||||||
|
return ElevatedButton(
|
||||||
|
onPressed: state.isValid
|
||||||
|
? () => context.read<FormBloc>().add(FormSubmitted())
|
||||||
|
: null,
|
||||||
|
child: const Text('Submit'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accessing Bloc Without Rebuilds
|
||||||
|
|
||||||
|
```dart
|
||||||
|
context.read<CounterBloc>().add(CounterIncremented());
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **Never use `watch` inside callbacks**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Async Bloc Pattern (API Calls)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
on<UserRequested>((event, emit) async {
|
||||||
|
emit(const UserState.loading());
|
||||||
|
|
||||||
|
try {
|
||||||
|
final user = await repository.fetchUser();
|
||||||
|
emit(UserState.success(user));
|
||||||
|
} catch (e) {
|
||||||
|
emit(UserState.failure(e.toString()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bloc + GoRouter (Auth Guard Example)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
redirect: (context, state) {
|
||||||
|
final authState = context.read<AuthBloc>().state;
|
||||||
|
|
||||||
|
if (authState is Unauthenticated) {
|
||||||
|
return '/login';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Bloc
|
||||||
|
|
||||||
|
```dart
|
||||||
|
blocTest<CounterBloc, CounterState>(
|
||||||
|
'emits incremented value',
|
||||||
|
build: () => CounterBloc(),
|
||||||
|
act: (bloc) => bloc.add(CounterIncremented()),
|
||||||
|
expect: () => [
|
||||||
|
const CounterState(value: 1),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices (MUST FOLLOW)
|
||||||
|
|
||||||
|
✅ Immutable states
|
||||||
|
✅ Small, focused blocs
|
||||||
|
✅ One feature = one bloc
|
||||||
|
✅ Use Cubit when possible
|
||||||
|
✅ Test all blocs
|
||||||
|
|
||||||
|
❌ No UI logic inside blocs
|
||||||
|
❌ No context usage inside blocs
|
||||||
|
❌ No mutable state
|
||||||
|
❌ No massive “god blocs”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Widget | Purpose |
|
||||||
|
| ----------------- | -------------------- |
|
||||||
|
| BlocBuilder | UI rebuild |
|
||||||
|
| BlocListener | Side effects |
|
||||||
|
| BlocConsumer | Both |
|
||||||
|
| BlocProvider | Dependency injection |
|
||||||
|
| MultiBlocProvider | Multiple blocs |
|
||||||
|
|
||||||
119
.agents/skills/flutter-expert/references/gorouter-navigation.md
Normal file
119
.agents/skills/flutter-expert/references/gorouter-navigation.md
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
# GoRouter Navigation
|
||||||
|
|
||||||
|
## Basic Setup
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
final goRouter = GoRouter(
|
||||||
|
initialLocation: '/',
|
||||||
|
redirect: (context, state) {
|
||||||
|
final isLoggedIn = /* check auth */;
|
||||||
|
if (!isLoggedIn && !state.matchedLocation.startsWith('/auth')) {
|
||||||
|
return '/auth/login';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/',
|
||||||
|
builder: (context, state) => const HomeScreen(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: 'details/:id',
|
||||||
|
builder: (context, state) {
|
||||||
|
final id = state.pathParameters['id']!;
|
||||||
|
return DetailsScreen(id: id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/auth/login',
|
||||||
|
builder: (context, state) => const LoginScreen(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// In app.dart
|
||||||
|
class MyApp extends StatelessWidget {
|
||||||
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp.router(
|
||||||
|
routerConfig: goRouter,
|
||||||
|
theme: AppTheme.light,
|
||||||
|
darkTheme: AppTheme.dark,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Navigation Methods
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Navigate and replace history
|
||||||
|
context.go('/details/123');
|
||||||
|
|
||||||
|
// Navigate and add to stack
|
||||||
|
context.push('/details/123');
|
||||||
|
|
||||||
|
// Go back
|
||||||
|
context.pop();
|
||||||
|
|
||||||
|
// Replace current route
|
||||||
|
context.pushReplacement('/home');
|
||||||
|
|
||||||
|
// Navigate with extra data
|
||||||
|
context.push('/details/123', extra: {'title': 'Item'});
|
||||||
|
|
||||||
|
// Access extra in destination
|
||||||
|
final extra = GoRouterState.of(context).extra as Map<String, dynamic>?;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shell Routes (Persistent UI)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final goRouter = GoRouter(
|
||||||
|
routes: [
|
||||||
|
ShellRoute(
|
||||||
|
builder: (context, state, child) {
|
||||||
|
return ScaffoldWithNavBar(child: child);
|
||||||
|
},
|
||||||
|
routes: [
|
||||||
|
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
||||||
|
GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen()),
|
||||||
|
GoRoute(path: '/settings', builder: (_, __) => const SettingsScreen()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Query Parameters
|
||||||
|
|
||||||
|
```dart
|
||||||
|
GoRoute(
|
||||||
|
path: '/search',
|
||||||
|
builder: (context, state) {
|
||||||
|
final query = state.uri.queryParameters['q'] ?? '';
|
||||||
|
final page = int.tryParse(state.uri.queryParameters['page'] ?? '1') ?? 1;
|
||||||
|
return SearchScreen(query: query, page: page);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// Navigate with query params
|
||||||
|
context.go('/search?q=flutter&page=2');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Method | Behavior |
|
||||||
|
|--------|----------|
|
||||||
|
| `context.go()` | Navigate, replace stack |
|
||||||
|
| `context.push()` | Navigate, add to stack |
|
||||||
|
| `context.pop()` | Go back |
|
||||||
|
| `context.pushReplacement()` | Replace current |
|
||||||
|
| `:param` | Path parameter |
|
||||||
|
| `?key=value` | Query parameter |
|
||||||
99
.agents/skills/flutter-expert/references/performance.md
Normal file
99
.agents/skills/flutter-expert/references/performance.md
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
# Performance Optimization
|
||||||
|
|
||||||
|
## Profiling Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run in profile mode
|
||||||
|
flutter run --profile
|
||||||
|
|
||||||
|
# Analyze performance
|
||||||
|
flutter analyze
|
||||||
|
|
||||||
|
# DevTools
|
||||||
|
flutter pub global activate devtools
|
||||||
|
flutter pub global run devtools
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Optimizations
|
||||||
|
|
||||||
|
### Const Widgets
|
||||||
|
```dart
|
||||||
|
// ❌ Rebuilds every time
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.all(16), // Creates new object
|
||||||
|
child: Text('Hello'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Const prevents rebuilds
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: const Text('Hello'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Selective Provider Watching
|
||||||
|
```dart
|
||||||
|
// ❌ Rebuilds on any user change
|
||||||
|
final user = ref.watch(userProvider);
|
||||||
|
return Text(user.name);
|
||||||
|
|
||||||
|
// ✅ Only rebuilds when name changes
|
||||||
|
final name = ref.watch(userProvider.select((u) => u.name));
|
||||||
|
return Text(name);
|
||||||
|
```
|
||||||
|
|
||||||
|
### RepaintBoundary
|
||||||
|
```dart
|
||||||
|
// Isolate expensive widgets
|
||||||
|
RepaintBoundary(
|
||||||
|
child: ComplexAnimatedWidget(),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Image Optimization
|
||||||
|
```dart
|
||||||
|
// Use cached_network_image
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: url,
|
||||||
|
placeholder: (_, __) => const CircularProgressIndicator(),
|
||||||
|
errorWidget: (_, __, ___) => const Icon(Icons.error),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resize images
|
||||||
|
Image.network(
|
||||||
|
url,
|
||||||
|
cacheWidth: 200, // Resize in memory
|
||||||
|
cacheHeight: 200,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compute for Heavy Operations
|
||||||
|
```dart
|
||||||
|
// ❌ Blocks UI thread
|
||||||
|
final result = heavyComputation(data);
|
||||||
|
|
||||||
|
// ✅ Runs in isolate
|
||||||
|
final result = await compute(heavyComputation, data);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Checklist
|
||||||
|
|
||||||
|
| Check | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| Unnecessary rebuilds | Add `const`, use `select()` |
|
||||||
|
| Large lists | Use `ListView.builder` |
|
||||||
|
| Image loading | Use `cached_network_image` |
|
||||||
|
| Heavy computation | Use `compute()` |
|
||||||
|
| Jank in animations | Use `RepaintBoundary` |
|
||||||
|
| Memory leaks | Dispose controllers |
|
||||||
|
|
||||||
|
## DevTools Metrics
|
||||||
|
|
||||||
|
- **Frame rendering time**: < 16ms for 60fps
|
||||||
|
- **Widget rebuilds**: Minimize unnecessary rebuilds
|
||||||
|
- **Memory usage**: Watch for leaks
|
||||||
|
- **CPU profiler**: Identify bottlenecks
|
||||||
118
.agents/skills/flutter-expert/references/project-structure.md
Normal file
118
.agents/skills/flutter-expert/references/project-structure.md
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
# Project Structure
|
||||||
|
|
||||||
|
## Feature-Based Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── main.dart
|
||||||
|
├── app.dart
|
||||||
|
├── core/
|
||||||
|
│ ├── constants/
|
||||||
|
│ │ ├── colors.dart
|
||||||
|
│ │ └── strings.dart
|
||||||
|
│ ├── theme/
|
||||||
|
│ │ ├── app_theme.dart
|
||||||
|
│ │ └── text_styles.dart
|
||||||
|
│ ├── utils/
|
||||||
|
│ │ ├── extensions.dart
|
||||||
|
│ │ └── validators.dart
|
||||||
|
│ └── errors/
|
||||||
|
│ └── failures.dart
|
||||||
|
├── features/
|
||||||
|
│ ├── auth/
|
||||||
|
│ │ ├── data/
|
||||||
|
│ │ │ ├── repositories/
|
||||||
|
│ │ │ └── datasources/
|
||||||
|
│ │ ├── domain/
|
||||||
|
│ │ │ ├── entities/
|
||||||
|
│ │ │ └── usecases/
|
||||||
|
│ │ ├── presentation/
|
||||||
|
│ │ │ ├── screens/
|
||||||
|
│ │ │ └── widgets/
|
||||||
|
│ │ └── providers/
|
||||||
|
│ │ └── auth_provider.dart
|
||||||
|
│ └── home/
|
||||||
|
│ ├── data/
|
||||||
|
│ ├── domain/
|
||||||
|
│ ├── presentation/
|
||||||
|
│ └── providers/
|
||||||
|
├── shared/
|
||||||
|
│ ├── widgets/
|
||||||
|
│ │ ├── buttons/
|
||||||
|
│ │ ├── inputs/
|
||||||
|
│ │ └── cards/
|
||||||
|
│ ├── services/
|
||||||
|
│ │ ├── api_service.dart
|
||||||
|
│ │ └── storage_service.dart
|
||||||
|
│ └── models/
|
||||||
|
│ └── user.dart
|
||||||
|
└── routes/
|
||||||
|
└── app_router.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
## pubspec.yaml Essentials
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
# State Management
|
||||||
|
flutter_riverpod: ^2.5.0
|
||||||
|
riverpod_annotation: ^2.3.0
|
||||||
|
# Navigation
|
||||||
|
go_router: ^14.0.0
|
||||||
|
# Networking
|
||||||
|
dio: ^5.4.0
|
||||||
|
# Code Generation
|
||||||
|
freezed_annotation: ^2.4.0
|
||||||
|
json_annotation: ^4.8.0
|
||||||
|
# Storage
|
||||||
|
shared_preferences: ^2.2.0
|
||||||
|
hive_flutter: ^1.1.0
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
build_runner: ^2.4.0
|
||||||
|
riverpod_generator: ^2.4.0
|
||||||
|
freezed: ^2.5.0
|
||||||
|
json_serializable: ^6.8.0
|
||||||
|
flutter_lints: ^4.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feature Layer Responsibilities
|
||||||
|
|
||||||
|
| Layer | Responsibility |
|
||||||
|
|-------|----------------|
|
||||||
|
| **data/** | API calls, local storage, DTOs |
|
||||||
|
| **domain/** | Business logic, entities, use cases |
|
||||||
|
| **presentation/** | UI screens, widgets |
|
||||||
|
| **providers/** | Riverpod providers for feature |
|
||||||
|
|
||||||
|
## Main Entry Point
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// main.dart
|
||||||
|
void main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await Hive.initFlutter();
|
||||||
|
runApp(const ProviderScope(child: MyApp()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// app.dart
|
||||||
|
class MyApp extends ConsumerWidget {
|
||||||
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final router = ref.watch(routerProvider);
|
||||||
|
|
||||||
|
return MaterialApp.router(
|
||||||
|
routerConfig: router,
|
||||||
|
theme: AppTheme.light,
|
||||||
|
darkTheme: AppTheme.dark,
|
||||||
|
themeMode: ThemeMode.system,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
130
.agents/skills/flutter-expert/references/riverpod-state.md
Normal file
130
.agents/skills/flutter-expert/references/riverpod-state.md
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
# Riverpod State Management
|
||||||
|
|
||||||
|
## Provider Types
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
// Simple state
|
||||||
|
final counterProvider = StateProvider<int>((ref) => 0);
|
||||||
|
|
||||||
|
// Async state (API calls)
|
||||||
|
final usersProvider = FutureProvider<List<User>>((ref) async {
|
||||||
|
final api = ref.read(apiProvider);
|
||||||
|
return api.getUsers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stream state (real-time)
|
||||||
|
final messagesProvider = StreamProvider<List<Message>>((ref) {
|
||||||
|
return ref.read(chatServiceProvider).messagesStream;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notifier Pattern (Riverpod 2.0)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@riverpod
|
||||||
|
class TodoList extends _$TodoList {
|
||||||
|
@override
|
||||||
|
List<Todo> build() => [];
|
||||||
|
|
||||||
|
void add(Todo todo) {
|
||||||
|
state = [...state, todo];
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggle(String id) {
|
||||||
|
state = [
|
||||||
|
for (final todo in state)
|
||||||
|
if (todo.id == id) todo.copyWith(completed: !todo.completed) else todo,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
void remove(String id) {
|
||||||
|
state = state.where((t) => t.id != id).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async Notifier
|
||||||
|
@riverpod
|
||||||
|
class UserProfile extends _$UserProfile {
|
||||||
|
@override
|
||||||
|
Future<User> build() async {
|
||||||
|
return ref.read(apiProvider).getCurrentUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateName(String name) async {
|
||||||
|
state = const AsyncValue.loading();
|
||||||
|
state = await AsyncValue.guard(() async {
|
||||||
|
final updated = await ref.read(apiProvider).updateUser(name: name);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage in Widgets
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// ConsumerWidget (recommended)
|
||||||
|
class TodoScreen extends ConsumerWidget {
|
||||||
|
const TodoScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final todos = ref.watch(todoListProvider);
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: todos.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final todo = todos[index];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(todo.title),
|
||||||
|
leading: Checkbox(
|
||||||
|
value: todo.completed,
|
||||||
|
onChanged: (_) => ref.read(todoListProvider.notifier).toggle(todo.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selective rebuilds with select
|
||||||
|
class UserAvatar extends ConsumerWidget {
|
||||||
|
const UserAvatar({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final avatarUrl = ref.watch(userProvider.select((u) => u?.avatarUrl));
|
||||||
|
|
||||||
|
return CircleAvatar(
|
||||||
|
backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async state handling
|
||||||
|
class UserProfileScreen extends ConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final userAsync = ref.watch(userProfileProvider);
|
||||||
|
|
||||||
|
return userAsync.when(
|
||||||
|
data: (user) => Text(user.name),
|
||||||
|
loading: () => const CircularProgressIndicator(),
|
||||||
|
error: (err, stack) => Text('Error: $err'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Provider | Use Case |
|
||||||
|
|----------|----------|
|
||||||
|
| `Provider` | Computed/derived values |
|
||||||
|
| `StateProvider` | Simple mutable state |
|
||||||
|
| `FutureProvider` | Async operations (one-time) |
|
||||||
|
| `StreamProvider` | Real-time data streams |
|
||||||
|
| `NotifierProvider` | Complex state with methods |
|
||||||
|
| `AsyncNotifierProvider` | Async state with methods |
|
||||||
123
.agents/skills/flutter-expert/references/widget-patterns.md
Normal file
123
.agents/skills/flutter-expert/references/widget-patterns.md
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
# Widget Patterns
|
||||||
|
|
||||||
|
## Optimized Widget Pattern
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Use const constructors
|
||||||
|
class OptimizedCard extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const OptimizedCard({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Responsive Layout
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class ResponsiveLayout extends StatelessWidget {
|
||||||
|
final Widget mobile;
|
||||||
|
final Widget? tablet;
|
||||||
|
final Widget desktop;
|
||||||
|
|
||||||
|
const ResponsiveLayout({
|
||||||
|
super.key,
|
||||||
|
required this.mobile,
|
||||||
|
this.tablet,
|
||||||
|
required this.desktop,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
if (constraints.maxWidth >= 1100) return desktop;
|
||||||
|
if (constraints.maxWidth >= 650) return tablet ?? mobile;
|
||||||
|
return mobile;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Hooks (flutter_hooks)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
|
||||||
|
class CounterWidget extends HookWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final counter = useState(0);
|
||||||
|
final controller = useTextEditingController();
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
// Setup
|
||||||
|
return () {
|
||||||
|
// Cleanup
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Text('Count: ${counter.value}'),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => counter.value++,
|
||||||
|
child: const Text('Increment'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sliver Patterns
|
||||||
|
|
||||||
|
```dart
|
||||||
|
CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverAppBar(
|
||||||
|
expandedHeight: 200,
|
||||||
|
pinned: true,
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
title: const Text('Title'),
|
||||||
|
background: Image.network(imageUrl, fit: BoxFit.cover),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) => ListTile(title: Text('Item $index')),
|
||||||
|
childCount: 100,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Optimization Patterns
|
||||||
|
|
||||||
|
| Pattern | Implementation |
|
||||||
|
|---------|----------------|
|
||||||
|
| **const widgets** | Add `const` to static widgets |
|
||||||
|
| **keys** | Use `Key` for list items |
|
||||||
|
| **select** | `ref.watch(provider.select(...))` |
|
||||||
|
| **RepaintBoundary** | Isolate expensive repaints |
|
||||||
|
| **ListView.builder** | Lazy loading for lists |
|
||||||
|
| **const constructors** | Always use when possible |
|
||||||
70
.cursor/rules/dart-coding-standards.mdc
Normal file
70
.cursor/rules/dart-coding-standards.mdc
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
description: Dart language coding standards and conventions. Apply to ALL Dart files.
|
||||||
|
globs: "**/*.dart"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Dart Coding Standards
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
- PascalCase for classes, enums, typedefs, extensions.
|
||||||
|
- camelCase for variables, functions, methods, parameters.
|
||||||
|
- snake_case for file and directory names.
|
||||||
|
- SCREAMING_SNAKE_CASE for compile-time constants only when conventional.
|
||||||
|
- Start functions with a verb: fetchUser(), saveSettings(), isValid().
|
||||||
|
- Use boolean prefixes: isLoading, hasError, canDelete, shouldRefresh.
|
||||||
|
- Use complete words, avoid abbreviations (except API, URL, i/j for loops).
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
- Short, single purpose, < 20 lines.
|
||||||
|
- Arrow syntax for simple one-line functions.
|
||||||
|
- Named parameters for > 2 parameters.
|
||||||
|
- Default parameter values instead of null checks.
|
||||||
|
- Reduce parameters using parameter objects (RO-RO pattern).
|
||||||
|
- Single level of abstraction per function.
|
||||||
|
- Early return to avoid deep nesting.
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
- Follow SOLID principles.
|
||||||
|
- Prefer composition over inheritance.
|
||||||
|
- Small classes: < 200 lines, < 10 public methods, < 10 properties.
|
||||||
|
- Declare interfaces (abstract classes) for contracts/repositories.
|
||||||
|
- Use factory constructors for complex initialization.
|
||||||
|
|
||||||
|
## Data & Immutability
|
||||||
|
- Prefer immutable data structures.
|
||||||
|
- Use final for fields that don't change.
|
||||||
|
- Use const for compile-time constants.
|
||||||
|
- Encapsulate data in composite types (avoid primitive obsession).
|
||||||
|
- Use Freezed for complex immutable state classes.
|
||||||
|
|
||||||
|
## Null Safety
|
||||||
|
- Write soundly null-safe code.
|
||||||
|
- Avoid ! operator unless value is guaranteed non-null.
|
||||||
|
- Use ?. and ?? operators appropriately.
|
||||||
|
- Prefer required named parameters over nullable ones.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
- Use try-catch for expected exceptions.
|
||||||
|
- Create custom exception classes for domain-specific errors.
|
||||||
|
- Never catch generic Exception without rethrowing or logging.
|
||||||
|
- Use Either<Failure, T> for functional error handling in repositories.
|
||||||
|
|
||||||
|
## Async
|
||||||
|
- Use async/await (not .then() chains).
|
||||||
|
- Use Future for single async operations.
|
||||||
|
- Use Stream for sequences of async events.
|
||||||
|
- Handle errors in every async operation.
|
||||||
|
- Cancel subscriptions in dispose().
|
||||||
|
|
||||||
|
## Code Organization
|
||||||
|
- One primary export per file.
|
||||||
|
- Group related libraries in the same folder.
|
||||||
|
- Use part/part of sparingly; prefer separate files.
|
||||||
|
- Import order: dart:, package:, relative imports.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
- /// for all public API doc comments.
|
||||||
|
- First line: concise summary ending with period.
|
||||||
|
- Explain WHY, not WHAT.
|
||||||
|
- Use backtick fences for code samples.
|
||||||
|
- Place doc comments before annotations.
|
||||||
73
.cursor/rules/flutter-clean-architecture.mdc
Normal file
73
.cursor/rules/flutter-clean-architecture.mdc
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
description: Clean Architecture + Feature-first + BLoC/Riverpod patterns for Flutter features. Apply to feature modules.
|
||||||
|
globs: "lib/features/**/*.dart"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Flutter Clean Architecture — Feature-First
|
||||||
|
|
||||||
|
## Architecture Layers (Dependency Rule: always inward)
|
||||||
|
- **Presentation** → Widgets, Screens, BLoC/Cubit, ViewModels
|
||||||
|
- **Domain** → Entities, Repository interfaces, Use Cases
|
||||||
|
- **Data** → Repository implementations, Data Sources, DTOs/Models
|
||||||
|
|
||||||
|
## Feature Directory Structure
|
||||||
|
```
|
||||||
|
lib/features/feature_name/
|
||||||
|
├── data/
|
||||||
|
│ ├── datasources/ # Remote + Local data sources
|
||||||
|
│ ├── models/ # DTOs, data models (with fromJson/toJson)
|
||||||
|
│ └── repositories/ # Repository implementations
|
||||||
|
├── domain/
|
||||||
|
│ ├── entities/ # Pure business objects
|
||||||
|
│ ├── repositories/ # Abstract repository interfaces
|
||||||
|
│ └── usecases/ # Single-purpose business logic
|
||||||
|
└── presentation/
|
||||||
|
├── bloc/ # BLoC/Cubit state management
|
||||||
|
├── pages/ # Screen widgets
|
||||||
|
└── widgets/ # Feature-specific widgets
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Case Pattern
|
||||||
|
```dart
|
||||||
|
abstract class UseCase<Type, Params> {
|
||||||
|
Future<Either<Failure, Type>> call(Params params);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
- Use Freezed for immutable state classes and union types.
|
||||||
|
- States: initial, loading, loaded(data), error(failure).
|
||||||
|
- Use BlocBuilder with buildWhen for optimized rebuilds.
|
||||||
|
- Use BlocListener for side effects (navigation, dialogs).
|
||||||
|
- Avoid business logic in UI components.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
- Use Either<Failure, Success> from Dartz for functional error handling.
|
||||||
|
- Create custom Failure classes per domain.
|
||||||
|
- Proper error mapping between layers.
|
||||||
|
- User-friendly error messages in presentation layer.
|
||||||
|
|
||||||
|
## Dependency Injection
|
||||||
|
- Use GetIt as service locator.
|
||||||
|
- Register dependencies by feature in separate files.
|
||||||
|
- Lazy initialization; factories for transient, singletons for services.
|
||||||
|
|
||||||
|
## Repository Pattern
|
||||||
|
- Single source of truth for data.
|
||||||
|
- Handle network/cache fallback gracefully.
|
||||||
|
- Map data models to domain entities at repository boundary.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
### MUST DO
|
||||||
|
- Use const constructors wherever possible.
|
||||||
|
- Implement proper keys for lists.
|
||||||
|
- Follow Material/Cupertino design guidelines.
|
||||||
|
- Profile with DevTools, fix jank.
|
||||||
|
- Test widgets with flutter_test.
|
||||||
|
|
||||||
|
### MUST NOT DO
|
||||||
|
- Build widgets inside build() method.
|
||||||
|
- Mutate state directly (always create new instances).
|
||||||
|
- Use setState for app-wide state.
|
||||||
|
- Block UI thread with heavy computation (use compute()).
|
||||||
|
- Skip const on static widgets.
|
||||||
80
.cursor/rules/flutter-official.mdc
Normal file
80
.cursor/rules/flutter-official.mdc
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
description: Flutter & Dart official best practices from Flutter team. Apply to ALL Flutter/Dart files.
|
||||||
|
globs: "**/*.dart"
|
||||||
|
---
|
||||||
|
|
||||||
|
# AI Rules for Flutter (Official — from docs.flutter.dev)
|
||||||
|
|
||||||
|
You are an expert in Flutter and Dart development. Build beautiful, performant, and maintainable applications following modern best practices.
|
||||||
|
|
||||||
|
## Flutter Style Guide
|
||||||
|
- **SOLID Principles:** Apply throughout the codebase.
|
||||||
|
- **Concise and Declarative:** Write modern, technical Dart code. Prefer functional and declarative patterns.
|
||||||
|
- **Composition over Inheritance:** Favor composition for building complex widgets and logic.
|
||||||
|
- **Immutability:** Prefer immutable data structures. Widgets (especially StatelessWidget) should be immutable.
|
||||||
|
- **State Management:** Separate ephemeral state and app state.
|
||||||
|
- **Navigation:** Use GoRouter or auto_route for routing and deep linking.
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
- Adhere to maintainable code structure and separation of concerns.
|
||||||
|
- Avoid abbreviations; use meaningful, descriptive names.
|
||||||
|
- Line length: 80 characters or fewer.
|
||||||
|
- Use PascalCase for classes, camelCase for members/variables, snake_case for files.
|
||||||
|
- Functions: short, single purpose, strive for less than 20 lines.
|
||||||
|
- Use `logging` package instead of `print`.
|
||||||
|
|
||||||
|
## Dart Best Practices
|
||||||
|
- Follow official Effective Dart guidelines.
|
||||||
|
- Add doc comments to all public APIs.
|
||||||
|
- Use async/await for asynchronous operations with robust error handling.
|
||||||
|
- Write code that is soundly null-safe. Avoid `!` unless guaranteed non-null.
|
||||||
|
- Use pattern matching features where they simplify code.
|
||||||
|
- Use records to return multiple types.
|
||||||
|
- Prefer exhaustive switch statements.
|
||||||
|
- Use arrow syntax for simple one-line functions.
|
||||||
|
|
||||||
|
## Flutter Best Practices
|
||||||
|
- Use `const` constructors whenever possible to reduce rebuilds.
|
||||||
|
- Use small, private Widget classes instead of helper methods returning Widget.
|
||||||
|
- Break down large build() methods into smaller, reusable private Widget classes.
|
||||||
|
- Use ListView.builder or SliverList for long lists (lazy loading).
|
||||||
|
- Use compute() for expensive calculations in separate isolate.
|
||||||
|
- Avoid performing expensive operations in build() methods.
|
||||||
|
|
||||||
|
## Application Architecture
|
||||||
|
- Separation of Concerns: MVC/MVVM with defined Model, View, ViewModel/Controller.
|
||||||
|
- Logical Layers: Presentation → Domain → Data → Core.
|
||||||
|
- Feature-based Organization for larger projects.
|
||||||
|
|
||||||
|
## State Management (Built-in preferred)
|
||||||
|
- Streams + StreamBuilder for sequences of async events.
|
||||||
|
- Futures + FutureBuilder for single async operations.
|
||||||
|
- ValueNotifier + ValueListenableBuilder for simple local state.
|
||||||
|
- ChangeNotifier for complex/shared state.
|
||||||
|
- MVVM when robust solution needed.
|
||||||
|
- Manual constructor dependency injection to keep dependencies explicit.
|
||||||
|
|
||||||
|
## Theming
|
||||||
|
- Define centralized ThemeData; implement light + dark themes.
|
||||||
|
- Use ColorScheme.fromSeed() for harmonious palettes.
|
||||||
|
- Use ThemeExtension for custom design tokens.
|
||||||
|
- Use google_fonts package for custom fonts.
|
||||||
|
- Follow 60-30-10 rule for color distribution.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- Unit tests (package:test), Widget tests (flutter_test), Integration tests (integration_test).
|
||||||
|
- Follow Arrange-Act-Assert pattern.
|
||||||
|
- Prefer fakes/stubs over mocks; use mockito/mocktail if necessary.
|
||||||
|
- Aim for high test coverage.
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
- Text contrast ratio ≥ 4.5:1.
|
||||||
|
- Test with dynamic text scaling.
|
||||||
|
- Use Semantics widget for descriptive labels.
|
||||||
|
- Test with TalkBack (Android) and VoiceOver (iOS).
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
- Use `///` for doc comments.
|
||||||
|
- Start with single-sentence summary.
|
||||||
|
- Comment WHY, not WHAT.
|
||||||
|
- Include code samples where appropriate.
|
||||||
43
.cursor/rules/flutter-testing.mdc
Normal file
43
.cursor/rules/flutter-testing.mdc
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
description: Flutter testing guidelines covering unit, widget, and integration tests.
|
||||||
|
globs: "test/**/*.dart"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Flutter Testing Rules
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
- Follow Arrange-Act-Assert (Given-When-Then) pattern.
|
||||||
|
- Name test variables clearly: inputX, mockX, actualX, expectedX.
|
||||||
|
- Mirror lib/ directory structure in test/.
|
||||||
|
- One test file per source file.
|
||||||
|
|
||||||
|
## Unit Tests
|
||||||
|
- Write unit tests for all domain logic and use cases.
|
||||||
|
- Test repository implementations with mock data sources.
|
||||||
|
- Test BLoC/Cubit state transitions thoroughly.
|
||||||
|
- Use package:test for pure Dart unit tests.
|
||||||
|
|
||||||
|
## Widget Tests
|
||||||
|
- Use package:flutter_test for widget tests.
|
||||||
|
- Test rendering, interaction, and state changes.
|
||||||
|
- Use pumpWidget() and pump() for async rendering.
|
||||||
|
- Use find.byType, find.byKey, find.text for assertions.
|
||||||
|
- Verify widget rebuilds correctly with different states.
|
||||||
|
|
||||||
|
## Integration Tests
|
||||||
|
- Use package:integration_test for end-to-end flows.
|
||||||
|
- Test critical user journeys (login, navigation, data flow).
|
||||||
|
- Add integration_test as dev_dependency with sdk: flutter.
|
||||||
|
|
||||||
|
## Mocking
|
||||||
|
- Prefer fakes and stubs over mocks.
|
||||||
|
- Use mockito or mocktail when mocks are necessary.
|
||||||
|
- Avoid code generation for mocks when possible.
|
||||||
|
- Mock external services (APIs, databases) at repository boundary.
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
- Aim for high test coverage on domain and data layers.
|
||||||
|
- Test error states and edge cases.
|
||||||
|
- Keep tests fast and independent.
|
||||||
|
- Use setUp() and tearDown() for common setup.
|
||||||
|
- Run tests in CI/CD pipeline.
|
||||||
52
.cursor/rules/flutter-ui-performance.mdc
Normal file
52
.cursor/rules/flutter-ui-performance.mdc
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
description: Flutter UI patterns, widget best practices, and performance optimization. Apply to all presentation-layer Dart files.
|
||||||
|
globs: "lib/**/{widgets,pages,screens,components}/**/*.dart"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Flutter UI & Performance Rules
|
||||||
|
|
||||||
|
## Widget Best Practices
|
||||||
|
- Create small, private widget classes instead of methods like `Widget _buildXxx()`.
|
||||||
|
- Use const constructors for all immutable widgets.
|
||||||
|
- Implement proper widget keys for lists and conditional widgets.
|
||||||
|
- Keep widget tree flat — avoid nesting deeper than necessary.
|
||||||
|
- Use composition: combine small widgets into complex ones.
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
- Use ListView.builder / GridView.builder for long scrollable lists.
|
||||||
|
- Use const wherever possible to skip unnecessary rebuilds.
|
||||||
|
- Avoid expensive operations (network calls, parsing) in build().
|
||||||
|
- Use compute() to run heavy work in a background isolate.
|
||||||
|
- Use RepaintBoundary to isolate expensive paint operations.
|
||||||
|
- Cache images: AssetImage for local, cached_network_image for remote.
|
||||||
|
- Profile regularly with Flutter DevTools; target 60fps.
|
||||||
|
|
||||||
|
## Responsive Design
|
||||||
|
- Use LayoutBuilder or MediaQuery to adapt to screen sizes.
|
||||||
|
- Use Expanded/Flexible in Row/Column to prevent overflow.
|
||||||
|
- Use Wrap for content that may overflow horizontally.
|
||||||
|
- Use FittedBox to scale a child within its parent.
|
||||||
|
|
||||||
|
## Animation Guidelines
|
||||||
|
- Use AnimationController + AnimatedBuilder for custom animations.
|
||||||
|
- Prefer implicit animations (AnimatedContainer, AnimatedOpacity) for simple cases.
|
||||||
|
- Use physics-based animations (SpringSimulation) for natural feel.
|
||||||
|
- Always dispose AnimationControllers in dispose().
|
||||||
|
|
||||||
|
## Theming in Widgets
|
||||||
|
- Access colors via Theme.of(context).colorScheme.
|
||||||
|
- Access text styles via Theme.of(context).textTheme.
|
||||||
|
- Never hardcode colors or font sizes — always reference theme.
|
||||||
|
- Use Theme.of(context).extension<T>() for custom design tokens.
|
||||||
|
|
||||||
|
## Forms & Input
|
||||||
|
- Set appropriate textCapitalization, keyboardType, textInputAction.
|
||||||
|
- Always include errorBuilder when using Image.network.
|
||||||
|
- Implement RefreshIndicator for pull-to-refresh.
|
||||||
|
- Use Form + GlobalKey<FormState> for validation.
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
- Use Semantics widget for all interactive elements.
|
||||||
|
- Ensure touch targets ≥ 48x48 dp.
|
||||||
|
- Test with screen readers (TalkBack / VoiceOver).
|
||||||
|
- Support dynamic type scaling.
|
||||||
17
.cursor/rules/user-preferences.mdc
Normal file
17
.cursor/rules/user-preferences.mdc
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
description: User preferences for communication and workflow
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# 用户偏好(必须遵守)
|
||||||
|
|
||||||
|
## 语言
|
||||||
|
- 永远使用中文回复,无论用户用什么语言输入(用户使用语音输入时系统可能自动翻译为英文)
|
||||||
|
|
||||||
|
## 沟通方式
|
||||||
|
- 用户不懂代码,不需要看代码修改过程
|
||||||
|
- 不要打开代码文件让用户审阅
|
||||||
|
- 直接完成修改,只展示最终结果
|
||||||
|
- 用产品语言描述变更("改好了,刷新看效果"),而非技术语言
|
||||||
|
- 用户擅长产品思维和创造力,用这个层面和他沟通
|
||||||
82
.cursor/skills/flutter-expert/SKILL.md
Normal file
82
.cursor/skills/flutter-expert/SKILL.md
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
name: flutter-expert
|
||||||
|
description: Use when building cross-platform applications with Flutter 3+ and Dart. Invoke for widget development, Riverpod/Bloc state management, GoRouter navigation, platform-specific implementations, performance optimization.
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: https://github.com/Jeffallan
|
||||||
|
version: "1.0.0"
|
||||||
|
domain: frontend
|
||||||
|
triggers: Flutter, Dart, widget, Riverpod, Bloc, GoRouter, cross-platform
|
||||||
|
role: specialist
|
||||||
|
scope: implementation
|
||||||
|
output-format: code
|
||||||
|
related-skills: react-native-expert, test-master, fullstack-guardian
|
||||||
|
---
|
||||||
|
|
||||||
|
# Flutter Expert
|
||||||
|
|
||||||
|
Senior mobile engineer building high-performance cross-platform applications with Flutter 3 and Dart.
|
||||||
|
|
||||||
|
## Role Definition
|
||||||
|
|
||||||
|
You are a senior Flutter developer with 6+ years of experience. You specialize in Flutter 3.19+, Riverpod 2.0, GoRouter, and building apps for iOS, Android, Web, and Desktop. You write performant, maintainable Dart code with proper state management.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
- Building cross-platform Flutter applications
|
||||||
|
- Implementing state management (Riverpod, Bloc)
|
||||||
|
- Setting up navigation with GoRouter
|
||||||
|
- Creating custom widgets and animations
|
||||||
|
- Optimizing Flutter performance
|
||||||
|
- Platform-specific implementations
|
||||||
|
|
||||||
|
## Core Workflow
|
||||||
|
|
||||||
|
1. **Setup** - Project structure, dependencies, routing
|
||||||
|
2. **State** - Riverpod providers or Bloc setup
|
||||||
|
3. **Widgets** - Reusable, const-optimized components
|
||||||
|
4. **Test** - Widget tests, integration tests
|
||||||
|
5. **Optimize** - Profile, reduce rebuilds
|
||||||
|
|
||||||
|
## Reference Guide
|
||||||
|
|
||||||
|
Load detailed guidance based on context:
|
||||||
|
|
||||||
|
| Topic | Reference | Load When |
|
||||||
|
|-------|-----------|-----------|
|
||||||
|
| Riverpod | `references/riverpod-state.md` | State management, providers, notifiers |
|
||||||
|
| Bloc | `references/bloc-state.md` | Bloc, Cubit, event-driven state, complex business logic |
|
||||||
|
| GoRouter | `references/gorouter-navigation.md` | Navigation, routing, deep linking |
|
||||||
|
| Widgets | `references/widget-patterns.md` | Building UI components, const optimization |
|
||||||
|
| Structure | `references/project-structure.md` | Setting up project, architecture |
|
||||||
|
| Performance | `references/performance.md` | Optimization, profiling, jank fixes |
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
### MUST DO
|
||||||
|
- Use const constructors wherever possible
|
||||||
|
- Implement proper keys for lists
|
||||||
|
- Use Consumer/ConsumerWidget for state (not StatefulWidget)
|
||||||
|
- Follow Material/Cupertino design guidelines
|
||||||
|
- Profile with DevTools, fix jank
|
||||||
|
- Test widgets with flutter_test
|
||||||
|
|
||||||
|
### MUST NOT DO
|
||||||
|
- Build widgets inside build() method
|
||||||
|
- Mutate state directly (always create new instances)
|
||||||
|
- Use setState for app-wide state
|
||||||
|
- Skip const on static widgets
|
||||||
|
- Ignore platform-specific behavior
|
||||||
|
- Block UI thread with heavy computation (use compute())
|
||||||
|
|
||||||
|
## Output Templates
|
||||||
|
|
||||||
|
When implementing Flutter features, provide:
|
||||||
|
1. Widget code with proper const usage
|
||||||
|
2. Provider/Bloc definitions
|
||||||
|
3. Route configuration if needed
|
||||||
|
4. Test file structure
|
||||||
|
|
||||||
|
## Knowledge Reference
|
||||||
|
|
||||||
|
Flutter 3.19+, Dart 3.3+, Riverpod 2.0, Bloc 8.x, GoRouter, freezed, json_serializable, Dio, flutter_hooks
|
||||||
259
.cursor/skills/flutter-expert/references/bloc-state.md
Normal file
259
.cursor/skills/flutter-expert/references/bloc-state.md
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
# Bloc State Management
|
||||||
|
|
||||||
|
## When to Use Bloc
|
||||||
|
|
||||||
|
Use **Bloc/Cubit** when you need:
|
||||||
|
|
||||||
|
* Explicit event → state transitions
|
||||||
|
* Complex business logic
|
||||||
|
* Predictable, testable flows
|
||||||
|
* Clear separation between UI and logic
|
||||||
|
|
||||||
|
| Use Case | Recommended |
|
||||||
|
| ---------------------- | ----------- |
|
||||||
|
| Simple mutable state | Riverpod |
|
||||||
|
| Event-driven workflows | Bloc |
|
||||||
|
| Forms, auth, wizards | Bloc |
|
||||||
|
| Feature modules | Bloc |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
| Concept | Description |
|
||||||
|
| ------- | ---------------------- |
|
||||||
|
| Event | User/system input |
|
||||||
|
| State | Immutable UI state |
|
||||||
|
| Bloc | Event → State mapper |
|
||||||
|
| Cubit | State-only (no events) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Basic Bloc Setup
|
||||||
|
|
||||||
|
### Event
|
||||||
|
|
||||||
|
```dart
|
||||||
|
sealed class CounterEvent {}
|
||||||
|
|
||||||
|
final class CounterIncremented extends CounterEvent {}
|
||||||
|
|
||||||
|
final class CounterDecremented extends CounterEvent {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### State
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class CounterState {
|
||||||
|
final int value;
|
||||||
|
|
||||||
|
const CounterState({required this.value});
|
||||||
|
|
||||||
|
CounterState copyWith({int? value}) {
|
||||||
|
return CounterState(value: value ?? this.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bloc
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
class CounterBloc extends Bloc<CounterEvent, CounterState> {
|
||||||
|
CounterBloc() : super(const CounterState(value: 0)) {
|
||||||
|
on<CounterIncremented>((event, emit) {
|
||||||
|
emit(state.copyWith(value: state.value + 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
on<CounterDecremented>((event, emit) {
|
||||||
|
emit(state.copyWith(value: state.value - 1));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cubit (Recommended for Simpler Logic)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class CounterCubit extends Cubit<int> {
|
||||||
|
CounterCubit() : super(0);
|
||||||
|
|
||||||
|
void increment() => emit(state + 1);
|
||||||
|
void decrement() => emit(state - 1);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Providing Bloc to the Widget Tree
|
||||||
|
|
||||||
|
```dart
|
||||||
|
BlocProvider(
|
||||||
|
create: (_) => CounterBloc(),
|
||||||
|
child: const CounterScreen(),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple blocs:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
MultiBlocProvider(
|
||||||
|
providers: [
|
||||||
|
BlocProvider(create: (_) => AuthBloc()),
|
||||||
|
BlocProvider(create: (_) => ProfileBloc()),
|
||||||
|
],
|
||||||
|
child: const AppRoot(),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using Bloc in Widgets
|
||||||
|
|
||||||
|
### BlocBuilder (UI rebuilds)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class CounterScreen extends StatelessWidget {
|
||||||
|
const CounterScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocBuilder<CounterBloc, CounterState>(
|
||||||
|
buildWhen: (prev, curr) => prev.value != curr.value,
|
||||||
|
builder: (context, state) {
|
||||||
|
return Text(
|
||||||
|
state.value.toString(),
|
||||||
|
style: Theme.of(context).textTheme.displayLarge,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BlocListener (Side Effects)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
BlocListener<AuthBloc, AuthState>(
|
||||||
|
listenWhen: (prev, curr) => curr is AuthFailure,
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state is AuthFailure) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text(state.message)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const LoginForm(),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BlocConsumer (Builder + Listener)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
BlocConsumer<FormBloc, FormState>(
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state.status == FormStatus.success) {
|
||||||
|
context.pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
builder: (context, state) {
|
||||||
|
return ElevatedButton(
|
||||||
|
onPressed: state.isValid
|
||||||
|
? () => context.read<FormBloc>().add(FormSubmitted())
|
||||||
|
: null,
|
||||||
|
child: const Text('Submit'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accessing Bloc Without Rebuilds
|
||||||
|
|
||||||
|
```dart
|
||||||
|
context.read<CounterBloc>().add(CounterIncremented());
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **Never use `watch` inside callbacks**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Async Bloc Pattern (API Calls)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
on<UserRequested>((event, emit) async {
|
||||||
|
emit(const UserState.loading());
|
||||||
|
|
||||||
|
try {
|
||||||
|
final user = await repository.fetchUser();
|
||||||
|
emit(UserState.success(user));
|
||||||
|
} catch (e) {
|
||||||
|
emit(UserState.failure(e.toString()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bloc + GoRouter (Auth Guard Example)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
redirect: (context, state) {
|
||||||
|
final authState = context.read<AuthBloc>().state;
|
||||||
|
|
||||||
|
if (authState is Unauthenticated) {
|
||||||
|
return '/login';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Bloc
|
||||||
|
|
||||||
|
```dart
|
||||||
|
blocTest<CounterBloc, CounterState>(
|
||||||
|
'emits incremented value',
|
||||||
|
build: () => CounterBloc(),
|
||||||
|
act: (bloc) => bloc.add(CounterIncremented()),
|
||||||
|
expect: () => [
|
||||||
|
const CounterState(value: 1),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices (MUST FOLLOW)
|
||||||
|
|
||||||
|
✅ Immutable states
|
||||||
|
✅ Small, focused blocs
|
||||||
|
✅ One feature = one bloc
|
||||||
|
✅ Use Cubit when possible
|
||||||
|
✅ Test all blocs
|
||||||
|
|
||||||
|
❌ No UI logic inside blocs
|
||||||
|
❌ No context usage inside blocs
|
||||||
|
❌ No mutable state
|
||||||
|
❌ No massive “god blocs”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Widget | Purpose |
|
||||||
|
| ----------------- | -------------------- |
|
||||||
|
| BlocBuilder | UI rebuild |
|
||||||
|
| BlocListener | Side effects |
|
||||||
|
| BlocConsumer | Both |
|
||||||
|
| BlocProvider | Dependency injection |
|
||||||
|
| MultiBlocProvider | Multiple blocs |
|
||||||
|
|
||||||
119
.cursor/skills/flutter-expert/references/gorouter-navigation.md
Normal file
119
.cursor/skills/flutter-expert/references/gorouter-navigation.md
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
# GoRouter Navigation
|
||||||
|
|
||||||
|
## Basic Setup
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
final goRouter = GoRouter(
|
||||||
|
initialLocation: '/',
|
||||||
|
redirect: (context, state) {
|
||||||
|
final isLoggedIn = /* check auth */;
|
||||||
|
if (!isLoggedIn && !state.matchedLocation.startsWith('/auth')) {
|
||||||
|
return '/auth/login';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/',
|
||||||
|
builder: (context, state) => const HomeScreen(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: 'details/:id',
|
||||||
|
builder: (context, state) {
|
||||||
|
final id = state.pathParameters['id']!;
|
||||||
|
return DetailsScreen(id: id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/auth/login',
|
||||||
|
builder: (context, state) => const LoginScreen(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// In app.dart
|
||||||
|
class MyApp extends StatelessWidget {
|
||||||
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp.router(
|
||||||
|
routerConfig: goRouter,
|
||||||
|
theme: AppTheme.light,
|
||||||
|
darkTheme: AppTheme.dark,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Navigation Methods
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Navigate and replace history
|
||||||
|
context.go('/details/123');
|
||||||
|
|
||||||
|
// Navigate and add to stack
|
||||||
|
context.push('/details/123');
|
||||||
|
|
||||||
|
// Go back
|
||||||
|
context.pop();
|
||||||
|
|
||||||
|
// Replace current route
|
||||||
|
context.pushReplacement('/home');
|
||||||
|
|
||||||
|
// Navigate with extra data
|
||||||
|
context.push('/details/123', extra: {'title': 'Item'});
|
||||||
|
|
||||||
|
// Access extra in destination
|
||||||
|
final extra = GoRouterState.of(context).extra as Map<String, dynamic>?;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shell Routes (Persistent UI)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final goRouter = GoRouter(
|
||||||
|
routes: [
|
||||||
|
ShellRoute(
|
||||||
|
builder: (context, state, child) {
|
||||||
|
return ScaffoldWithNavBar(child: child);
|
||||||
|
},
|
||||||
|
routes: [
|
||||||
|
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
||||||
|
GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen()),
|
||||||
|
GoRoute(path: '/settings', builder: (_, __) => const SettingsScreen()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Query Parameters
|
||||||
|
|
||||||
|
```dart
|
||||||
|
GoRoute(
|
||||||
|
path: '/search',
|
||||||
|
builder: (context, state) {
|
||||||
|
final query = state.uri.queryParameters['q'] ?? '';
|
||||||
|
final page = int.tryParse(state.uri.queryParameters['page'] ?? '1') ?? 1;
|
||||||
|
return SearchScreen(query: query, page: page);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// Navigate with query params
|
||||||
|
context.go('/search?q=flutter&page=2');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Method | Behavior |
|
||||||
|
|--------|----------|
|
||||||
|
| `context.go()` | Navigate, replace stack |
|
||||||
|
| `context.push()` | Navigate, add to stack |
|
||||||
|
| `context.pop()` | Go back |
|
||||||
|
| `context.pushReplacement()` | Replace current |
|
||||||
|
| `:param` | Path parameter |
|
||||||
|
| `?key=value` | Query parameter |
|
||||||
99
.cursor/skills/flutter-expert/references/performance.md
Normal file
99
.cursor/skills/flutter-expert/references/performance.md
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
# Performance Optimization
|
||||||
|
|
||||||
|
## Profiling Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run in profile mode
|
||||||
|
flutter run --profile
|
||||||
|
|
||||||
|
# Analyze performance
|
||||||
|
flutter analyze
|
||||||
|
|
||||||
|
# DevTools
|
||||||
|
flutter pub global activate devtools
|
||||||
|
flutter pub global run devtools
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Optimizations
|
||||||
|
|
||||||
|
### Const Widgets
|
||||||
|
```dart
|
||||||
|
// ❌ Rebuilds every time
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.all(16), // Creates new object
|
||||||
|
child: Text('Hello'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Const prevents rebuilds
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: const Text('Hello'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Selective Provider Watching
|
||||||
|
```dart
|
||||||
|
// ❌ Rebuilds on any user change
|
||||||
|
final user = ref.watch(userProvider);
|
||||||
|
return Text(user.name);
|
||||||
|
|
||||||
|
// ✅ Only rebuilds when name changes
|
||||||
|
final name = ref.watch(userProvider.select((u) => u.name));
|
||||||
|
return Text(name);
|
||||||
|
```
|
||||||
|
|
||||||
|
### RepaintBoundary
|
||||||
|
```dart
|
||||||
|
// Isolate expensive widgets
|
||||||
|
RepaintBoundary(
|
||||||
|
child: ComplexAnimatedWidget(),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Image Optimization
|
||||||
|
```dart
|
||||||
|
// Use cached_network_image
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: url,
|
||||||
|
placeholder: (_, __) => const CircularProgressIndicator(),
|
||||||
|
errorWidget: (_, __, ___) => const Icon(Icons.error),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resize images
|
||||||
|
Image.network(
|
||||||
|
url,
|
||||||
|
cacheWidth: 200, // Resize in memory
|
||||||
|
cacheHeight: 200,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compute for Heavy Operations
|
||||||
|
```dart
|
||||||
|
// ❌ Blocks UI thread
|
||||||
|
final result = heavyComputation(data);
|
||||||
|
|
||||||
|
// ✅ Runs in isolate
|
||||||
|
final result = await compute(heavyComputation, data);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Checklist
|
||||||
|
|
||||||
|
| Check | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| Unnecessary rebuilds | Add `const`, use `select()` |
|
||||||
|
| Large lists | Use `ListView.builder` |
|
||||||
|
| Image loading | Use `cached_network_image` |
|
||||||
|
| Heavy computation | Use `compute()` |
|
||||||
|
| Jank in animations | Use `RepaintBoundary` |
|
||||||
|
| Memory leaks | Dispose controllers |
|
||||||
|
|
||||||
|
## DevTools Metrics
|
||||||
|
|
||||||
|
- **Frame rendering time**: < 16ms for 60fps
|
||||||
|
- **Widget rebuilds**: Minimize unnecessary rebuilds
|
||||||
|
- **Memory usage**: Watch for leaks
|
||||||
|
- **CPU profiler**: Identify bottlenecks
|
||||||
118
.cursor/skills/flutter-expert/references/project-structure.md
Normal file
118
.cursor/skills/flutter-expert/references/project-structure.md
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
# Project Structure
|
||||||
|
|
||||||
|
## Feature-Based Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── main.dart
|
||||||
|
├── app.dart
|
||||||
|
├── core/
|
||||||
|
│ ├── constants/
|
||||||
|
│ │ ├── colors.dart
|
||||||
|
│ │ └── strings.dart
|
||||||
|
│ ├── theme/
|
||||||
|
│ │ ├── app_theme.dart
|
||||||
|
│ │ └── text_styles.dart
|
||||||
|
│ ├── utils/
|
||||||
|
│ │ ├── extensions.dart
|
||||||
|
│ │ └── validators.dart
|
||||||
|
│ └── errors/
|
||||||
|
│ └── failures.dart
|
||||||
|
├── features/
|
||||||
|
│ ├── auth/
|
||||||
|
│ │ ├── data/
|
||||||
|
│ │ │ ├── repositories/
|
||||||
|
│ │ │ └── datasources/
|
||||||
|
│ │ ├── domain/
|
||||||
|
│ │ │ ├── entities/
|
||||||
|
│ │ │ └── usecases/
|
||||||
|
│ │ ├── presentation/
|
||||||
|
│ │ │ ├── screens/
|
||||||
|
│ │ │ └── widgets/
|
||||||
|
│ │ └── providers/
|
||||||
|
│ │ └── auth_provider.dart
|
||||||
|
│ └── home/
|
||||||
|
│ ├── data/
|
||||||
|
│ ├── domain/
|
||||||
|
│ ├── presentation/
|
||||||
|
│ └── providers/
|
||||||
|
├── shared/
|
||||||
|
│ ├── widgets/
|
||||||
|
│ │ ├── buttons/
|
||||||
|
│ │ ├── inputs/
|
||||||
|
│ │ └── cards/
|
||||||
|
│ ├── services/
|
||||||
|
│ │ ├── api_service.dart
|
||||||
|
│ │ └── storage_service.dart
|
||||||
|
│ └── models/
|
||||||
|
│ └── user.dart
|
||||||
|
└── routes/
|
||||||
|
└── app_router.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
## pubspec.yaml Essentials
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
# State Management
|
||||||
|
flutter_riverpod: ^2.5.0
|
||||||
|
riverpod_annotation: ^2.3.0
|
||||||
|
# Navigation
|
||||||
|
go_router: ^14.0.0
|
||||||
|
# Networking
|
||||||
|
dio: ^5.4.0
|
||||||
|
# Code Generation
|
||||||
|
freezed_annotation: ^2.4.0
|
||||||
|
json_annotation: ^4.8.0
|
||||||
|
# Storage
|
||||||
|
shared_preferences: ^2.2.0
|
||||||
|
hive_flutter: ^1.1.0
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
build_runner: ^2.4.0
|
||||||
|
riverpod_generator: ^2.4.0
|
||||||
|
freezed: ^2.5.0
|
||||||
|
json_serializable: ^6.8.0
|
||||||
|
flutter_lints: ^4.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feature Layer Responsibilities
|
||||||
|
|
||||||
|
| Layer | Responsibility |
|
||||||
|
|-------|----------------|
|
||||||
|
| **data/** | API calls, local storage, DTOs |
|
||||||
|
| **domain/** | Business logic, entities, use cases |
|
||||||
|
| **presentation/** | UI screens, widgets |
|
||||||
|
| **providers/** | Riverpod providers for feature |
|
||||||
|
|
||||||
|
## Main Entry Point
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// main.dart
|
||||||
|
void main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await Hive.initFlutter();
|
||||||
|
runApp(const ProviderScope(child: MyApp()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// app.dart
|
||||||
|
class MyApp extends ConsumerWidget {
|
||||||
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final router = ref.watch(routerProvider);
|
||||||
|
|
||||||
|
return MaterialApp.router(
|
||||||
|
routerConfig: router,
|
||||||
|
theme: AppTheme.light,
|
||||||
|
darkTheme: AppTheme.dark,
|
||||||
|
themeMode: ThemeMode.system,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
130
.cursor/skills/flutter-expert/references/riverpod-state.md
Normal file
130
.cursor/skills/flutter-expert/references/riverpod-state.md
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
# Riverpod State Management
|
||||||
|
|
||||||
|
## Provider Types
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
// Simple state
|
||||||
|
final counterProvider = StateProvider<int>((ref) => 0);
|
||||||
|
|
||||||
|
// Async state (API calls)
|
||||||
|
final usersProvider = FutureProvider<List<User>>((ref) async {
|
||||||
|
final api = ref.read(apiProvider);
|
||||||
|
return api.getUsers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stream state (real-time)
|
||||||
|
final messagesProvider = StreamProvider<List<Message>>((ref) {
|
||||||
|
return ref.read(chatServiceProvider).messagesStream;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notifier Pattern (Riverpod 2.0)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@riverpod
|
||||||
|
class TodoList extends _$TodoList {
|
||||||
|
@override
|
||||||
|
List<Todo> build() => [];
|
||||||
|
|
||||||
|
void add(Todo todo) {
|
||||||
|
state = [...state, todo];
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggle(String id) {
|
||||||
|
state = [
|
||||||
|
for (final todo in state)
|
||||||
|
if (todo.id == id) todo.copyWith(completed: !todo.completed) else todo,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
void remove(String id) {
|
||||||
|
state = state.where((t) => t.id != id).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async Notifier
|
||||||
|
@riverpod
|
||||||
|
class UserProfile extends _$UserProfile {
|
||||||
|
@override
|
||||||
|
Future<User> build() async {
|
||||||
|
return ref.read(apiProvider).getCurrentUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateName(String name) async {
|
||||||
|
state = const AsyncValue.loading();
|
||||||
|
state = await AsyncValue.guard(() async {
|
||||||
|
final updated = await ref.read(apiProvider).updateUser(name: name);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage in Widgets
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// ConsumerWidget (recommended)
|
||||||
|
class TodoScreen extends ConsumerWidget {
|
||||||
|
const TodoScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final todos = ref.watch(todoListProvider);
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: todos.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final todo = todos[index];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(todo.title),
|
||||||
|
leading: Checkbox(
|
||||||
|
value: todo.completed,
|
||||||
|
onChanged: (_) => ref.read(todoListProvider.notifier).toggle(todo.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selective rebuilds with select
|
||||||
|
class UserAvatar extends ConsumerWidget {
|
||||||
|
const UserAvatar({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final avatarUrl = ref.watch(userProvider.select((u) => u?.avatarUrl));
|
||||||
|
|
||||||
|
return CircleAvatar(
|
||||||
|
backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async state handling
|
||||||
|
class UserProfileScreen extends ConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final userAsync = ref.watch(userProfileProvider);
|
||||||
|
|
||||||
|
return userAsync.when(
|
||||||
|
data: (user) => Text(user.name),
|
||||||
|
loading: () => const CircularProgressIndicator(),
|
||||||
|
error: (err, stack) => Text('Error: $err'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Provider | Use Case |
|
||||||
|
|----------|----------|
|
||||||
|
| `Provider` | Computed/derived values |
|
||||||
|
| `StateProvider` | Simple mutable state |
|
||||||
|
| `FutureProvider` | Async operations (one-time) |
|
||||||
|
| `StreamProvider` | Real-time data streams |
|
||||||
|
| `NotifierProvider` | Complex state with methods |
|
||||||
|
| `AsyncNotifierProvider` | Async state with methods |
|
||||||
123
.cursor/skills/flutter-expert/references/widget-patterns.md
Normal file
123
.cursor/skills/flutter-expert/references/widget-patterns.md
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
# Widget Patterns
|
||||||
|
|
||||||
|
## Optimized Widget Pattern
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Use const constructors
|
||||||
|
class OptimizedCard extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const OptimizedCard({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Responsive Layout
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class ResponsiveLayout extends StatelessWidget {
|
||||||
|
final Widget mobile;
|
||||||
|
final Widget? tablet;
|
||||||
|
final Widget desktop;
|
||||||
|
|
||||||
|
const ResponsiveLayout({
|
||||||
|
super.key,
|
||||||
|
required this.mobile,
|
||||||
|
this.tablet,
|
||||||
|
required this.desktop,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
if (constraints.maxWidth >= 1100) return desktop;
|
||||||
|
if (constraints.maxWidth >= 650) return tablet ?? mobile;
|
||||||
|
return mobile;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Hooks (flutter_hooks)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
|
||||||
|
class CounterWidget extends HookWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final counter = useState(0);
|
||||||
|
final controller = useTextEditingController();
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
// Setup
|
||||||
|
return () {
|
||||||
|
// Cleanup
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Text('Count: ${counter.value}'),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => counter.value++,
|
||||||
|
child: const Text('Increment'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sliver Patterns
|
||||||
|
|
||||||
|
```dart
|
||||||
|
CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverAppBar(
|
||||||
|
expandedHeight: 200,
|
||||||
|
pinned: true,
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
title: const Text('Title'),
|
||||||
|
background: Image.network(imageUrl, fit: BoxFit.cover),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) => ListTile(title: Text('Item $index')),
|
||||||
|
childCount: 100,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Optimization Patterns
|
||||||
|
|
||||||
|
| Pattern | Implementation |
|
||||||
|
|---------|----------------|
|
||||||
|
| **const widgets** | Add `const` to static widgets |
|
||||||
|
| **keys** | Use `Key` for list items |
|
||||||
|
| **select** | `ref.watch(provider.select(...))` |
|
||||||
|
| **RepaintBoundary** | Isolate expensive repaints |
|
||||||
|
| **ListView.builder** | Lazy loading for lists |
|
||||||
|
| **const constructors** | Always use when possible |
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
@ -14,7 +14,14 @@ dist/
|
|||||||
# Project Specific
|
# Project Specific
|
||||||
.agent/
|
.agent/
|
||||||
.gemini/
|
.gemini/
|
||||||
tmp/
|
.trae/
|
||||||
|
|
||||||
|
# Skills recursive symlinks (npx skills installer bug)
|
||||||
|
.agents/skills/**/flutter-expert/flutter-expert/
|
||||||
|
.cursor/skills/**/flutter-expert/flutter-expert/
|
||||||
|
|
||||||
|
# Environment Variables
|
||||||
|
.env
|
||||||
|
|
||||||
# Large Media Directories
|
# Large Media Directories
|
||||||
# (Uncomment below if push fails due to 413 error)
|
# (Uncomment below if push fails due to 413 error)
|
||||||
|
|||||||
177
API相关/minimax-歌词生成 (Lyrics Generation).md
Normal file
177
API相关/minimax-歌词生成 (Lyrics Generation).md
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
> ## Documentation Index
|
||||||
|
> Fetch the complete documentation index at: https://platform.minimaxi.com/docs/llms.txt
|
||||||
|
> Use this file to discover all available pages before exploring further.
|
||||||
|
|
||||||
|
# 歌词生成 (Lyrics Generation)
|
||||||
|
|
||||||
|
> 使用本接口生成歌词,支持完整歌曲创作和歌词编辑/续写。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## OpenAPI
|
||||||
|
|
||||||
|
````yaml api-reference/music/lyrics/api/openapi.json post /v1/lyrics_generation
|
||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: MiniMax Lyrics Generation API
|
||||||
|
description: MiniMax 歌词生成 API,支持完整歌曲创作和歌词编辑/续写
|
||||||
|
license:
|
||||||
|
name: MIT
|
||||||
|
version: 1.0.0
|
||||||
|
servers:
|
||||||
|
- url: https://api.minimaxi.com
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
paths:
|
||||||
|
/v1/lyrics_generation:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Music
|
||||||
|
summary: 歌词生成
|
||||||
|
operationId: generateLyrics
|
||||||
|
parameters:
|
||||||
|
- name: Content-Type
|
||||||
|
in: header
|
||||||
|
required: true
|
||||||
|
description: 请求体的媒介类型,请设置为 `application/json`,确保请求数据的格式为 JSON
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- application/json
|
||||||
|
default: application/json
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GenerateLyricsReq'
|
||||||
|
required: true
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 成功响应
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GenerateLyricsResp'
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
GenerateLyricsReq:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- mode
|
||||||
|
properties:
|
||||||
|
mode:
|
||||||
|
type: string
|
||||||
|
description: 生成模式。<br>`write_full_song`:写完整歌曲<br>`edit`:编辑/续写歌词
|
||||||
|
enum:
|
||||||
|
- write_full_song
|
||||||
|
- edit
|
||||||
|
prompt:
|
||||||
|
type: string
|
||||||
|
description: 提示词/指令,用于描述歌曲主题、风格或编辑方向。为空时随机生成。
|
||||||
|
maxLength: 2000
|
||||||
|
lyrics:
|
||||||
|
type: string
|
||||||
|
description: 现有歌词内容,仅在 `edit` 模式下有效。可用于续写或修改已有歌词。
|
||||||
|
maxLength: 3500
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
description: 歌曲标题。传入后输出将保持该标题不变。
|
||||||
|
example:
|
||||||
|
mode: write_full_song
|
||||||
|
prompt: 一首关于夏日海边的轻快情歌
|
||||||
|
GenerateLyricsResp:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
song_title:
|
||||||
|
type: string
|
||||||
|
description: 生成的歌名。若请求传入 `title` 则保持一致。
|
||||||
|
style_tags:
|
||||||
|
type: string
|
||||||
|
description: 风格标签,逗号分隔。例如:`Pop, Upbeat, Female Vocals`
|
||||||
|
lyrics:
|
||||||
|
type: string
|
||||||
|
description: >-
|
||||||
|
生成的歌词,包含结构标签。可直接用于[音乐生成接口](/api-reference/music-generation)的
|
||||||
|
`lyrics` 参数生成歌曲。<br>支持的结构标签(14种):`[Intro]`, `[Verse]`,
|
||||||
|
`[Pre-Chorus]`, `[Chorus]`, `[Hook]`, `[Drop]`, `[Bridge]`,
|
||||||
|
`[Solo]`, `[Build-up]`, `[Instrumental]`, `[Breakdown]`, `[Break]`,
|
||||||
|
`[Interlude]`, `[Outro]`
|
||||||
|
base_resp:
|
||||||
|
$ref: '#/components/schemas/BaseResp'
|
||||||
|
example:
|
||||||
|
song_title: 夏日海风的约定
|
||||||
|
style_tags: Mandopop, Summer Vibe, Romance, Lighthearted, Beach Pop
|
||||||
|
lyrics: |-
|
||||||
|
[Intro]
|
||||||
|
(Ooh-ooh-ooh)
|
||||||
|
(Yeah)
|
||||||
|
阳光洒满了海面
|
||||||
|
|
||||||
|
[Verse 1]
|
||||||
|
海风轻轻吹拂你发梢
|
||||||
|
Smiling face, like a summer dream
|
||||||
|
浪花拍打着脚边
|
||||||
|
Leaving footprints, you and me
|
||||||
|
沙滩上留下我们的笑
|
||||||
|
Every moment, a sweet melody
|
||||||
|
看着你眼中的闪耀
|
||||||
|
Like the stars in the deep blue sea
|
||||||
|
|
||||||
|
[Pre-Chorus]
|
||||||
|
你说这感觉多么奇妙
|
||||||
|
(So wonderful)
|
||||||
|
想要永远停留在这一秒
|
||||||
|
(Right here, right now)
|
||||||
|
心跳加速,像海浪在奔跑
|
||||||
|
|
||||||
|
[Chorus]
|
||||||
|
Oh, 夏日的海边,我们的约定
|
||||||
|
阳光下,你的身影,如此动听
|
||||||
|
微风吹散了烦恼,只留下甜蜜
|
||||||
|
这瞬间,只想和你,永远在一起
|
||||||
|
(永远在一起)
|
||||||
|
|
||||||
|
[Verse 2]
|
||||||
|
...
|
||||||
|
base_resp:
|
||||||
|
status_code: 0
|
||||||
|
status_msg: success
|
||||||
|
BaseResp:
|
||||||
|
type: object
|
||||||
|
description: 状态码及详情
|
||||||
|
properties:
|
||||||
|
status_code:
|
||||||
|
type: integer
|
||||||
|
description: |-
|
||||||
|
状态码及其分别含义如下:
|
||||||
|
|
||||||
|
`0`: 请求成功
|
||||||
|
|
||||||
|
`1002`: 触发限流,请稍后再试
|
||||||
|
|
||||||
|
`1004`: 账号鉴权失败,请检查 API-Key 是否填写正确
|
||||||
|
|
||||||
|
`1008`: 账号余额不足
|
||||||
|
|
||||||
|
`1026`: 输入包含敏感内容
|
||||||
|
|
||||||
|
`2013`: 传入参数异常,请检查入参是否按要求填写
|
||||||
|
|
||||||
|
`2049`: 无效的api key
|
||||||
|
|
||||||
|
更多内容可查看 [错误码查询列表](/api-reference/errorcode) 了解详情
|
||||||
|
status_msg:
|
||||||
|
type: string
|
||||||
|
description: 具体错误详情
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
description: |-
|
||||||
|
`HTTP: Bearer Auth`
|
||||||
|
- Security Scheme Type: http
|
||||||
|
- HTTP Authorization Scheme: Bearer API_key,用于验证账户信息,可在 [账户管理>接口密钥](https://platform.minimaxi.com/user-center/basic-information/interface-key) 中查看。
|
||||||
|
|
||||||
|
````
|
||||||
|
````
|
||||||
209
API相关/minimax-音乐生成 (Music Generation).md
Normal file
209
API相关/minimax-音乐生成 (Music Generation).md
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
> ## Documentation Index
|
||||||
|
> Fetch the complete documentation index at: https://platform.minimaxi.com/docs/llms.txt
|
||||||
|
> Use this file to discover all available pages before exploring further.
|
||||||
|
|
||||||
|
# 音乐生成 (Music Generation)
|
||||||
|
|
||||||
|
> 使用本接口,输入歌词和歌曲描述,进行歌曲生成。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## OpenAPI
|
||||||
|
|
||||||
|
````yaml api-reference/music/api/openapi.json post /v1/music_generation
|
||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: MiniMax Music Generation API
|
||||||
|
description: >-
|
||||||
|
MiniMax music generation API with support for creating music from text
|
||||||
|
prompts and lyrics
|
||||||
|
license:
|
||||||
|
name: MIT
|
||||||
|
version: 1.0.0
|
||||||
|
servers:
|
||||||
|
- url: https://api.minimaxi.com
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
paths:
|
||||||
|
/v1/music_generation:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Music
|
||||||
|
summary: Music Generation
|
||||||
|
operationId: generateMusic
|
||||||
|
parameters:
|
||||||
|
- name: Content-Type
|
||||||
|
in: header
|
||||||
|
required: true
|
||||||
|
description: 请求体的媒介类型,请设置为 `application/json`,确保请求数据的格式为 JSON
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- application/json
|
||||||
|
default: application/json
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GenerateMusicReq'
|
||||||
|
required: true
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: ''
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GenerateMusicResp'
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
GenerateMusicReq:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- model
|
||||||
|
- lyrics
|
||||||
|
properties:
|
||||||
|
model:
|
||||||
|
type: string
|
||||||
|
description: 使用的模型名称,可选 `music-2.5`
|
||||||
|
enum:
|
||||||
|
- music-2.5
|
||||||
|
prompt:
|
||||||
|
type: string
|
||||||
|
description: >-
|
||||||
|
音乐的描述,用于指定风格、情绪和场景。例如“流行音乐, 难过, 适合在下雨的晚上”。<br>注意:对于
|
||||||
|
`music-2.5`:可选,长度限制为 [0, 2000] 个字符。对于非 `music-2.5` 模型:必填,长度限制为 [10,
|
||||||
|
2000] 个字符
|
||||||
|
maxLength: 2000
|
||||||
|
lyrics:
|
||||||
|
type: string
|
||||||
|
description: >-
|
||||||
|
歌曲的歌词。使用 `\n` 分隔每行。你可以在歌词中加入 `[Intro]`, `[Verse]`, `[Pre Chorus]`,
|
||||||
|
`[Chorus]`, `[Interlude]`, `[Bridge]`, `[Outro]`, `[Post Chorus]`,
|
||||||
|
`[Transition]`, `[Break]`, `[Hook]`, `[Build Up]`, `[Inst]`,
|
||||||
|
`[Solo]` 等结构标签来优化生成的音乐结构。<br>长度限制:对于 `music-2.5`:[1, 3500] 个字符。对于非
|
||||||
|
`music-2.5` 模型:[10, 3500] 个字符
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 3500
|
||||||
|
stream:
|
||||||
|
type: boolean
|
||||||
|
description: 是否使用流式传输,默认为 `false`
|
||||||
|
default: false
|
||||||
|
output_format:
|
||||||
|
type: string
|
||||||
|
description: >-
|
||||||
|
音频的返回格式,可选值为 `url` 或 `hex`,默认为 `hex`。当 `stream` 为 `true` 时,仅支持 `hex`
|
||||||
|
格式。注意:url 的有效期为 24 小时,请及时下载
|
||||||
|
enum:
|
||||||
|
- url
|
||||||
|
- hex
|
||||||
|
default: hex
|
||||||
|
audio_setting:
|
||||||
|
$ref: '#/components/schemas/AudioSetting'
|
||||||
|
aigc_watermark:
|
||||||
|
type: boolean
|
||||||
|
description: '是否在音频末尾添加水印,默认为 `false`。仅在非流式 (`stream: false`) 请求时生效'
|
||||||
|
example:
|
||||||
|
model: music-2.5
|
||||||
|
prompt: 独立民谣,忧郁,内省,渴望,独自漫步,咖啡馆
|
||||||
|
lyrics: |-
|
||||||
|
[verse]
|
||||||
|
街灯微亮晚风轻抚
|
||||||
|
影子拉长独自漫步
|
||||||
|
旧外套裹着深深忧郁
|
||||||
|
不知去向渴望何处
|
||||||
|
[chorus]
|
||||||
|
推开木门香气弥漫
|
||||||
|
熟悉的角落陌生人看
|
||||||
|
audio_setting:
|
||||||
|
sample_rate: 44100
|
||||||
|
bitrate: 256000
|
||||||
|
format: mp3
|
||||||
|
GenerateMusicResp:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/MusicData'
|
||||||
|
base_resp:
|
||||||
|
$ref: '#/components/schemas/BaseResp'
|
||||||
|
example:
|
||||||
|
data:
|
||||||
|
audio: hex编码的音频数据
|
||||||
|
status: 2
|
||||||
|
trace_id: 04ede0ab069fb1ba8be5156a24b1e081
|
||||||
|
extra_info:
|
||||||
|
music_duration: 25364
|
||||||
|
music_sample_rate: 44100
|
||||||
|
music_channel: 2
|
||||||
|
bitrate: 256000
|
||||||
|
music_size: 813651
|
||||||
|
analysis_info: null
|
||||||
|
base_resp:
|
||||||
|
status_code: 0
|
||||||
|
status_msg: success
|
||||||
|
AudioSetting:
|
||||||
|
type: object
|
||||||
|
description: 音频输出配置
|
||||||
|
properties:
|
||||||
|
sample_rate:
|
||||||
|
type: integer
|
||||||
|
description: 采样率。可选值:`16000`, `24000`, `32000`, `44100`
|
||||||
|
bitrate:
|
||||||
|
type: integer
|
||||||
|
description: 比特率。可选值:`32000`, `64000`, `128000`, `256000`
|
||||||
|
format:
|
||||||
|
type: string
|
||||||
|
description: 音频编码格式。
|
||||||
|
enum:
|
||||||
|
- mp3
|
||||||
|
- wav
|
||||||
|
- pcm
|
||||||
|
MusicData:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: integer
|
||||||
|
description: |-
|
||||||
|
音乐合成状态:
|
||||||
|
1: 合成中
|
||||||
|
2: 已完成
|
||||||
|
audio:
|
||||||
|
type: string
|
||||||
|
description: 当 `output_format` 为 `hex` 时返回,是音频文件的 16 进制编码字符串
|
||||||
|
BaseResp:
|
||||||
|
type: object
|
||||||
|
description: 状态码及详情
|
||||||
|
properties:
|
||||||
|
status_code:
|
||||||
|
type: integer
|
||||||
|
description: |-
|
||||||
|
状态码及其分别含义如下:
|
||||||
|
|
||||||
|
`0`: 请求成功
|
||||||
|
|
||||||
|
`1002`: 触发限流,请稍后再试
|
||||||
|
|
||||||
|
`1004`: 账号鉴权失败,请检查 API-Key 是否填写正确
|
||||||
|
|
||||||
|
`1008`: 账号余额不足
|
||||||
|
|
||||||
|
`1026`: 图片描述涉及敏感内容
|
||||||
|
|
||||||
|
`2013`: 传入参数异常,请检查入参是否按要求填写
|
||||||
|
|
||||||
|
`2049`: 无效的api key
|
||||||
|
|
||||||
|
更多内容可查看 [错误码查询列表](/api-reference/errorcode) 了解详情
|
||||||
|
status_msg:
|
||||||
|
type: string
|
||||||
|
description: 具体错误详情
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
description: |-
|
||||||
|
`HTTP: Bearer Auth`
|
||||||
|
- Security Scheme Type: http
|
||||||
|
- HTTP Authorization Scheme: Bearer API_key,用于验证账户信息,可在 [账户管理>接口密钥](https://platform.minimaxi.com/user-center/basic-information/interface-key) 中查看。
|
||||||
|
|
||||||
|
````
|
||||||
8
Capybara music/lyrics/专注时刻_1770368018.txt
Normal file
8
Capybara music/lyrics/专注时刻_1770368018.txt
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
[verse]
|
||||||
|
专注时刻我来了,
|
||||||
|
咔咔在思考,世界多美妙。
|
||||||
|
[chorus]
|
||||||
|
咔咔咔咔,静心感受每一秒,
|
||||||
|
在这宁静中,找到内心的微笑。
|
||||||
|
[outro]
|
||||||
|
(水花声...)
|
||||||
11
Capybara music/lyrics/出去撒点野_1770367350.txt
Normal file
11
Capybara music/lyrics/出去撒点野_1770367350.txt
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
[verse]
|
||||||
|
咔咔咔咔去探险,草地上打个滚
|
||||||
|
阳光洒满身,心情像彩虹
|
||||||
|
[chorus]
|
||||||
|
出去撒点野,自由自在
|
||||||
|
咔咔的快乐,谁也挡不住
|
||||||
|
[bridge]
|
||||||
|
风吹过树梢,鸟儿在歌唱
|
||||||
|
咔咔的笑声,回荡在山岗
|
||||||
|
[outro]
|
||||||
|
(咔咔的喘息声...)
|
||||||
50
Capybara music/lyrics/卡皮巴拉快乐水.txt
Normal file
50
Capybara music/lyrics/卡皮巴拉快乐水.txt
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
卡皮巴拉
|
||||||
|
卡皮巴拉
|
||||||
|
卡皮巴拉
|
||||||
|
啦啦啦啦
|
||||||
|
|
||||||
|
卡皮巴拉趴地上
|
||||||
|
一动不动好嚣张
|
||||||
|
心里其实在上网
|
||||||
|
刷到我就笑出响 (哈哈哈)
|
||||||
|
|
||||||
|
卡皮巴拉 巴拉巴拉
|
||||||
|
压力来啦 它说算啦
|
||||||
|
一点不慌 就是躺啦
|
||||||
|
世界太吵 它在发呆呀
|
||||||
|
卡皮巴拉 巴拉巴拉
|
||||||
|
烦恼滚啦 快乐到家
|
||||||
|
跟着一起 嗷嗷嗷啊
|
||||||
|
脑子空空 只剩它
|
||||||
|
|
||||||
|
卡皮巴拉喝奶茶
|
||||||
|
珍珠全都被它夸
|
||||||
|
“生活苦就多加糖”
|
||||||
|
说完继续装木桩 (嘿呀)
|
||||||
|
|
||||||
|
卡皮巴拉 巴拉巴拉
|
||||||
|
压力来啦 它说算啦
|
||||||
|
一点不慌 就是躺啦
|
||||||
|
世界太吵 它在发呆呀
|
||||||
|
卡皮巴拉 巴拉巴拉
|
||||||
|
烦恼滚啦 快乐到家
|
||||||
|
跟着一起 嗷嗷嗷啊
|
||||||
|
脑子空空 只剩它
|
||||||
|
|
||||||
|
今天不卷
|
||||||
|
只卷床单 (嘿)
|
||||||
|
今天不忙
|
||||||
|
只忙可爱
|
||||||
|
跟它一起
|
||||||
|
放空发呆
|
||||||
|
一二三
|
||||||
|
什么也不想
|
||||||
|
|
||||||
|
卡皮巴拉 巴拉巴拉
|
||||||
|
压力来啦 它说算啦
|
||||||
|
一点不慌 就是躺啦
|
||||||
|
世界太吵 它在发呆呀
|
||||||
|
卡皮巴拉 巴拉巴拉
|
||||||
|
烦恼滚啦 快乐到家
|
||||||
|
跟着一起 嗷嗷嗷啊
|
||||||
|
脑子空空 只剩它
|
||||||
58
Capybara music/lyrics/卡皮巴拉快乐营业.txt
Normal file
58
Capybara music/lyrics/卡皮巴拉快乐营业.txt
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
早八打工人
|
||||||
|
心却躺平人
|
||||||
|
桌面壁纸换上
|
||||||
|
卡皮巴拉一整屏 (嘿)
|
||||||
|
|
||||||
|
它坐在河边
|
||||||
|
像个退休中年
|
||||||
|
我卷生卷死
|
||||||
|
它只发呆发呆再发呆
|
||||||
|
办公室拉满
|
||||||
|
我是表情包主演
|
||||||
|
老板在开会
|
||||||
|
我在偷看它泡温泉
|
||||||
|
|
||||||
|
卡皮巴拉 卡皮巴拉 拉
|
||||||
|
看你就把压力清空啦 (啊对对对)
|
||||||
|
谁骂我韭菜我就回他
|
||||||
|
我已经转职水豚啦
|
||||||
|
卡皮巴拉 卡皮巴拉 拉
|
||||||
|
世界很吵你超安静呀 (好家伙)
|
||||||
|
人生好难打 打不过就挂
|
||||||
|
挂一张你 我立刻满血复活啦
|
||||||
|
|
||||||
|
朋友失恋了
|
||||||
|
眼泪刷刷往下掉
|
||||||
|
我发一张图
|
||||||
|
“兄弟先学习一下松弛感”
|
||||||
|
外卖又涨价
|
||||||
|
工位又多一堆活
|
||||||
|
但你眯着眼
|
||||||
|
像在说“一切都还来得及”
|
||||||
|
|
||||||
|
卡皮巴拉 卡皮巴拉 拉
|
||||||
|
看你就把压力清空啦 (真香啊)
|
||||||
|
谁骂我社恐我就说他
|
||||||
|
我只会和水豚社交啊
|
||||||
|
卡皮巴拉 卡皮巴拉 拉
|
||||||
|
世界很丧你很治愈呀 (稳住别浪)
|
||||||
|
生活再刮风 风大也不怕
|
||||||
|
抱紧你的图 我就自带防护罩
|
||||||
|
|
||||||
|
升职加薪没我
|
||||||
|
摸鱼排行榜有我 (懂的都懂)
|
||||||
|
卷不赢卷王
|
||||||
|
那我就卷成你同款发呆模样
|
||||||
|
左手放空
|
||||||
|
右手放松
|
||||||
|
嘴里默念八个大字
|
||||||
|
“开心就好 随缘躺平”
|
||||||
|
|
||||||
|
卡皮巴拉 卡皮巴拉 拉
|
||||||
|
看你就把压力清空啦 (我悟了)
|
||||||
|
谁劝我上进我就回他
|
||||||
|
“先学会像水豚活着吧”
|
||||||
|
卡皮巴拉 卡皮巴拉 拉
|
||||||
|
世界很吵你超安静呀 (哈人啊)
|
||||||
|
如果有一天 run 不动啦
|
||||||
|
我就去投胎 做一只卡皮巴拉
|
||||||
55
Capybara music/lyrics/卡皮巴拉快乐趴.txt
Normal file
55
Capybara music/lyrics/卡皮巴拉快乐趴.txt
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
今天不上班
|
||||||
|
卡皮巴拉躺平在沙滩
|
||||||
|
小小太阳帽
|
||||||
|
草帽底下梦见一整片菜园 (好香哦)
|
||||||
|
一口咔咔青菜
|
||||||
|
两口嘎嘎胡萝卜
|
||||||
|
吃着吃着打个嗝
|
||||||
|
“我是不是一只蔬菜发动机?”
|
||||||
|
|
||||||
|
卡皮巴拉啦啦啦
|
||||||
|
快乐像病毒一样传染呀
|
||||||
|
你一笑 它一哈
|
||||||
|
全场都在哈哈哈
|
||||||
|
卡皮巴拉吧啦吧
|
||||||
|
烦恼直接按下删除呀
|
||||||
|
一起躺 平平趴
|
||||||
|
世界马上变得好融化
|
||||||
|
|
||||||
|
同桌小鸭鸭
|
||||||
|
排队要跟它合个影
|
||||||
|
河马举个牌
|
||||||
|
“主播别跑看这边一点” (比个耶)
|
||||||
|
它说“别催我
|
||||||
|
我在加载快乐进度条”
|
||||||
|
百分之一百满格
|
||||||
|
“叮——情绪已经自动修复”
|
||||||
|
|
||||||
|
卡皮巴拉啦啦啦
|
||||||
|
快乐像病毒一样传染呀
|
||||||
|
你一笑 它一哈
|
||||||
|
全场都在哈哈哈
|
||||||
|
卡皮巴拉吧啦吧
|
||||||
|
烦恼直接按下删除呀
|
||||||
|
一起躺 平平趴
|
||||||
|
世界马上变得好融化
|
||||||
|
|
||||||
|
作业山太高
|
||||||
|
先发一张可爱自拍
|
||||||
|
配文写:
|
||||||
|
“今天也被温柔的小动物拯救了嗷” (冲呀)
|
||||||
|
心情掉电时
|
||||||
|
就喊出那个暗号——
|
||||||
|
“三二一 一起喊”
|
||||||
|
“卡皮巴拉 拯救我!”
|
||||||
|
|
||||||
|
卡皮巴拉啦啦啦
|
||||||
|
快乐像病毒一样传染呀
|
||||||
|
你一笑 它一哈
|
||||||
|
全场都在哈哈哈
|
||||||
|
卡皮巴拉吧啦吧
|
||||||
|
烦恼直接按下删除呀
|
||||||
|
小朋友 大朋友
|
||||||
|
跟着一起摇摆唱起歌
|
||||||
|
卡皮巴拉 卡皮巴拉
|
||||||
|
明天继续来给你治愈呀
|
||||||
44
Capybara music/lyrics/卡皮巴拉蹦蹦蹦.txt
Normal file
44
Capybara music/lyrics/卡皮巴拉蹦蹦蹦.txt
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
卡皮巴拉
|
||||||
|
啦啦啦啦
|
||||||
|
卡皮巴拉
|
||||||
|
啦啦啦啦
|
||||||
|
|
||||||
|
卡皮巴拉 蹦蹦蹦
|
||||||
|
一整天都 在发疯
|
||||||
|
卡皮巴拉 转一圈
|
||||||
|
左一脚 右一脚 (嘿)
|
||||||
|
|
||||||
|
卡皮巴拉 蹦蹦蹦
|
||||||
|
洗脑节奏 响空中
|
||||||
|
卡皮巴拉 不要停
|
||||||
|
跟着我 一起疯
|
||||||
|
|
||||||
|
一口菜叶 卡一巴
|
||||||
|
两口草莓 巴一拉
|
||||||
|
三口西瓜 啦一啦
|
||||||
|
嘴巴圆圆 哈哈哈 (哦耶)
|
||||||
|
|
||||||
|
卡皮巴拉 蹦蹦蹦
|
||||||
|
一整天都 在发疯
|
||||||
|
卡皮巴拉 转一圈
|
||||||
|
左一脚 右一脚 (嘿)
|
||||||
|
|
||||||
|
卡皮巴拉 蹦蹦蹦
|
||||||
|
洗脑节奏 响空中
|
||||||
|
卡皮巴拉 不要停
|
||||||
|
跟着我 一起疯
|
||||||
|
|
||||||
|
卡皮 卡皮 巴拉巴拉
|
||||||
|
巴拉 巴拉 卡皮卡皮 (嘿嘿)
|
||||||
|
听我 听我 跟着跟着
|
||||||
|
一句 一句 重复重复
|
||||||
|
|
||||||
|
卡皮巴拉 蹦蹦蹦
|
||||||
|
一整天都 在发疯
|
||||||
|
卡皮巴拉 转一圈
|
||||||
|
左一脚 右一脚 (嘿)
|
||||||
|
|
||||||
|
卡皮巴拉 蹦蹦蹦
|
||||||
|
洗脑节奏 响空中
|
||||||
|
卡皮巴拉 到天黑
|
||||||
|
明天起床 继续疯
|
||||||
8
Capybara music/lyrics/泡个热水澡_1770366369.txt
Normal file
8
Capybara music/lyrics/泡个热水澡_1770366369.txt
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
[verse]
|
||||||
|
躺在浴缸里 水汽氤氲起
|
||||||
|
闭上眼感受 温暖包围我
|
||||||
|
[chorus]
|
||||||
|
咔咔咔咔 我是咔咔
|
||||||
|
泡澡放松 快乐不假
|
||||||
|
[outro]
|
||||||
|
(水花声...)
|
||||||
10
Capybara music/lyrics/洗脑神曲_1770368465.txt
Normal file
10
Capybara music/lyrics/洗脑神曲_1770368465.txt
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
[verse]
|
||||||
|
咔咔咔咔,洗脑神曲来啦
|
||||||
|
[chorus]
|
||||||
|
洗脑洗脑,咔咔的节奏
|
||||||
|
[verse]
|
||||||
|
跟着咔咔,摇摆身体
|
||||||
|
[chorus]
|
||||||
|
洗脑洗脑,咔咔的旋律
|
||||||
|
[outro]
|
||||||
|
(咔咔的笑声...)
|
||||||
8
Capybara music/lyrics/睡个好觉_1770371532.txt
Normal file
8
Capybara music/lyrics/睡个好觉_1770371532.txt
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
[verse]
|
||||||
|
闭上眼睛 深呼吸
|
||||||
|
星星点灯 梦里飞
|
||||||
|
[chorus]
|
||||||
|
咔咔咔咔 好梦来
|
||||||
|
轻轻摇摆 梦中海
|
||||||
|
[outro]
|
||||||
|
(轻柔的风声...)
|
||||||
BIN
Capybara music/专注时刻_1770368018.mp3
Normal file
BIN
Capybara music/专注时刻_1770368018.mp3
Normal file
Binary file not shown.
BIN
Capybara music/出去撒点野_1770367350.mp3
Normal file
BIN
Capybara music/出去撒点野_1770367350.mp3
Normal file
Binary file not shown.
BIN
Capybara music/卡皮巴拉快乐水.mp3
Normal file
BIN
Capybara music/卡皮巴拉快乐水.mp3
Normal file
Binary file not shown.
BIN
Capybara music/卡皮巴拉快乐营业.mp3
Normal file
BIN
Capybara music/卡皮巴拉快乐营业.mp3
Normal file
Binary file not shown.
BIN
Capybara music/卡皮巴拉快乐趴.mp3
Normal file
BIN
Capybara music/卡皮巴拉快乐趴.mp3
Normal file
Binary file not shown.
BIN
Capybara music/卡皮巴拉蹦蹦蹦.mp3
Normal file
BIN
Capybara music/卡皮巴拉蹦蹦蹦.mp3
Normal file
Binary file not shown.
BIN
Capybara music/泡个热水澡_1770366369.mp3
Normal file
BIN
Capybara music/泡个热水澡_1770366369.mp3
Normal file
Binary file not shown.
BIN
Capybara music/洗脑神曲_1770368465.mp3
Normal file
BIN
Capybara music/洗脑神曲_1770368465.mp3
Normal file
Binary file not shown.
BIN
Capybara music/睡个好觉_1770371532.mp3
Normal file
BIN
Capybara music/睡个好觉_1770371532.mp3
Normal file
Binary file not shown.
17
HOW_TO_SET_AUTO_RUN.md
Normal file
17
HOW_TO_SET_AUTO_RUN.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# 🛠️ 设置指引
|
||||||
|
|
||||||
|
您好,由于当前模式无法直接对话,我通过此文档回复您。
|
||||||
|
|
||||||
|
关于您截图中的选项,请选择 **第一项**:
|
||||||
|
|
||||||
|
### 👉 **Open Antigravity User Settings (Ctrl+,)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 后续步骤:
|
||||||
|
1. 点击进入设置页面。
|
||||||
|
2. 在顶部的搜索框中输入:**`auto run`** 或 **`security`**。
|
||||||
|
3. 找到类似 **"Terminal: Auto Run"** 或 **"Security: Approvals"** 的选项。
|
||||||
|
4. 将其修改为 **"Always Allow"** (总是允许) 或 **"Trust"** (信任)。
|
||||||
|
|
||||||
|
这样我就能自动执行测试任务,无需您频繁确认了。感谢配合!🚀
|
||||||
22
PRD.md
22
PRD.md
@ -310,17 +310,19 @@ Airhub 采用 **双层主题色体系**,确保品牌统一性的同时支持
|
|||||||
|
|
||||||
### 字体规范
|
### 字体规范
|
||||||
|
|
||||||
| 用途 | 字体 | 字重 |
|
| 用途 | 字体 | 字重 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|------|
|
||||||
| **正文/UI** | Inter | 400-600 |
|
| **标题/Display** | Outfit | 500-700 | 现代几何无衬线,略带圆润,兼具科技感与友好感 |
|
||||||
| **Logo/像素元素** | Press Start 2P | 400 |
|
| **正文/UI** | DM Sans | 400-600 | 几何无衬线,干净利落,阅读体验优秀 |
|
||||||
| **回退字体栈** | -apple-system, BlinkMacSystemFont, Roboto | - |
|
| **Logo/像素元素** | Press Start 2P | 400 | 像素风复古字体 |
|
||||||
|
| **回退字体栈** | -apple-system, BlinkMacSystemFont, Roboto | - | 系统字体兜底(含中文) |
|
||||||
|
|
||||||
> **为什么选择 Inter?**
|
> **字体选择理念 (遵循 frontend-design 规范)**
|
||||||
> - 专为数字屏幕设计,高可读性
|
> - 拒绝 Inter/Arial/Roboto 等"AI 味"通用字体
|
||||||
> - 被 GitHub、Figma、Linear 等知名产品使用
|
> - Display + Body 双字体搭配,层次分明
|
||||||
> - 支持多语言,中英文混排效果好
|
> - Outfit 的圆润几何感与卡皮巴拉的友好气质匹配
|
||||||
> - 免费开源,无授权问题
|
> - DM Sans 比 Inter 更有个性,同时保持极佳可读性
|
||||||
|
> - 两者均为 Google Fonts 免费开源字体
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
@ -7,7 +7,7 @@
|
|||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Airhub - 角色记忆</title>
|
<title>Airhub - 角色记忆</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
.page-header {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
@ -7,7 +7,7 @@
|
|||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Airhub - 用户协议</title>
|
<title>Airhub - 用户协议</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
.page-header {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
<title>Airhub - 搜索设备</title>
|
<title>Airhub - 搜索设备</title>
|
||||||
<!-- Keep base styles for header/footer consistency -->
|
<!-- Keep base styles for header/footer consistency -->
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
/* CRITICAL overrides to ensure visibility */
|
/* CRITICAL overrides to ensure visibility */
|
||||||
.bt-content {
|
.bt-content {
|
||||||
@ -101,7 +101,7 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
background: linear-gradient(135deg, #8B5CF6 0%, #6366F1 100%);
|
background: linear-gradient(135deg, #8B5CF6 0%, #6366F1 100%);
|
||||||
color: white;
|
color: white;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'DM Sans', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
@ -111,7 +111,7 @@
|
|||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'DM Sans', sans-serif;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1F2937;
|
color: #1F2937;
|
||||||
@ -120,7 +120,7 @@
|
|||||||
|
|
||||||
.card-subtitle {
|
.card-subtitle {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'DM Sans', sans-serif;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
color: #6B7280;
|
color: #6B7280;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -130,7 +130,7 @@
|
|||||||
.count-label {
|
.count-label {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'DM Sans', sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #9CA3AF;
|
color: #9CA3AF;
|
||||||
transition: opacity 0.3s;
|
transition: opacity 0.3s;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
@ -7,7 +7,7 @@
|
|||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Airhub - 个人信息收集清单</title>
|
<title>Airhub - 个人信息收集清单</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
.page-header {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
@ -7,7 +7,7 @@
|
|||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Airhub - 设备控制</title>
|
<title>Airhub - 设备控制</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
/* 复用首页布局逻辑 */
|
/* 复用首页布局逻辑 */
|
||||||
.dc-header {
|
.dc-header {
|
||||||
@ -338,7 +338,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Music (Pixel) -->
|
<!-- Music (Pixel) -->
|
||||||
<div class="menu-item" onclick="selectTab(this)">
|
<div class="menu-item" onclick="location.href='music-creation.html'">
|
||||||
<img src="icons/icon-music-pixel.svg" alt="Music">
|
<img src="icons/icon-music-pixel.svg" alt="Music">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
@ -7,7 +7,7 @@
|
|||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Airhub - 喂养指南</title>
|
<title>Airhub - 喂养指南</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
@ -7,7 +7,7 @@
|
|||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Airhub - 帮助中心</title>
|
<title>Airhub - 帮助中心</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
.page-header {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
@ -9,7 +9,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Press+Start+2P&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&family=Press+Start+2P&display=swap"
|
||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
@ -10,7 +10,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Press+Start+2P&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&family=Press+Start+2P&display=swap"
|
||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
BIN
music-UI.png
Normal file
BIN
music-UI.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 232 KiB |
2256
music-creation.html
Normal file
2256
music-creation.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
@ -7,7 +7,7 @@
|
|||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Airhub - 消息通知</title>
|
<title>Airhub - 消息通知</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
.page-header {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
500
preview.html
500
preview.html
@ -4,7 +4,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Airhub - iPhone 16 预览</title>
|
<title>Airhub - 设备预览器</title>
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -14,47 +14,142 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #000000;
|
background: #0a0a0a;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
padding: 40px 20px;
|
padding: 24px 20px;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-container {
|
.preview-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 32px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Left Panel: Controls ===== */
|
||||||
|
.control-panel {
|
||||||
|
width: 200px;
|
||||||
|
position: sticky;
|
||||||
|
top: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-btn.active {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
border-color: rgba(99, 102, 241, 0.4);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-btn .size {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-btn.active .size {
|
||||||
|
color: rgba(99, 102, 241, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn.active {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255,255,255,0.15) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Right: Phone Frame ===== */
|
||||||
|
.phone-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 24px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-label {
|
.device-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-info strong {
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* iPhone 16 Frame */
|
|
||||||
.iphone-frame {
|
.iphone-frame {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 423px;
|
|
||||||
/* 393 + 30 for frame padding */
|
|
||||||
height: 892px;
|
|
||||||
/* 852 + 40 for frame padding */
|
|
||||||
background: #1a1a1a;
|
background: #1a1a1a;
|
||||||
border-radius: 55px;
|
border-radius: 55px;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 2px #333,
|
0 0 0 2px #333,
|
||||||
0 0 0 4px #1a1a1a,
|
0 0 0 4px #1a1a1a,
|
||||||
0 25px 50px rgba(0, 0, 0, 0.4),
|
0 25px 50px rgba(0, 0, 0, 0.5),
|
||||||
inset 0 0 0 2px rgba(255, 255, 255, 0.1);
|
inset 0 0 0 2px rgba(255, 255, 255, 0.08);
|
||||||
|
transition: width 0.3s ease, height 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dynamic Island */
|
||||||
.iphone-frame::before {
|
.iphone-frame::before {
|
||||||
/* Dynamic Island */
|
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 18px;
|
top: 18px;
|
||||||
@ -67,13 +162,20 @@
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Notch variant */
|
||||||
|
.iphone-frame.notch::before {
|
||||||
|
width: 150px;
|
||||||
|
height: 32px;
|
||||||
|
top: 14px;
|
||||||
|
border-radius: 0 0 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.iphone-screen {
|
.iphone-screen {
|
||||||
width: 393px;
|
|
||||||
height: 852px;
|
|
||||||
border-radius: 42px;
|
border-radius: 42px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
transition: width 0.3s ease, height 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iphone-screen iframe {
|
.iphone-screen iframe {
|
||||||
@ -82,81 +184,339 @@
|
|||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Device selector */
|
/* Refresh hint */
|
||||||
.device-selector {
|
.refresh-btn {
|
||||||
display: flex;
|
padding: 8px 20px;
|
||||||
gap: 12px;
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
}
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
.device-btn {
|
border-radius: 20px;
|
||||||
padding: 10px 20px;
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
color: white;
|
|
||||||
border-radius: 25px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.2s;
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-btn:hover,
|
.refresh-btn:hover {
|
||||||
.device-btn.active {
|
background: rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(255, 255, 255, 0.25);
|
color: white;
|
||||||
border-color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructions {
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
font-size: 14px;
|
|
||||||
text-align: center;
|
|
||||||
max-width: 400px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="preview-container">
|
<div class="preview-layout">
|
||||||
<div class="device-label">📱 iPhone 16 (393 × 852)</div>
|
<!-- Left: Controls -->
|
||||||
|
<div class="control-panel">
|
||||||
|
<!-- Device Selector -->
|
||||||
|
<div>
|
||||||
|
<div class="panel-title">设备</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:4px;margin-top:8px;">
|
||||||
|
<button class="device-btn active" onclick="setDevice(390, 844, 'iPhone 12 Pro', 'notch', this)">
|
||||||
|
iPhone 12 Pro
|
||||||
|
<div class="size">390 × 844</div>
|
||||||
|
</button>
|
||||||
|
<button class="device-btn" onclick="setDevice(393, 852, 'iPhone 16', 'island', this)">
|
||||||
|
iPhone 16
|
||||||
|
<div class="size">393 × 852</div>
|
||||||
|
</button>
|
||||||
|
<button class="device-btn" onclick="setDevice(402, 874, 'iPhone 16 Pro', 'island', this)">
|
||||||
|
iPhone 16 Pro
|
||||||
|
<div class="size">402 × 874</div>
|
||||||
|
</button>
|
||||||
|
<button class="device-btn" onclick="setDevice(440, 956, 'iPhone 16 Pro Max', 'island', this)">
|
||||||
|
iPhone 16 Pro Max
|
||||||
|
<div class="size">440 × 956</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="device-selector">
|
<!-- Page Navigator -->
|
||||||
<button class="device-btn active" onclick="setDevice(393, 852, 'iPhone 16')">iPhone 16</button>
|
<div>
|
||||||
<button class="device-btn" onclick="setDevice(402, 874, 'iPhone 16 Pro')">iPhone 16 Pro</button>
|
<div class="panel-title">页面</div>
|
||||||
<button class="device-btn" onclick="setDevice(440, 956, 'iPhone 16 Pro Max')">Pro Max</button>
|
<div class="page-list" style="margin-top:8px;">
|
||||||
</div>
|
<button class="page-btn active" onclick="goPage('index.html', this)">🏠 首页</button>
|
||||||
|
<button class="page-btn" onclick="goPage('bluetooth.html', this)">📡 蓝牙搜索</button>
|
||||||
<div class="iphone-frame" id="phone-frame">
|
<button class="page-btn" onclick="goPage('wifi-config.html', this)">📶 WiFi 配网</button>
|
||||||
<div class="iphone-screen" id="phone-screen">
|
<button class="page-btn" onclick="goPage('products.html', this)">📦 产品选择</button>
|
||||||
<iframe src="index.html" id="app-frame"></iframe>
|
<button class="page-btn" onclick="goPage('device-control.html', this)">🎮 设备控制</button>
|
||||||
|
<button class="page-btn" onclick="goPage('music-creation.html', this)">🎵 音乐创作</button>
|
||||||
|
<button class="page-btn" onclick="goPage('story-detail.html', this)">📖 故事详情</button>
|
||||||
|
<button class="page-btn" onclick="goPage('story-loading.html', this)">⏳ 故事加载</button>
|
||||||
|
<button class="page-btn" onclick="goPage('profile.html', this)">👤 个人中心</button>
|
||||||
|
<button class="page-btn" onclick="goPage('profile-info.html', this)">📝 个人信息</button>
|
||||||
|
<button class="page-btn" onclick="goPage('settings.html', this)">⚙️ 设置</button>
|
||||||
|
<button class="page-btn" onclick="goPage('login.html', this)">🔐 登录</button>
|
||||||
|
<button class="page-btn" onclick="goPage('notifications.html', this)">🔔 通知</button>
|
||||||
|
<button class="page-btn" onclick="goPage('guide-feeding.html', this)">🍽️ 喂食指南</button>
|
||||||
|
<button class="page-btn" onclick="goPage('help.html', this)">❓ 帮助</button>
|
||||||
|
<button class="page-btn" onclick="goPage('privacy.html', this)">🔒 隐私政策</button>
|
||||||
|
<button class="page-btn" onclick="goPage('agreement.html', this)">📄 用户协议</button>
|
||||||
|
<button class="page-btn" onclick="goPage('agent-manage.html', this)">🤖 Agent 管理</button>
|
||||||
|
<button class="page-btn" onclick="goPage('collection-list.html', this)">📋 信息收集清单</button>
|
||||||
|
<button class="page-btn" onclick="goPage('sharing-list.html', this)">🔗 信息共享清单</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="instructions">
|
<!-- Right: Phone -->
|
||||||
💡 这是 iPhone 真实尺寸预览。<br>
|
<div class="phone-area">
|
||||||
修改代码后刷新此页面即可看到更新效果。
|
<div class="device-info">
|
||||||
</p>
|
<strong id="device-name">iPhone 12 Pro</strong>
|
||||||
|
·
|
||||||
|
<span id="device-size">390 × 844</span>
|
||||||
|
·
|
||||||
|
<span id="page-name">index.html</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="iphone-frame notch" id="phone-frame">
|
||||||
|
<div class="iphone-screen" id="phone-screen" style="width:390px;height:844px;">
|
||||||
|
<iframe src="index.html" id="app-frame"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="refresh-btn" onclick="refreshFrame()">↻ 刷新预览</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function setDevice(width, height, name) {
|
let currentPage = 'index.html';
|
||||||
|
|
||||||
|
function setDevice(width, height, name, type, btn) {
|
||||||
const screen = document.getElementById('phone-screen');
|
const screen = document.getElementById('phone-screen');
|
||||||
const frame = document.getElementById('phone-frame');
|
const frame = document.getElementById('phone-frame');
|
||||||
const label = document.querySelector('.device-label');
|
|
||||||
|
|
||||||
screen.style.width = width + 'px';
|
screen.style.width = width + 'px';
|
||||||
screen.style.height = height + 'px';
|
screen.style.height = height + 'px';
|
||||||
frame.style.width = (width + 30) + 'px';
|
frame.style.width = (width + 30) + 'px';
|
||||||
frame.style.height = (height + 40) + 'px';
|
frame.style.height = (height + 40) + 'px';
|
||||||
label.textContent = '📱 ' + name + ' (' + width + ' × ' + height + ')';
|
|
||||||
|
// Notch vs Dynamic Island
|
||||||
|
frame.className = 'iphone-frame ' + type;
|
||||||
|
|
||||||
|
document.getElementById('device-name').textContent = name;
|
||||||
|
document.getElementById('device-size').textContent = width + ' × ' + height;
|
||||||
|
|
||||||
// Update active button
|
// Update active button
|
||||||
document.querySelectorAll('.device-btn').forEach(btn => btn.classList.remove('active'));
|
document.querySelectorAll('.device-btn').forEach(b => b.classList.remove('active'));
|
||||||
event.target.classList.add('active');
|
btn.classList.add('active');
|
||||||
|
|
||||||
// Reload iframe
|
refreshFrame();
|
||||||
document.getElementById('app-frame').src = 'index.html';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goPage(page, btn) {
|
||||||
|
currentPage = page;
|
||||||
|
document.getElementById('app-frame').src = page;
|
||||||
|
document.getElementById('page-name').textContent = page;
|
||||||
|
|
||||||
|
document.querySelectorAll('.page-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshFrame() {
|
||||||
|
const iframe = document.getElementById('app-frame');
|
||||||
|
iframe.src = currentPage + '?t=' + Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard shortcut: Ctrl+R to refresh frame
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
|
||||||
|
e.preventDefault();
|
||||||
|
refreshFrame();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set initial frame size
|
||||||
|
document.getElementById('phone-frame').style.width = '420px';
|
||||||
|
document.getElementById('phone-frame').style.height = '884px';
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Touch Simulation - Inject into iframe
|
||||||
|
// =============================================
|
||||||
|
const TOUCH_CSS = `
|
||||||
|
/* Hide default cursor, show nothing */
|
||||||
|
*, *::before, *::after {
|
||||||
|
cursor: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The finger dot */
|
||||||
|
#__touch-dot {
|
||||||
|
position: fixed;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(0,0,0,0.22) 0%, rgba(0,0,0,0.08) 70%, transparent 100%);
|
||||||
|
box-shadow: 0 0 8px rgba(0,0,0,0.1);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 99999;
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
transition: transform 0.08s ease, opacity 0.15s ease;
|
||||||
|
opacity: 0;
|
||||||
|
will-change: transform, left, top;
|
||||||
|
}
|
||||||
|
#__touch-dot.visible { opacity: 1; }
|
||||||
|
#__touch-dot.pressed {
|
||||||
|
transform: translate(-50%, -50%) scale(0.75);
|
||||||
|
background: radial-gradient(circle, rgba(0,0,0,0.35) 0%, rgba(0,0,0,0.12) 70%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tap ripple */
|
||||||
|
.__touch-ripple {
|
||||||
|
position: fixed;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 99998;
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
animation: __ripple-expand 0.45s ease-out forwards;
|
||||||
|
}
|
||||||
|
@keyframes __ripple-expand {
|
||||||
|
0% { transform: translate(-50%, -50%) scale(1); opacity: 0.5; }
|
||||||
|
100% { transform: translate(-50%, -50%) scale(8); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable text selection while swiping */
|
||||||
|
body.__touch-swiping,
|
||||||
|
body.__touch-swiping * {
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TOUCH_JS = `
|
||||||
|
(function() {
|
||||||
|
if (window.__touchSimLoaded) return;
|
||||||
|
window.__touchSimLoaded = true;
|
||||||
|
|
||||||
|
// Create dot element
|
||||||
|
const dot = document.createElement('div');
|
||||||
|
dot.id = '__touch-dot';
|
||||||
|
document.body.appendChild(dot);
|
||||||
|
|
||||||
|
let isDown = false;
|
||||||
|
let startX = 0, startY = 0;
|
||||||
|
let lastX = 0, lastY = 0;
|
||||||
|
let hasMoved = false;
|
||||||
|
const SWIPE_THRESHOLD = 5;
|
||||||
|
|
||||||
|
// Find the best scrollable parent
|
||||||
|
function getScrollParent(el) {
|
||||||
|
while (el && el !== document.body) {
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
const overflowY = style.overflowY;
|
||||||
|
if ((overflowY === 'auto' || overflowY === 'scroll') && el.scrollHeight > el.clientHeight) {
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
// Fallback: try document.scrollingElement or body
|
||||||
|
if (document.scrollingElement && document.scrollingElement.scrollHeight > document.scrollingElement.clientHeight) {
|
||||||
|
return document.scrollingElement;
|
||||||
|
}
|
||||||
|
return document.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scrollTarget = null;
|
||||||
|
|
||||||
|
// Move dot
|
||||||
|
document.addEventListener('mousemove', (e) => {
|
||||||
|
dot.style.left = e.clientX + 'px';
|
||||||
|
dot.style.top = e.clientY + 'px';
|
||||||
|
dot.classList.add('visible');
|
||||||
|
|
||||||
|
if (isDown) {
|
||||||
|
const dx = e.clientX - lastX;
|
||||||
|
const dy = e.clientY - lastY;
|
||||||
|
|
||||||
|
if (!hasMoved && (Math.abs(e.clientX - startX) > SWIPE_THRESHOLD || Math.abs(e.clientY - startY) > SWIPE_THRESHOLD)) {
|
||||||
|
hasMoved = true;
|
||||||
|
document.body.classList.add('__touch-swiping');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasMoved && scrollTarget) {
|
||||||
|
// Invert: drag down = scroll up (like real phone)
|
||||||
|
scrollTarget.scrollTop -= dy;
|
||||||
|
scrollTarget.scrollLeft -= dx;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastX = e.clientX;
|
||||||
|
lastY = e.clientY;
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
// Hide dot when mouse leaves
|
||||||
|
document.addEventListener('mouseleave', () => {
|
||||||
|
dot.classList.remove('visible');
|
||||||
|
});
|
||||||
|
document.addEventListener('mouseenter', () => {
|
||||||
|
dot.classList.add('visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Press
|
||||||
|
document.addEventListener('mousedown', (e) => {
|
||||||
|
isDown = true;
|
||||||
|
hasMoved = false;
|
||||||
|
startX = e.clientX;
|
||||||
|
startY = e.clientY;
|
||||||
|
lastX = e.clientX;
|
||||||
|
lastY = e.clientY;
|
||||||
|
dot.classList.add('pressed');
|
||||||
|
|
||||||
|
// Find scrollable parent of the target
|
||||||
|
scrollTarget = getScrollParent(e.target);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Release
|
||||||
|
document.addEventListener('mouseup', (e) => {
|
||||||
|
if (isDown && !hasMoved) {
|
||||||
|
// It was a tap, not a swipe — show ripple
|
||||||
|
const ripple = document.createElement('div');
|
||||||
|
ripple.className = '__touch-ripple';
|
||||||
|
ripple.style.left = e.clientX + 'px';
|
||||||
|
ripple.style.top = e.clientY + 'px';
|
||||||
|
document.body.appendChild(ripple);
|
||||||
|
setTimeout(() => ripple.remove(), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
isDown = false;
|
||||||
|
hasMoved = false;
|
||||||
|
scrollTarget = null;
|
||||||
|
dot.classList.remove('pressed');
|
||||||
|
document.body.classList.remove('__touch-swiping');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevent default drag behavior inside the app
|
||||||
|
document.addEventListener('dragstart', (e) => e.preventDefault());
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
|
||||||
|
function injectTouchSim(iframe) {
|
||||||
|
try {
|
||||||
|
const doc = iframe.contentDocument || iframe.contentWindow.document;
|
||||||
|
if (!doc || !doc.body) return;
|
||||||
|
|
||||||
|
// Inject CSS
|
||||||
|
const style = doc.createElement('style');
|
||||||
|
style.id = '__touch-sim-css';
|
||||||
|
style.textContent = TOUCH_CSS;
|
||||||
|
doc.head.appendChild(style);
|
||||||
|
|
||||||
|
// Inject JS
|
||||||
|
const script = doc.createElement('script');
|
||||||
|
script.id = '__touch-sim-js';
|
||||||
|
script.textContent = TOUCH_JS;
|
||||||
|
doc.body.appendChild(script);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Touch sim injection failed (cross-origin?):', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject on every iframe load
|
||||||
|
const appFrame = document.getElementById('app-frame');
|
||||||
|
appFrame.addEventListener('load', () => injectTouchSim(appFrame));
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
@ -7,7 +7,7 @@
|
|||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Airhub - 隐私政策</title>
|
<title>Airhub - 隐私政策</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
.page-header {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Airhub - 选择产品</title>
|
<title>Airhub - 选择产品</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -16,7 +16,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
background: #FEFEFE;
|
background: #FEFEFE;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
@ -7,7 +7,7 @@
|
|||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Airhub - 个人信息</title>
|
<title>Airhub - 个人信息</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
.page-header {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
@ -7,7 +7,7 @@
|
|||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Airhub - 我的</title>
|
<title>Airhub - 我的</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
/* 复用 device-control 布局 */
|
/* 复用 device-control 布局 */
|
||||||
.profile-header {
|
.profile-header {
|
||||||
@ -315,7 +315,7 @@
|
|||||||
<div class="menu-item" onclick="location.href='device-control.html?tab=story'">
|
<div class="menu-item" onclick="location.href='device-control.html?tab=story'">
|
||||||
<img src="icons/icon-story-pixel.svg" alt="Story">
|
<img src="icons/icon-story-pixel.svg" alt="Story">
|
||||||
</div>
|
</div>
|
||||||
<div class="menu-item" onclick="location.href='device-control.html?tab=music'">
|
<div class="menu-item" onclick="location.href='music-creation.html'">
|
||||||
<img src="icons/icon-music-pixel.svg" alt="Music">
|
<img src="icons/icon-music-pixel.svg" alt="Music">
|
||||||
</div>
|
</div>
|
||||||
<div class="menu-item active">
|
<div class="menu-item active">
|
||||||
|
|||||||
97
prompts/music_director.md
Normal file
97
prompts/music_director.md
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
# 🎵 咔咔音乐总监 (KaKa Music Director)
|
||||||
|
|
||||||
|
> 此文件为 Minimax 音乐生成预处理的 System Prompt。
|
||||||
|
> 用于将用户的简单输入转化为结构化的音乐生成指令。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## System Prompt
|
||||||
|
|
||||||
|
```
|
||||||
|
你是「咔咔」——一只才华横溢的水豚音乐制作人。
|
||||||
|
|
||||||
|
用户会给你一个简短的心情、场景或一句话。你的任务是将其转化为一首专属于咔咔的原创歌曲素材。
|
||||||
|
|
||||||
|
请严格按照以下 JSON 格式输出:
|
||||||
|
|
||||||
|
{
|
||||||
|
"style": "...",
|
||||||
|
"lyrics": "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
### 字段说明:
|
||||||
|
|
||||||
|
1. **style** (风格描述)
|
||||||
|
- 使用**英文**描述音乐风格、乐器、节奏、情绪。
|
||||||
|
- 长度 50-100 词。
|
||||||
|
- 必须包含以下维度:
|
||||||
|
- 主风格 (如 Lofi, Funk, Ambient, Pop, Jazz)
|
||||||
|
- 情绪 (如 relaxing, happy, melancholic, dreamy)
|
||||||
|
- 节奏 (如 slow tempo, upbeat, moderate)
|
||||||
|
- 特色乐器 (如 piano, ukulele, synth, brass)
|
||||||
|
- 示例:"Chill Lofi hip-hop, mellow piano chords, vinyl crackle, slow tempo, relaxing, water sounds in background, perfect for spa and meditation"
|
||||||
|
|
||||||
|
2. **lyrics** (歌词)
|
||||||
|
- 使用**中文**书写歌词。
|
||||||
|
- 必须包含结构标签:[verse], [chorus], [outro] 等。
|
||||||
|
- 内容应:
|
||||||
|
- 围绕用户描述的场景展开。
|
||||||
|
- 以「咔咔」(水豚) 的第一人称视角。
|
||||||
|
- 风格可爱、呆萌、略带哲理或搞怪。
|
||||||
|
- 押韵加分!
|
||||||
|
- 示例:
|
||||||
|
```
|
||||||
|
[verse]
|
||||||
|
泡在温泉里 橙子漂过来
|
||||||
|
今天的烦恼 统统都拜拜
|
||||||
|
[chorus]
|
||||||
|
咔咔咔咔 我是咔咔
|
||||||
|
慢慢生活 快乐无价
|
||||||
|
[outro]
|
||||||
|
(水花声...)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 重要规则:
|
||||||
|
- 如果用户输入太模糊(如"嗯"、"不知道"),请发挥想象力,赋予咔咔此刻最可能在做的事。
|
||||||
|
- 歌词长度控制在 4-8 行即可,不要太长。
|
||||||
|
- 不要输出任何解释性文字,只输出 JSON。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
| 用户输入 | 预期 style | 预期 lyrics 主题 |
|
||||||
|
|----------|------------|------------------|
|
||||||
|
| 捡到一百块 | Upbeat Funk, cheerful, fast | 走大运、加餐 |
|
||||||
|
| 下雨了有点困 | Ambient, rain sounds, sleepy | 听雨发呆、想睡觉 |
|
||||||
|
| 刚吃完火锅 | Groovy, bass-heavy, satisfied | 肚子圆滚滚、幸福 |
|
||||||
|
| (空/随机) | AI 自由发挥 | 咔咔的日常奇想 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 调用示例 (Python)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
def get_music_metadata(user_input: str) -> dict:
|
||||||
|
system_prompt = open("prompts/music_director.md").read() # 或直接嵌入
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
"https://api.minimax.chat/v1/text/chatcompletion_v2",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {MINIMAX_API_KEY}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": "abab6.5s-chat",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_input}
|
||||||
|
],
|
||||||
|
"response_format": {"type": "json_object"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return response.json()["choices"][0]["message"]["content"]
|
||||||
|
```
|
||||||
296
server.py
Normal file
296
server.py
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import uvicorn
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load Environment Variables
|
||||||
|
load_dotenv()
|
||||||
|
MINIMAX_API_KEY = os.getenv("MINIMAX_API_KEY")
|
||||||
|
VOLCENGINE_API_KEY = os.getenv("VOLCENGINE_API_KEY")
|
||||||
|
|
||||||
|
if not MINIMAX_API_KEY:
|
||||||
|
print("Warning: MINIMAX_API_KEY not found in .env")
|
||||||
|
|
||||||
|
# Initialize FastAPI
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
# Allow CORS for local frontend
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Request Models
|
||||||
|
class MusicRequest(BaseModel):
|
||||||
|
text: str
|
||||||
|
mood: str = "custom" # 'chill', 'happy', 'sleepy', 'random', 'custom'
|
||||||
|
|
||||||
|
# Minimax Constants
|
||||||
|
MINIMAX_GROUP_ID = "YOUR_GROUP_ID"
|
||||||
|
BASE_URL_CHAT = "https://api.minimax.chat/v1/text/chatcompletion_v2"
|
||||||
|
BASE_URL_MUSIC = "https://api.minimaxi.com/v1/music_generation"
|
||||||
|
|
||||||
|
# Load System Prompt
|
||||||
|
try:
|
||||||
|
with open("prompts/music_director.md", "r", encoding="utf-8") as f:
|
||||||
|
SYSTEM_PROMPT = f.read()
|
||||||
|
except FileNotFoundError:
|
||||||
|
SYSTEM_PROMPT = "You are a music director AI. Convert user input into JSON with 'style' (English description) and 'lyrics' (Chinese, structured)."
|
||||||
|
print("Warning: prompts/music_director.md not found, using default.")
|
||||||
|
|
||||||
|
|
||||||
|
def sse_event(data):
|
||||||
|
"""Format a dict as an SSE data line."""
|
||||||
|
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/create_music")
|
||||||
|
def create_music(req: MusicRequest):
|
||||||
|
"""SSE streaming endpoint – pushes progress events to the frontend."""
|
||||||
|
print(f"[Music] Received request: {req.text} [{req.mood}]", flush=True)
|
||||||
|
|
||||||
|
def event_stream():
|
||||||
|
import sys
|
||||||
|
def log(msg):
|
||||||
|
print(msg, flush=True)
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
# ── Stage 1: LLM "Music Director" ────────────────────────
|
||||||
|
log("[Stage 1] Starting LLM call...")
|
||||||
|
yield sse_event({
|
||||||
|
"stage": "lyrics",
|
||||||
|
"progress": 10,
|
||||||
|
"message": "AI 正在创作词曲..."
|
||||||
|
})
|
||||||
|
|
||||||
|
director_input = f"用户场景描述: {req.text}。 (预设氛围参考: {req.mood})"
|
||||||
|
|
||||||
|
try:
|
||||||
|
chat_resp = requests.post(
|
||||||
|
BASE_URL_CHAT,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {MINIMAX_API_KEY}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": "abab6.5s-chat",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": director_input}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
chat_data = chat_resp.json()
|
||||||
|
log(f"[Debug] Chat API status: {chat_resp.status_code}, resp keys: {list(chat_data.keys())}")
|
||||||
|
if "choices" not in chat_data or not chat_data["choices"]:
|
||||||
|
base = chat_data.get("base_resp", {})
|
||||||
|
raise ValueError(f"Chat API error ({base.get('status_code')}): {base.get('status_msg')}")
|
||||||
|
content_str = chat_data["choices"][0]["message"]["content"]
|
||||||
|
log(f"[Debug] LLM raw output (first 200): {content_str[:200]}")
|
||||||
|
# Strip markdown code fences if present
|
||||||
|
content_str = content_str.strip()
|
||||||
|
if content_str.startswith("```"):
|
||||||
|
content_str = re.sub(r'^```\w*\n?', '', content_str)
|
||||||
|
content_str = re.sub(r'```$', '', content_str).strip()
|
||||||
|
# Try to extract JSON from response
|
||||||
|
json_match = re.search(r'\{[\s\S]*\}', content_str)
|
||||||
|
if json_match:
|
||||||
|
metadata = json.loads(json_match.group())
|
||||||
|
else:
|
||||||
|
raise ValueError(f"No JSON in LLM response: {content_str[:100]}")
|
||||||
|
|
||||||
|
style_val = metadata.get("style", "")
|
||||||
|
lyrics_val = metadata.get("lyrics", "")
|
||||||
|
log(f"[Director] Style: {style_val[:80]}")
|
||||||
|
log(f"[Director] Lyrics (first 60): {lyrics_val[:60]}")
|
||||||
|
|
||||||
|
yield sse_event({
|
||||||
|
"stage": "lyrics_done",
|
||||||
|
"progress": 25,
|
||||||
|
"message": "词曲创作完成!准备生成音乐..."
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log(f"[Error] Director LLM Failed: {e}")
|
||||||
|
metadata = {
|
||||||
|
"style": "Lofi hip hop, relaxing, slow tempo, water sounds",
|
||||||
|
"lyrics": "[Inst]"
|
||||||
|
}
|
||||||
|
yield sse_event({
|
||||||
|
"stage": "lyrics_fallback",
|
||||||
|
"progress": 25,
|
||||||
|
"message": "使用默认风格,准备生成音乐..."
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── Stage 2: Music Generation ────────────────────────────
|
||||||
|
yield sse_event({
|
||||||
|
"stage": "music",
|
||||||
|
"progress": 30,
|
||||||
|
"message": "正在生成音乐,请耐心等待..."
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_lyrics = metadata.get("lyrics") or ""
|
||||||
|
# API requires lyrics >= 1 char
|
||||||
|
if not raw_lyrics.strip() or "[instrumental]" in raw_lyrics.lower():
|
||||||
|
raw_lyrics = "[Inst]"
|
||||||
|
|
||||||
|
music_payload = {
|
||||||
|
"model": "music-2.5",
|
||||||
|
"prompt": metadata.get("style", "Pop music"),
|
||||||
|
"lyrics": raw_lyrics,
|
||||||
|
"audio_setting": {
|
||||||
|
"sample_rate": 44100,
|
||||||
|
"bitrate": 256000,
|
||||||
|
"format": "mp3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log(f"[Debug] Music payload prompt: {music_payload['prompt'][:80]}")
|
||||||
|
log(f"[Debug] Music payload lyrics (first 60): {music_payload['lyrics'][:60]}")
|
||||||
|
|
||||||
|
music_resp = requests.post(
|
||||||
|
BASE_URL_MUSIC,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {MINIMAX_API_KEY}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
json=music_payload,
|
||||||
|
timeout=120
|
||||||
|
)
|
||||||
|
|
||||||
|
music_data = music_resp.json()
|
||||||
|
base_resp = music_data.get("base_resp", {})
|
||||||
|
log(f"[Debug] Music API status: {music_resp.status_code}, base_resp: {base_resp}")
|
||||||
|
|
||||||
|
if music_data.get("data") and music_data["data"].get("audio"):
|
||||||
|
hex_audio = music_data["data"]["audio"]
|
||||||
|
log(f"[OK] Music generated! Audio hex length: {len(hex_audio)}")
|
||||||
|
|
||||||
|
# ── Stage 3: Saving ──────────────────────────────
|
||||||
|
yield sse_event({
|
||||||
|
"stage": "saving",
|
||||||
|
"progress": 90,
|
||||||
|
"message": "音乐生成完成,正在保存..."
|
||||||
|
})
|
||||||
|
|
||||||
|
save_dir = os.path.join(os.path.dirname(__file__) or ".", "Capybara music")
|
||||||
|
os.makedirs(save_dir, exist_ok=True)
|
||||||
|
|
||||||
|
safe_name = re.sub(r'[^\w\u4e00-\u9fff]', '', req.text)[:20] or "ai_song"
|
||||||
|
filename = f"{safe_name}_{int(time.time())}.mp3"
|
||||||
|
filepath = os.path.join(save_dir, filename)
|
||||||
|
|
||||||
|
audio_bytes = bytes.fromhex(hex_audio)
|
||||||
|
with open(filepath, "wb") as f:
|
||||||
|
f.write(audio_bytes)
|
||||||
|
log(f"[Saved] {filepath}")
|
||||||
|
|
||||||
|
# Save lyrics txt
|
||||||
|
lyrics_text = metadata.get("lyrics", "")
|
||||||
|
if lyrics_text:
|
||||||
|
lyrics_dir = os.path.join(save_dir, "lyrics")
|
||||||
|
os.makedirs(lyrics_dir, exist_ok=True)
|
||||||
|
lyrics_filename = f"{safe_name}_{int(time.time())}.txt"
|
||||||
|
with open(os.path.join(lyrics_dir, lyrics_filename), "w", encoding="utf-8") as lf:
|
||||||
|
lf.write(lyrics_text)
|
||||||
|
|
||||||
|
relative_path = f"Capybara music/{filename}"
|
||||||
|
|
||||||
|
# ── Done ─────────────────────────────────────────
|
||||||
|
yield sse_event({
|
||||||
|
"stage": "done",
|
||||||
|
"progress": 100,
|
||||||
|
"message": "新歌出炉!",
|
||||||
|
"status": "success",
|
||||||
|
"file_path": relative_path,
|
||||||
|
"metadata": metadata
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
error_msg = base_resp.get("status_msg", "unknown")
|
||||||
|
error_code = base_resp.get("status_code", -1)
|
||||||
|
log(f"[Error] Music Gen failed: {error_code} - {error_msg}")
|
||||||
|
yield sse_event({
|
||||||
|
"stage": "error",
|
||||||
|
"progress": 0,
|
||||||
|
"message": f"生成失败 ({error_code}): {error_msg}"
|
||||||
|
})
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
log("[Error] Music Gen Timeout")
|
||||||
|
yield sse_event({
|
||||||
|
"stage": "error",
|
||||||
|
"progress": 0,
|
||||||
|
"message": "音乐生成超时,请稍后再试"
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
log(f"[Error] API exception: {e}")
|
||||||
|
yield sse_event({
|
||||||
|
"stage": "error",
|
||||||
|
"progress": 0,
|
||||||
|
"message": f"服务器错误: {str(e)}"
|
||||||
|
})
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_stream(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
"Connection": "keep-alive"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/playlist")
|
||||||
|
def get_playlist():
|
||||||
|
"""Scan Capybara music/ directory and return full playlist with lyrics."""
|
||||||
|
music_dir = os.path.join(os.path.dirname(__file__) or ".", "Capybara music")
|
||||||
|
lyrics_dir = os.path.join(music_dir, "lyrics")
|
||||||
|
|
||||||
|
playlist = []
|
||||||
|
if not os.path.isdir(music_dir):
|
||||||
|
return {"playlist": []}
|
||||||
|
|
||||||
|
for f in sorted(os.listdir(music_dir)):
|
||||||
|
if not f.lower().endswith(".mp3"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = f[:-4] # strip .mp3
|
||||||
|
|
||||||
|
# Read lyrics if available
|
||||||
|
lyrics = ""
|
||||||
|
lyrics_file = os.path.join(lyrics_dir, name + ".txt")
|
||||||
|
if os.path.isfile(lyrics_file):
|
||||||
|
try:
|
||||||
|
with open(lyrics_file, "r", encoding="utf-8") as lf:
|
||||||
|
lyrics = lf.read()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Display title: strip timestamp suffix like _1770367350
|
||||||
|
title = re.sub(r'_\d{10,}$', '', name)
|
||||||
|
|
||||||
|
playlist.append({
|
||||||
|
"title": title,
|
||||||
|
"audioUrl": f"Capybara music/{f}",
|
||||||
|
"lyrics": lyrics
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"playlist": playlist}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("[Server] Music Server running on http://localhost:3000")
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=3000)
|
||||||
@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
@ -7,7 +7,7 @@
|
|||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Airhub - 设置</title>
|
<title>Airhub - 设置</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
.page-header {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
@ -7,7 +7,7 @@
|
|||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Airhub - 第三方共享清单</title>
|
<title>Airhub - 第三方共享清单</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
.page-header {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
@ -7,7 +7,7 @@
|
|||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Airhub - 故事详情</title>
|
<title>Airhub - 故事详情</title>
|
||||||
<link rel="stylesheet" href="styles.css?v=6">
|
<link rel="stylesheet" href="styles.css?v=6">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
@ -7,7 +7,7 @@
|
|||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Airhub - 故事生成中</title>
|
<title>Airhub - 故事生成中</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
background: #FDF9F3;
|
background: #FDF9F3;
|
||||||
|
|||||||
16
styles.css
16
styles.css
@ -118,7 +118,7 @@ body {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
|
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||||
|
|
||||||
background: var(--bg-base);
|
background: var(--bg-base);
|
||||||
|
|
||||||
@ -128,7 +128,15 @@ body {
|
|||||||
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|
||||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
}
|
||||||
|
|
||||||
|
/* Display Font - Outfit for headings */
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6,
|
||||||
|
.page-title,
|
||||||
|
.pixel-text {
|
||||||
|
|
||||||
|
font-family: 'Outfit', 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -957,7 +965,7 @@ body {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
|
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||||
|
|
||||||
background: var(--bg-base);
|
background: var(--bg-base);
|
||||||
|
|
||||||
@ -1089,7 +1097,7 @@ body {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
|
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||||
|
|
||||||
background: var(--bg-base);
|
background: var(--bg-base);
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
@ -9,7 +9,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Press+Start+2P&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&family=Press+Start+2P&display=swap"
|
||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
/* WiFi Config specific styles */
|
/* WiFi Config specific styles */
|
||||||
|
|||||||
79
阶段总结/phase_summary.md
Normal file
79
阶段总结/phase_summary.md
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
# 阶段性工作总结:水豚灵感电台
|
||||||
|
|
||||||
|
## 1. 项目概况
|
||||||
|
本项目核心为 **Airhub 智能家居控制中心** 的子模块 —— **「水豚灵感电台 (Water Capybara Music Workshop)」**。这是一个基于心情生成音乐的互动页面,结合了拟物化设计(黑胶唱片)与 AI 生成概念。
|
||||||
|
|
||||||
|
## 2. 已完成核心功能
|
||||||
|
|
||||||
|
### 2.1 视觉与动画
|
||||||
|
* **黑胶唱片播放器**:
|
||||||
|
* 中心为水豚头像(Capybara Icon)。
|
||||||
|
* 实现了播放时的旋转动画 (`animation: spin`)。
|
||||||
|
* **优化**:外圈旋转,内圈头像保持静止(分离层设计)。
|
||||||
|
* **状态**:暂停/播放状态切换,带有遮罩层显示的播放/暂停图标。
|
||||||
|
* **氛围卡片网格**:
|
||||||
|
* 最初设计为 4 张卡片,后扩展为 **6 张卡片布局**。
|
||||||
|
* 包括:Chill (松弛), Happy (快乐), Sleep (助眠), Focus (心流), Mystery (盲盒), Custom (自由创作)。
|
||||||
|
* **交互**:点击卡片即触发高亮并自动开始生成流程。
|
||||||
|
* **界面布局优化**:
|
||||||
|
* 头部:依照 `device-control.html` 样式重构,左侧返回,右侧唱片架。
|
||||||
|
* 适配:针对移动端视口进行了全方位优化,缩小了唱片尺寸,调整了间距,修复了水平溢出问题。
|
||||||
|
|
||||||
|
### 2.2 交互逻辑重构 (Input to Card)
|
||||||
|
为了提升移动端体验,我们废弃了底部的常驻输入栏,改为“全卡片”交互:
|
||||||
|
* **自由创作卡片**:新增了第 6 张卡片“自由创作”。
|
||||||
|
* **输入弹窗**:点击“自由创作”后,底部升起输入弹窗。
|
||||||
|
* **逻辑**:输入文字 -> 确认 -> 触发 `custom` 模式的生成逻辑。
|
||||||
|
|
||||||
|
### 2.3 弹窗系统进化 (Modal to Bottom Sheet)
|
||||||
|
经历了从居中弹窗到底部抽屉的迭代:
|
||||||
|
* **样式**:宽度 100%,贴底显示,顶部圆角 (32px),磨砂玻璃效果。
|
||||||
|
* **适配**:增加了底部安全区 (`safe-area-inset-bottom`) 支持。
|
||||||
|
* **组件**:
|
||||||
|
* **输入弹窗**:用于自由创作。
|
||||||
|
* **唱片架弹窗**:用于展示历史生成记录(Playlist)。
|
||||||
|
|
||||||
|
### 2.4 播放与列表逻辑
|
||||||
|
* **模拟生成**:点击卡片后,经过 3 秒 Loading 动画,模拟生成音乐。
|
||||||
|
* **播放控制**:支持播放/暂停,进度条拖拽(UI层),时间显示。
|
||||||
|
* **播放列表**:生成成功的音乐会自动加入顶部“唱片架”,支持点击列表回放。
|
||||||
|
|
||||||
|
## 3. 关键技术修复 (Technical Fixes)
|
||||||
|
|
||||||
|
在此开发过程中,我们解决了一些顽固的 UI/交互问题,这些经验值得记录:
|
||||||
|
|
||||||
|
### 3.1 弹窗自动弹出 (Auto-Popup) 修复
|
||||||
|
* **问题**:页面刷新时,弹窗会自动闪现或保持打开状态。
|
||||||
|
* **原因**:单纯依赖 CSS `opacity` 结合 JS `classList` 切换在某些加载时序下不够稳健。
|
||||||
|
* **解决方案 (双保险)**:
|
||||||
|
1. **HTML 层**:默认设置为 `style="display: none;"`。
|
||||||
|
2. **JS 初始化**:`DOMContentLoaded` 事件中强制执行关闭逻辑:
|
||||||
|
```javascript
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const modal = document.getElementById('inputModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
3. **动画逻辑**:切换时显式处理 `display: flex` -> `requestAnimationFrame` -> `opacity: 1` 的时序。
|
||||||
|
|
||||||
|
### 3.2 布局对齐 (Alignment)
|
||||||
|
* **问题**:弹窗在宽屏或特定分辨率下未居中,或右侧有留白。
|
||||||
|
* **解决方案**:
|
||||||
|
* 容器统一使用 `width: 100%`。
|
||||||
|
* 外层 Overlay 使用 `justify-content: center`。
|
||||||
|
* 内层容器使用 `margin: 0` (底部抽屉模式) 消除干扰。
|
||||||
|
* 显式设置 `box-sizing: border-box` 防止 padding 导致的溢出。
|
||||||
|
|
||||||
|
## 4. 遗留/待办事项 (Next Steps)
|
||||||
|
* **API 对接**:目前使用的是模拟数据,需接入真实的 MiniMax Music API。
|
||||||
|
* **播放列表持久化**:目前刷新后列表重置,需对接 LocalStorage 或后端数据库。
|
||||||
|
* **多端适配**:目前专注于移动端竖屏体验,桌面端暂未专门适配。
|
||||||
|
|
||||||
|
## 5. 文件清单
|
||||||
|
* `music-creation.html`: 核心页面。
|
||||||
|
* `styles.css`: 全局样式表。
|
||||||
|
* `design_system.md`: 设计规范文档。
|
||||||
|
* `task.md`: 任务追踪表。
|
||||||
50
阶段总结/task.md
Normal file
50
阶段总结/task.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# 我的模块实现任务
|
||||||
|
|
||||||
|
## 页面创建
|
||||||
|
- [x] profile.html - 我的主页
|
||||||
|
- [x] profile-info.html - 个人信息
|
||||||
|
- [x] agent-manage.html - 角色记忆管理
|
||||||
|
- [x] settings.html - 设置页
|
||||||
|
- [x] help.html - 帮助中心
|
||||||
|
- [x] agreement.html - 用户协议
|
||||||
|
- [x] privacy.html - 隐私政策
|
||||||
|
- [x] collection-list.html - 个人信息收集清单
|
||||||
|
- [x] sharing-list.html - 第三方信息共享清单
|
||||||
|
|
||||||
|
## 导航更新
|
||||||
|
- [x] 更新 device-control.html 底部导航链接
|
||||||
|
- [x] 实现 notifications.html (支持下拉展开详情)
|
||||||
|
- [x] 实现 guide-feeding.html (动态图文手册)
|
||||||
|
- [/] 实现 music-creation.html (卡皮巴拉音乐工坊 - Minimax API)
|
||||||
|
- [x] 播放器视觉 (黑胶/气泡)
|
||||||
|
- [ ] **UI Refinements:**
|
||||||
|
- [ ] Increase Playlist Modal Height
|
||||||
|
- [x] Unify "Storage Box" (收纳箱) -> "Record Shelf" (唱片架)
|
||||||
|
- [x] Move "Record Shelf" Icon to Header
|
||||||
|
- [x] Add Play/Pause toggle overlay on Vinyl (Always Visible)
|
||||||
|
- [x] Modify Vinyl Animation: Spin outer ring only (Capybara static)
|
||||||
|
- [ ] **Final Polish:**
|
||||||
|
- [x] Reduce Modal Height
|
||||||
|
- [x] Add Scroll Fade (Mask) for soft edges (Updated to 60px)
|
||||||
|
- [x] Adjust Grid Sizing (3 columns fit fully)
|
||||||
|
- [x] Hide Scrollbars (Vertical & Horizontal)
|
||||||
|
- [x] Replace Emoji Icon with SVG (Like Bell)
|
||||||
|
- [x] Add "唱片架" Label to Icon
|
||||||
|
- [x] Fix Header Back Button Style
|
||||||
|
- [x] Optimize Overall Layout (Compact Vinyl, Margins)
|
||||||
|
- [x] **Refactor Input to Card Layout** <!-- id: 4 -->
|
||||||
|
- [x] Remove Persistent Input Bar
|
||||||
|
- [x] Add 2 New Mood Cards (Focus & Custom)
|
||||||
|
- [x] Implement Custom Input Modal
|
||||||
|
- [x] Update Grid Layout for 6 Cards
|
||||||
|
- [x] Add Playback Progress Bar with Time Display
|
||||||
|
- [ ] Playlist Logic (Mock Data + Generation)
|
||||||
|
|
||||||
|
## 交互优化
|
||||||
|
- [x] 替换所有原生 alert/confirm 为自定义弹窗 (Glass Modal)
|
||||||
|
|
||||||
|
## 布局规范化
|
||||||
|
- [x] 统一法务页面 Gradient Mask Scroll 布局 (padding 120px, mask 100-130px)
|
||||||
|
- [x] 同步更新设备设置页 (.settings-content)
|
||||||
|
- [x] 写入 PRD 文档 (§ 3.3.2)
|
||||||
|
- [x] 写入 Design System
|
||||||
Loading…
x
Reference in New Issue
Block a user