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:
pp-bot
2026-03-24 20:30:56 +09:00
parent db10d1fcd2
commit 8744e2c0b7
16 changed files with 1389 additions and 2 deletions

View 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: []);
}
}

View 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;
}