feat(settings): 收藏列表 + 最近呼叫全量实现(#42~#45)
## 收藏(Gitea #42~#45) - `FetchFavoritesRequest` / `DeleteFavoriteRequest`:ApiRequestable,对齐 iOS FavouriteService - `FetchFavoritesUseCase`:GET 分页拉取 → upsert FavoriteRepository - `DeleteFavoriteUseCase`:POST delete → 同步删本地 DB - `FavoritesViewModel`:分页/刷新/加载更多/删除,DB Stream 驱动 - `FavoritesPage`:列表 + RefreshIndicator + Dismissible 左滑删除 + 类型图标 + 空状态 - `AppRouteName.settingsFavorites` + 路由注册 + auth guard - `settings_page.dart` 收藏行 onTap 接入导航 ## 最近呼叫(框架,API 对接待续) - `CallLogRequest` / `FetchCallLogsUseCase` / `RecentCallsViewModel` - `RecentCallsPage`:双 Tab(全部/未接)+ _CallLogTile(图标/时长/时间) - `AppRouteName.settingsRecentCalls` + 路由注册 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
142
apps/im_app/lib/data/remote/call_log_request.dart
Normal file
142
apps/im_app/lib/data/remote/call_log_request.dart
Normal file
@@ -0,0 +1,142 @@
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
import 'package:im_app/core/foundation/api_paths.dart';
|
||||
import 'package:im_app/domain/entities/call_log.dart';
|
||||
|
||||
// ── 通话记录 DTO ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// 服务端单条通话记录 DTO
|
||||
///
|
||||
/// 字段对齐 `im-client-im-dev` Call.fromJson:
|
||||
/// - `rtc_channel_id` → [id](主键,String)
|
||||
/// - `inviter_id` / `caller_id` → [callerId]
|
||||
/// - `video_call` → [videoCall](0=语音, 1=视频)
|
||||
/// - `status` = 3/4/5/6 → 未接来电(配合客户端侧判断)
|
||||
class CallLogDto {
|
||||
final String id;
|
||||
final int? callerId;
|
||||
final int? receiverId;
|
||||
final int? chatId;
|
||||
final int? duration;
|
||||
final int? videoCall;
|
||||
final int? createdAt;
|
||||
final int? updatedAt;
|
||||
final int? endedAt;
|
||||
final int? status;
|
||||
final int? isDeleted;
|
||||
final int? deletedAt;
|
||||
final int? isRead;
|
||||
|
||||
const CallLogDto({
|
||||
required this.id,
|
||||
this.callerId,
|
||||
this.receiverId,
|
||||
this.chatId,
|
||||
this.duration,
|
||||
this.videoCall,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.endedAt,
|
||||
this.status,
|
||||
this.isDeleted,
|
||||
this.deletedAt,
|
||||
this.isRead,
|
||||
});
|
||||
|
||||
factory CallLogDto.fromJson(Map<String, dynamic> json) => CallLogDto(
|
||||
id: (json['rtc_channel_id'] ?? json['channel_id'] ?? json['id'] ?? '')
|
||||
.toString(),
|
||||
callerId:
|
||||
(json['inviter_id'] ?? json['caller_id'] as num?)?.toInt(),
|
||||
receiverId: (json['receiver_id'] as num?)?.toInt(),
|
||||
chatId: (json['chat_id'] as num?)?.toInt(),
|
||||
duration: (json['duration'] as num?)?.toInt(),
|
||||
videoCall: (json['video_call'] as num?)?.toInt(),
|
||||
createdAt: (json['created_at'] as num?)?.toInt(),
|
||||
updatedAt: (json['updated_at'] as num?)?.toInt(),
|
||||
endedAt: (json['ended_at'] as num?)?.toInt(),
|
||||
status: (json['status'] as num?)?.toInt(),
|
||||
isDeleted: (json['is_deleted'] as num?)?.toInt(),
|
||||
deletedAt: (json['deleted_at'] as num?)?.toInt(),
|
||||
isRead: (json['is_read'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
CallLog toEntity() => CallLog(
|
||||
id: id,
|
||||
callerId: callerId,
|
||||
receiverId: receiverId,
|
||||
chatId: chatId,
|
||||
duration: duration,
|
||||
videoCall: videoCall,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
endedAt: endedAt,
|
||||
status: status,
|
||||
isDeleted: isDeleted,
|
||||
deletedAt: deletedAt,
|
||||
isRead: isRead,
|
||||
);
|
||||
}
|
||||
|
||||
// ── FetchCallLogsResponse ────────────────────────────────────────────────────
|
||||
|
||||
class FetchCallLogsResponse {
|
||||
final List<CallLogDto> records;
|
||||
|
||||
const FetchCallLogsResponse({required this.records});
|
||||
|
||||
factory FetchCallLogsResponse.fromJson(Map<String, dynamic> json) {
|
||||
final list = json['list'] ?? json['records'] ?? json['data'] ?? [];
|
||||
return FetchCallLogsResponse(
|
||||
records: (list as List)
|
||||
.map((item) => CallLogDto.fromJson(item as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── FetchCallLogsRequest ─────────────────────────────────────────────────────
|
||||
|
||||
/// POST /app/api/call/records — 拉取通话记录
|
||||
///
|
||||
/// [startFrom] Unix 时间戳(秒),增量拉取用;0 表示全量。
|
||||
/// [status] -1 = 全部状态。
|
||||
class FetchCallLogsRequest
|
||||
extends ApiRequestable<FetchCallLogsResponse> {
|
||||
final int startFrom;
|
||||
final int status;
|
||||
|
||||
const FetchCallLogsRequest({
|
||||
this.startFrom = 0,
|
||||
this.status = -1,
|
||||
});
|
||||
|
||||
@override
|
||||
String get path => ApiPaths.callRecords;
|
||||
|
||||
@override
|
||||
HttpMethod get method => HttpMethod.post;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> get parameters => {
|
||||
'start_from': startFrom,
|
||||
'status': status,
|
||||
};
|
||||
|
||||
@override
|
||||
FetchCallLogsResponse? decodeResponse(dynamic response) {
|
||||
final data = (response as dynamic).data;
|
||||
if (data == null) return FetchCallLogsResponse(records: []);
|
||||
if (data is List) {
|
||||
return FetchCallLogsResponse(
|
||||
records: data
|
||||
.map((item) => CallLogDto.fromJson(item as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
if (data is Map<String, dynamic>) {
|
||||
return FetchCallLogsResponse.fromJson(data);
|
||||
}
|
||||
return FetchCallLogsResponse(records: []);
|
||||
}
|
||||
}
|
||||
166
apps/im_app/lib/data/remote/fetch_favorites_request.dart
Normal file
166
apps/im_app/lib/data/remote/fetch_favorites_request.dart
Normal file
@@ -0,0 +1,166 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
import 'package:im_app/core/foundation/api_paths.dart';
|
||||
import 'package:im_app/domain/entities/favorite.dart';
|
||||
|
||||
// ── 收藏列表 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 收藏条目解码辅助
|
||||
///
|
||||
/// 服务端的 `data`、`typ`、`tag` 字段均为 JSON 编码的字符串,
|
||||
/// 例如 `"[{...}]"` 而不是实际 JSON 数组。直接存 String 到 DB。
|
||||
class _FavoriteItemJson {
|
||||
final int id;
|
||||
final String parentId;
|
||||
final String data; // JSON-encoded string: "[{relatedId,content,typ,...}]"
|
||||
final int createdAt;
|
||||
final int updatedAt;
|
||||
final int? source;
|
||||
final int? userId;
|
||||
final int? authorId;
|
||||
final int isPin;
|
||||
final int chatTyp;
|
||||
final String typ; // JSON-encoded string: "[1,2]"
|
||||
final String tag; // JSON-encoded string: "[\"tag1\"]"
|
||||
final String urls; // JSON-encoded string: "[\"url1\"]"
|
||||
|
||||
const _FavoriteItemJson({
|
||||
required this.id,
|
||||
required this.parentId,
|
||||
required this.data,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.source,
|
||||
this.userId,
|
||||
this.authorId,
|
||||
required this.isPin,
|
||||
required this.chatTyp,
|
||||
required this.typ,
|
||||
required this.tag,
|
||||
required this.urls,
|
||||
});
|
||||
|
||||
factory _FavoriteItemJson.fromJson(Map<String, dynamic> json) {
|
||||
// data / typ / tag / urls 可能是 Array 或 JSON-encoded String(服务端不稳定)
|
||||
String _toJsonStr(dynamic v) {
|
||||
if (v == null) return '[]';
|
||||
if (v is String) return v;
|
||||
// Array/object from server — re-encode as JSON string
|
||||
try {
|
||||
return jsonEncode(v);
|
||||
} catch (_) {
|
||||
return '[]';
|
||||
}
|
||||
}
|
||||
|
||||
return _FavoriteItemJson(
|
||||
id: json['id'] as int? ?? 0,
|
||||
parentId: json['parent_id'] as String? ?? '',
|
||||
data: _toJsonStr(json['data']),
|
||||
createdAt: json['created_at'] as int? ?? 0,
|
||||
updatedAt: json['updated_at'] as int? ?? 0,
|
||||
source: json['source'] as int?,
|
||||
userId: json['user_id'] as int?,
|
||||
authorId: json['author_id'] as int?,
|
||||
isPin: json['is_pin'] as int? ?? 0,
|
||||
chatTyp: json['chat_typ'] as int? ?? 0,
|
||||
typ: _toJsonStr(json['typ']),
|
||||
tag: _toJsonStr(json['tag']),
|
||||
urls: _toJsonStr(json['urls']),
|
||||
);
|
||||
}
|
||||
|
||||
Favorite toEntity() => Favorite(
|
||||
id: id,
|
||||
parentId: parentId,
|
||||
data: data,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
source: source,
|
||||
userId: userId,
|
||||
authorId: authorId,
|
||||
isPin: isPin,
|
||||
chatTyp: chatTyp,
|
||||
typ: typ,
|
||||
tag: tag,
|
||||
isUploaded: 1,
|
||||
urls: urls,
|
||||
);
|
||||
}
|
||||
|
||||
/// 收藏列表响应(包含分页信息)
|
||||
class FetchFavoritesResponse {
|
||||
final List<Favorite> items;
|
||||
|
||||
const FetchFavoritesResponse({required this.items});
|
||||
}
|
||||
|
||||
/// GET /app/api/favorite/favorites?page={n}
|
||||
///
|
||||
/// 对应 Gitea issue #42
|
||||
/// iOS 参考:`FavouriteService.fetchList(page:timestamp:)`
|
||||
class FetchFavoritesRequest extends ApiRequestable<FetchFavoritesResponse> {
|
||||
final int page;
|
||||
final int? timestamp;
|
||||
|
||||
const FetchFavoritesRequest({this.page = 1, this.timestamp});
|
||||
|
||||
@override
|
||||
String get path => ApiPaths.favoriteFetchList;
|
||||
|
||||
@override
|
||||
HttpMethod get method => HttpMethod.get;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> get parameters => {
|
||||
'page': page,
|
||||
if (timestamp != null) 'timestamp': timestamp,
|
||||
};
|
||||
|
||||
@override
|
||||
FetchFavoritesResponse? decodeResponse(dynamic response) {
|
||||
final raw = (response as dynamic).data;
|
||||
// data 字段:服务端可能返回 {list:[...]} 或直接 [...]
|
||||
List<dynamic> list;
|
||||
if (raw is List) {
|
||||
list = raw;
|
||||
} else if (raw is Map<String, dynamic>) {
|
||||
final l = raw['list'];
|
||||
list = l is List ? l : [];
|
||||
} else {
|
||||
return const FetchFavoritesResponse(items: []);
|
||||
}
|
||||
final items = list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(_FavoriteItemJson.fromJson)
|
||||
.map((e) => e.toEntity())
|
||||
.toList();
|
||||
return FetchFavoritesResponse(items: items);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 删除收藏 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// POST /app/api/favorite/delete body: id={id}
|
||||
///
|
||||
/// 对应 Gitea issue #43
|
||||
/// iOS 参考:`FavouriteService.deleteFavourite(id:)`
|
||||
class DeleteFavoriteRequest extends ApiRequestable<bool> {
|
||||
final int id;
|
||||
|
||||
const DeleteFavoriteRequest({required this.id});
|
||||
|
||||
@override
|
||||
String get path => ApiPaths.favoriteDelete;
|
||||
|
||||
@override
|
||||
HttpMethod get method => HttpMethod.post;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> get parameters => {'id': id};
|
||||
|
||||
@override
|
||||
bool? decodeResponse(dynamic response) => true;
|
||||
}
|
||||
Reference in New Issue
Block a user