Compare commits

...

8 Commits

Author SHA1 Message Date
repair-agent
3971f8dc19 fix: restart.sh 改用 kill -9 强杀并等待端口释放后再启动
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 15:58:52 +08:00
repair-agent
73cffb704f fix: 保存故事时将 cover_url 写入后端,修复书架封面为空的问题
_addNewBookWithAnimation 新增 coverUrl 参数,POST /stories/ 时携带
cover_url 字段,两处调用均从 saveResult 传入生成好的封面 URL。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 15:57:14 +08:00
repair-agent
51f994a2cc feat: 故事加载页接收并传递封面 URL
- 新增 storyCoverUrl 变量,从 SSE done 事件中读取 cover_url
- 新增 cover SSE 阶段的进度展示(正在绘制故事封面...)
- 将 cover_url 传递给 StoryDetailPage 和返回结果

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 15:33:16 +08:00
repair-agent
d741fd4f5c feat: 实现故事绘本视频播放及修复TTS状态管理
- 添加 video_player 依赖,实现 OSS 视频播放
- 故事有 has_video 时自动切换到绘本 Tab 并初始化播放器
- 修复播放按钮尺寸及 GestureDetector 事件穿透问题
- TTSService 新增 errorTitle 字段,避免跨故事错误状态污染
- 修复 device entity 相关代码

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 15:16:58 +08:00
zyc
d29cf88516 Merge pull request 'fix: auto repair bugs #53' (#3) from fix/auto-20260228-145348 into main
Reviewed-on: #3
2026-02-28 18:00:04 +08:00
repair-agent
b59a2da513 fix: auto repair bugs #10 2026-02-28 15:35:12 +08:00
repair-agent
3a89ad4b55 fix: auto repair bugs #53 2026-02-28 14:57:20 +08:00
zyc
3f7b38a59b Merge pull request 'fix: auto repair bugs #53' (#2) from fix/auto-20260228-143427 into main
Merge PR #2 (approved via Log Center)
2026-02-28 14:47:24 +08:00
23 changed files with 795 additions and 175 deletions

View File

@ -2,6 +2,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../../core/network/api_client.dart'; import '../../../../core/network/api_client.dart';
import '../../domain/entities/device.dart'; import '../../domain/entities/device.dart';
import '../../domain/entities/device_detail.dart'; import '../../domain/entities/device_detail.dart';
import '../../domain/entities/role_memory.dart';
part 'device_remote_data_source.g.dart'; part 'device_remote_data_source.g.dart';
@ -32,6 +33,9 @@ abstract class DeviceRemoteDataSource {
/// POST /devices/{id}/wifi/ /// POST /devices/{id}/wifi/
Future<void> configWifi(int userDeviceId, String ssid); Future<void> configWifi(int userDeviceId, String ssid);
/// GET /devices/role-memories/
Future<List<RoleMemory>> listRoleMemories({int? deviceTypeId});
} }
@riverpod @riverpod
@ -125,4 +129,18 @@ class DeviceRemoteDataSourceImpl implements DeviceRemoteDataSource {
data: {'ssid': ssid}, data: {'ssid': ssid},
); );
} }
@override
Future<List<RoleMemory>> listRoleMemories({int? deviceTypeId}) async {
final queryParams = <String, dynamic>{};
if (deviceTypeId != null) queryParams['device_type_id'] = deviceTypeId;
final data = await _apiClient.get(
'/devices/role-memories/',
queryParameters: queryParams.isNotEmpty ? queryParams : null,
);
final list = data as List<dynamic>;
return list
.map((e) => RoleMemory.fromJson(e as Map<String, dynamic>))
.toList();
}
} }

View File

@ -4,6 +4,7 @@ import '../../../../core/errors/exceptions.dart';
import '../../../../core/errors/failures.dart'; import '../../../../core/errors/failures.dart';
import '../../domain/entities/device.dart'; import '../../domain/entities/device.dart';
import '../../domain/entities/device_detail.dart'; import '../../domain/entities/device_detail.dart';
import '../../domain/entities/role_memory.dart';
import '../../domain/repositories/device_repository.dart'; import '../../domain/repositories/device_repository.dart';
import '../datasources/device_remote_data_source.dart'; import '../datasources/device_remote_data_source.dart';
@ -144,4 +145,20 @@ class DeviceRepositoryImpl implements DeviceRepository {
return left(NetworkFailure(e.message)); return left(NetworkFailure(e.message));
} }
} }
@override
Future<Either<Failure, List<RoleMemory>>> listRoleMemories({
int? deviceTypeId,
}) async {
try {
final result = await _remoteDataSource.listRoleMemories(
deviceTypeId: deviceTypeId,
);
return right(result);
} on ServerException catch (e) {
return left(ServerFailure(e.message));
} on NetworkException catch (e) {
return left(NetworkFailure(e.message));
}
}
} }

View File

