Compare commits
10 Commits
f3ef1d1242
...
679e33428c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
679e33428c | ||
| 5d8619450d | |||
| ce53e93864 | |||
| 6e0c8e943f | |||
| 86d1b77fa7 | |||
| 1140d2c440 | |||
|
|
8a65d49a44 | ||
|
|
80978f12b5 | ||
|
|
f26627a83f | ||
| 942daeffa0 |
4
.env
Normal file
4
.env
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
MINIMAX_API_KEY=sk-api-MG0xNIYWDOnMVVus1nHbVS_PQleDh9-aJOCiVJLpAyzqWwUfYcMrbiWcHrV4Ri1HFsYgycSZSMirUEI_1L5wzcvAnu8ijoMDmGBkoGEBAKAl8MCzMpj8XRk
|
||||||
|
VOLCENGINE_API_KEY=846b6981-9954-4c58-bb39-63079393bdb8
|
||||||
|
TTS_APP_ID=6617606300
|
||||||
|
TTS_ACCESS_TOKEN=U6owQeCXDzh_A5a6pN9DJms1DgBIIYNn
|
||||||
BIN
Capybara audio/魔法帽小猫_1770795515.mp3
Normal file
BIN
Capybara audio/魔法帽小猫_1770795515.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
9
Capybara stories/小刺猬找彩虹_1770866826.txt
Normal file
9
Capybara stories/小刺猬找彩虹_1770866826.txt
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# 小刺猬找彩虹
|
||||||
|
|
||||||
|
清晨的森林里,小刺猬朵朵背着苹果出门啦——她听说雨后的彩虹藏着甜甜的星星糖!
|
||||||
|
|
||||||
|
刚走到小溪边,朵朵听见“呜呜呜”的哭声。原来是小松鼠的橡果掉进了树洞,树洞太深,小爪子够不着。朵朵眨眨眼:“我有办法!”她蜷成刺球滚进树洞,刺刺刚好勾住橡果,“咕噜噜”滚了出来!小松鼠举着橡果笑:“谢谢你,我带你找彩虹!”
|
||||||
|
|
||||||
|
两人跑到山顶,却看见彩虹桥断了一节——原来是小鸟的风筝卡在云里,扯歪了彩虹!朵朵看看自己的苹果,又看看小松鼠的橡果,突然眼睛亮了:“我们搭个桥吧!”她把苹果摆成一排,小松鼠把橡果叠成小塔,刚好补上缺口。“飞啦!”小鸟叼着风筝飞过来,彩虹“唰”地变亮,桥面上撒满了星星糖!
|
||||||
|
|
||||||
|
朵朵咬了一口星星糖,甜甜的味道里藏着小松鼠的笑声、小鸟的歌声。原来呀,帮助朋友的快乐,比星星糖更甜呢!
|
||||||
13
Capybara stories/机器人小叮咚_1770866818.txt
Normal file
13
Capybara stories/机器人小叮咚_1770866818.txt
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# 机器人小叮咚
|
||||||
|
|
||||||
|
在阳光森林里,住着一个圆滚滚的机器人小叮咚。他的肚子上有个彩色按钮,按一下就会“叮咚”唱儿歌。
|
||||||
|
|
||||||
|
这天,森林邮局的鸽子姐姐受伤了,不能送包裹。小叮咚主动说:“我来帮忙!”他把包裹放进肚子里的小抽屉,“叮咚——启动飞行模式!”
|
||||||
|
|
||||||
|
可飞到半山腰时,突然刮起大风!小叮咚的天线被树枝缠住,“咔嗒”一声歪了。他急得转圈圈,包裹差点掉下去。
|
||||||
|
|
||||||
|
这时,小松鼠奇奇举着松果跑过来:“小叮咚别怕!我帮你够天线!”奇奇跳起来,用松果轻轻敲开树枝。小叮咚的天线“啪”地弹回原位,又“叮咚”唱起歌来。
|
||||||
|
|
||||||
|
最后,小叮咚把包裹安全送到了小兔子家。小兔子举着胡萝卜饼干说:“谢谢你!这是给你的‘能量饼干’!”
|
||||||
|
|
||||||
|
夕阳下,小叮咚的肚子闪着暖光,他笑着说:“原来帮助朋友,就是最棒的能量呀!”
|
||||||
9
Capybara stories/魔法帽小猫_1770792516.txt
Normal file
9
Capybara stories/魔法帽小猫_1770792516.txt
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# 魔法帽小猫
|
||||||
|
|
||||||
|
森林里住着一只叫米米的小猫,她有一顶会发光的蓝色魔法帽。每天清晨,米米都会戴着帽子在蘑菇丛里蹦蹦跳跳,帽子上的星星亮片闪呀闪。
|
||||||
|
|
||||||
|
这天,森林里突然刮起了大风!小松鼠的橡果被吹跑了,小鸟的羽毛被吹乱了,连最胖的蘑菇爷爷都差点被吹倒。米米着急地摸了摸魔法帽,突然听见帽子里传来细细的声音:“喵呜——要帮助朋友,就念‘星星亮晶晶’!”
|
||||||
|
|
||||||
|
米米立刻跳到最高的石头上,举起帽子大喊:“星星亮晶晶!”魔法帽突然“呼”地喷出彩虹色的风,把乱飘的橡果轻轻吹回松鼠的树洞里,把小鸟的羽毛抚平,还稳稳地扶住了蘑菇爷爷。大风停了,森林里响起“叽叽喳喳”“咚咚锵锵”的欢呼声!
|
||||||
|
|
||||||
|
晚上,动物们围在篝火旁,小松鼠送给米米最大的橡果蛋糕,小鸟衔来最软的羽毛围巾。米米摸着魔法帽笑了:原来帮助朋友,就是最神奇的魔法呀!
|
||||||
@ -1,8 +1,4 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- ali_auth (1.3.7):
|
|
||||||
- Flutter
|
|
||||||
- MJExtension
|
|
||||||
- SDWebImage
|
|
||||||
- audio_session (0.0.1):
|
- audio_session (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
@ -14,12 +10,8 @@ PODS:
|
|||||||
- just_audio (0.0.1):
|
- just_audio (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- MJExtension (3.4.2)
|
|
||||||
- permission_handler_apple (9.3.0):
|
- permission_handler_apple (9.3.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- SDWebImage (5.21.6):
|
|
||||||
- SDWebImage/Core (= 5.21.6)
|
|
||||||
- SDWebImage/Core (5.21.6)
|
|
||||||
- shared_preferences_foundation (0.0.1):
|
- shared_preferences_foundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
@ -28,7 +20,6 @@ PODS:
|
|||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- ali_auth (from `.symlinks/plugins/ali_auth/ios`)
|
|
||||||
- audio_session (from `.symlinks/plugins/audio_session/ios`)
|
- audio_session (from `.symlinks/plugins/audio_session/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
|
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
|
||||||
@ -38,14 +29,7 @@ DEPENDENCIES:
|
|||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
||||||
|
|
||||||
SPEC REPOS:
|
|
||||||
trunk:
|
|
||||||
- MJExtension
|
|
||||||
- SDWebImage
|
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
ali_auth:
|
|
||||||
:path: ".symlinks/plugins/ali_auth/ios"
|
|
||||||
audio_session:
|
audio_session:
|
||||||
:path: ".symlinks/plugins/audio_session/ios"
|
:path: ".symlinks/plugins/audio_session/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
@ -64,15 +48,12 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
ali_auth: fe9a6188a90eb39227f3674c05a71383ac4ec6a2
|
|
||||||
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
|
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
||||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||||
just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed
|
just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed
|
||||||
MJExtension: e97d164cb411aa9795cf576093a1fa208b4a8dd8
|
|
||||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||||
SDWebImage: 1bb6a1b84b6fe87b972a102bdc77dd589df33477
|
|
||||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
|
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
class ApiConfig {
|
class ApiConfig {
|
||||||
/// 后端服务器地址(开发环境请替换为实际 IP)
|
/// 后端服务器地址(开发环境请替换为实际 IP)
|
||||||
static const String baseUrl = 'http://192.168.124.24:8000';
|
static const String baseUrl = 'http://192.168.124.8:8000';
|
||||||
|
|
||||||
/// App 端 API 前缀
|
/// App 端 API 前缀
|
||||||
static const String apiPrefix = '/api/v1';
|
static const String apiPrefix = '/api/v1';
|
||||||
|
|||||||
@ -11,9 +11,14 @@ LogCenterService logCenterService(Ref ref) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class LogCenterService {
|
class LogCenterService {
|
||||||
static const String _url =
|
static const String _baseUrl = String.fromEnvironment(
|
||||||
'https://qiyuan-log-center-api.airlabs.art/api/v1/logs/report';
|
'LOG_CENTER_URL',
|
||||||
|
defaultValue: 'https://qiyuan-log-center-api.airlabs.art',
|
||||||
|
);
|
||||||
|
static const String _url = '$_baseUrl/api/v1/logs/report';
|
||||||
|
static const String _registerUrl = '$_baseUrl/api/v1/projects/register';
|
||||||
static const String _projectId = 'airhub_app';
|
static const String _projectId = 'airhub_app';
|
||||||
|
static const String _repoUrl = 'https://gitea.airlabs.art/zyc/rtc_prd.git';
|
||||||
|
|
||||||
late final Dio _dio;
|
late final Dio _dio;
|
||||||
|
|
||||||
@ -25,6 +30,24 @@ class LogCenterService {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化:向 Log Center 注册项目
|
||||||
|
Future<void> initialize() async {
|
||||||
|
try {
|
||||||
|
await _dio.post(_registerUrl, data: {
|
||||||
|
'project_id': _projectId,
|
||||||
|
'repo_url': _repoUrl,
|
||||||
|
'description': 'Flutter mobile app (subdirectory of rtc_prd repo)',
|
||||||
|
'platform': defaultTargetPlatform.name,
|
||||||
|
'environment': const String.fromEnvironment(
|
||||||
|
'ENVIRONMENT',
|
||||||
|
defaultValue: kDebugMode ? 'development' : 'production',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// 静默失败,不影响 App 启动
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 上报 Flutter 框架错误(FlutterError)
|
/// 上报 Flutter 框架错误(FlutterError)
|
||||||
void reportFlutterError(FlutterErrorDetails details) {
|
void reportFlutterError(FlutterErrorDetails details) {
|
||||||
_report(
|
_report(
|
||||||
@ -88,8 +111,12 @@ class LogCenterService {
|
|||||||
|
|
||||||
final payload = {
|
final payload = {
|
||||||
'project_id': _projectId,
|
'project_id': _projectId,
|
||||||
'environment': kDebugMode ? 'development' : 'production',
|
'environment': const String.fromEnvironment(
|
||||||
|
'ENVIRONMENT',
|
||||||
|
defaultValue: kDebugMode ? 'development' : 'production',
|
||||||
|
),
|
||||||
'level': 'ERROR',
|
'level': 'ERROR',
|
||||||
|
'repo_url': _repoUrl,
|
||||||
'error': {
|
'error': {
|
||||||
'type': errorType,
|
'type': errorType,
|
||||||
'message': message.length > 2000 ? message.substring(0, 2000) : message,
|
'message': message.length > 2000 ? message.substring(0, 2000) : message,
|
||||||
|
|||||||
@ -12,8 +12,8 @@ abstract class DeviceRemoteDataSource {
|
|||||||
/// POST /devices/verify/
|
/// POST /devices/verify/
|
||||||
Future<Map<String, dynamic>> verifyDevice(String sn);
|
Future<Map<String, dynamic>> verifyDevice(String sn);
|
||||||
|
|
||||||
/// POST /devices/bind/
|
/// POST /devices/bind/ — 返回绑定 ID (int)
|
||||||
Future<UserDevice> bindDevice(String sn, {int? spiritId});
|
Future<int> bindDevice(String sn, {int? spiritId});
|
||||||
|
|
||||||
/// GET /devices/my_devices/
|
/// GET /devices/my_devices/
|
||||||
Future<List<UserDevice>> getMyDevices();
|
Future<List<UserDevice>> getMyDevices();
|
||||||
@ -61,11 +61,11 @@ class DeviceRemoteDataSourceImpl implements DeviceRemoteDataSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<UserDevice> bindDevice(String sn, {int? spiritId}) async {
|
Future<int> bindDevice(String sn, {int? spiritId}) async {
|
||||||
final body = <String, dynamic>{'sn': sn};
|
final body = <String, dynamic>{'sn': sn};
|
||||||
if (spiritId != null) body['spirit_id'] = spiritId;
|
if (spiritId != null) body['spirit_id'] = spiritId;
|
||||||
final data = await _apiClient.post('/devices/bind/', data: body);
|
final data = await _apiClient.post('/devices/bind/', data: body);
|
||||||
return UserDevice.fromJson(data as Map<String, dynamic>);
|
return data as int;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@ -45,7 +45,7 @@ class DeviceRepositoryImpl implements DeviceRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Either<Failure, UserDevice>> bindDevice(
|
Future<Either<Failure, int>> bindDevice(
|
||||||
String sn, {
|
String sn, {
|
||||||
int? spiritId,
|
int? spiritId,
|
||||||
}) async {
|
}) async {
|
||||||
|
|||||||
@ -30,12 +30,12 @@ Map<String, dynamic> _$DeviceTypeToJson(_DeviceType instance) =>
|
|||||||
_DeviceInfo _$DeviceInfoFromJson(Map<String, dynamic> json) => _DeviceInfo(
|
_DeviceInfo _$DeviceInfoFromJson(Map<String, dynamic> json) => _DeviceInfo(
|
||||||
id: (json['id'] as num).toInt(),
|
id: (json['id'] as num).toInt(),
|
||||||
sn: json['sn'] as String,
|
sn: json['sn'] as String,
|
||||||
deviceType: json['device_type'] == null
|
deviceType: (json['device_type'] is Map<String, dynamic>)
|
||||||
? null
|
? DeviceType.fromJson(json['device_type'] as Map<String, dynamic>)
|
||||||
: DeviceType.fromJson(json['device_type'] as Map<String, dynamic>),
|
: null,
|
||||||
deviceTypeInfo: json['device_type_info'] == null
|
deviceTypeInfo: (json['device_type_info'] is Map<String, dynamic>)
|
||||||
? null
|
? DeviceType.fromJson(json['device_type_info'] as Map<String, dynamic>)
|
||||||
: DeviceType.fromJson(json['device_type_info'] as Map<String, dynamic>),
|
: null,
|
||||||
macAddress: json['mac_address'] as String?,
|
macAddress: json['mac_address'] as String?,
|
||||||
name: json['name'] as String? ?? '',
|
name: json['name'] as String? ?? '',
|
||||||
status: json['status'] as String? ?? 'in_stock',
|
status: json['status'] as String? ?? 'in_stock',
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import '../entities/device_detail.dart';
|
|||||||
abstract class DeviceRepository {
|
abstract class DeviceRepository {
|
||||||
Future<Either<Failure, Map<String, dynamic>>> queryByMac(String mac);
|
Future<Either<Failure, Map<String, dynamic>>> queryByMac(String mac);
|
||||||
Future<Either<Failure, Map<String, dynamic>>> verifyDevice(String sn);
|
Future<Either<Failure, Map<String, dynamic>>> verifyDevice(String sn);
|
||||||
Future<Either<Failure, UserDevice>> bindDevice(String sn, {int? spiritId});
|
Future<Either<Failure, int>> bindDevice(String sn, {int? spiritId});
|
||||||
Future<Either<Failure, List<UserDevice>>> getMyDevices();
|
Future<Either<Failure, List<UserDevice>>> getMyDevices();
|
||||||
Future<Either<Failure, DeviceDetail>> getDeviceDetail(int userDeviceId);
|
Future<Either<Failure, DeviceDetail>> getDeviceDetail(int userDeviceId);
|
||||||
Future<Either<Failure, void>> unbindDevice(int userDeviceId);
|
Future<Either<Failure, void>> unbindDevice(int userDeviceId);
|
||||||
|
|||||||
@ -23,9 +23,8 @@ class DeviceController extends _$DeviceController {
|
|||||||
final result = await repository.bindDevice(sn, spiritId: spiritId);
|
final result = await repository.bindDevice(sn, spiritId: spiritId);
|
||||||
return result.fold(
|
return result.fold(
|
||||||
(failure) => false,
|
(failure) => false,
|
||||||
(userDevice) {
|
(bindingId) {
|
||||||
final current = state.value ?? [];
|
ref.invalidateSelf();
|
||||||
state = AsyncData([...current, userDevice]);
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -14,6 +14,7 @@ void main() {
|
|||||||
|
|
||||||
final container = ProviderContainer();
|
final container = ProviderContainer();
|
||||||
final logCenter = container.read(logCenterServiceProvider);
|
final logCenter = container.read(logCenterServiceProvider);
|
||||||
|
logCenter.initialize();
|
||||||
|
|
||||||
// 捕获 Flutter 框架错误(Widget build 异常等)
|
// 捕获 Flutter 框架错误(Widget build 异常等)
|
||||||
FlutterError.onError = (details) {
|
FlutterError.onError = (details) {
|
||||||
|
|||||||
@ -75,7 +75,7 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
static const _airhubPrefix = 'Airhub_';
|
static const _airhubPrefix = 'Airhub_';
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
bool _isSearching = true;
|
bool _isSearching = !kIsWeb; // Web 平台不自动搜索,需用户手势触发
|
||||||
bool _isBluetoothOn = false;
|
bool _isBluetoothOn = false;
|
||||||
List<MockDevice> _devices = [];
|
List<MockDevice> _devices = [];
|
||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
@ -132,7 +132,8 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
setState(() => _isBluetoothOn = isOn);
|
setState(() => _isBluetoothOn = isOn);
|
||||||
|
|
||||||
if (isOn) {
|
if (isOn) {
|
||||||
_startSearch();
|
// Web 平台: BLE scan 必须由用户手势触发,不自动扫描
|
||||||
|
if (!kIsWeb) _startSearch();
|
||||||
} else if (state == BluetoothAdapterState.off) {
|
} else if (state == BluetoothAdapterState.off) {
|
||||||
FlutterBluePlus.stopScan();
|
FlutterBluePlus.stopScan();
|
||||||
setState(() {
|
setState(() {
|
||||||
@ -307,6 +308,7 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
|
|
||||||
/// 请求蓝牙权限
|
/// 请求蓝牙权限
|
||||||
Future<void> _requestPermissions() async {
|
Future<void> _requestPermissions() async {
|
||||||
|
if (kIsWeb) return; // Web 平台无需请求原生权限
|
||||||
try {
|
try {
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
// Android 需要位置权限才能扫描 BLE
|
// Android 需要位置权限才能扫描 BLE
|
||||||
@ -324,7 +326,7 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
|
|
||||||
/// 蓝牙未开启弹窗
|
/// 蓝牙未开启弹窗
|
||||||
void _showBluetoothOffDialog() {
|
void _showBluetoothOffDialog() {
|
||||||
if (!mounted) return;
|
if (!mounted || kIsWeb) return; // Web 平台不弹原生蓝牙设置弹窗
|
||||||
showGlassDialog(
|
showGlassDialog(
|
||||||
context: context,
|
context: context,
|
||||||
title: '蓝牙未开启',
|
title: '蓝牙未开启',
|
||||||
@ -781,9 +783,9 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
return Container(
|
return Container(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
20, // HTML: 20px sides
|
20, // HTML: 20px sides
|
||||||
20, // HTML: 20px top
|
16, // HTML: 20px top (reduced to prevent overflow)
|
||||||
20,
|
20,
|
||||||
MediaQuery.of(context).padding.bottom + 60, // HTML: safe-area + 60px
|
MediaQuery.of(context).padding.bottom + 40, // HTML: safe-area + bottom padding
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@ -812,6 +814,16 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// 搜索按钮 (Web 平台初始状态或无设备时显示)
|
||||||
|
if (!_isSearching && _devices.isEmpty) ...[
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
GradientButton(
|
||||||
|
text: '搜索设备',
|
||||||
|
width: 180,
|
||||||
|
height: 52,
|
||||||
|
onPressed: _startSearch,
|
||||||
|
),
|
||||||
|
],
|
||||||
// 连接按钮 (搜索完成后显示)
|
// 连接按钮 (搜索完成后显示)
|
||||||
if (!_isSearching && _devices.isNotEmpty) ...[
|
if (!_isSearching && _devices.isNotEmpty) ...[
|
||||||
const SizedBox(width: 16), // HTML: gap 16px
|
const SizedBox(width: 16), // HTML: gap 16px
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import '../core/network/api_client.dart';
|
||||||
import 'story_detail_page.dart';
|
import 'story_detail_page.dart';
|
||||||
import 'product_selection_page.dart';
|
import 'product_selection_page.dart';
|
||||||
import 'settings_page.dart';
|
import 'settings_page.dart';
|
||||||
@ -42,41 +41,9 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
// Animation for new book
|
// Animation for new book
|
||||||
int? _newBookIndex;
|
int? _newBookIndex;
|
||||||
|
|
||||||
// Track unlocked shelves (start with 1 shelf + 1 locked placeholder)
|
// Shelves loaded from backend: [{id, name, capacity, story_count, stories: [...]}]
|
||||||
int _unlockedShelves = 1;
|
List<Map<String, dynamic>> _shelves = [];
|
||||||
|
bool _shelvesLoading = true;
|
||||||
final List<Map<String, dynamic>> _mockStories = [
|
|
||||||
{
|
|
||||||
'title': '卡皮巴拉的奇幻漂流',
|
|
||||||
'cover': 'assets/www/story_covers/capybara_adventure.png',
|
|
||||||
'locked': false,
|
|
||||||
'content': '在一条蜿蜒的小河边,住着一只名叫咖啡的卡皮巴拉。咖啡最喜欢做的事情,就是泡在温泉里,顶着一颗橘子发呆。\n\n有一天,河水突然变成了七彩的颜色!一个写着"冒险邀请函"的漂流瓶飘到了咖啡面前。\n\n"亲爱的咖啡,彩虹尽头有一座糖果山,里面藏着能让所有动物快乐的魔法种子。你愿意来找它吗?"\n\n咖啡想了想,把橘子吃掉,跳进了七彩的河流。一路上,它遇到了会唱歌的青蛙、爱画画的松鼠、和一只总是迷路的猫头鹰。它们组成了最奇怪也最温暖的冒险小队。\n\n经过重重挑战,它们终于到达了糖果山。魔法种子发出金色的光芒,落在每个小伙伴的头顶上。从此以后,每个人路过这条小河,都会不自觉地微笑起来。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'title': '勇敢的小裁缝',
|
|
||||||
'cover': 'assets/www/story_covers/brave_tailor.png',
|
|
||||||
'locked': false,
|
|
||||||
'content': '从前有一个小裁缝,他住在一座热闹的小镇上。虽然他的个子不高,手艺却是全镇最好的。\n\n一天早上,小裁缝正在缝一件漂亮的外套,七只苍蝇飞来偷吃他的果酱面包。他一巴掌打下去——"啪!一下打死了七个!"\n\n小裁缝得意极了,在腰带上绣了一行大字:"一下打死七个!"然后他出门去闯荡世界。\n\n一路上,所有人都以为他打死的是七个巨人!连国王都请他去消灭山里的两个巨人。小裁缝靠着机智和勇气,用石头让两个巨人互相打了起来。\n\n最终,小裁缝不仅消灭了巨人,还救了公主。国王为他举办了盛大的庆典。小裁缝笑着说:"勇气不在于个子大小,而在于心有多大。"',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'title': '小红帽与大灰狼',
|
|
||||||
'cover': 'assets/www/story_covers/red_riding_hood.png',
|
|
||||||
'locked': false,
|
|
||||||
'content': '在森林边的小村庄里,住着一个总是戴红帽子的小女孩,大家都叫她小红帽。\n\n有一天,妈妈让小红帽给生病的外婆送一篮子蛋糕和葡萄酒。"走大路,不要在森林里乱跑哦。"妈妈叮嘱道。\n\n小红帽刚进森林,就遇到了一只看起来很友善的大灰狼。"你要去哪里呀,小红帽?""我去看望外婆!"\n\n大灰狼眼珠一转,抄近路先跑到了外婆家,假扮成外婆躺在床上。等小红帽到了,它假装生病的外婆说话。\n\n"外婆,你的耳朵怎么这么大?""为了更好地听你说话呀。"\n"外婆,你的嘴巴怎么这么大?""为了——"\n\n就在这时,经过的猎人听到了动静。他冲进来赶走了大灰狼,救出了外婆和小红帽。从此以后,小红帽再也不在森林里跟陌生人说话了。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'title': '杰克与魔豆',
|
|
||||||
'cover': 'assets/www/story_covers/jack_and_beanstalk.png',
|
|
||||||
'locked': false,
|
|
||||||
'content': '杰克和妈妈住在一间破旧的小屋里,家里穷得只剩下一头老奶牛。妈妈让杰克把牛拿去集市上卖掉。\n\n路上,一个神秘的老人用五颗"魔法豆子"换走了杰克的牛。妈妈气坏了,把豆子扔出窗外。\n\n第二天早上,杰克发现窗外长出了一棵直冲云霄的巨大豆茎!他鼓起勇气爬了上去,在云端发现了一座巨人的城堡。\n\n城堡里有一只会下金蛋的鹅和一把会自己弹奏的金竖琴。杰克趁巨人睡着,偷偷带走了金鹅。巨人醒来追了出来!\n\n杰克飞快地顺着豆茎滑下来,拿起斧头砍断了豆茎。"轰!"巨人连同豆茎一起掉了下来。\n\n从此,杰克和妈妈靠着金蛋过上了幸福的生活。杰克明白了:勇气和机智,才是最大的财富。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'title': '糖果屋历险记',
|
|
||||||
'cover': 'assets/www/story_covers/hansel_and_gretel.png',
|
|
||||||
'locked': false,
|
|
||||||
'content': '汉赛尔和格蕾特是一对兄妹。有一天,他们在森林里迷了路,又累又饿。\n\n突然,一座用糖果和饼干做成的小屋出现在眼前!屋顶是巧克力,窗户是透明的硬糖,门把手是一根棒棒糖。兄妹俩开心极了,忍不住吃了起来。\n\n"嘿嘿嘿,是谁在啃我的房子?"门开了,一个笑眯眯的老婆婆走出来。她请兄妹俩进屋吃饭休息。可是,这个老婆婆其实是一个坏巫婆!\n\n巫婆把汉赛尔关进笼子,想把他养胖了吃掉。聪明的格蕾特想出了一个办法:她假装不会用烤炉,让巫婆弯腰演示——然后用力一推!\n\n巫婆掉进了自己的烤炉里。兄妹俩找到了巫婆藏的宝石和金币,高高兴兴地回了家。爸爸看到他们回来,高兴得流下了眼泪。',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -94,43 +61,49 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load historical stories from backend
|
// Load shelves and stories from backend
|
||||||
_loadHistoricalStories();
|
_loadShelves();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch saved stories from backend and prepend to bookshelf
|
/// Fetch shelves and their stories from backend
|
||||||
Future<void> _loadHistoricalStories() async {
|
Future<void> _loadShelves() async {
|
||||||
try {
|
try {
|
||||||
final resp = await http.get(Uri.parse('http://localhost:3000/api/stories'));
|
final api = ref.read(apiClientProvider);
|
||||||
if (resp.statusCode == 200) {
|
|
||||||
final data = jsonDecode(resp.body);
|
|
||||||
final List stories = data['stories'] ?? [];
|
|
||||||
if (stories.isEmpty) return;
|
|
||||||
|
|
||||||
// Collect titles already in the mock list to avoid duplicates
|
// Load shelves
|
||||||
final existingTitles = _mockStories.map((s) => s['title'] as String).toSet();
|
final shelvesData = await api.get('/stories/shelves/') as List;
|
||||||
|
final shelves = <Map<String, dynamic>>[];
|
||||||
|
|
||||||
final newStories = <Map<String, dynamic>>[];
|
for (final shelf in shelvesData) {
|
||||||
for (final s in stories) {
|
final shelfId = shelf['id'];
|
||||||
final title = s['title'] as String? ?? '';
|
// Load stories for this shelf
|
||||||
if (title.isNotEmpty && !existingTitles.contains(title)) {
|
final storiesData = await api.get(
|
||||||
newStories.add({
|
'/stories/',
|
||||||
'title': title,
|
queryParameters: {'shelf_id': shelfId, 'page_size': 10},
|
||||||
'cover': null, // No cover yet for generated stories
|
);
|
||||||
'locked': false,
|
final stories = (storiesData['items'] as List?)
|
||||||
'content': s['content'] as String? ?? '',
|
?.map((s) => Map<String, dynamic>.from(s))
|
||||||
});
|
.toList() ?? [];
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newStories.isNotEmpty && mounted) {
|
shelves.add({
|
||||||
setState(() {
|
...Map<String, dynamic>.from(shelf),
|
||||||
_mockStories.addAll(newStories);
|
'stories': stories,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_shelves = shelves;
|
||||||
|
_shelvesLoading = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Failed to load historical stories: $e');
|
debugPrint('Failed to load shelves: $e');
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_shelvesLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -418,35 +391,38 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
|
|
||||||
// Bookshelf PageView - constrained height for proper proportions
|
// Bookshelf PageView - constrained height for proper proportions
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: MediaQuery.of(context).size.height * 0.64, // ~64% of screen height for bookshelf
|
height: MediaQuery.of(context).size.height * 0.64,
|
||||||
child: PageView.builder(
|
child: _shelvesLoading
|
||||||
controller: _bookshelfController,
|
? const Center(child: CircularProgressIndicator())
|
||||||
clipBehavior: Clip.none,
|
: PageView.builder(
|
||||||
padEnds: false,
|
controller: _bookshelfController,
|
||||||
itemCount: _unlockedShelves + 1, // unlocked shelves + 1 locked placeholder
|
clipBehavior: Clip.none,
|
||||||
itemBuilder: (context, index) {
|
padEnds: false,
|
||||||
if (index < _unlockedShelves) {
|
itemCount: _shelves.length + 1, // shelves + 1 locked placeholder
|
||||||
// Unlocked shelf
|
itemBuilder: (context, index) {
|
||||||
final shelfNumber = index + 1;
|
if (index < _shelves.length) {
|
||||||
final stories = index == 0 ? _mockStories : <Map<String, dynamic>>[];
|
final shelf = _shelves[index];
|
||||||
final count = '${stories.length}/10';
|
final stories = (shelf['stories'] as List?)
|
||||||
return Padding(
|
?.cast<Map<String, dynamic>>() ?? [];
|
||||||
padding: const EdgeInsets.only(left: 16, right: 6),
|
final capacity = shelf['capacity'] as int? ?? 10;
|
||||||
child: _buildBookshelfSlide(
|
final count = '${stories.length}/$capacity';
|
||||||
'我的故事书 #$shelfNumber',
|
return Padding(
|
||||||
count,
|
padding: const EdgeInsets.only(left: 16, right: 6),
|
||||||
stories,
|
child: _buildBookshelfSlide(
|
||||||
),
|
shelf['name'] as String? ?? '书架',
|
||||||
);
|
count,
|
||||||
} else {
|
stories,
|
||||||
// Last item is always the locked placeholder
|
shelfId: shelf['id'] as int,
|
||||||
return Padding(
|
),
|
||||||
padding: const EdgeInsets.only(left: 6, right: 16),
|
);
|
||||||
child: _buildLockedShelf(),
|
} else {
|
||||||
);
|
return Padding(
|
||||||
}
|
padding: const EdgeInsets.only(left: 6, right: 16),
|
||||||
},
|
child: _buildLockedShelf(),
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Flexible bottom space
|
// Flexible bottom space
|
||||||
@ -495,7 +471,7 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (saveResult is Map && saveResult['action'] == 'saved') {
|
if (saveResult is Map && saveResult['action'] == 'saved') {
|
||||||
_addNewBookWithAnimation(
|
await _addNewBookWithAnimation(
|
||||||
title: saveResult['title'] as String? ?? '新故事',
|
title: saveResult['title'] as String? ?? '新故事',
|
||||||
content: saveResult['content'] as String? ?? '',
|
content: saveResult['content'] as String? ?? '',
|
||||||
);
|
);
|
||||||
@ -508,8 +484,9 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
Widget _buildBookshelfSlide(
|
Widget _buildBookshelfSlide(
|
||||||
String title,
|
String title,
|
||||||
String count,
|
String count,
|
||||||
List<Map<String, dynamic>> stories,
|
List<Map<String, dynamic>> stories, {
|
||||||
) {
|
required int shelfId,
|
||||||
|
}) {
|
||||||
// PRD: .bookshelf-container height: 600px, .story-book height: 100%
|
// PRD: .bookshelf-container height: 600px, .story-book height: 100%
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
@ -595,7 +572,8 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStorySlot(Map<String, dynamic> story, {bool isNew = false}) {
|
Widget _buildStorySlot(Map<String, dynamic> story, {bool isNew = false}) {
|
||||||
final bool hasCover = story['cover'] != null && (story['cover'] as String).isNotEmpty;
|
final coverUrl = story['cover_url'] as String? ?? story['cover'] as String? ?? '';
|
||||||
|
final bool hasCover = coverUrl.isNotEmpty;
|
||||||
final bool hasContent = story['content'] != null && (story['content'] as String).isNotEmpty;
|
final bool hasContent = story['content'] != null && (story['content'] as String).isNotEmpty;
|
||||||
|
|
||||||
// Empty/Clickable Slot — no content, just a "+" to create new story
|
// Empty/Clickable Slot — no content, just a "+" to create new story
|
||||||
@ -647,11 +625,17 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
// Cover widget: real image or "未生成封面" placeholder
|
// Cover widget: real image or "未生成封面" placeholder
|
||||||
Widget coverWidget;
|
Widget coverWidget;
|
||||||
if (hasCover) {
|
if (hasCover) {
|
||||||
coverWidget = Image.asset(
|
coverWidget = coverUrl.startsWith('http')
|
||||||
story['cover'],
|
? Image.network(
|
||||||
fit: BoxFit.cover,
|
coverUrl,
|
||||||
errorBuilder: (_, __, ___) => Container(color: Colors.grey.shade200),
|
fit: BoxFit.cover,
|
||||||
);
|
errorBuilder: (_, __, ___) => Container(color: Colors.grey.shade200),
|
||||||
|
)
|
||||||
|
: Image.asset(
|
||||||
|
coverUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => Container(color: Colors.grey.shade200),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// No cover — show soft placeholder
|
// No cover — show soft placeholder
|
||||||
coverWidget = Container(
|
coverWidget = Container(
|
||||||
@ -889,48 +873,79 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
showGlassDialog(
|
showGlassDialog(
|
||||||
context: context,
|
context: context,
|
||||||
title: '解锁新书架',
|
title: '解锁新书架',
|
||||||
description: '确认消耗 500 积分以永久解锁该书架?',
|
description: '确认消耗 100 积分以永久解锁该书架?',
|
||||||
confirmText: '确认解锁',
|
confirmText: '确认解锁',
|
||||||
onConfirm: () {
|
onConfirm: () async {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
setState(() {
|
try {
|
||||||
_unlockedShelves++;
|
final api = ref.read(apiClientProvider);
|
||||||
});
|
await api.post('/stories/shelves/unlock/');
|
||||||
// Auto-scroll to the newly unlocked shelf
|
// Reload shelves to get the new one
|
||||||
Future.delayed(const Duration(milliseconds: 300), () {
|
await _loadShelves();
|
||||||
if (mounted) {
|
// Auto-scroll to the newly unlocked shelf
|
||||||
_bookshelfController.animateToPage(
|
Future.delayed(const Duration(milliseconds: 300), () {
|
||||||
_unlockedShelves - 1, // scroll to the new shelf (0-indexed)
|
if (mounted) {
|
||||||
duration: const Duration(milliseconds: 500),
|
_bookshelfController.animateToPage(
|
||||||
curve: Curves.easeOutCubic,
|
_shelves.length - 1,
|
||||||
);
|
duration: const Duration(milliseconds: 500),
|
||||||
}
|
curve: Curves.easeOutCubic,
|
||||||
});
|
);
|
||||||
AppToast.show(context, '解锁成功!新书架已添加');
|
}
|
||||||
|
});
|
||||||
|
if (mounted) AppToast.show(context, '解锁成功!新书架已添加');
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) AppToast.show(context, '解锁失败: ${e.toString()}');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _addNewBookWithAnimation({String title = '新故事', String content = ''}) {
|
Future<void> _addNewBookWithAnimation({String title = '新故事', String content = ''}) async {
|
||||||
setState(() {
|
// Find the first shelf that has space
|
||||||
_mockStories.add({
|
int? targetShelfId;
|
||||||
'title': title,
|
for (final shelf in _shelves) {
|
||||||
'cover': null, // No cover yet for generated stories
|
final stories = shelf['stories'] as List? ?? [];
|
||||||
'type': 'new',
|
final capacity = shelf['capacity'] as int? ?? 10;
|
||||||
'locked': false,
|
if (stories.length < capacity) {
|
||||||
'content': content,
|
targetShelfId = shelf['id'] as int;
|
||||||
});
|
break;
|
||||||
_newBookIndex = _mockStories.length - 1;
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
if (targetShelfId == null) {
|
||||||
|
if (mounted) AppToast.show(context, '所有书架已满,请解锁新书架');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
await api.post('/stories/', data: {
|
||||||
|
'title': title,
|
||||||
|
'content': content,
|
||||||
|
'shelf_id': targetShelfId,
|
||||||
|
});
|
||||||
|
// Reload to get the new story
|
||||||
|
await _loadShelves();
|
||||||
|
|
||||||
// Clear animation flag after animation completes
|
|
||||||
Future.delayed(const Duration(milliseconds: 800), () {
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
// Find the shelf index and trigger animation
|
||||||
_newBookIndex = null;
|
for (int i = 0; i < _shelves.length; i++) {
|
||||||
|
if (_shelves[i]['id'] == targetShelfId) {
|
||||||
|
final stories = _shelves[i]['stories'] as List? ?? [];
|
||||||
|
setState(() {
|
||||||
|
_newBookIndex = stories.length - 1;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Future.delayed(const Duration(milliseconds: 800), () {
|
||||||
|
if (mounted) setState(() => _newBookIndex = null);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
} catch (e) {
|
||||||
|
debugPrint('Save story failed: $e');
|
||||||
|
if (mounted) AppToast.show(context, '保存失败: ${e.toString()}');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart' show PlatformException;
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:just_audio/just_audio.dart';
|
import 'package:just_audio/just_audio.dart';
|
||||||
import '../services/music_generation_service.dart';
|
import '../services/music_generation_service.dart';
|
||||||
@ -18,22 +19,24 @@ import '../theme/app_colors.dart' as appclr;
|
|||||||
/// Playlist track data
|
/// Playlist track data
|
||||||
class _Track {
|
class _Track {
|
||||||
final int id;
|
final int id;
|
||||||
|
final int? serverId; // Backend track ID (for delete/favorite)
|
||||||
final String title;
|
final String title;
|
||||||
final String lyrics;
|
final String lyrics;
|
||||||
String audioAsset;
|
String audioAsset;
|
||||||
final bool isRemote; // true = URL from server, false = local asset
|
final bool isRemote; // true = URL from server, false = local asset
|
||||||
|
final bool isDefault; // Backend default track (undeletable)
|
||||||
|
|
||||||
_Track({
|
_Track({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
this.serverId,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.lyrics,
|
required this.lyrics,
|
||||||
required this.audioAsset,
|
required this.audioAsset,
|
||||||
this.isRemote = false,
|
this.isRemote = false,
|
||||||
|
this.isDefault = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Server base URL — change this when deploying
|
|
||||||
|
|
||||||
class MusicCreationPage extends StatefulWidget {
|
class MusicCreationPage extends StatefulWidget {
|
||||||
/// Whether this page is embedded as a tab (hides back button)
|
/// Whether this page is embedded as a tab (hides back button)
|
||||||
final bool isTab;
|
final bool isTab;
|
||||||
@ -214,6 +217,9 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
_currentTime = _formatDuration(position);
|
_currentTime = _formatDuration(position);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}, onError: (e) {
|
||||||
|
// Silently ignore PlatformException(abort) — expected when switching tracks
|
||||||
|
debugPrint('positionStream error (ignored): $e');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen to duration → update total time label
|
// Listen to duration → update total time label
|
||||||
@ -222,6 +228,8 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_totalTime = _formatDuration(duration);
|
_totalTime = _formatDuration(duration);
|
||||||
});
|
});
|
||||||
|
}, onError: (e) {
|
||||||
|
debugPrint('durationStream error (ignored): $e');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen to player state → detect track completion
|
// Listen to player state → detect track completion
|
||||||
@ -230,6 +238,8 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
if (state.processingState == ProcessingState.completed) {
|
if (state.processingState == ProcessingState.completed) {
|
||||||
_onTrackComplete();
|
_onTrackComplete();
|
||||||
}
|
}
|
||||||
|
}, onError: (e) {
|
||||||
|
debugPrint('playerStateStream error (ignored): $e');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pre-load the first track (don't auto-play)
|
// Pre-load the first track (don't auto-play)
|
||||||
@ -269,40 +279,49 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Load historical songs from server ──
|
// ── Load playlist from backend API ──
|
||||||
_loadHistoricalSongs();
|
_loadPlaylist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Load historical songs from server into playlist ──
|
// ── Load playlist from backend API — replaces entire playlist ──
|
||||||
Future<void> _loadHistoricalSongs() async {
|
Future<void> _loadPlaylist() async {
|
||||||
final songs = await _genService.fetchPlaylist();
|
final songs = await _genService.fetchPlaylist();
|
||||||
if (!mounted || songs.isEmpty) return;
|
if (!mounted || songs.isEmpty) return;
|
||||||
|
|
||||||
// Collect titles already in playlist to avoid duplicates
|
// Remember the currently playing track title to restore position
|
||||||
final existingTitles = _playlist.map((t) => t.title).toSet();
|
final currentTitle = _playlist.isNotEmpty
|
||||||
|
? _playlist[_currentTrackIndex].title
|
||||||
|
: null;
|
||||||
|
|
||||||
final newTracks = <_Track>[];
|
final backendTracks = songs.map((song) => _Track(
|
||||||
for (final song in songs) {
|
id: song.id ?? DateTime.now().millisecondsSinceEpoch,
|
||||||
if (existingTitles.contains(song.title)) continue;
|
serverId: song.id,
|
||||||
newTracks.add(_Track(
|
title: song.title,
|
||||||
id: DateTime.now().millisecondsSinceEpoch + newTracks.length,
|
lyrics: song.lyrics,
|
||||||
title: song.title,
|
audioAsset: song.audioUrl,
|
||||||
lyrics: song.lyrics,
|
isRemote: true,
|
||||||
audioAsset: song.audioUrl,
|
isDefault: song.isDefault,
|
||||||
isRemote: true,
|
)).toList();
|
||||||
));
|
|
||||||
|
// Find the index of the previously playing track in the new list
|
||||||
|
int newIndex = 0;
|
||||||
|
if (currentTitle != null) {
|
||||||
|
final found = backendTracks.indexWhere((t) => t.title == currentTitle);
|
||||||
|
if (found >= 0) newIndex = found;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newTracks.isEmpty) return;
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
// Insert server songs at the beginning (before hardcoded tracks)
|
_playlist.clear();
|
||||||
_playlist.insertAll(0, newTracks);
|
_playlist.addAll(backendTracks);
|
||||||
// Shift current track index so it still points to the same track
|
_currentTrackIndex = newIndex;
|
||||||
_currentTrackIndex += newTracks.length;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
debugPrint('Loaded ${newTracks.length} historical songs from server');
|
// Re-load the current track if not playing
|
||||||
|
if (!_isPlaying) {
|
||||||
|
_loadTrack(_currentTrackIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('Loaded ${backendTracks.length} tracks from backend');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Duration formatter ──
|
// ── Duration formatter ──
|
||||||
@ -323,6 +342,16 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
// Local preset track — load from assets
|
// Local preset track — load from assets
|
||||||
await _audioPlayer.setAsset(track.audioAsset);
|
await _audioPlayer.setAsset(track.audioAsset);
|
||||||
}
|
}
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
// PlatformException(abort) is expected when switching tracks quickly
|
||||||
|
if (e.code == 'abort') {
|
||||||
|
debugPrint('Track load interrupted (expected): $e');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debugPrint('Error loading track: $e');
|
||||||
|
if (mounted) {
|
||||||
|
_showSpeech('音频加载失败,请重试');
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error loading track: $e');
|
debugPrint('Error loading track: $e');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@ -501,13 +530,23 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _selectedMoodIndex = index);
|
|
||||||
final mood = _moods[index];
|
final mood = _moods[index];
|
||||||
_generateMusic(
|
final text = (mood['prompt'] as String).isNotEmpty
|
||||||
text: (mood['prompt'] as String).isNotEmpty
|
? mood['prompt'] as String
|
||||||
? mood['prompt'] as String
|
: '咔咔今天想来点惊喜';
|
||||||
: '咔咔今天想来点惊喜',
|
final moodValue = mood['mood'] as String;
|
||||||
mood: mood['mood'] as String,
|
|
||||||
|
showGlassDialog(
|
||||||
|
context: context,
|
||||||
|
title: '创作新歌',
|
||||||
|
description: '确认消耗 100 积分生成音乐?',
|
||||||
|
cancelText: '再想想',
|
||||||
|
confirmText: '开始创作',
|
||||||
|
onConfirm: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
setState(() => _selectedMoodIndex = index);
|
||||||
|
_generateMusic(text: text, mood: moodValue);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -572,7 +611,8 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
});
|
});
|
||||||
|
|
||||||
final newTrack = _Track(
|
final newTrack = _Track(
|
||||||
id: DateTime.now().millisecondsSinceEpoch,
|
id: result.id ?? DateTime.now().millisecondsSinceEpoch,
|
||||||
|
serverId: result.id,
|
||||||
title: result.title,
|
title: result.title,
|
||||||
lyrics: result.lyrics,
|
lyrics: result.lyrics,
|
||||||
audioAsset: result.audioUrl,
|
audioAsset: result.audioUrl,
|
||||||
@ -581,6 +621,8 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_playlist.insert(0, newTrack);
|
_playlist.insert(0, newTrack);
|
||||||
|
// Shift current track index since we inserted at 0
|
||||||
|
_currentTrackIndex++;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Always show dialog, never auto-play
|
// Always show dialog, never auto-play
|
||||||
@ -597,7 +639,8 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
});
|
});
|
||||||
|
|
||||||
final newTrack = _Track(
|
final newTrack = _Track(
|
||||||
id: DateTime.now().millisecondsSinceEpoch,
|
id: result.id ?? DateTime.now().millisecondsSinceEpoch,
|
||||||
|
serverId: result.id,
|
||||||
title: result.title,
|
title: result.title,
|
||||||
lyrics: result.lyrics,
|
lyrics: result.lyrics,
|
||||||
audioAsset: result.audioUrl,
|
audioAsset: result.audioUrl,
|
||||||
@ -606,6 +649,8 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_playlist.insert(0, newTrack);
|
_playlist.insert(0, newTrack);
|
||||||
|
// Shift current track index since we inserted at 0
|
||||||
|
_currentTrackIndex++;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (_isPlaying) {
|
if (_isPlaying) {
|
||||||
@ -1495,8 +1540,18 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
controller: controller,
|
controller: controller,
|
||||||
onSubmit: (text) {
|
onSubmit: (text) {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
setState(() => _selectedMoodIndex = 5);
|
showGlassDialog(
|
||||||
_generateMusic(text: text, mood: 'custom');
|
context: context,
|
||||||
|
title: '创作新歌',
|
||||||
|
description: '确认消耗 100 积分生成音乐?',
|
||||||
|
cancelText: '再想想',
|
||||||
|
confirmText: '开始创作',
|
||||||
|
onConfirm: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
setState(() => _selectedMoodIndex = 5);
|
||||||
|
_generateMusic(text: text, mood: 'custom');
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -22,7 +22,7 @@ class GuideFeedingPage extends StatelessWidget {
|
|||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [Colors.transparent, Colors.black, Colors.black, Colors.transparent],
|
colors: [Colors.transparent, Colors.black, Colors.black, Colors.transparent],
|
||||||
stops: [0.0, 0.12, 0.92, 1.0],
|
stops: [0.0, 0.05, 0.95, 1.0],
|
||||||
).createShader(rect);
|
).createShader(rect);
|
||||||
},
|
},
|
||||||
blendMode: BlendMode.dstIn,
|
blendMode: BlendMode.dstIn,
|
||||||
|
|||||||
@ -23,7 +23,7 @@ class HelpPage extends StatelessWidget {
|
|||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [Colors.transparent, Colors.black, Colors.black, Colors.transparent],
|
colors: [Colors.transparent, Colors.black, Colors.black, Colors.transparent],
|
||||||
stops: [0.0, 0.12, 0.92, 1.0],
|
stops: [0.0, 0.05, 0.95, 1.0],
|
||||||
).createShader(rect);
|
).createShader(rect);
|
||||||
},
|
},
|
||||||
blendMode: BlendMode.dstIn,
|
blendMode: BlendMode.dstIn,
|
||||||
|
|||||||
@ -308,7 +308,7 @@ class _NotificationPageState extends ConsumerState<NotificationPage> {
|
|||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Text(
|
child: Text(
|
||||||
notif.detail,
|
notif.content,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: Color(0xFF374151),
|
color: Color(0xFF374151),
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import 'package:airhub_app/pages/product_selection_page.dart';
|
|||||||
import 'package:airhub_app/widgets/glass_dialog.dart';
|
import 'package:airhub_app/widgets/glass_dialog.dart';
|
||||||
import 'package:airhub_app/features/auth/presentation/controllers/auth_controller.dart';
|
import 'package:airhub_app/features/auth/presentation/controllers/auth_controller.dart';
|
||||||
import 'package:airhub_app/features/system/data/datasources/system_remote_data_source.dart';
|
import 'package:airhub_app/features/system/data/datasources/system_remote_data_source.dart';
|
||||||
|
import 'package:airhub_app/features/device/presentation/controllers/device_controller.dart';
|
||||||
|
|
||||||
class SettingsPage extends ConsumerStatefulWidget {
|
class SettingsPage extends ConsumerStatefulWidget {
|
||||||
const SettingsPage({super.key});
|
const SettingsPage({super.key});
|
||||||
@ -23,6 +24,8 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// watch 保持 provider 存活,确保硬件信息可用
|
||||||
|
ref.watch(deviceControllerProvider);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
body: Stack(
|
body: Stack(
|
||||||
@ -81,10 +84,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
|||||||
_buildItem(
|
_buildItem(
|
||||||
'💻',
|
'💻',
|
||||||
'硬件信息',
|
'硬件信息',
|
||||||
onTap: () => _showMessage(
|
onTap: _showHardwareInfo,
|
||||||
'硬件信息',
|
|
||||||
'设备型号: Airhub_5G\n固件版本: 2.1.3',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
_buildItem(
|
_buildItem(
|
||||||
'📄',
|
'📄',
|
||||||
@ -294,6 +294,24 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showHardwareInfo() {
|
||||||
|
final devicesAsync = ref.read(deviceControllerProvider);
|
||||||
|
final devices = devicesAsync.value ?? [];
|
||||||
|
final firstDevice = devices.isNotEmpty ? devices.first : null;
|
||||||
|
|
||||||
|
final deviceModel = firstDevice?.device.deviceTypeInfo?.name ?? '未知';
|
||||||
|
final sn = firstDevice?.device.sn ?? '未知';
|
||||||
|
final firmwareVersion = firstDevice?.device.firmwareVersion;
|
||||||
|
final fwDisplay = (firmwareVersion != null && firmwareVersion.isNotEmpty)
|
||||||
|
? firmwareVersion
|
||||||
|
: '等待设备上报';
|
||||||
|
|
||||||
|
_showMessage(
|
||||||
|
'硬件信息',
|
||||||
|
'设备型号: $deviceModel\nSN码: $sn\n固件版本: $fwDisplay',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _checkUpdate() async {
|
Future<void> _checkUpdate() async {
|
||||||
try {
|
try {
|
||||||
final ds = ref.read(systemRemoteDataSourceProvider);
|
final ds = ref.read(systemRemoteDataSourceProvider);
|
||||||
|
|||||||
@ -31,7 +31,7 @@ class SettingsContentPage extends StatelessWidget {
|
|||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [Colors.transparent, Colors.black, Colors.black, Colors.transparent],
|
colors: [Colors.transparent, Colors.black, Colors.black, Colors.transparent],
|
||||||
stops: [0.0, 0.12, 0.92, 1.0],
|
stops: [0.0, 0.05, 0.95, 1.0],
|
||||||
).createShader(rect);
|
).createShader(rect);
|
||||||
},
|
},
|
||||||
blendMode: BlendMode.dstIn,
|
blendMode: BlendMode.dstIn,
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart' show PlatformException;
|
||||||
import 'package:just_audio/just_audio.dart';
|
import 'package:just_audio/just_audio.dart';
|
||||||
import '../theme/design_tokens.dart';
|
import '../theme/design_tokens.dart';
|
||||||
import '../widgets/gradient_button.dart';
|
import '../widgets/gradient_button.dart';
|
||||||
@ -87,23 +88,46 @@ class _StoryDetailPageState extends State<StoryDetailPage>
|
|||||||
_audioPosition = Duration.zero;
|
_audioPosition = Duration.zero;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}, onError: (e) {
|
||||||
|
debugPrint('playerStateStream error (ignored): $e');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen to playback position for ring progress
|
// Listen to playback position for ring progress
|
||||||
_positionSub = _audioPlayer.positionStream.listen((pos) {
|
_positionSub = _audioPlayer.positionStream.listen((pos) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _audioPosition = pos);
|
setState(() => _audioPosition = pos);
|
||||||
|
}, onError: (e) {
|
||||||
|
debugPrint('positionStream error (ignored): $e');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen to duration changes
|
// Listen to duration changes
|
||||||
_audioPlayer.durationStream.listen((dur) {
|
_audioPlayer.durationStream.listen((dur) {
|
||||||
if (!mounted || dur == null) return;
|
if (!mounted || dur == null) return;
|
||||||
setState(() => _audioDuration = dur);
|
setState(() => _audioDuration = dur);
|
||||||
|
}, onError: (e) {
|
||||||
|
debugPrint('durationStream error (ignored): $e');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if audio already exists (via TTSService)
|
// Check if audio already exists
|
||||||
|
debugPrint('[StoryDetail] story keys: ${_currentStory.keys.toList()}');
|
||||||
|
debugPrint('[StoryDetail] audio_url value: "${_currentStory['audio_url']}"');
|
||||||
|
debugPrint('[StoryDetail] id value: ${_currentStory['id']}');
|
||||||
|
|
||||||
final title = _currentStory['title'] as String? ?? '';
|
final title = _currentStory['title'] as String? ?? '';
|
||||||
_ttsService.checkExistingAudio(title);
|
final audioUrl = _currentStory['audio_url'] as String? ?? '';
|
||||||
|
final storyId = _currentStory['id'] as int?;
|
||||||
|
|
||||||
|
debugPrint('[StoryDetail] parsed: title=$title, audioUrl=$audioUrl, storyId=$storyId');
|
||||||
|
|
||||||
|
if (audioUrl.isNotEmpty) {
|
||||||
|
debugPrint('[StoryDetail] -> setExistingAudio');
|
||||||
|
_ttsService.setExistingAudio(title, audioUrl);
|
||||||
|
} else if (storyId != null) {
|
||||||
|
debugPrint('[StoryDetail] -> checkExistingAudio');
|
||||||
|
_ttsService.checkExistingAudio(title, storyId: storyId);
|
||||||
|
} else {
|
||||||
|
debugPrint('[StoryDetail] -> no audio, no storyId');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onTTSChanged() {
|
void _onTTSChanged() {
|
||||||
@ -196,7 +220,8 @@ class _StoryDetailPageState extends State<StoryDetailPage>
|
|||||||
case TTSButtonState.error:
|
case TTSButtonState.error:
|
||||||
final title = _currentStory['title'] as String? ?? '';
|
final title = _currentStory['title'] as String? ?? '';
|
||||||
final content = _currentStory['content'] as String? ?? '';
|
final content = _currentStory['content'] as String? ?? '';
|
||||||
_ttsService.generate(title: title, content: content);
|
final storyId = _currentStory['id'] as int? ?? 0;
|
||||||
|
_ttsService.generate(title: title, content: content, storyId: storyId);
|
||||||
break;
|
break;
|
||||||
case TTSButtonState.generating:
|
case TTSButtonState.generating:
|
||||||
break;
|
break;
|
||||||
@ -232,6 +257,13 @@ class _StoryDetailPageState extends State<StoryDetailPage>
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _isPlaying = true);
|
setState(() => _isPlaying = true);
|
||||||
}
|
}
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
// PlatformException(abort) is expected when loading is interrupted
|
||||||
|
if (e.code == 'abort') {
|
||||||
|
debugPrint('Audio load interrupted (expected): $e');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debugPrint('Audio play error: $e');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Audio play error: $e');
|
debugPrint('Audio play error: $e');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../core/network/api_config.dart';
|
||||||
import 'story_detail_page.dart';
|
import 'story_detail_page.dart';
|
||||||
|
|
||||||
class StoryLoadingPage extends StatefulWidget {
|
class StoryLoadingPage extends StatefulWidget {
|
||||||
@ -22,8 +24,6 @@ class StoryLoadingPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _StoryLoadingPageState extends State<StoryLoadingPage> {
|
class _StoryLoadingPageState extends State<StoryLoadingPage> {
|
||||||
static const String _kServerBase = 'http://localhost:3000';
|
|
||||||
|
|
||||||
double _progress = 0.0;
|
double _progress = 0.0;
|
||||||
String _loadingText = '正在收集灵感碎片...';
|
String _loadingText = '正在收集灵感碎片...';
|
||||||
bool _hasError = false;
|
bool _hasError = false;
|
||||||
@ -34,14 +34,23 @@ class _StoryLoadingPageState extends State<StoryLoadingPage> {
|
|||||||
_generateStory();
|
_generateStory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String?> _getToken() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString('access_token');
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _generateStory() async {
|
Future<void> _generateStory() async {
|
||||||
try {
|
try {
|
||||||
// ── Start SSE request ──
|
final token = await _getToken();
|
||||||
|
|
||||||
final request = http.Request(
|
final request = http.Request(
|
||||||
'POST',
|
'POST',
|
||||||
Uri.parse('$_kServerBase/api/create_story'),
|
Uri.parse('${ApiConfig.fullBaseUrl}/stories/generate/'),
|
||||||
);
|
);
|
||||||
request.headers['Content-Type'] = 'application/json';
|
request.headers['Content-Type'] = 'application/json';
|
||||||
|
if (token != null) {
|
||||||
|
request.headers['Authorization'] = 'Bearer $token';
|
||||||
|
}
|
||||||
request.body = jsonEncode({
|
request.body = jsonEncode({
|
||||||
'characters': widget.characters,
|
'characters': widget.characters,
|
||||||
'scenes': widget.scenes,
|
'scenes': widget.scenes,
|
||||||
@ -69,11 +78,18 @@ class _StoryLoadingPageState extends State<StoryLoadingPage> {
|
|||||||
|
|
||||||
while (buffer.contains('\n\n')) {
|
while (buffer.contains('\n\n')) {
|
||||||
final idx = buffer.indexOf('\n\n');
|
final idx = buffer.indexOf('\n\n');
|
||||||
final line = buffer.substring(0, idx).trim();
|
final block = buffer.substring(0, idx).trim();
|
||||||
buffer = buffer.substring(idx + 2);
|
buffer = buffer.substring(idx + 2);
|
||||||
|
|
||||||
if (!line.startsWith('data: ')) continue;
|
// Extract the data line from the SSE block (may contain event: + data:)
|
||||||
final jsonStr = line.substring(6);
|
String? jsonStr;
|
||||||
|
for (final line in block.split('\n')) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
jsonStr = line.substring(6);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jsonStr == null) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final event = jsonDecode(jsonStr) as Map<String, dynamic>;
|
final event = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../core/network/api_config.dart';
|
||||||
|
|
||||||
/// Lightweight singleton that runs music generation in the background.
|
/// Lightweight singleton that runs music generation in the background.
|
||||||
/// Survives page navigation — results are held until the music page picks them up.
|
/// Survives page navigation — results are held until the music page picks them up.
|
||||||
@ -8,7 +10,10 @@ class MusicGenerationService {
|
|||||||
MusicGenerationService._();
|
MusicGenerationService._();
|
||||||
static final MusicGenerationService instance = MusicGenerationService._();
|
static final MusicGenerationService instance = MusicGenerationService._();
|
||||||
|
|
||||||
static const String _kServerBase = 'http://localhost:3000';
|
Future<String?> _getToken() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString('access_token');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Current task state ──
|
// ── Current task state ──
|
||||||
bool _isGenerating = false;
|
bool _isGenerating = false;
|
||||||
@ -60,11 +65,15 @@ class MusicGenerationService {
|
|||||||
onProgress?.call(_progress, _currentStage, _statusMessage);
|
onProgress?.call(_progress, _currentStage, _statusMessage);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final token = await _getToken();
|
||||||
final request = http.Request(
|
final request = http.Request(
|
||||||
'POST',
|
'POST',
|
||||||
Uri.parse('$_kServerBase/api/create_music'),
|
Uri.parse('${ApiConfig.fullBaseUrl}/music/generate/'),
|
||||||
);
|
);
|
||||||
request.headers['Content-Type'] = 'application/json';
|
request.headers['Content-Type'] = 'application/json';
|
||||||
|
if (token != null) {
|
||||||
|
request.headers['Authorization'] = 'Bearer $token';
|
||||||
|
}
|
||||||
request.body = jsonEncode({'text': text, 'mood': mood});
|
request.body = jsonEncode({'text': text, 'mood': mood});
|
||||||
|
|
||||||
final client = http.Client();
|
final client = http.Client();
|
||||||
@ -73,14 +82,23 @@ class MusicGenerationService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw Exception('Server returned ${response.statusCode}');
|
// Backend may return JSON error for non-SSE responses (e.g. points not enough)
|
||||||
|
final body = await response.stream.bytesToString();
|
||||||
|
String errMsg = '服务器返回错误 (${response.statusCode})';
|
||||||
|
try {
|
||||||
|
final json = jsonDecode(body) as Map<String, dynamic>;
|
||||||
|
errMsg = json['message'] as String? ?? errMsg;
|
||||||
|
} catch (_) {}
|
||||||
|
throw Exception(errMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse SSE stream
|
// Parse SSE stream
|
||||||
String buffer = '';
|
String buffer = '';
|
||||||
String? newTitle;
|
String? newTitle;
|
||||||
String? newLyrics;
|
String? newLyrics;
|
||||||
String? newFilePath;
|
String? newAudioUrl;
|
||||||
|
String? newCoverUrl;
|
||||||
|
int? newTrackId;
|
||||||
|
|
||||||
await for (final chunk in response.stream.transform(utf8.decoder)) {
|
await for (final chunk in response.stream.transform(utf8.decoder)) {
|
||||||
buffer += chunk;
|
buffer += chunk;
|
||||||
@ -113,13 +131,14 @@ class MusicGenerationService {
|
|||||||
_updateProgress(90, stage, '音乐生成完成,正在保存...');
|
_updateProgress(90, stage, '音乐生成完成,正在保存...');
|
||||||
break;
|
break;
|
||||||
case 'done':
|
case 'done':
|
||||||
newFilePath = event['file_path'] as String?;
|
newTrackId = event['track_id'] as int?;
|
||||||
|
newAudioUrl = event['audio_url'] as String?;
|
||||||
|
newCoverUrl = event['cover_url'] as String?;
|
||||||
final metadata = event['metadata'] as Map<String, dynamic>?;
|
final metadata = event['metadata'] as Map<String, dynamic>?;
|
||||||
newLyrics = metadata?['lyrics'] as String? ?? '';
|
newLyrics = metadata?['lyrics'] as String? ?? '';
|
||||||
newTitle = metadata?['song_title'] as String?;
|
newTitle = metadata?['song_title'] as String?;
|
||||||
if ((newTitle == null || newTitle.isEmpty) && newFilePath != null) {
|
if (newTitle == null || newTitle.isEmpty) {
|
||||||
final fname = newFilePath.split('/').last;
|
newTitle = '咔咔新歌';
|
||||||
newTitle = fname.replaceAll(RegExp(r'_\d{10,}\.mp3$'), '');
|
|
||||||
}
|
}
|
||||||
_updateProgress(100, stage, '新歌出炉!');
|
_updateProgress(100, stage, '新歌出炉!');
|
||||||
break;
|
break;
|
||||||
@ -148,11 +167,13 @@ class MusicGenerationService {
|
|||||||
_isGenerating = false;
|
_isGenerating = false;
|
||||||
_progress = 0;
|
_progress = 0;
|
||||||
|
|
||||||
if (newFilePath != null) {
|
if (newAudioUrl != null) {
|
||||||
final result = MusicGenResult(
|
final result = MusicGenResult(
|
||||||
|
id: newTrackId,
|
||||||
title: newTitle ?? '新歌',
|
title: newTitle ?? '新歌',
|
||||||
lyrics: newLyrics ?? '',
|
lyrics: newLyrics ?? '',
|
||||||
audioUrl: '$_kServerBase/$newFilePath',
|
audioUrl: newAudioUrl,
|
||||||
|
coverUrl: newCoverUrl ?? '',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Always store as pending first; callback decides whether to consume
|
// Always store as pending first; callback decides whether to consume
|
||||||
@ -163,7 +184,7 @@ class MusicGenerationService {
|
|||||||
debugPrint('Generate music error: $e');
|
debugPrint('Generate music error: $e');
|
||||||
_isGenerating = false;
|
_isGenerating = false;
|
||||||
_progress = 0;
|
_progress = 0;
|
||||||
const errMsg = '网络开小差了,再试一次~';
|
final errMsg = e.toString().replaceFirst('Exception: ', '');
|
||||||
_statusMessage = errMsg;
|
_statusMessage = errMsg;
|
||||||
if (onError != null) {
|
if (onError != null) {
|
||||||
onError!(errMsg);
|
onError!(errMsg);
|
||||||
@ -180,24 +201,33 @@ class MusicGenerationService {
|
|||||||
onProgress?.call(progress, stage, message);
|
onProgress?.call(progress, stage, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch saved songs from the server (scans Capybara music/ folder).
|
/// Fetch playlist from backend API.
|
||||||
Future<List<MusicGenResult>> fetchPlaylist() async {
|
Future<List<MusicGenResult>> fetchPlaylist() async {
|
||||||
try {
|
try {
|
||||||
|
final token = await _getToken();
|
||||||
final response = await http.get(
|
final response = await http.get(
|
||||||
Uri.parse('$_kServerBase/api/playlist'),
|
Uri.parse('${ApiConfig.fullBaseUrl}/music/playlist/'),
|
||||||
|
headers: {
|
||||||
|
if (token != null) 'Authorization': 'Bearer $token',
|
||||||
|
},
|
||||||
).timeout(const Duration(seconds: 10));
|
).timeout(const Duration(seconds: 10));
|
||||||
|
|
||||||
if (response.statusCode != 200) return [];
|
if (response.statusCode != 200) return [];
|
||||||
|
|
||||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final data = body['data'] as Map<String, dynamic>? ?? {};
|
||||||
final list = data['playlist'] as List<dynamic>? ?? [];
|
final list = data['playlist'] as List<dynamic>? ?? [];
|
||||||
|
|
||||||
return list.map((item) {
|
return list.map((item) {
|
||||||
final m = item as Map<String, dynamic>;
|
final m = item as Map<String, dynamic>;
|
||||||
return MusicGenResult(
|
return MusicGenResult(
|
||||||
|
id: m['id'] as int?,
|
||||||
title: m['title'] as String? ?? '',
|
title: m['title'] as String? ?? '',
|
||||||
lyrics: m['lyrics'] as String? ?? '',
|
lyrics: m['lyrics'] as String? ?? '',
|
||||||
audioUrl: '$_kServerBase/${m['audioUrl'] as String? ?? ''}',
|
audioUrl: m['audio_url'] as String? ?? '',
|
||||||
|
coverUrl: m['cover_url'] as String? ?? '',
|
||||||
|
isFavorite: m['is_favorite'] as bool? ?? false,
|
||||||
|
isDefault: m['is_default'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -207,15 +237,23 @@ class MusicGenerationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of a completed music generation.
|
/// Result of a completed music generation or a playlist track.
|
||||||
class MusicGenResult {
|
class MusicGenResult {
|
||||||
|
final int? id;
|
||||||
final String title;
|
final String title;
|
||||||
final String lyrics;
|
final String lyrics;
|
||||||
final String audioUrl;
|
final String audioUrl;
|
||||||
|
final String coverUrl;
|
||||||
|
final bool isFavorite;
|
||||||
|
final bool isDefault;
|
||||||
|
|
||||||
const MusicGenResult({
|
const MusicGenResult({
|
||||||
|
this.id,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.lyrics,
|
required this.lyrics,
|
||||||
required this.audioUrl,
|
required this.audioUrl,
|
||||||
|
this.coverUrl = '',
|
||||||
|
this.isFavorite = false,
|
||||||
|
this.isDefault = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../core/network/api_config.dart';
|
||||||
|
|
||||||
/// Singleton service that manages TTS generation in the background.
|
/// Singleton service that manages TTS generation in the background.
|
||||||
/// Survives page navigation — when user leaves and comes back,
|
/// Survives page navigation — when user leaves and comes back,
|
||||||
@ -9,7 +11,10 @@ class TTSService extends ChangeNotifier {
|
|||||||
TTSService._();
|
TTSService._();
|
||||||
static final TTSService instance = TTSService._();
|
static final TTSService instance = TTSService._();
|
||||||
|
|
||||||
static const String _kServerBase = 'http://localhost:3000';
|
Future<String?> _getToken() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString('access_token');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Current task state ──
|
// ── Current task state ──
|
||||||
bool _isGenerating = false;
|
bool _isGenerating = false;
|
||||||
@ -59,20 +64,23 @@ class TTSService extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check server for existing audio file.
|
/// Check server for existing audio file by story ID.
|
||||||
Future<void> checkExistingAudio(String title) async {
|
Future<void> checkExistingAudio(String title, {int? storyId}) async {
|
||||||
if (title.isEmpty) return;
|
if (title.isEmpty || storyId == null) return;
|
||||||
try {
|
try {
|
||||||
|
final token = await _getToken();
|
||||||
final resp = await http.get(
|
final resp = await http.get(
|
||||||
Uri.parse(
|
Uri.parse('${ApiConfig.fullBaseUrl}/stories/$storyId/tts/'),
|
||||||
'$_kServerBase/api/tts_check?title=${Uri.encodeComponent(title)}',
|
headers: {
|
||||||
),
|
if (token != null) 'Authorization': 'Bearer $token',
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (resp.statusCode == 200) {
|
if (resp.statusCode == 200) {
|
||||||
final data = jsonDecode(resp.body);
|
final body = jsonDecode(resp.body);
|
||||||
if (data['exists'] == true && data['audio_url'] != null) {
|
final data = body['data'];
|
||||||
|
if (data != null && data['exists'] == true && data['audio_url'] != null) {
|
||||||
_completedStoryTitle = title;
|
_completedStoryTitle = title;
|
||||||
_audioUrl = '$_kServerBase/${data['audio_url']}';
|
_audioUrl = data['audio_url'] as String;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -80,9 +88,11 @@ class TTSService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Start TTS generation. Safe to call even if page navigates away.
|
/// Start TTS generation. Safe to call even if page navigates away.
|
||||||
|
/// [storyId] is required to call the Django backend TTS endpoint.
|
||||||
Future<void> generate({
|
Future<void> generate({
|
||||||
required String title,
|
required String title,
|
||||||
required String content,
|
required String content,
|
||||||
|
required int storyId,
|
||||||
}) async {
|
}) async {
|
||||||
if (_isGenerating) return;
|
if (_isGenerating) return;
|
||||||
|
|
||||||
@ -97,21 +107,39 @@ class TTSService extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final token = await _getToken();
|
||||||
final client = http.Client();
|
final client = http.Client();
|
||||||
final request = http.Request(
|
final request = http.Request(
|
||||||
'POST',
|
'POST',
|
||||||
Uri.parse('$_kServerBase/api/create_tts'),
|
Uri.parse('${ApiConfig.fullBaseUrl}/stories/$storyId/tts/'),
|
||||||
);
|
);
|
||||||
request.headers['Content-Type'] = 'application/json';
|
request.headers['Content-Type'] = 'application/json';
|
||||||
request.body = jsonEncode({'title': title, 'content': content});
|
if (token != null) {
|
||||||
|
request.headers['Authorization'] = 'Bearer $token';
|
||||||
|
}
|
||||||
|
|
||||||
final streamed = await client.send(request);
|
final streamed = await client.send(request);
|
||||||
|
|
||||||
|
String buffer = '';
|
||||||
await for (final chunk in streamed.stream.transform(utf8.decoder)) {
|
await for (final chunk in streamed.stream.transform(utf8.decoder)) {
|
||||||
for (final line in chunk.split('\n')) {
|
buffer += chunk;
|
||||||
if (!line.startsWith('data: ')) continue;
|
|
||||||
|
while (buffer.contains('\n\n')) {
|
||||||
|
final idx = buffer.indexOf('\n\n');
|
||||||
|
final block = buffer.substring(0, idx).trim();
|
||||||
|
buffer = buffer.substring(idx + 2);
|
||||||
|
|
||||||
|
String? jsonStr;
|
||||||
|
for (final line in block.split('\n')) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
jsonStr = line.substring(6);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jsonStr == null) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final data = jsonDecode(line.substring(6));
|
final data = jsonDecode(jsonStr);
|
||||||
final stage = data['stage'] as String? ?? '';
|
final stage = data['stage'] as String? ?? '';
|
||||||
final message = data['message'] as String? ?? '';
|
final message = data['message'] as String? ?? '';
|
||||||
|
|
||||||
@ -127,7 +155,7 @@ class TTSService extends ChangeNotifier {
|
|||||||
break;
|
break;
|
||||||
case 'done':
|
case 'done':
|
||||||
if (data['audio_url'] != null) {
|
if (data['audio_url'] != null) {
|
||||||
_audioUrl = '$_kServerBase/${data['audio_url']}';
|
_audioUrl = data['audio_url'] as String;
|
||||||
_completedStoryTitle = title;
|
_completedStoryTitle = title;
|
||||||
_justCompleted = true;
|
_justCompleted = true;
|
||||||
_updateProgress(1.0, '生成完成');
|
_updateProgress(1.0, '生成完成');
|
||||||
@ -136,7 +164,6 @@ class TTSService extends ChangeNotifier {
|
|||||||
case 'error':
|
case 'error':
|
||||||
throw Exception(message);
|
throw Exception(message);
|
||||||
default:
|
default:
|
||||||
// Progress slowly increases during generation
|
|
||||||
if (_progress < 0.85) {
|
if (_progress < 0.85) {
|
||||||
_updateProgress(_progress + 0.02, message);
|
_updateProgress(_progress + 0.02, message);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -101,10 +101,10 @@ packages:
|
|||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: build_runner
|
name: build_runner
|
||||||
sha256: ac78098de97893812b7aff1154f29008fa2464cad9e8e7044d39bc905dad4fbc
|
sha256: "39ad4ca8a2876779737c60e4228b4bcd35d4352ef7e14e47514093edc012c734"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.11.0"
|
version: "2.11.1"
|
||||||
built_collection:
|
built_collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -301,10 +301,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: ffi
|
name: ffi
|
||||||
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
|
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.5"
|
version: "2.2.0"
|
||||||
file:
|
file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
14
airhub_app/restart.sh
Executable file
14
airhub_app/restart.sh
Executable file
@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 重新编译并启动 Flutter Web (localhost:8080)
|
||||||
|
|
||||||
|
# 杀掉占用 8080 端口的进程
|
||||||
|
PID=$(lsof -ti:8080 2>/dev/null)
|
||||||
|
if [ -n "$PID" ]; then
|
||||||
|
echo "正在停止旧进程 (PID: $PID)..."
|
||||||
|
kill $PID 2>/dev/null
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "正在编译并启动 Flutter Web..."
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
flutter run -d web-server --web-port=8080 --no-pub
|
||||||
Loading…
x
Reference in New Issue
Block a user