@ -29,6 +29,7 @@ abstract class DeviceInfo with _$DeviceInfo {
String? macAddress, String? macAddress,
@Default('') String name, @Default('') String name,
@Default('in_stock') String status, @Default('in_stock') String status,
@Default(false) bool isOnline,
@Default('') String firmwareVersion, @Default('') String firmwareVersion,
String? lastOnlineAt, String? lastOnlineAt,
String? createdAt, String? createdAt,

View File

@ -296,7 +296,7 @@ as String?,
/// @nodoc /// @nodoc
mixin _$DeviceInfo { mixin _$DeviceInfo {
int get id; String get sn; DeviceType? get deviceType; DeviceType? get deviceTypeInfo; String? get macAddress; String get name; String get status; String get firmwareVersion; String? get lastOnlineAt; String? get createdAt; int get id; String get sn; DeviceType? get deviceType; DeviceType? get deviceTypeInfo; String? get macAddress; String get name; String get status; bool get isOnline; String get firmwareVersion; String? get lastOnlineAt; String? get createdAt;
/// Create a copy of DeviceInfo /// Create a copy of DeviceInfo
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@ -309,16 +309,16 @@ $DeviceInfoCopyWith<DeviceInfo> get copyWith => _$DeviceInfoCopyWithImpl<DeviceI
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is DeviceInfo&&(identical(other.id, id) || other.id == id)&&(identical(other.sn, sn) || other.sn == sn)&&(identical(other.deviceType, deviceType) || other.deviceType == deviceType)&&(identical(other.deviceTypeInfo, deviceTypeInfo) || other.deviceTypeInfo == deviceTypeInfo)&&(identical(other.macAddress, macAddress) || other.macAddress == macAddress)&&(identical(other.name, name) || other.name == name)&&(identical(other.status, status) || other.status == status)&&(identical(other.firmwareVersion, firmwareVersion) || other.firmwareVersion == firmwareVersion)&&(identical(other.lastOnlineAt, lastOnlineAt) || other.lastOnlineAt == lastOnlineAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)); return identical(this, other) || (other.runtimeType == runtimeType&&other is DeviceInfo&&(identical(other.id, id) || other.id == id)&&(identical(other.sn, sn) || other.sn == sn)&&(identical(other.deviceType, deviceType) || other.deviceType == deviceType)&&(identical(other.deviceTypeInfo, deviceTypeInfo) || other.deviceTypeInfo == deviceTypeInfo)&&(identical(other.macAddress, macAddress) || other.macAddress == macAddress)&&(identical(other.name, name) || other.name == name)&&(identical(other.status, status) || other.status == status)&&(identical(other.isOnline, isOnline) || other.isOnline == isOnline)&&(identical(other.firmwareVersion, firmwareVersion) || other.firmwareVersion == firmwareVersion)&&(identical(other.lastOnlineAt, lastOnlineAt) || other.lastOnlineAt == lastOnlineAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,id,sn,deviceType,deviceTypeInfo,macAddress,name,status,firmwareVersion,lastOnlineAt,createdAt); int get hashCode => Object.hash(runtimeType,id,sn,deviceType,deviceTypeInfo,macAddress,name,status,isOnline,firmwareVersion,lastOnlineAt,createdAt);
@override @override
String toString() { String toString() {
return 'DeviceInfo(id: $id, sn: $sn, deviceType: $deviceType, deviceTypeInfo: $deviceTypeInfo, macAddress: $macAddress, name: $name, status: $status, firmwareVersion: $firmwareVersion, lastOnlineAt: $lastOnlineAt, createdAt: $createdAt)'; return 'DeviceInfo(id: $id, sn: $sn, deviceType: $deviceType, deviceTypeInfo: $deviceTypeInfo, macAddress: $macAddress, name: $name, status: $status, isOnline: $isOnline, firmwareVersion: $firmwareVersion, lastOnlineAt: $lastOnlineAt, createdAt: $createdAt)';
} }
@ -329,7 +329,7 @@ abstract mixin class $DeviceInfoCopyWith<$Res> {
factory $DeviceInfoCopyWith(DeviceInfo value, $Res Function(DeviceInfo) _then) = _$DeviceInfoCopyWithImpl; factory $DeviceInfoCopyWith(DeviceInfo value, $Res Function(DeviceInfo) _then) = _$DeviceInfoCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
int id, String sn, DeviceType? deviceType, DeviceType? deviceTypeInfo, String? macAddress, String name, String status, String firmwareVersion, String? lastOnlineAt, String? createdAt int id, String sn, DeviceType? deviceType, DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt
}); });
@ -346,7 +346,7 @@ class _$DeviceInfoCopyWithImpl<$Res>
/// Create a copy of DeviceInfo /// Create a copy of DeviceInfo
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? sn = null,Object? deviceType = freezed,Object? deviceTypeInfo = freezed,Object? macAddress = freezed,Object? name = null,Object? status = null,Object? firmwareVersion = null,Object? lastOnlineAt = freezed,Object? createdAt = freezed,}) { @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? sn = null,Object? deviceType = freezed,Object? deviceTypeInfo = freezed,Object? macAddress = freezed,Object? name = null,Object? status = null,Object? isOnline = null,Object? firmwareVersion = null,Object? lastOnlineAt = freezed,Object? createdAt = freezed,}) {
return _then(_self.copyWith( return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as int,sn: null == sn ? _self.sn : sn // ignore: cast_nullable_to_non_nullable as int,sn: null == sn ? _self.sn : sn // ignore: cast_nullable_to_non_nullable
@ -355,7 +355,8 @@ as DeviceType?,deviceTypeInfo: freezed == deviceTypeInfo ? _self.deviceTypeInfo
as DeviceType?,macAddress: freezed == macAddress ? _self.macAddress : macAddress // ignore: cast_nullable_to_non_nullable as DeviceType?,macAddress: freezed == macAddress ? _self.macAddress : macAddress // ignore: cast_nullable_to_non_nullable
as String?,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String?,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as String,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as String,firmwareVersion: null == firmwareVersion ? _self.firmwareVersion : firmwareVersion // ignore: cast_nullable_to_non_nullable as String,isOnline: null == isOnline ? _self.isOnline : isOnline // ignore: cast_nullable_to_non_nullable
as bool,firmwareVersion: null == firmwareVersion ? _self.firmwareVersion : firmwareVersion // ignore: cast_nullable_to_non_nullable
as String,lastOnlineAt: freezed == lastOnlineAt ? _self.lastOnlineAt : lastOnlineAt // ignore: cast_nullable_to_non_nullable as String,lastOnlineAt: freezed == lastOnlineAt ? _self.lastOnlineAt : lastOnlineAt // ignore: cast_nullable_to_non_nullable
as String?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable as String?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as String?, as String?,
@ -467,10 +468,10 @@ return $default(_that);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int id, String sn, DeviceType? deviceType, DeviceType? deviceTypeInfo, String? macAddress, String name, String status, String firmwareVersion, String? lastOnlineAt, String? createdAt)? $default,{required TResult orElse(),}) {final _that = this; @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int id, String sn, DeviceType? deviceType, DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) { switch (_that) {
case _DeviceInfo() when $default != null: case _DeviceInfo() when $default != null:
return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.macAddress,_that.name,_that.status,_that.firmwareVersion,_that.lastOnlineAt,_that.createdAt);case _: return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.macAddress,_that.name,_that.status,_that.isOnline,_that.firmwareVersion,_that.lastOnlineAt,_that.createdAt);case _:
return orElse(); return orElse();
} }
@ -488,10 +489,10 @@ return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.ma
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int id, String sn, DeviceType? deviceType, DeviceType? deviceTypeInfo, String? macAddress, String name, String status, String firmwareVersion, String? lastOnlineAt, String? createdAt) $default,) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int id, String sn, DeviceType? deviceType, DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt) $default,) {final _that = this;
switch (_that) { switch (_that) {
case _DeviceInfo(): case _DeviceInfo():
return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.macAddress,_that.name,_that.status,_that.firmwareVersion,_that.lastOnlineAt,_that.createdAt);case _: return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.macAddress,_that.name,_that.status,_that.isOnline,_that.firmwareVersion,_that.lastOnlineAt,_that.createdAt);case _:
throw StateError('Unexpected subclass'); throw StateError('Unexpected subclass');
} }
@ -508,10 +509,10 @@ return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.ma
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int id, String sn, DeviceType? deviceType, DeviceType? deviceTypeInfo, String? macAddress, String name, String status, String firmwareVersion, String? lastOnlineAt, String? createdAt)? $default,) {final _that = this; @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int id, String sn, DeviceType? deviceType, DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt)? $default,) {final _that = this;
switch (_that) { switch (_that) {
case _DeviceInfo() when $default != null: case _DeviceInfo() when $default != null:
return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.macAddress,_that.name,_that.status,_that.firmwareVersion,_that.lastOnlineAt,_that.createdAt);case _: return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.macAddress,_that.name,_that.status,_that.isOnline,_that.firmwareVersion,_that.lastOnlineAt,_that.createdAt);case _:
return null; return null;
} }
@ -523,7 +524,7 @@ return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.ma
@JsonSerializable() @JsonSerializable()
class _DeviceInfo implements DeviceInfo { class _DeviceInfo implements DeviceInfo {
const _DeviceInfo({required this.id, required this.sn, this.deviceType, this.deviceTypeInfo, this.macAddress, this.name = '', this.status = 'in_stock', this.firmwareVersion = '', this.lastOnlineAt, this.createdAt}); const _DeviceInfo({required this.id, required this.sn, this.deviceType, this.deviceTypeInfo, this.macAddress, this.name = '', this.status = 'in_stock', this.isOnline = false, this.firmwareVersion = '', this.lastOnlineAt, this.createdAt});
factory _DeviceInfo.fromJson(Map<String, dynamic> json) => _$DeviceInfoFromJson(json); factory _DeviceInfo.fromJson(Map<String, dynamic> json) => _$DeviceInfoFromJson(json);
@override final int id; @override final int id;
@ -533,6 +534,7 @@ class _DeviceInfo implements DeviceInfo {
@override final String? macAddress; @override final String? macAddress;
@override@JsonKey() final String name; @override@JsonKey() final String name;
@override@JsonKey() final String status; @override@JsonKey() final String status;
@override@JsonKey() final bool isOnline;
@override@JsonKey() final String firmwareVersion; @override@JsonKey() final String firmwareVersion;
@override final String? lastOnlineAt; @override final String? lastOnlineAt;
@override final String? createdAt; @override final String? createdAt;
@ -550,16 +552,16 @@ Map<String, dynamic> toJson() {
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _DeviceInfo&&(identical(other.id, id) || other.id == id)&&(identical(other.sn, sn) || other.sn == sn)&&(identical(other.deviceType, deviceType) || other.deviceType == deviceType)&&(identical(other.deviceTypeInfo, deviceTypeInfo) || other.deviceTypeInfo == deviceTypeInfo)&&(identical(other.macAddress, macAddress) || other.macAddress == macAddress)&&(identical(other.name, name) || other.name == name)&&(identical(other.status, status) || other.status == status)&&(identical(other.firmwareVersion, firmwareVersion) || other.firmwareVersion == firmwareVersion)&&(identical(other.lastOnlineAt, lastOnlineAt) || other.lastOnlineAt == lastOnlineAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)); return identical(this, other) || (other.runtimeType == runtimeType&&other is _DeviceInfo&&(identical(other.id, id) || other.id == id)&&(identical(other.sn, sn) || other.sn == sn)&&(identical(other.deviceType, deviceType) || other.deviceType == deviceType)&&(identical(other.deviceTypeInfo, deviceTypeInfo) || other.deviceTypeInfo == deviceTypeInfo)&&(identical(other.macAddress, macAddress) || other.macAddress == macAddress)&&(identical(other.name, name) || other.name == name)&&(identical(other.status, status) || other.status == status)&&(identical(other.isOnline, isOnline) || other.isOnline == isOnline)&&(identical(other.firmwareVersion, firmwareVersion) || other.firmwareVersion == firmwareVersion)&&(identical(other.lastOnlineAt, lastOnlineAt) || other.lastOnlineAt == lastOnlineAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,id,sn,deviceType,deviceTypeInfo,macAddress,name,status,firmwareVersion,lastOnlineAt,createdAt); int get hashCode => Object.hash(runtimeType,id,sn,deviceType,deviceTypeInfo,macAddress,name,status,isOnline,firmwareVersion,lastOnlineAt,createdAt);
@override @override
String toString() { String toString() {
return 'DeviceInfo(id: $id, sn: $sn, deviceType: $deviceType, deviceTypeInfo: $deviceTypeInfo, macAddress: $macAddress, name: $name, status: $status, firmwareVersion: $firmwareVersion, lastOnlineAt: $lastOnlineAt, createdAt: $createdAt)'; return 'DeviceInfo(id: $id, sn: $sn, deviceType: $deviceType, deviceTypeInfo: $deviceTypeInfo, macAddress: $macAddress, name: $name, status: $status, isOnline: $isOnline, firmwareVersion: $firmwareVersion, lastOnlineAt: $lastOnlineAt, createdAt: $createdAt)';
} }
@ -570,7 +572,7 @@ abstract mixin class _$DeviceInfoCopyWith<$Res> implements $DeviceInfoCopyWith<$
factory _$DeviceInfoCopyWith(_DeviceInfo value, $Res Function(_DeviceInfo) _then) = __$DeviceInfoCopyWithImpl; factory _$DeviceInfoCopyWith(_DeviceInfo value, $Res Function(_DeviceInfo) _then) = __$DeviceInfoCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
int id, String sn, DeviceType? deviceType, DeviceType? deviceTypeInfo, String? macAddress, String name, String status, String firmwareVersion, String? lastOnlineAt, String? createdAt int id, String sn, DeviceType? deviceType, DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt
}); });
@ -587,7 +589,7 @@ class __$DeviceInfoCopyWithImpl<$Res>
/// Create a copy of DeviceInfo /// Create a copy of DeviceInfo
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? sn = null,Object? deviceType = freezed,Object? deviceTypeInfo = freezed,Object? macAddress = freezed,Object? name = null,Object? status = null,Object? firmwareVersion = null,Object? lastOnlineAt = freezed,Object? createdAt = freezed,}) { @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? sn = null,Object? deviceType = freezed,Object? deviceTypeInfo = freezed,Object? macAddress = freezed,Object? name = null,Object? status = null,Object? isOnline = null,Object? firmwareVersion = null,Object? lastOnlineAt = freezed,Object? createdAt = freezed,}) {
return _then(_DeviceInfo( return _then(_DeviceInfo(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as int,sn: null == sn ? _self.sn : sn // ignore: cast_nullable_to_non_nullable as int,sn: null == sn ? _self.sn : sn // ignore: cast_nullable_to_non_nullable
@ -596,7 +598,8 @@ as DeviceType?,deviceTypeInfo: freezed == deviceTypeInfo ? _self.deviceTypeInfo
as DeviceType?,macAddress: freezed == macAddress ? _self.macAddress : macAddress // ignore: cast_nullable_to_non_nullable as DeviceType?,macAddress: freezed == macAddress ? _self.macAddress : macAddress // ignore: cast_nullable_to_non_nullable
as String?,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String?,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as String,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as String,firmwareVersion: null == firmwareVersion ? _self.firmwareVersion : firmwareVersion // ignore: cast_nullable_to_non_nullable as String,isOnline: null == isOnline ? _self.isOnline : isOnline // ignore: cast_nullable_to_non_nullable
as bool,firmwareVersion: null == firmwareVersion ? _self.firmwareVersion : firmwareVersion // ignore: cast_nullable_to_non_nullable
as String,lastOnlineAt: freezed == lastOnlineAt ? _self.lastOnlineAt : lastOnlineAt // ignore: cast_nullable_to_non_nullable as String,lastOnlineAt: freezed == lastOnlineAt ? _self.lastOnlineAt : lastOnlineAt // ignore: cast_nullable_to_non_nullable
as String?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable as String?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as String?, as String?,

View File

@ -39,6 +39,7 @@ _DeviceInfo _$DeviceInfoFromJson(Map<String, dynamic> json) => _DeviceInfo(
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',
isOnline: json['is_online'] as bool? ?? false,
firmwareVersion: json['firmware_version'] as String? ?? '', firmwareVersion: json['firmware_version'] as String? ?? '',
lastOnlineAt: json['last_online_at'] as String?, lastOnlineAt: json['last_online_at'] as String?,
createdAt: json['created_at'] as String?, createdAt: json['created_at'] as String?,
@ -53,6 +54,7 @@ Map<String, dynamic> _$DeviceInfoToJson(_DeviceInfo instance) =>
'mac_address': instance.macAddress, 'mac_address': instance.macAddress,
'name': instance.name, 'name': instance.name,
'status': instance.status, 'status': instance.status,
'is_online': instance.isOnline,
'firmware_version': instance.firmwareVersion, 'firmware_version': instance.firmwareVersion,
'last_online_at': instance.lastOnlineAt, 'last_online_at': instance.lastOnlineAt,
'created_at': instance.createdAt, 'created_at': instance.createdAt,

View File

@ -0,0 +1,28 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'role_memory.freezed.dart';
part 'role_memory.g.dart';
@freezed
abstract class RoleMemory with _$RoleMemory {
const factory RoleMemory({
required int id,
required int deviceType,
@Default('') String deviceTypeName,
@Default(true) bool isBound,
@Default('') String nickname,
@Default('') String userName,
@Default(50) int volume,
@Default(50) int brightness,
@Default(true) bool allowInterrupt,
@Default(false) bool privacyMode,
@Default('') String prompt,
@Default('') String voiceId,
@Default('') String memorySummary,
String? createdAt,
String? updatedAt,
}) = _RoleMemory;
factory RoleMemory.fromJson(Map<String, dynamic> json) =>
_$RoleMemoryFromJson(json);
}

View File

@ -0,0 +1,319 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'role_memory.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$RoleMemory {
int get id; int get deviceType; String get deviceTypeName; bool get isBound; String get nickname; String get userName; int get volume; int get brightness; bool get allowInterrupt; bool get privacyMode; String get prompt; String get voiceId; String get memorySummary; String? get createdAt; String? get updatedAt;
/// Create a copy of RoleMemory
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$RoleMemoryCopyWith<RoleMemory> get copyWith => _$RoleMemoryCopyWithImpl<RoleMemory>(this as RoleMemory, _$identity);
/// Serializes this RoleMemory to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is RoleMemory&&(identical(other.id, id) || other.id == id)&&(identical(other.deviceType, deviceType) || other.deviceType == deviceType)&&(identical(other.deviceTypeName, deviceTypeName) || other.deviceTypeName == deviceTypeName)&&(identical(other.isBound, isBound) || other.isBound == isBound)&&(identical(other.nickname, nickname) || other.nickname == nickname)&&(identical(other.userName, userName) || other.userName == userName)&&(identical(other.volume, volume) || other.volume == volume)&&(identical(other.brightness, brightness) || other.brightness == brightness)&&(identical(other.allowInterrupt, allowInterrupt) || other.allowInterrupt == allowInterrupt)&&(identical(other.privacyMode, privacyMode) || other.privacyMode == privacyMode)&&(identical(other.prompt, prompt) || other.prompt == prompt)&&(identical(other.voiceId, voiceId) || other.voiceId == voiceId)&&(identical(other.memorySummary, memorySummary) || other.memorySummary == memorySummary)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,deviceType,deviceTypeName,isBound,nickname,userName,volume,brightness,allowInterrupt,privacyMode,prompt,voiceId,memorySummary,createdAt,updatedAt);
@override
String toString() {
return 'RoleMemory(id: $id, deviceType: $deviceType, deviceTypeName: $deviceTypeName, isBound: $isBound, nickname: $nickname, userName: $userName, volume: $volume, brightness: $brightness, allowInterrupt: $allowInterrupt, privacyMode: $privacyMode, prompt: $prompt, voiceId: $voiceId, memorySummary: $memorySummary, createdAt: $createdAt, updatedAt: $updatedAt)';
}
}
/// @nodoc
abstract mixin class $RoleMemoryCopyWith<$Res> {
factory $RoleMemoryCopyWith(RoleMemory value, $Res Function(RoleMemory) _then) = _$RoleMemoryCopyWithImpl;
@useResult
$Res call({
int id, int deviceType, String deviceTypeName, bool isBound, String nickname, String userName, int volume, int brightness, bool allowInterrupt, bool privacyMode, String prompt, String voiceId, String memorySummary, String? createdAt, String? updatedAt
});
}
/// @nodoc
class _$RoleMemoryCopyWithImpl<$Res>
implements $RoleMemoryCopyWith<$Res> {
_$RoleMemoryCopyWithImpl(this._self, this._then);
final RoleMemory _self;
final $Res Function(RoleMemory) _then;
/// Create a copy of RoleMemory
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? deviceType = null,Object? deviceTypeName = null,Object? isBound = null,Object? nickname = null,Object? userName = null,Object? volume = null,Object? brightness = null,Object? allowInterrupt = null,Object? privacyMode = null,Object? prompt = null,Object? voiceId = null,Object? memorySummary = null,Object? createdAt = freezed,Object? updatedAt = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as int,deviceType: null == deviceType ? _self.deviceType : deviceType // ignore: cast_nullable_to_non_nullable
as int,deviceTypeName: null == deviceTypeName ? _self.deviceTypeName : deviceTypeName // ignore: cast_nullable_to_non_nullable
as String,isBound: null == isBound ? _self.isBound : isBound // ignore: cast_nullable_to_non_nullable
as bool,nickname: null == nickname ? _self.nickname : nickname // ignore: cast_nullable_to_non_nullable
as String,userName: null == userName ? _self.userName : userName // ignore: cast_nullable_to_non_nullable
as String,volume: null == volume ? _self.volume : volume // ignore: cast_nullable_to_non_nullable
as int,brightness: null == brightness ? _self.brightness : brightness // ignore: cast_nullable_to_non_nullable
as int,allowInterrupt: null == allowInterrupt ? _self.allowInterrupt : allowInterrupt // ignore: cast_nullable_to_non_nullable
as bool,privacyMode: null == privacyMode ? _self.privacyMode : privacyMode // ignore: cast_nullable_to_non_nullable
as bool,prompt: null == prompt ? _self.prompt : prompt // ignore: cast_nullable_to_non_nullable
as String,voiceId: null == voiceId ? _self.voiceId : voiceId // ignore: cast_nullable_to_non_nullable
as String,memorySummary: null == memorySummary ? _self.memorySummary : memorySummary // ignore: cast_nullable_to_non_nullable
as String,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as String?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// Adds pattern-matching-related methods to [RoleMemory].
extension RoleMemoryPatterns on RoleMemory {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _RoleMemory value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _RoleMemory() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _RoleMemory value) $default,){
final _that = this;
switch (_that) {
case _RoleMemory():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _RoleMemory value)? $default,){
final _that = this;
switch (_that) {
case _RoleMemory() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int id, int deviceType, String deviceTypeName, bool isBound, String nickname, String userName, int volume, int brightness, bool allowInterrupt, bool privacyMode, String prompt, String voiceId, String memorySummary, String? createdAt, String? updatedAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _RoleMemory() when $default != null:
return $default(_that.id,_that.deviceType,_that.deviceTypeName,_that.isBound,_that.nickname,_that.userName,_that.volume,_that.brightness,_that.allowInterrupt,_that.privacyMode,_that.prompt,_that.voiceId,_that.memorySummary,_that.createdAt,_that.updatedAt);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int id, int deviceType, String deviceTypeName, bool isBound, String nickname, String userName, int volume, int brightness, bool allowInterrupt, bool privacyMode, String prompt, String voiceId, String memorySummary, String? createdAt, String? updatedAt) $default,) {final _that = this;
switch (_that) {
case _RoleMemory():
return $default(_that.id,_that.deviceType,_that.deviceTypeName,_that.isBound,_that.nickname,_that.userName,_that.volume,_that.brightness,_that.allowInterrupt,_that.privacyMode,_that.prompt,_that.voiceId,_that.memorySummary,_that.createdAt,_that.updatedAt);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int id, int deviceType, String deviceTypeName, bool isBound, String nickname, String userName, int volume, int brightness, bool allowInterrupt, bool privacyMode, String prompt, String voiceId, String memorySummary, String? createdAt, String? updatedAt)? $default,) {final _that = this;
switch (_that) {
case _RoleMemory() when $default != null:
return $default(_that.id,_that.deviceType,_that.deviceTypeName,_that.isBound,_that.nickname,_that.userName,_that.volume,_that.brightness,_that.allowInterrupt,_that.privacyMode,_that.prompt,_that.voiceId,_that.memorySummary,_that.createdAt,_that.updatedAt);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _RoleMemory implements RoleMemory {
const _RoleMemory({required this.id, required this.deviceType, this.deviceTypeName = '', this.isBound = true, this.nickname = '', this.userName = '', this.volume = 50, this.brightness = 50, this.allowInterrupt = true, this.privacyMode = false, this.prompt = '', this.voiceId = '', this.memorySummary = '', this.createdAt, this.updatedAt});
factory _RoleMemory.fromJson(Map<String, dynamic> json) => _$RoleMemoryFromJson(json);
@override final int id;
@override final int deviceType;
@override@JsonKey() final String deviceTypeName;
@override@JsonKey() final bool isBound;
@override@JsonKey() final String nickname;
@override@JsonKey() final String userName;
@override@JsonKey() final int volume;
@override@JsonKey() final int brightness;
@override@JsonKey() final bool allowInterrupt;
@override@JsonKey() final bool privacyMode;
@override@JsonKey() final String prompt;
@override@JsonKey() final String voiceId;
@override@JsonKey() final String memorySummary;
@override final String? createdAt;
@override final String? updatedAt;
/// Create a copy of RoleMemory
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$RoleMemoryCopyWith<_RoleMemory> get copyWith => __$RoleMemoryCopyWithImpl<_RoleMemory>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$RoleMemoryToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _RoleMemory&&(identical(other.id, id) || other.id == id)&&(identical(other.deviceType, deviceType) || other.deviceType == deviceType)&&(identical(other.deviceTypeName, deviceTypeName) || other.deviceTypeName == deviceTypeName)&&(identical(other.isBound, isBound) || other.isBound == isBound)&&(identical(other.nickname, nickname) || other.nickname == nickname)&&(identical(other.userName, userName) || other.userName == userName)&&(identical(other.volume, volume) || other.volume == volume)&&(identical(other.brightness, brightness) || other.brightness == brightness)&&(identical(other.allowInterrupt, allowInterrupt) || other.allowInterrupt == allowInterrupt)&&(identical(other.privacyMode, privacyMode) || other.privacyMode == privacyMode)&&(identical(other.prompt, prompt) || other.prompt == prompt)&&(identical(other.voiceId, voiceId) || other.voiceId == voiceId)&&(identical(other.memorySummary, memorySummary) || other.memorySummary == memorySummary)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,deviceType,deviceTypeName,isBound,nickname,userName,volume,brightness,allowInterrupt,privacyMode,prompt,voiceId,memorySummary,createdAt,updatedAt);
@override
String toString() {
return 'RoleMemory(id: $id, deviceType: $deviceType, deviceTypeName: $deviceTypeName, isBound: $isBound, nickname: $nickname, userName: $userName, volume: $volume, brightness: $brightness, allowInterrupt: $allowInterrupt, privacyMode: $privacyMode, prompt: $prompt, voiceId: $voiceId, memorySummary: $memorySummary, createdAt: $createdAt, updatedAt: $updatedAt)';
}
}
/// @nodoc
abstract mixin class _$RoleMemoryCopyWith<$Res> implements $RoleMemoryCopyWith<$Res> {
factory _$RoleMemoryCopyWith(_RoleMemory value, $Res Function(_RoleMemory) _then) = __$RoleMemoryCopyWithImpl;
@override @useResult
$Res call({
int id, int deviceType, String deviceTypeName, bool isBound, String nickname, String userName, int volume, int brightness, bool allowInterrupt, bool privacyMode, String prompt, String voiceId, String memorySummary, String? createdAt, String? updatedAt
});
}
/// @nodoc
class __$RoleMemoryCopyWithImpl<$Res>
implements _$RoleMemoryCopyWith<$Res> {
__$RoleMemoryCopyWithImpl(this._self, this._then);
final _RoleMemory _self;
final $Res Function(_RoleMemory) _then;
/// Create a copy of RoleMemory
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? deviceType = null,Object? deviceTypeName = null,Object? isBound = null,Object? nickname = null,Object? userName = null,Object? volume = null,Object? brightness = null,Object? allowInterrupt = null,Object? privacyMode = null,Object? prompt = null,Object? voiceId = null,Object? memorySummary = null,Object? createdAt = freezed,Object? updatedAt = freezed,}) {
return _then(_RoleMemory(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as int,deviceType: null == deviceType ? _self.deviceType : deviceType // ignore: cast_nullable_to_non_nullable
as int,deviceTypeName: null == deviceTypeName ? _self.deviceTypeName : deviceTypeName // ignore: cast_nullable_to_non_nullable
as String,isBound: null == isBound ? _self.isBound : isBound // ignore: cast_nullable_to_non_nullable
as bool,nickname: null == nickname ? _self.nickname : nickname // ignore: cast_nullable_to_non_nullable
as String,userName: null == userName ? _self.userName : userName // ignore: cast_nullable_to_non_nullable
as String,volume: null == volume ? _self.volume : volume // ignore: cast_nullable_to_non_nullable
as int,brightness: null == brightness ? _self.brightness : brightness // ignore: cast_nullable_to_non_nullable
as int,allowInterrupt: null == allowInterrupt ? _self.allowInterrupt : allowInterrupt // ignore: cast_nullable_to_non_nullable
as bool,privacyMode: null == privacyMode ? _self.privacyMode : privacyMode // ignore: cast_nullable_to_non_nullable
as bool,prompt: null == prompt ? _self.prompt : prompt // ignore: cast_nullable_to_non_nullable
as String,voiceId: null == voiceId ? _self.voiceId : voiceId // ignore: cast_nullable_to_non_nullable
as String,memorySummary: null == memorySummary ? _self.memorySummary : memorySummary // ignore: cast_nullable_to_non_nullable
as String,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as String?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
// dart format on

View File

@ -0,0 +1,44 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'role_memory.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_RoleMemory _$RoleMemoryFromJson(Map<String, dynamic> json) => _RoleMemory(
id: (json['id'] as num).toInt(),
deviceType: (json['device_type'] as num).toInt(),
deviceTypeName: json['device_type_name'] as String? ?? '',
isBound: json['is_bound'] as bool? ?? true,
nickname: json['nickname'] as String? ?? '',
userName: json['user_name'] as String? ?? '',
volume: (json['volume'] as num?)?.toInt() ?? 50,
brightness: (json['brightness'] as num?)?.toInt() ?? 50,
allowInterrupt: json['allow_interrupt'] as bool? ?? true,
privacyMode: json['privacy_mode'] as bool? ?? false,
prompt: json['prompt'] as String? ?? '',
voiceId: json['voice_id'] as String? ?? '',
memorySummary: json['memory_summary'] as String? ?? '',
createdAt: json['created_at'] as String?,
updatedAt: json['updated_at'] as String?,
);
Map<String, dynamic> _$RoleMemoryToJson(_RoleMemory instance) =>
<String, dynamic>{
'id': instance.id,
'device_type': instance.deviceType,
'device_type_name': instance.deviceTypeName,
'is_bound': instance.isBound,
'nickname': instance.nickname,
'user_name': instance.userName,
'volume': instance.volume,
'brightness': instance.brightness,
'allow_interrupt': instance.allowInterrupt,
'privacy_mode': instance.privacyMode,
'prompt': instance.prompt,
'voice_id': instance.voiceId,
'memory_summary': instance.memorySummary,
'created_at': instance.createdAt,
'updated_at': instance.updatedAt,
};

View File

@ -2,6 +2,7 @@ import 'package:fpdart/fpdart.dart';
import '../../../../core/errors/failures.dart'; import '../../../../core/errors/failures.dart';
import '../entities/device.dart'; import '../entities/device.dart';
import '../entities/device_detail.dart'; import '../entities/device_detail.dart';
import '../entities/role_memory.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);
@ -13,4 +14,5 @@ abstract class DeviceRepository {
Future<Either<Failure, UserDevice>> updateSpirit(int userDeviceId, int spiritId); Future<Either<Failure, UserDevice>> updateSpirit(int userDeviceId, int spiritId);
Future<Either<Failure, void>> updateSettings(int userDeviceId, Map<String, dynamic> settings); Future<Either<Failure, void>> updateSettings(int userDeviceId, Map<String, dynamic> settings);
Future<Either<Failure, void>> configWifi(int userDeviceId, String ssid); Future<Either<Failure, void>> configWifi(int userDeviceId, String ssid);
Future<Either<Failure, List<RoleMemory>>> listRoleMemories({int? deviceTypeId});
} }

View File

@ -1,6 +1,7 @@
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../domain/entities/device.dart'; import '../../domain/entities/device.dart';
import '../../domain/entities/device_detail.dart'; import '../../domain/entities/device_detail.dart';
import '../../domain/entities/role_memory.dart';
import '../../data/repositories/device_repository_impl.dart'; import '../../data/repositories/device_repository_impl.dart';
part 'device_controller.g.dart'; part 'device_controller.g.dart';
@ -25,6 +26,7 @@ class DeviceController extends _$DeviceController {
return result.fold( return result.fold(
(failure) => false, (failure) => false,
(bindingId) { (bindingId) {
if (!ref.mounted) return false;
ref.invalidateSelf(); ref.invalidateSelf();
return true; return true;
}, },
@ -38,6 +40,7 @@ class DeviceController extends _$DeviceController {
return result.fold( return result.fold(
(failure) => false, (failure) => false,
(_) { (_) {
if (!ref.mounted) return false;
final current = state.value ?? []; final current = state.value ?? [];
state = AsyncData( state = AsyncData(
current.where((d) => d.id != userDeviceId).toList(), current.where((d) => d.id != userDeviceId).toList(),
@ -54,6 +57,7 @@ class DeviceController extends _$DeviceController {
return result.fold( return result.fold(
(failure) => false, (failure) => false,
(updated) { (updated) {
if (!ref.mounted) return false;
ref.invalidateSelf(); ref.invalidateSelf();
return true; return true;
}, },
@ -61,6 +65,7 @@ class DeviceController extends _$DeviceController {
} }
void refresh() { void refresh() {
if (!ref.mounted) return;
ref.invalidateSelf(); ref.invalidateSelf();
} }
} }
@ -85,6 +90,7 @@ class DeviceDetailController extends _$DeviceDetailController {
return result.fold( return result.fold(
(failure) => false, (failure) => false,
(_) { (_) {
if (!ref.mounted) return false;
ref.invalidateSelf(); ref.invalidateSelf();
return true; return true;
}, },
@ -98,6 +104,7 @@ class DeviceDetailController extends _$DeviceDetailController {
return result.fold( return result.fold(
(failure) => false, (failure) => false,
(_) { (_) {
if (!ref.mounted) return false;
ref.invalidateSelf(); ref.invalidateSelf();
return true; return true;
}, },
@ -105,6 +112,26 @@ class DeviceDetailController extends _$DeviceDetailController {
} }
void refresh() { void refresh() {
if (!ref.mounted) return;
ref.invalidateSelf();
}
}
///
@riverpod
class RoleMemoryController extends _$RoleMemoryController {
@override
FutureOr<List<RoleMemory>> build() async {
final repository = ref.read(deviceRepositoryProvider);
final result = await repository.listRoleMemories();
return result.fold(
(failure) => <RoleMemory>[],
(memories) => memories,
);
}
void refresh() {
if (!ref.mounted) return;
ref.invalidateSelf(); ref.invalidateSelf();
} }
} }

View File

@ -36,7 +36,7 @@ final class DeviceControllerProvider
DeviceController create() => DeviceController(); DeviceController create() => DeviceController();
} }
String _$deviceControllerHash() => r'3f73a13c7f93fecb9fe781efc4ee305b6186639e'; String _$deviceControllerHash() => r'94e697fab82bfeb03a25eb12fc548fed925ef5cc';
/// ///
@ -107,7 +107,7 @@ final class DeviceDetailControllerProvider
} }
String _$deviceDetailControllerHash() => String _$deviceDetailControllerHash() =>
r'1d9049597e39a0af3a70331378559aca0e1da54d'; r'd4e78c0f2298de55e7df31b4a34778b8169387a5';
/// ///
@ -161,3 +161,56 @@ abstract class _$DeviceDetailController extends $AsyncNotifier<DeviceDetail?> {
element.handleValue(ref, created); element.handleValue(ref, created);
} }
} }
///
@ProviderFor(RoleMemoryController)
const roleMemoryControllerProvider = RoleMemoryControllerProvider._();
///
final class RoleMemoryControllerProvider
extends $AsyncNotifierProvider<RoleMemoryController, List<RoleMemory>> {
///
const RoleMemoryControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'roleMemoryControllerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$roleMemoryControllerHash();
@$internal
@override
RoleMemoryController create() => RoleMemoryController();
}
String _$roleMemoryControllerHash() =>
r'e02cd6952277bf766c0b657979b28f4bf8e98c1b';
///
abstract class _$RoleMemoryController extends $AsyncNotifier<List<RoleMemory>> {
FutureOr<List<RoleMemory>> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref =
this.ref as $Ref<AsyncValue<List<RoleMemory>>, List<RoleMemory>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<RoleMemory>>, List<RoleMemory>>,
AsyncValue<List<RoleMemory>>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@ -174,12 +174,18 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
/// BLE API /// BLE API
Future<void> _startSearch() async { Future<void> _startSearch() async {
if (!_isBluetoothOn) { // Web : Web Bluetooth API
if (!kIsWeb && !_isBluetoothOn) {
_showBluetoothOffDialog(); _showBluetoothOffDialog();
return; return;
} }
await _requestPermissions(); // Web : startScan await
// Web Bluetooth API
// requestDevice
if (!kIsWeb) {
await _requestPermissions();
}
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {

View File

@ -474,6 +474,7 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
await _addNewBookWithAnimation( await _addNewBookWithAnimation(
title: saveResult['title'] as String? ?? '新故事', title: saveResult['title'] as String? ?? '新故事',
content: saveResult['content'] as String? ?? '', content: saveResult['content'] as String? ?? '',
coverUrl: saveResult['cover_url'] as String? ?? '',
); );
} }
} }
@ -601,6 +602,7 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
_addNewBookWithAnimation( _addNewBookWithAnimation(
title: saveResult['title'] as String? ?? '新故事', title: saveResult['title'] as String? ?? '新故事',
content: saveResult['content'] as String? ?? '', content: saveResult['content'] as String? ?? '',
coverUrl: saveResult['cover_url'] as String? ?? '',
); );
} }
} }
@ -900,7 +902,7 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
); );
} }
Future<void> _addNewBookWithAnimation({String title = '新故事', String content = ''}) async { Future<void> _addNewBookWithAnimation({String title = '新故事', String content = '', String coverUrl = ''}) async {
// Find the first shelf that has space // Find the first shelf that has space
int? targetShelfId; int? targetShelfId;
for (final shelf in _shelves) { for (final shelf in _shelves) {
@ -923,6 +925,7 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
'title': title, 'title': title,
'content': content, 'content': content,
'shelf_id': targetShelfId, 'shelf_id': targetShelfId,
if (coverUrl.isNotEmpty) 'cover_url': coverUrl,
}); });
// Reload to get the new story // Reload to get the new story
await _loadShelves(); await _loadShelves();

View File

@ -3,9 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:airhub_app/theme/design_tokens.dart'; import 'package:airhub_app/theme/design_tokens.dart';
import 'package:airhub_app/widgets/animated_gradient_background.dart'; import 'package:airhub_app/widgets/animated_gradient_background.dart';
import 'package:airhub_app/widgets/glass_dialog.dart'; import 'package:airhub_app/widgets/glass_dialog.dart';
import 'package:airhub_app/widgets/ios_toast.dart'; import 'package:airhub_app/features/device/domain/entities/role_memory.dart';
import 'package:airhub_app/features/spirit/domain/entities/spirit.dart'; import 'package:airhub_app/features/device/presentation/controllers/device_controller.dart';
import 'package:airhub_app/features/spirit/presentation/controllers/spirit_controller.dart';
class AgentManagePage extends ConsumerStatefulWidget { class AgentManagePage extends ConsumerStatefulWidget {
const AgentManagePage({super.key}); const AgentManagePage({super.key});
@ -17,7 +16,7 @@ class AgentManagePage extends ConsumerStatefulWidget {
class _AgentManagePageState extends ConsumerState<AgentManagePage> { class _AgentManagePageState extends ConsumerState<AgentManagePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final spiritsAsync = ref.watch(spiritControllerProvider); final memoriesAsync = ref.watch(roleMemoryControllerProvider);
return Scaffold( return Scaffold(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
@ -28,7 +27,7 @@ class _AgentManagePageState extends ConsumerState<AgentManagePage> {
children: [ children: [
_buildHeader(context), _buildHeader(context),
Expanded( Expanded(
child: spiritsAsync.when( child: memoriesAsync.when(
loading: () => const Center( loading: () => const Center(
child: CircularProgressIndicator(color: Colors.white), child: CircularProgressIndicator(color: Colors.white),
), ),
@ -45,7 +44,7 @@ class _AgentManagePageState extends ConsumerState<AgentManagePage> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
GestureDetector( GestureDetector(
onTap: () => ref.read(spiritControllerProvider.notifier).refresh(), onTap: () => ref.read(roleMemoryControllerProvider.notifier).refresh(),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -61,8 +60,8 @@ class _AgentManagePageState extends ConsumerState<AgentManagePage> {
], ],
), ),
), ),
data: (spirits) { data: (memories) {
if (spirits.isEmpty) { if (memories.isEmpty) {
return Center( return Center(
child: Text( child: Text(
'暂无角色记忆', '暂无角色记忆',
@ -80,9 +79,9 @@ class _AgentManagePageState extends ConsumerState<AgentManagePage> {
right: 20, right: 20,
bottom: 40 + MediaQuery.of(context).padding.bottom, bottom: 40 + MediaQuery.of(context).padding.bottom,
), ),
itemCount: spirits.length, itemCount: memories.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return _buildAgentCard(spirits[index]); return _buildMemoryCard(memories[index]);
}, },
); );
}, },
@ -159,16 +158,16 @@ class _AgentManagePageState extends ConsumerState<AgentManagePage> {
); );
} }
Widget _buildAgentCard(Spirit spirit) { Widget _buildMemoryCard(RoleMemory memory) {
final dateStr = spirit.createdAt != null final dateStr = memory.createdAt != null
? spirit.createdAt!.substring(0, 10).replaceAll('-', '/') ? memory.createdAt!.substring(0, 10).replaceAll('-', '/')
: ''; : '';
return Container( return Container(
margin: const EdgeInsets.only(bottom: 16), margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFD4A373), // Fallback color: const Color(0xFFD4A373),
gradient: const LinearGradient(colors: AppColors.gradientCapybara), gradient: const LinearGradient(colors: AppColors.gradientCapybara),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
boxShadow: [ boxShadow: [
@ -181,7 +180,6 @@ class _AgentManagePageState extends ConsumerState<AgentManagePage> {
), ),
child: Stack( child: Stack(
children: [ children: [
// Top highlight layer
Positioned( Positioned(
left: 0, left: 0,
right: 0, right: 0,
@ -226,19 +224,7 @@ class _AgentManagePageState extends ConsumerState<AgentManagePage> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
alignment: Alignment.center, alignment: Alignment.center,
child: spirit.avatar != null && spirit.avatar!.isNotEmpty child: const Text('🧠', style: TextStyle(fontSize: 24)),
? ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
spirit.avatar!,
width: 48,
height: 48,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
const Text('🧠', style: TextStyle(fontSize: 24)),
),
)
: const Text('🧠', style: TextStyle(fontSize: 24)),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
@ -246,7 +232,9 @@ class _AgentManagePageState extends ConsumerState<AgentManagePage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
spirit.name, memory.deviceTypeName.isNotEmpty
? memory.deviceTypeName
: '角色记忆 #${memory.id}',
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -260,32 +248,36 @@ class _AgentManagePageState extends ConsumerState<AgentManagePage> {
], ],
), ),
), ),
if (memory.nickname.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
memory.nickname,
style: TextStyle(
fontSize: 13,
color: Colors.white.withOpacity(0.8),
),
),
],
], ],
), ),
), ),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildDetailRow('状态:', spirit.isActive ? '活跃' : '未激活'), _buildDetailRow('状态:', memory.isBound ? '已绑定' : '空闲'),
if (memory.memorySummary.isNotEmpty) ...[
const SizedBox(height: 6),
_buildDetailRow('记忆:', memory.memorySummary.length > 30
? '${memory.memorySummary.substring(0, 30)}...'
: memory.memorySummary),
],
const SizedBox(height: 12), const SizedBox(height: 12),
Container(height: 1, color: Colors.white.withOpacity(0.2)), Container(height: 1, color: Colors.white.withOpacity(0.2)),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
_buildActionBtn( _buildStatusTag(memory.isBound),
'解绑',
isDanger: true,
onTap: () => _showUnbindDialog(spirit),
),
const SizedBox(width: 8),
_buildActionBtn(
'删除',
isDanger: true,
onTap: () => _showDeleteDialog(spirit),
),
], ],
), ),
], ],
@ -313,84 +305,33 @@ class _AgentManagePageState extends ConsumerState<AgentManagePage> {
); );
} }
Widget _buildActionBtn( Widget _buildStatusTag(bool isBound) {
String text, { return Container(
bool isDanger = false, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
bool isInject = false, decoration: BoxDecoration(
VoidCallback? onTap, color: Colors.white.withOpacity(0.2),
}) { borderRadius: BorderRadius.circular(20),
return GestureDetector( border: Border.all(color: Colors.white.withOpacity(0.3)),
onTap: onTap, ),
child: Container( child: Row(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), mainAxisSize: MainAxisSize.min,
decoration: BoxDecoration( children: [
color: Colors.white.withOpacity(0.2), Icon(
borderRadius: BorderRadius.circular(20), isBound ? Icons.link : Icons.link_off,
border: Border.all(color: Colors.white.withOpacity(0.3)), size: 14,
), color: Colors.white.withOpacity(0.9),
child: Row( ),
mainAxisSize: MainAxisSize.min, const SizedBox(width: 4),
children: [ Text(
if (isDanger) ...[ isBound ? '使用中' : '空闲',
Icon( style: const TextStyle(
Icons.link_off, fontSize: 13,
size: 14, fontWeight: FontWeight.w600,
color: AppColors.danger.withOpacity(0.9), color: Colors.white,
),
const SizedBox(width: 4),
] else if (isInject) ...[
Icon(Icons.download, size: 14, color: const Color(0xFFB07D5A)),
const SizedBox(width: 4),
],
Text(
text,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDanger
? AppColors.danger
: (isInject ? const Color(0xFFB07D5A) : Colors.white),
),
), ),
], ),
), ],
), ),
); );
} }
void _showUnbindDialog(Spirit spirit) {
showGlassDialog(
context: context,
title: '确认解绑角色记忆?',
description: '解绑后,该角色记忆将与当前设备断开连接,但数据会保留在云端。',
cancelText: '取消',
confirmText: '确认解绑',
isDanger: true,
onConfirm: () async {
Navigator.pop(context); // Close dialog
final success = await ref.read(spiritControllerProvider.notifier).unbind(spirit.id);
if (mounted) {
AppToast.show(context, success ? '已解绑: ${spirit.name}' : '解绑失败');
}
},
);
}
void _showDeleteDialog(Spirit spirit) {
showGlassDialog(
context: context,
title: '确认删除角色记忆?',
description: '删除后,该角色记忆数据将无法恢复。',
cancelText: '取消',
confirmText: '确认删除',
isDanger: true,
onConfirm: () async {
Navigator.pop(context);
final success = await ref.read(spiritControllerProvider.notifier).delete(spirit.id);
if (mounted) {
AppToast.show(context, success ? '已删除: ${spirit.name}' : '删除失败');
}
},
);
}
} }

View File

@ -11,6 +11,7 @@ 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'; import 'package:airhub_app/features/device/presentation/controllers/device_controller.dart';
import 'package:airhub_app/features/user/presentation/controllers/user_controller.dart';
class SettingsPage extends ConsumerStatefulWidget { class SettingsPage extends ConsumerStatefulWidget {
const SettingsPage({super.key}); const SettingsPage({super.key});
@ -26,6 +27,11 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
// watch provider // watch provider
ref.watch(deviceControllerProvider); ref.watch(deviceControllerProvider);
final user = ref.watch(userControllerProvider).value;
final phone = user?.phone ?? '';
final maskedPhone = phone.length >= 7
? '${phone.substring(0, 3)}****${phone.substring(phone.length - 4)}'
: phone;
return Scaffold( return Scaffold(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
body: Stack( body: Stack(
@ -48,8 +54,8 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
_buildItem( _buildItem(
'📱', '📱',
'绑定手机', '绑定手机',
value: '138****3069', value: maskedPhone.isNotEmpty ? maskedPhone : '未绑定',
onTap: () => _showMessage('绑定手机', '138****3069'), onTap: () => _showMessage('绑定手机', maskedPhone.isNotEmpty ? maskedPhone : '未绑定'),
), ),
_buildItem( _buildItem(
'🔐', '🔐',

View File

@ -4,6 +4,7 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show PlatformException; import 'package:flutter/services.dart' show PlatformException;
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
import 'package:video_player/video_player.dart';
import '../theme/design_tokens.dart'; import '../theme/design_tokens.dart';
import '../widgets/gradient_button.dart'; import '../widgets/gradient_button.dart';
import '../widgets/pill_progress_button.dart'; import '../widgets/pill_progress_button.dart';
@ -34,6 +35,10 @@ class _StoryDetailPageState extends State<StoryDetailPage>
bool _hasGeneratedVideo = false; bool _hasGeneratedVideo = false;
bool _isLoadingVideo = false; bool _isLoadingVideo = false;
// Video Player
VideoPlayerController? _videoController;
bool _videoInitialized = false;
// TTS uses global TTSService singleton // TTS uses global TTSService singleton
final TTSService _ttsService = TTSService.instance; final TTSService _ttsService = TTSService.instance;
final AudioPlayer _audioPlayer = AudioPlayer(); final AudioPlayer _audioPlayer = AudioPlayer();
@ -108,6 +113,15 @@ class _StoryDetailPageState extends State<StoryDetailPage>
debugPrint('durationStream error (ignored): $e'); debugPrint('durationStream error (ignored): $e');
}); });
// Auto-show video tab if story already has a video
final hasVideo = _currentStory['has_video'] == true;
final videoUrl = _currentStory['video_url'] as String? ?? '';
if (hasVideo && videoUrl.isNotEmpty) {
_hasGeneratedVideo = true;
_activeTab = 'video';
_initVideoPlayer(videoUrl);
}
// Check if audio already exists // Check if audio already exists
debugPrint('[StoryDetail] story keys: ${_currentStory.keys.toList()}'); debugPrint('[StoryDetail] story keys: ${_currentStory.keys.toList()}');
debugPrint('[StoryDetail] audio_url value: "${_currentStory['audio_url']}"'); debugPrint('[StoryDetail] audio_url value: "${_currentStory['audio_url']}"');
@ -151,12 +165,33 @@ class _StoryDetailPageState extends State<StoryDetailPage>
setState(() {}); setState(() {});
} }
Future<void> _initVideoPlayer(String url) async {
try {
final controller = VideoPlayerController.networkUrl(Uri.parse(url));
_videoController = controller;
controller.addListener(_onVideoChanged);
await controller.initialize();
if (mounted) {
setState(() => _videoInitialized = true);
}
} catch (e) {
debugPrint('Video init error: $e');
}
}
void _onVideoChanged() {
if (!mounted) return;
setState(() {});
}
@override @override
void dispose() { void dispose() {
_ttsService.removeListener(_onTTSChanged); _ttsService.removeListener(_onTTSChanged);
_positionSub?.cancel(); _positionSub?.cancel();
_playerStateSub?.cancel(); _playerStateSub?.cancel();
_audioPlayer.dispose(); _audioPlayer.dispose();
_videoController?.removeListener(_onVideoChanged);
_videoController?.dispose();
_genieController?.dispose(); _genieController?.dispose();
super.dispose(); super.dispose();
} }
@ -171,7 +206,8 @@ class _StoryDetailPageState extends State<StoryDetailPage>
if (_ttsService.error != null && if (_ttsService.error != null &&
!_ttsService.isGenerating && !_ttsService.isGenerating &&
_ttsService.audioUrl == null) { _ttsService.audioUrl == null &&
_ttsService.errorTitle == title) {
return TTSButtonState.error; return TTSButtonState.error;
} }
if (_ttsService.isGeneratingFor(title)) { if (_ttsService.isGeneratingFor(title)) {
@ -518,28 +554,58 @@ class _StoryDetailPageState extends State<StoryDetailPage>
); );
} }
return Stack( // Not yet initialized black + spinner while video loads
alignment: Alignment.center, if (!_videoInitialized || _videoController == null) {
children: [ return const AspectRatio(
AspectRatio( aspectRatio: 16 / 9,
aspectRatio: 16 / 9, child: ColoredBox(
child: Container( color: Colors.black,
color: Colors.black, child: Center(
child: const Center( child: CircularProgressIndicator(color: Colors.white54, strokeWidth: 3),
child: Icon(Icons.videocam, color: Colors.white54, size: 48), ),
),
);
}
// Initialized: VideoPlayer shows frame 0 naturally as thumbnail when paused
final isPlaying = _videoController!.value.isPlaying;
return AspectRatio(
aspectRatio: _videoController!.value.aspectRatio,
child: Stack(
alignment: Alignment.center,
children: [
// Video fills the area (Positioned.fill avoids StackFit.expand distortion)
Positioned.fill(child: VideoPlayer(_videoController!)),
// Full-area tap handler
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () async {
if (_videoController!.value.isPlaying) {
await _videoController!.pause();
} else {
await _videoController!.play();
}
if (mounted) setState(() {});
},
), ),
), ),
), // Play button IgnorePointer lets taps pass through to GestureDetector below
Container( if (!isPlaying)
width: 48, IgnorePointer(
height: 48, child: Container(
decoration: BoxDecoration( width: 48,
color: Colors.white.withOpacity(0.8), height: 48,
shape: BoxShape.circle, decoration: BoxDecoration(
), color: Colors.white.withOpacity(0.8),
child: const Icon(Icons.play_arrow, color: Colors.black), shape: BoxShape.circle,
), ),
], child: const Icon(Icons.play_arrow, color: Colors.black),
),
),
],
),
); );
} }

View File

@ -72,6 +72,7 @@ class _StoryLoadingPageState extends State<StoryLoadingPage> {
String buffer = ''; String buffer = '';
String? storyTitle; String? storyTitle;
String? storyContent; String? storyContent;
String storyCoverUrl = '';
await for (final chunk in response.stream.transform(utf8.decoder)) { await for (final chunk in response.stream.transform(utf8.decoder)) {
buffer += chunk; buffer += chunk;
@ -109,9 +110,13 @@ class _StoryLoadingPageState extends State<StoryLoadingPage> {
case 'parsing': case 'parsing':
_updateProgress(progress / 100, '正在编制最后的魔法...'); _updateProgress(progress / 100, '正在编制最后的魔法...');
break; break;
case 'cover':
_updateProgress(progress / 100, '正在绘制故事封面...');
break;
case 'done': case 'done':
storyTitle = event['title'] as String? ?? '卡皮巴拉的故事'; storyTitle = event['title'] as String? ?? '卡皮巴拉的故事';
storyContent = event['content'] as String? ?? ''; storyContent = event['content'] as String? ?? '';
storyCoverUrl = event['cover_url'] as String? ?? '';
_updateProgress(1.0, '大功告成!'); _updateProgress(1.0, '大功告成!');
break; break;
case 'error': case 'error':
@ -142,6 +147,7 @@ class _StoryLoadingPageState extends State<StoryLoadingPage> {
story: { story: {
'title': storyTitle, 'title': storyTitle,
'content': storyContent, 'content': storyContent,
'cover_url': storyCoverUrl,
}, },
), ),
), ),
@ -154,6 +160,7 @@ class _StoryLoadingPageState extends State<StoryLoadingPage> {
'action': 'saved', 'action': 'saved',
'title': storyTitle, 'title': storyTitle,
'content': storyContent, 'content': storyContent,
'cover_url': storyCoverUrl,
}); });
} else { } else {
Navigator.of(context).pop(result); Navigator.of(context).pop(result);

View File

@ -29,6 +29,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
double _progress = 0.0; double _progress = 0.0;
String _progressText = '正在连接WiFi...'; String _progressText = '正在连接WiFi...';
bool _connectFailed = false; bool _connectFailed = false;
bool _isBinding = false;
// Device Info // Device Info
Map<String, dynamic> _deviceInfo = {}; Map<String, dynamic> _deviceInfo = {};
@ -128,12 +129,20 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
if (_currentStep == 2 && _passwordController.text.isEmpty) return; if (_currentStep == 2 && _passwordController.text.isEmpty) return;
if (_currentStep == 4) { if (_currentStep == 4) {
if (_isBinding) return;
setState(() => _isBinding = true);
final sn = _deviceInfo['sn'] as String? ?? ''; final sn = _deviceInfo['sn'] as String? ?? '';
if (sn.isNotEmpty) { if (sn.isNotEmpty) {
debugPrint('[WiFi Config] Binding device sn=$sn'); try {
await ref.read(deviceControllerProvider.notifier).bindDevice(sn); debugPrint('[WiFi Config] Binding device sn=$sn');
await ref.read(deviceControllerProvider.notifier).bindDevice(sn);
} catch (e) {
debugPrint('[WiFi Config] bindDevice 异常: $e');
}
} }
if (!mounted) return; if (!mounted) return;
setState(() => _isBinding = false);
context.go('/device-control'); context.go('/device-control');
return; return;
} }
@ -705,7 +714,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
} }
if (_currentStep == 4) { if (_currentStep == 4) {
showNext = true; showNext = true;
nextText = '进入设备'; nextText = _isBinding ? '绑定中...' : '进入设备';
} }
if (!showNext && _currentStep != 3) { if (!showNext && _currentStep != 3) {
@ -764,7 +773,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
if (_currentStep < 4) const SizedBox(width: 16), if (_currentStep < 4) const SizedBox(width: 16),
GradientButton( GradientButton(
text: nextText, text: nextText,
onPressed: _handleNext, onPressed: _isBinding ? null : _handleNext,
height: 56, height: 56,
width: _currentStep == 4 ? 200 : 160, width: _currentStep == 4 ? 200 : 160,
), ),

View File

@ -29,6 +29,7 @@ class TTSService extends ChangeNotifier {
// Error // Error
String? _error; String? _error;
String? _errorTitle; // Which story the error belongs to
// Getters // Getters
bool get isGenerating => _isGenerating; bool get isGenerating => _isGenerating;
@ -39,6 +40,7 @@ class TTSService extends ChangeNotifier {
String? get completedStoryTitle => _completedStoryTitle; String? get completedStoryTitle => _completedStoryTitle;
bool get justCompleted => _justCompleted; bool get justCompleted => _justCompleted;
String? get error => _error; String? get error => _error;
String? get errorTitle => _errorTitle;
/// Check if audio is ready for a specific story. /// Check if audio is ready for a specific story.
bool hasAudioFor(String title) { bool hasAudioFor(String title) {
@ -182,6 +184,7 @@ class TTSService extends ChangeNotifier {
_isGenerating = false; _isGenerating = false;
if (_audioUrl == null) { if (_audioUrl == null) {
_error = '未获取到音频'; _error = '未获取到音频';
_errorTitle = title;
_statusMessage = '生成失败'; _statusMessage = '生成失败';
} }
notifyListeners(); notifyListeners();
@ -190,6 +193,7 @@ class TTSService extends ChangeNotifier {
_isGenerating = false; _isGenerating = false;
_progress = 0.0; _progress = 0.0;
_error = e.toString(); _error = e.toString();
_errorTitle = title;
_statusMessage = '生成失败'; _statusMessage = '生成失败';
_justCompleted = false; _justCompleted = false;
notifyListeners(); notifyListeners();
@ -212,6 +216,7 @@ class TTSService extends ChangeNotifier {
_completedStoryTitle = null; _completedStoryTitle = null;
_justCompleted = false; _justCompleted = false;
_error = null; _error = null;
_errorTitle = null;
notifyListeners(); notifyListeners();
} }
} }

View File

@ -10,6 +10,7 @@ import file_selector_macos
import flutter_blue_plus_darwin import flutter_blue_plus_darwin
import just_audio import just_audio
import shared_preferences_foundation import shared_preferences_foundation
import video_player_avfoundation
import webview_flutter_wkwebview import webview_flutter_wkwebview
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
@ -18,5 +19,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
} }

View File

@ -232,6 +232,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
custom_lint: custom_lint:
dependency: transitive dependency: transitive
description: description:
@ -540,6 +548,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.3.0" version: "4.3.0"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1265,6 +1281,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
video_player:
dependency: "direct main"
description:
name: video_player
sha256: "08bfba72e311d48219acad4e191b1f9c27ff8cf928f2c7234874592d9c9d7341"
url: "https://pub.dev"
source: hosted
version: "2.11.0"
video_player_android:
dependency: transitive
description:
name: video_player_android
sha256: "9862c67c4661c98f30fe707bc1a4f97d6a0faa76784f485d282668e4651a7ac3"
url: "https://pub.dev"
source: hosted
version: "2.9.4"
video_player_avfoundation:
dependency: transitive
description:
name: video_player_avfoundation
sha256: f93b93a3baa12ca0ff7d00ca8bc60c1ecd96865568a01ff0c18a99853ee201a5
url: "https://pub.dev"
source: hosted
version: "2.9.3"
video_player_platform_interface:
dependency: transitive
description:
name: video_player_platform_interface
sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec"
url: "https://pub.dev"
source: hosted
version: "6.6.0"
video_player_web:
dependency: transitive
description:
name: video_player_web
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:

View File

@ -67,6 +67,7 @@ dependencies:
image_picker: ^1.2.1 image_picker: ^1.2.1
just_audio: ^0.9.42 just_audio: ^0.9.42
http: ^1.2.0 http: ^1.2.0
video_player: ^2.9.2
flutter: flutter:
uses-material-design: true uses-material-design: true

View File

@ -1,12 +1,16 @@
#!/bin/bash #!/bin/bash
# 重新编译并启动 Flutter Web (localhost:8080) # 重新编译并启动 Flutter Web (localhost:8080)
# 杀掉占用 8080 端口的进程 # 杀掉占用 8080 端口的进程(强制)
PID=$(lsof -ti:8080 2>/dev/null) PID=$(lsof -ti:8080 2>/dev/null)
if [ -n "$PID" ]; then if [ -n "$PID" ]; then
echo "正在停止旧进程 (PID: $PID)..." echo "正在强制停止旧进程 (PID: $PID)..."
kill $PID 2>/dev/null kill -9 $PID 2>/dev/null
sleep 1 # 等待端口真正释放(最多 5 秒)
for i in $(seq 1 10); do
lsof -ti:8080 > /dev/null 2>&1 || break
sleep 0.5
done
fi fi
echo "正在编译并启动 Flutter Web..." echo "正在编译并启动 Flutter Web..."