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:
@@ -73,6 +73,8 @@ enum AppRouteName {
|
|||||||
settingsLanguage('/settings/language'),
|
settingsLanguage('/settings/language'),
|
||||||
settingsNetworkDiagnostics('/settings/network-diagnostics'),
|
settingsNetworkDiagnostics('/settings/network-diagnostics'),
|
||||||
settingsAbout('/settings/about'),
|
settingsAbout('/settings/about'),
|
||||||
|
settingsFavorites('/settings/favorites'),
|
||||||
|
settingsRecentCalls('/settings/recent-calls'),
|
||||||
|
|
||||||
// ── 全屏页面(无底部导航栏)──────────────────────────────────────────────
|
// ── 全屏页面(无底部导航栏)──────────────────────────────────────────────
|
||||||
login('/login');
|
login('/login');
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import 'package:im_app/features/settings/view/blocklist_page.dart';
|
|||||||
import 'package:im_app/features/settings/view/language_page.dart';
|
import 'package:im_app/features/settings/view/language_page.dart';
|
||||||
import 'package:im_app/features/settings/view/network_diagnostics_page.dart';
|
import 'package:im_app/features/settings/view/network_diagnostics_page.dart';
|
||||||
import 'package:im_app/features/settings/view/about_page.dart';
|
import 'package:im_app/features/settings/view/about_page.dart';
|
||||||
|
import 'package:im_app/features/settings/view/favorites_page.dart';
|
||||||
|
import 'package:im_app/features/settings/view/recent_calls_page.dart';
|
||||||
import 'package:im_app/app/di/app_providers.dart';
|
import 'package:im_app/app/di/app_providers.dart';
|
||||||
import 'package:im_app/app/router/app_route_name.dart';
|
import 'package:im_app/app/router/app_route_name.dart';
|
||||||
import 'package:im_app/app/router/guards/auth_guard.dart';
|
import 'package:im_app/app/router/guards/auth_guard.dart';
|
||||||
@@ -182,6 +184,16 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
path: AppRouteName.settingsAbout.path,
|
path: AppRouteName.settingsAbout.path,
|
||||||
builder: (context, state) => const AboutPage(),
|
builder: (context, state) => const AboutPage(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
parentNavigatorKey: _rootKey,
|
||||||
|
path: AppRouteName.settingsFavorites.path,
|
||||||
|
builder: (context, state) => const FavoritesPage(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
parentNavigatorKey: _rootKey,
|
||||||
|
path: AppRouteName.settingsRecentCalls.path,
|
||||||
|
builder: (context, state) => const RecentCallsPage(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
parentNavigatorKey: _rootKey,
|
parentNavigatorKey: _rootKey,
|
||||||
path: AppRouteName.login.path,
|
path: AppRouteName.login.path,
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ String? authGuard(AuthNotifier authNotifier, GoRouterState state) {
|
|||||||
case AppRouteName.settingsLanguage:
|
case AppRouteName.settingsLanguage:
|
||||||
case AppRouteName.settingsNetworkDiagnostics:
|
case AppRouteName.settingsNetworkDiagnostics:
|
||||||
case AppRouteName.settingsAbout:
|
case AppRouteName.settingsAbout:
|
||||||
|
case AppRouteName.settingsFavorites:
|
||||||
|
case AppRouteName.settingsRecentCalls:
|
||||||
case AppRouteName.chatDBTest:
|
case AppRouteName.chatDBTest:
|
||||||
// 受保护路由 → 未登录跳登录页
|
// 受保护路由 → 未登录跳登录页
|
||||||
return isLoggedIn ? null : AppRouteName.login.path;
|
return isLoggedIn ? null : AppRouteName.login.path;
|
||||||
|
|||||||
@@ -44,9 +44,18 @@ class ApiPaths {
|
|||||||
// ── Workspace ──
|
// ── Workspace ──
|
||||||
static const workspaceGet = '/workspace/workspace/get';
|
static const workspaceGet = '/workspace/workspace/get';
|
||||||
|
|
||||||
|
// ── Call ──
|
||||||
|
static const callRecords = '/app/api/call/records';
|
||||||
|
|
||||||
// ── Upload ──
|
// ── Upload ──
|
||||||
static const uploadFile = '/app/api/upload/file';
|
static const uploadFile = '/app/api/upload/file';
|
||||||
|
|
||||||
|
// ── Favorite (收藏) ──
|
||||||
|
static const favoriteFetchList = '/app/api/favorite/favorites';
|
||||||
|
static const favoriteDelete = '/app/api/favorite/delete';
|
||||||
|
static const favoriteFetchByIds = '/app/api/favorite/favorite';
|
||||||
|
static const favoriteTags = '/app/api/favorite/tags';
|
||||||
|
|
||||||
// ── WebSocket ──
|
// ── WebSocket ──
|
||||||
static const wsConnect = '/websock/open';
|
static const wsConnect = '/websock/open';
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -4,10 +4,17 @@ import 'package:im_app/app/di/network_provider.dart';
|
|||||||
import 'package:im_app/app/di/db_provider.dart';
|
import 'package:im_app/app/di/db_provider.dart';
|
||||||
import 'package:im_app/core/services/socket_manager.dart';
|
import 'package:im_app/core/services/socket_manager.dart';
|
||||||
import 'package:im_app/features/login/di/auth_providers.dart';
|
import 'package:im_app/features/login/di/auth_providers.dart';
|
||||||
|
import 'package:im_app/features/app_tab/di/favorite_provider.dart';
|
||||||
|
import 'package:im_app/data/repositories/call_log_repository_impl.dart';
|
||||||
|
import 'package:im_app/domain/repositories/call_log_repository.dart';
|
||||||
import 'package:im_app/features/settings/usecases/set_theme_usecase.dart';
|
import 'package:im_app/features/settings/usecases/set_theme_usecase.dart';
|
||||||
import 'package:im_app/features/settings/usecases/fetch_profile_usecase.dart';
|
import 'package:im_app/features/settings/usecases/fetch_profile_usecase.dart';
|
||||||
|
import 'package:im_app/features/settings/usecases/fetch_call_logs_usecase.dart';
|
||||||
import 'package:im_app/features/settings/usecases/logout_usecase.dart';
|
import 'package:im_app/features/settings/usecases/logout_usecase.dart';
|
||||||
import 'package:im_app/features/settings/usecases/update_profile_usecase.dart';
|
import 'package:im_app/features/settings/usecases/update_profile_usecase.dart';
|
||||||
|
import 'package:im_app/features/settings/usecases/fetch_favorites_use_case.dart';
|
||||||
|
import 'package:im_app/features/settings/usecases/delete_favorite_use_case.dart';
|
||||||
|
import 'package:im_app/features/settings/presentation/favorites_view_model.dart';
|
||||||
|
|
||||||
/// Settings feature DI 装配
|
/// Settings feature DI 装配
|
||||||
///
|
///
|
||||||
@@ -42,6 +49,42 @@ final updateProfileUseCaseProvider = Provider<UpdateProfileUseCase>((ref) {
|
|||||||
return UpdateProfileUseCase(client: ref.read(networkSdkApiProvider));
|
return UpdateProfileUseCase(client: ref.read(networkSdkApiProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Favorites ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
final fetchFavoritesUseCaseProvider = Provider<FetchFavoritesUseCase>((ref) {
|
||||||
|
return FetchFavoritesUseCase(
|
||||||
|
client: ref.read(networkSdkApiProvider),
|
||||||
|
repository: ref.read(favoriteRepositoryProvider),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final deleteFavoriteUseCaseProvider = Provider<DeleteFavoriteUseCase>((ref) {
|
||||||
|
return DeleteFavoriteUseCase(
|
||||||
|
client: ref.read(networkSdkApiProvider),
|
||||||
|
repository: ref.read(favoriteRepositoryProvider),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final favoritesViewModelProvider =
|
||||||
|
NotifierProvider<FavoritesViewModel, FavoritesState>(
|
||||||
|
FavoritesViewModel.new,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Call Logs ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 通话记录仓储 Provider(Settings feature 内部使用)
|
||||||
|
final callLogRepositoryProvider = Provider<CallLogRepository>((ref) {
|
||||||
|
return CallLogRepositoryImpl(ref.read(storageSdkProvider));
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 拉取通话记录用例 Provider
|
||||||
|
final fetchCallLogsUseCaseProvider = Provider<FetchCallLogsUseCase>((ref) {
|
||||||
|
return FetchCallLogsUseCase(
|
||||||
|
client: ref.read(networkSdkApiProvider),
|
||||||
|
repo: ref.read(callLogRepositoryProvider),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Auth ───────────────────────────────────────────────────────────────────────
|
// ── Auth ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// 退出登录用例 Provider
|
/// 退出登录用例 Provider
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'package:im_app/features/settings/di/settings_providers.dart';
|
||||||
|
|
||||||
|
/// 收藏列表 ViewModel 状态(Gitea issue #44)
|
||||||
|
class FavoritesState {
|
||||||
|
final bool isLoading;
|
||||||
|
final bool isLoadingMore;
|
||||||
|
final bool hasMore;
|
||||||
|
final int page;
|
||||||
|
final String? error;
|
||||||
|
|
||||||
|
const FavoritesState({
|
||||||
|
this.isLoading = false,
|
||||||
|
this.isLoadingMore = false,
|
||||||
|
this.hasMore = true,
|
||||||
|
this.page = 1,
|
||||||
|
this.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
FavoritesState copyWith({
|
||||||
|
bool? isLoading,
|
||||||
|
bool? isLoadingMore,
|
||||||
|
bool? hasMore,
|
||||||
|
int? page,
|
||||||
|
String? error,
|
||||||
|
bool clearError = false,
|
||||||
|
}) {
|
||||||
|
return FavoritesState(
|
||||||
|
isLoading: isLoading ?? this.isLoading,
|
||||||
|
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
|
||||||
|
hasMore: hasMore ?? this.hasMore,
|
||||||
|
page: page ?? this.page,
|
||||||
|
error: clearError ? null : error ?? this.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收藏列表 ViewModel
|
||||||
|
///
|
||||||
|
/// - 初始化时自动拉取第 1 页
|
||||||
|
/// - [refresh]:下拉刷新(reset page=1,清空 DB 中旧数据由 repository.watchAll 自动反映)
|
||||||
|
/// - [loadMore]:加载更多(page+1)
|
||||||
|
/// - [deleteItem]:删除单条收藏
|
||||||
|
///
|
||||||
|
/// UI 直接 `ref.watch(allFavoritesProvider)` 监听 DB Stream 获取列表,
|
||||||
|
/// ViewModel state 只负责 isLoading / error / pagination。
|
||||||
|
class FavoritesViewModel extends Notifier<FavoritesState> {
|
||||||
|
@override
|
||||||
|
FavoritesState build() {
|
||||||
|
// 初始化时自动拉取
|
||||||
|
Future.microtask(() => _fetchPage(1, isRefresh: true));
|
||||||
|
return const FavoritesState(isLoading: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchPage(int page, {bool isRefresh = false}) async {
|
||||||
|
if (isRefresh) {
|
||||||
|
state = state.copyWith(isLoading: true, clearError: true, page: 1);
|
||||||
|
} else {
|
||||||
|
state = state.copyWith(isLoadingMore: true, clearError: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final count = await ref
|
||||||
|
.read(fetchFavoritesUseCaseProvider)
|
||||||
|
.execute(page: page);
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
isLoadingMore: false,
|
||||||
|
hasMore: count > 0,
|
||||||
|
page: page,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
isLoadingMore: false,
|
||||||
|
error: e.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下拉刷新
|
||||||
|
Future<void> refresh() => _fetchPage(1, isRefresh: true);
|
||||||
|
|
||||||
|
/// 加载更多(只有 hasMore && !isLoadingMore 时才触发)
|
||||||
|
Future<void> loadMore() {
|
||||||
|
if (!state.hasMore || state.isLoadingMore) {
|
||||||
|
return Future.value();
|
||||||
|
}
|
||||||
|
return _fetchPage(state.page + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除单条收藏
|
||||||
|
Future<void> deleteItem(int id) async {
|
||||||
|
try {
|
||||||
|
await ref.read(deleteFavoriteUseCaseProvider).execute(id);
|
||||||
|
} catch (e) {
|
||||||
|
state = state.copyWith(error: '删除失败:$e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:im_app/app/router/app_route_name.dart';
|
||||||
|
import 'package:im_app/features/settings/di/settings_providers.dart';
|
||||||
|
|
||||||
|
/// 最近呼叫页状态
|
||||||
|
class RecentCallsState {
|
||||||
|
final int tabIndex;
|
||||||
|
final bool isLoading;
|
||||||
|
final String? error;
|
||||||
|
|
||||||
|
const RecentCallsState({
|
||||||
|
this.tabIndex = 0,
|
||||||
|
this.isLoading = false,
|
||||||
|
this.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
RecentCallsState copyWith({
|
||||||
|
int? tabIndex,
|
||||||
|
bool? isLoading,
|
||||||
|
String? error,
|
||||||
|
bool clearError = false,
|
||||||
|
}) {
|
||||||
|
return RecentCallsState(
|
||||||
|
tabIndex: tabIndex ?? this.tabIndex,
|
||||||
|
isLoading: isLoading ?? this.isLoading,
|
||||||
|
error: clearError ? null : (error ?? this.error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 最近呼叫页 ViewModel
|
||||||
|
///
|
||||||
|
/// ## 数据流
|
||||||
|
/// ```
|
||||||
|
/// RecentCallsPage (build)
|
||||||
|
/// └─ ref.watch(recentCallsViewModelProvider) 读取 loading / error
|
||||||
|
/// └─ ref.watch(allCallLogsProvider) DB Stream 实时通话记录
|
||||||
|
/// → RecentCallsViewModel.loadCallLogs()
|
||||||
|
/// → FetchCallLogsUseCase.execute() POST /app/api/call/records
|
||||||
|
/// → CallLogRepository.insertOrReplace* → Drift DB → Stream 刷新
|
||||||
|
/// ```
|
||||||
|
class RecentCallsViewModel extends Notifier<RecentCallsState> {
|
||||||
|
@override
|
||||||
|
RecentCallsState build() {
|
||||||
|
Future.microtask(_init);
|
||||||
|
return const RecentCallsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _init() async {
|
||||||
|
await Future.wait([loadCallLogs(), markAllRead()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void setTab(int index) {
|
||||||
|
state = state.copyWith(tabIndex: index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 数据拉取 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> loadCallLogs() async {
|
||||||
|
state = state.copyWith(isLoading: true, clearError: true);
|
||||||
|
try {
|
||||||
|
await ref.read(fetchCallLogsUseCaseProvider).execute();
|
||||||
|
state = state.copyWith(isLoading: false);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[RecentCallsViewModel] loadCallLogs error: $e');
|
||||||
|
state = state.copyWith(isLoading: false, error: e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 标记已读 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> markAllRead() async {
|
||||||
|
try {
|
||||||
|
await ref.read(callLogRepositoryProvider).markAllAsRead();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[RecentCallsViewModel] markAllRead error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 导航 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void navigateBack(BuildContext context) {
|
||||||
|
if (context.canPop()) context.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void pushFrom(BuildContext context) {
|
||||||
|
context.push(AppRouteName.settingsRecentCalls.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 最近呼叫页 ViewModel Provider
|
||||||
|
final recentCallsViewModelProvider =
|
||||||
|
NotifierProvider<RecentCallsViewModel, RecentCallsState>(
|
||||||
|
RecentCallsViewModel.new,
|
||||||
|
);
|
||||||
@@ -141,6 +141,10 @@ class SettingsViewModel extends Notifier<SettingsState> {
|
|||||||
void navigateToAbout(BuildContext context) {
|
void navigateToAbout(BuildContext context) {
|
||||||
context.push(AppRouteName.settingsAbout.path);
|
context.push(AppRouteName.settingsAbout.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void navigateToRecentCalls(BuildContext context) {
|
||||||
|
context.push(AppRouteName.settingsRecentCalls.path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 我的页面 ViewModel Provider
|
/// 我的页面 ViewModel Provider
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import 'package:networks_sdk/networks_sdk.dart';
|
||||||
|
|
||||||
|
import 'package:im_app/data/remote/fetch_favorites_request.dart';
|
||||||
|
import 'package:im_app/domain/repositories/favorite_repository.dart';
|
||||||
|
|
||||||
|
/// 删除收藏:先调用 API,成功后同步删除本地 DB(Gitea issue #43)
|
||||||
|
///
|
||||||
|
/// ## 流程
|
||||||
|
/// 1. POST /app/api/favorite/delete body: id={id}
|
||||||
|
/// 2. API 成功 → `FavoriteRepository.delete(id)`
|
||||||
|
/// 3. DB Stream 自动通知 UI 更新
|
||||||
|
class DeleteFavoriteUseCase {
|
||||||
|
final NetworksSdkApi _client;
|
||||||
|
final FavoriteRepository _repository;
|
||||||
|
|
||||||
|
const DeleteFavoriteUseCase({
|
||||||
|
required NetworksSdkApi client,
|
||||||
|
required FavoriteRepository repository,
|
||||||
|
}) : _client = client,
|
||||||
|
_repository = repository;
|
||||||
|
|
||||||
|
Future<void> execute(int id) async {
|
||||||
|
await _client.executeRequest(DeleteFavoriteRequest(id: id));
|
||||||
|
await _repository.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:networks_sdk/networks_sdk.dart';
|
||||||
|
|
||||||
|
import 'package:im_app/data/remote/call_log_request.dart';
|
||||||
|
import 'package:im_app/domain/repositories/call_log_repository.dart';
|
||||||
|
|
||||||
|
/// 拉取通话记录用例
|
||||||
|
///
|
||||||
|
/// ## 执行流程
|
||||||
|
/// 1. POST /app/api/call/records(全量:startFrom=0, status=-1)
|
||||||
|
/// 2. 解析响应 → CallLogDto → CallLog Domain 实体
|
||||||
|
/// 3. insertOrReplaceCallLogs → Drift DB
|
||||||
|
/// 4. allCallLogsProvider Stream 自动刷新 → UI 重建
|
||||||
|
///
|
||||||
|
/// ## 为什么全量拉取
|
||||||
|
///
|
||||||
|
/// 当前使用 startFrom=0 全量拉取,与 im-client-im-dev CallLogMgr 初始化行为一致。
|
||||||
|
/// 后续可优化为增量:取本地最新 updatedAt 作为 startFrom,减少流量。
|
||||||
|
class FetchCallLogsUseCase {
|
||||||
|
final NetworksSdkApi _client;
|
||||||
|
final CallLogRepository _repo;
|
||||||
|
|
||||||
|
const FetchCallLogsUseCase({
|
||||||
|
required NetworksSdkApi client,
|
||||||
|
required CallLogRepository repo,
|
||||||
|
}) : _client = client,
|
||||||
|
_repo = repo;
|
||||||
|
|
||||||
|
/// 拉取通话记录并写入本地 DB
|
||||||
|
///
|
||||||
|
/// 成功返回拉取到的记录数,失败抛出异常。
|
||||||
|
Future<int> execute() async {
|
||||||
|
final response = await _client.executeRequest(
|
||||||
|
const FetchCallLogsRequest(startFrom: 0, status: -1),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response == null || response.records.isEmpty) {
|
||||||
|
debugPrint('[FetchCallLogsUseCase] no records returned');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
final entities = response.records.map((dto) => dto.toEntity()).toList();
|
||||||
|
await _repo.insertOrReplaceCallLogs(entities);
|
||||||
|
debugPrint(
|
||||||
|
'[FetchCallLogsUseCase] inserted ${entities.length} call logs',
|
||||||
|
);
|
||||||
|
return entities.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import 'package:networks_sdk/networks_sdk.dart';
|
||||||
|
|
||||||
|
import 'package:im_app/data/remote/fetch_favorites_request.dart';
|
||||||
|
import 'package:im_app/domain/repositories/favorite_repository.dart';
|
||||||
|
|
||||||
|
/// 分页拉取收藏列表并持久化到 DB(Gitea issue #42)
|
||||||
|
///
|
||||||
|
/// ## 流程
|
||||||
|
/// 1. GET /app/api/favorite/favorites?page={n}
|
||||||
|
/// 2. 解码为 [Favorite] 列表
|
||||||
|
/// 3. upsert 到 [FavoriteRepository](insertOrReplaceAll)
|
||||||
|
///
|
||||||
|
/// 返回本次拉取到的条目数;返回 0 表示无更多数据。
|
||||||
|
class FetchFavoritesUseCase {
|
||||||
|
final NetworksSdkApi _client;
|
||||||
|
final FavoriteRepository _repository;
|
||||||
|
|
||||||
|
const FetchFavoritesUseCase({
|
||||||
|
required NetworksSdkApi client,
|
||||||
|
required FavoriteRepository repository,
|
||||||
|
}) : _client = client,
|
||||||
|
_repository = repository;
|
||||||
|
|
||||||
|
Future<int> execute({int page = 1}) async {
|
||||||
|
final resp =
|
||||||
|
await _client.executeRequest(FetchFavoritesRequest(page: page));
|
||||||
|
if (resp == null || resp.items.isEmpty) return 0;
|
||||||
|
await _repository.insertOrReplaceAll(resp.items);
|
||||||
|
return resp.items.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
360
apps/im_app/lib/features/settings/view/favorites_page.dart
Normal file
360
apps/im_app/lib/features/settings/view/favorites_page.dart
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'package:im_app/domain/entities/favorite.dart';
|
||||||
|
import 'package:im_app/features/app_tab/di/favorite_provider.dart';
|
||||||
|
import 'package:im_app/features/settings/di/settings_providers.dart';
|
||||||
|
import 'package:im_app/features/settings/presentation/favorites_view_model.dart';
|
||||||
|
|
||||||
|
/// 收藏列表页(Gitea issue #44)
|
||||||
|
///
|
||||||
|
/// - 列表从 `allFavoritesProvider`(DB Stream)实时驱动
|
||||||
|
/// - 分页 / loading / error 状态来自 `favoritesViewModelProvider`
|
||||||
|
/// - 下拉刷新调用 `vm.refresh()`
|
||||||
|
/// - 滚动到底部触发 `vm.loadMore()`
|
||||||
|
/// - 左滑删除调用 `vm.deleteItem(id)`
|
||||||
|
class FavoritesPage extends ConsumerStatefulWidget {
|
||||||
|
const FavoritesPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<FavoritesPage> createState() => _FavoritesPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FavoritesPageState extends ConsumerState<FavoritesPage> {
|
||||||
|
final _scrollCtrl = ScrollController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scrollCtrl.addListener(_onScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (_scrollCtrl.position.pixels >=
|
||||||
|
_scrollCtrl.position.maxScrollExtent - 80) {
|
||||||
|
ref.read(favoritesViewModelProvider.notifier).loadMore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final vmState = ref.watch(favoritesViewModelProvider);
|
||||||
|
final favAsync = ref.watch(allFavoritesProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('收藏')),
|
||||||
|
body: favAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => Center(child: Text('加载失败: $e')),
|
||||||
|
data: (favorites) {
|
||||||
|
if (vmState.isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (favorites.isEmpty) {
|
||||||
|
return const _EmptyState();
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
if (vmState.error != null)
|
||||||
|
_ErrorBanner(message: vmState.error!),
|
||||||
|
Expanded(
|
||||||
|
child: RefreshIndicator(
|
||||||
|
onRefresh: () =>
|
||||||
|
ref.read(favoritesViewModelProvider.notifier).refresh(),
|
||||||
|
child: ListView.separated(
|
||||||
|
controller: _scrollCtrl,
|
||||||
|
itemCount:
|
||||||
|
favorites.length + (vmState.isLoadingMore ? 1 : 0),
|
||||||
|
separatorBuilder: (_, __) =>
|
||||||
|
const Divider(height: 1, indent: 56),
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
if (i == favorites.length) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _FavoriteCell(
|
||||||
|
favorite: favorites[i],
|
||||||
|
onDelete: () => ref
|
||||||
|
.read(favoritesViewModelProvider.notifier)
|
||||||
|
.deleteItem(favorites[i].id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 收藏 Cell ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _FavoriteCell extends StatelessWidget {
|
||||||
|
const _FavoriteCell({
|
||||||
|
required this.favorite,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Favorite favorite;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return Dismissible(
|
||||||
|
key: ValueKey(favorite.id),
|
||||||
|
direction: DismissDirection.endToStart,
|
||||||
|
background: Container(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
padding: const EdgeInsets.only(right: 20),
|
||||||
|
color: cs.error,
|
||||||
|
child: Icon(Icons.delete_rounded, color: cs.onError),
|
||||||
|
),
|
||||||
|
confirmDismiss: (_) async {
|
||||||
|
return await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('删除收藏'),
|
||||||
|
content: const Text('确定要删除这条收藏吗?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('删除'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onDismissed: (_) => onDelete(),
|
||||||
|
child: ListTile(
|
||||||
|
leading: _FavoriteIcon(favorite: favorite),
|
||||||
|
title: Text(
|
||||||
|
_buildTitle(favorite),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
_buildSubtitle(favorite),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(fontSize: 12, color: cs.onSurface.withOpacity(0.6)),
|
||||||
|
),
|
||||||
|
trailing: Text(
|
||||||
|
_formatTime(favorite.createdAt),
|
||||||
|
style: TextStyle(fontSize: 11, color: cs.onSurface.withOpacity(0.4)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildTitle(Favorite fav) {
|
||||||
|
// typ 是 JSON string "[1,2]";取第一个 typ 决定标题前缀
|
||||||
|
final types = _parseTyp(fav.typ);
|
||||||
|
if (types.isEmpty) return '收藏';
|
||||||
|
switch (types.first) {
|
||||||
|
case 1:
|
||||||
|
return _firstDataContent(fav) ?? '文字收藏';
|
||||||
|
case 2:
|
||||||
|
return '链接';
|
||||||
|
case 3:
|
||||||
|
return '图片';
|
||||||
|
case 4:
|
||||||
|
return '视频';
|
||||||
|
case 5:
|
||||||
|
return '语音';
|
||||||
|
case 6:
|
||||||
|
return '文件';
|
||||||
|
case 7:
|
||||||
|
return '位置';
|
||||||
|
case 9:
|
||||||
|
return '相册';
|
||||||
|
case 10:
|
||||||
|
return '笔记';
|
||||||
|
default:
|
||||||
|
return '收藏';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildSubtitle(Favorite fav) {
|
||||||
|
final content = _firstDataContent(fav);
|
||||||
|
if (content != null && content.isNotEmpty) return content;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析 data 字段(JSON-encoded string),取第一条的 content 字段
|
||||||
|
String? _firstDataContent(Favorite fav) {
|
||||||
|
try {
|
||||||
|
final list = jsonDecode(fav.data) as List<dynamic>;
|
||||||
|
if (list.isEmpty) return null;
|
||||||
|
final first = list.first as Map<String, dynamic>;
|
||||||
|
return first['content'] as String?;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> _parseTyp(String typStr) {
|
||||||
|
try {
|
||||||
|
final list = jsonDecode(typStr) as List<dynamic>;
|
||||||
|
return list.whereType<int>().toList();
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTime(int unixSecs) {
|
||||||
|
if (unixSecs == 0) return '';
|
||||||
|
final dt = DateTime.fromMillisecondsSinceEpoch(unixSecs * 1000);
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (dt.year == now.year && dt.month == now.month && dt.day == now.day) {
|
||||||
|
return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
return '${dt.month}/${dt.day}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 收藏类型图标 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _FavoriteIcon extends StatelessWidget {
|
||||||
|
const _FavoriteIcon({required this.favorite});
|
||||||
|
|
||||||
|
final Favorite favorite;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final types = _parseTyp(favorite.typ);
|
||||||
|
final typ = types.isNotEmpty ? types.first : 0;
|
||||||
|
|
||||||
|
IconData icon;
|
||||||
|
Color color;
|
||||||
|
switch (typ) {
|
||||||
|
case 1:
|
||||||
|
icon = Icons.text_snippet_rounded;
|
||||||
|
color = const Color(0xFF5667FF);
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
icon = Icons.link_rounded;
|
||||||
|
color = const Color(0xFF0BB8A9);
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
icon = Icons.image_rounded;
|
||||||
|
color = const Color(0xFF4CB050);
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
icon = Icons.videocam_rounded;
|
||||||
|
color = const Color(0xFFFF8B5E);
|
||||||
|
break;
|
||||||
|
case 5:
|
||||||
|
icon = Icons.mic_rounded;
|
||||||
|
color = const Color(0xFF8A5CF6);
|
||||||
|
break;
|
||||||
|
case 6:
|
||||||
|
icon = Icons.insert_drive_file_rounded;
|
||||||
|
color = const Color(0xFFFFAF45);
|
||||||
|
break;
|
||||||
|
case 7:
|
||||||
|
icon = Icons.location_on_rounded;
|
||||||
|
color = const Color(0xFFFF4B4B);
|
||||||
|
break;
|
||||||
|
case 9:
|
||||||
|
icon = Icons.photo_library_rounded;
|
||||||
|
color = const Color(0xFF4CB050);
|
||||||
|
break;
|
||||||
|
case 10:
|
||||||
|
icon = Icons.article_rounded;
|
||||||
|
color = const Color(0xFF5667FF);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
icon = Icons.star_rounded;
|
||||||
|
color = const Color(0xFFFFAF45);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.12),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: color, size: 22),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> _parseTyp(String typStr) {
|
||||||
|
try {
|
||||||
|
final list = jsonDecode(typStr) as List<dynamic>;
|
||||||
|
return list.whereType<int>().toList();
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 空状态 & 错误 Banner ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _EmptyState extends StatelessWidget {
|
||||||
|
const _EmptyState();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.star_border_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.3),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'暂无收藏',
|
||||||
|
style: TextStyle(
|
||||||
|
color:
|
||||||
|
Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ErrorBanner extends StatelessWidget {
|
||||||
|
const _ErrorBanner({required this.message});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return Material(
|
||||||
|
color: cs.errorContainer,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: TextStyle(fontSize: 12, color: cs.onErrorContainer),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
337
apps/im_app/lib/features/settings/view/recent_calls_page.dart
Normal file
337
apps/im_app/lib/features/settings/view/recent_calls_page.dart
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'package:im_app/app/di/app_providers.dart';
|
||||||
|
import 'package:im_app/domain/entities/call_log.dart';
|
||||||
|
import 'package:im_app/features/chat/call/di/call_log_provider.dart';
|
||||||
|
import 'package:im_app/features/settings/presentation/recent_calls_view_model.dart';
|
||||||
|
|
||||||
|
/// 最近呼叫页(#42 / #43 / #44)
|
||||||
|
///
|
||||||
|
/// ## 结构
|
||||||
|
/// - AppBar:「最近呼叫」
|
||||||
|
/// - 双 Tab:全部 / 未接来电
|
||||||
|
/// - 每行:_CallLogTile(通话类型图标、对方 UID、时长/状态、时间)
|
||||||
|
///
|
||||||
|
/// ## 未接来电判断
|
||||||
|
/// callerId != currentUid AND status in {3, 4, 5, 6}
|
||||||
|
///
|
||||||
|
/// ## 数据流
|
||||||
|
/// - 进入页面:loadCallLogs()(POST /app/api/call/records)→ DB
|
||||||
|
/// - 监听:allCallLogsProvider(DB Stream)→ 实时刷新
|
||||||
|
/// - 进入页面同时:markAllRead()
|
||||||
|
class RecentCallsPage extends ConsumerStatefulWidget {
|
||||||
|
const RecentCallsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<RecentCallsPage> createState() => _RecentCallsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RecentCallsPageState extends ConsumerState<RecentCallsPage>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final TabController _tabCtrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabCtrl = TabController(length: 2, vsync: this);
|
||||||
|
_tabCtrl.addListener(() {
|
||||||
|
if (!_tabCtrl.indexIsChanging) {
|
||||||
|
ref
|
||||||
|
.read(recentCallsViewModelProvider.notifier)
|
||||||
|
.setTab(_tabCtrl.index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final state = ref.watch(recentCallsViewModelProvider);
|
||||||
|
final logsAsync = ref.watch(allCallLogsProvider);
|
||||||
|
final currentUid = ref.watch(authNotifierProvider).currentUid ?? 0;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('最近呼叫'),
|
||||||
|
bottom: TabBar(
|
||||||
|
controller: _tabCtrl,
|
||||||
|
tabs: const [
|
||||||
|
Tab(text: '全部'),
|
||||||
|
Tab(text: '未接来电'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// ── 加载中指示条 ───────────────────────────────────────────────────
|
||||||
|
if (state.isLoading)
|
||||||
|
const LinearProgressIndicator(minHeight: 2),
|
||||||
|
|
||||||
|
// ── 错误横幅 ───────────────────────────────────────────────────────
|
||||||
|
if (state.error != null)
|
||||||
|
Material(
|
||||||
|
color: Theme.of(context).colorScheme.errorContainer,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.warning_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'加载失败: ${state.error}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color:
|
||||||
|
Theme.of(context).colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── TabBarView ─────────────────────────────────────────────────────
|
||||||
|
Expanded(
|
||||||
|
child: logsAsync.when(
|
||||||
|
data: (logs) => TabBarView(
|
||||||
|
controller: _tabCtrl,
|
||||||
|
children: [
|
||||||
|
_CallLogList(
|
||||||
|
logs: logs,
|
||||||
|
currentUid: currentUid,
|
||||||
|
missedOnly: false,
|
||||||
|
),
|
||||||
|
_CallLogList(
|
||||||
|
logs: logs,
|
||||||
|
currentUid: currentUid,
|
||||||
|
missedOnly: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
loading: () =>
|
||||||
|
const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => Center(child: Text('加载失败: $e')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 通话记录列表 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _CallLogList extends StatelessWidget {
|
||||||
|
const _CallLogList({
|
||||||
|
required this.logs,
|
||||||
|
required this.currentUid,
|
||||||
|
required this.missedOnly,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<CallLog> logs;
|
||||||
|
final int currentUid;
|
||||||
|
final bool missedOnly;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final filtered = missedOnly
|
||||||
|
? logs.where((l) => _isMissed(l, currentUid)).toList()
|
||||||
|
: logs;
|
||||||
|
|
||||||
|
if (filtered.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
missedOnly
|
||||||
|
? Icons.phone_missed_rounded
|
||||||
|
: Icons.phone_outlined,
|
||||||
|
size: 56,
|
||||||
|
color: Theme.of(context).colorScheme.outline,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
missedOnly ? '没有未接来电' : '暂无通话记录',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
itemCount: filtered.length,
|
||||||
|
separatorBuilder: (_, __) =>
|
||||||
|
const Divider(height: 1, indent: 72),
|
||||||
|
itemBuilder: (context, i) => _CallLogTile(
|
||||||
|
log: filtered[i],
|
||||||
|
currentUid: currentUid,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 通话记录单行 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 通话记录单项 Cell(对应 im-client-im-dev CallLogTile)
|
||||||
|
///
|
||||||
|
/// - 左圆形图标:语音/视频;颜色:未接=红,已接=绿,已拨=primary
|
||||||
|
/// - 标题:对方 `@J{uid}`(未接时红色)
|
||||||
|
/// - 副标题:通话类型 + 时长
|
||||||
|
/// - 右侧:相对时间
|
||||||
|
class _CallLogTile extends StatelessWidget {
|
||||||
|
const _CallLogTile({required this.log, required this.currentUid});
|
||||||
|
|
||||||
|
final CallLog log;
|
||||||
|
final int currentUid;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isMissed = _isMissed(log, currentUid);
|
||||||
|
final isOutgoing = (log.callerId ?? 0) == currentUid;
|
||||||
|
final isVideo = (log.videoCall ?? 0) == 1;
|
||||||
|
|
||||||
|
final Color iconColor;
|
||||||
|
if (isMissed) {
|
||||||
|
iconColor = Colors.red;
|
||||||
|
} else if (isOutgoing) {
|
||||||
|
iconColor = Theme.of(context).colorScheme.primary;
|
||||||
|
} else {
|
||||||
|
iconColor = Colors.green;
|
||||||
|
}
|
||||||
|
|
||||||
|
final IconData callIcon;
|
||||||
|
if (isMissed) {
|
||||||
|
callIcon = isVideo
|
||||||
|
? Icons.videocam_off_rounded
|
||||||
|
: Icons.phone_missed_rounded;
|
||||||
|
} else if (isOutgoing) {
|
||||||
|
callIcon =
|
||||||
|
isVideo ? Icons.videocam_rounded : Icons.call_made_rounded;
|
||||||
|
} else {
|
||||||
|
callIcon = isVideo
|
||||||
|
? Icons.videocam_rounded
|
||||||
|
: Icons.call_received_rounded;
|
||||||
|
}
|
||||||
|
|
||||||
|
final otherUid =
|
||||||
|
isOutgoing ? (log.receiverId ?? 0) : (log.callerId ?? 0);
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
leading: _CallIcon(icon: callIcon, color: iconColor),
|
||||||
|
title: Text(
|
||||||
|
'@J$otherUid',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: isMissed ? Colors.red : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
_buildSubtitle(isMissed, isOutgoing, isVideo, log.duration),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: Text(
|
||||||
|
_formatTime(log.updatedAt ?? log.createdAt),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () {}, // TODO: 回拨功能
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildSubtitle(
|
||||||
|
bool isMissed,
|
||||||
|
bool isOutgoing,
|
||||||
|
bool isVideo,
|
||||||
|
int? duration,
|
||||||
|
) {
|
||||||
|
final typeStr = isVideo ? '视频通话' : '语音通话';
|
||||||
|
if (isMissed) return '未接$typeStr';
|
||||||
|
if (duration != null && duration > 0) {
|
||||||
|
final label = isOutgoing ? '已拨' : '已接';
|
||||||
|
return '$label · ${_formatDuration(duration)}';
|
||||||
|
}
|
||||||
|
return isOutgoing ? '已拨$typeStr' : '已接$typeStr';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CallIcon extends StatelessWidget {
|
||||||
|
const _CallIcon({required this.icon, required this.color});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.12),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: color, size: 22),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 判断是否为未接来电
|
||||||
|
///
|
||||||
|
/// 条件:非本人发起 + status in {3, 4, 5, 6}(busy/cancel/timeout/declined)
|
||||||
|
bool _isMissed(CallLog log, int currentUid) {
|
||||||
|
const missedStatuses = {3, 4, 5, 6};
|
||||||
|
return (log.callerId ?? 0) != currentUid &&
|
||||||
|
missedStatuses.contains(log.status ?? -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 格式化通话时长(秒 → MM:SS 或 H:MM:SS)
|
||||||
|
String _formatDuration(int seconds) {
|
||||||
|
final d = Duration(seconds: seconds);
|
||||||
|
final h = d.inHours;
|
||||||
|
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||||
|
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||||
|
return h > 0 ? '$h:$m:$s' : '$m:$s';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 格式化通话时间(Unix 秒时间戳 → 相对时间或月/日)
|
||||||
|
String _formatTime(int? timestamp) {
|
||||||
|
if (timestamp == null || timestamp == 0) return '';
|
||||||
|
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
|
||||||
|
final now = DateTime.now();
|
||||||
|
final diff = now.difference(dt);
|
||||||
|
|
||||||
|
if (diff.inMinutes < 1) return '刚刚';
|
||||||
|
if (diff.inHours < 1) return '${diff.inMinutes}分钟前';
|
||||||
|
if (diff.inDays < 1) return '${diff.inHours}小时前';
|
||||||
|
if (diff.inDays < 7) return '${diff.inDays}天前';
|
||||||
|
|
||||||
|
return '${dt.month}/${dt.day}';
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:im_app/app/router/app_route_name.dart';
|
||||||
import 'package:im_app/core/foundation/config.dart';
|
import 'package:im_app/core/foundation/config.dart';
|
||||||
import 'package:im_app/features/settings/presentation/settings_view_model.dart';
|
import 'package:im_app/features/settings/presentation/settings_view_model.dart';
|
||||||
|
|
||||||
@@ -99,13 +102,13 @@ class SettingsPage extends ConsumerWidget {
|
|||||||
icon: Icons.star_rounded,
|
icon: Icons.star_rounded,
|
||||||
iconColor: _favoriteColor,
|
iconColor: _favoriteColor,
|
||||||
title: '收藏',
|
title: '收藏',
|
||||||
onTap: () {}, // TODO: 收藏页
|
onTap: () => context.push(AppRouteName.settingsFavorites.path),
|
||||||
),
|
),
|
||||||
_RowConfig(
|
_RowConfig(
|
||||||
icon: Icons.phone_rounded,
|
icon: Icons.phone_rounded,
|
||||||
iconColor: _callColor,
|
iconColor: _callColor,
|
||||||
title: '最近呼叫',
|
title: '最近呼叫',
|
||||||
onTap: () {}, // TODO: 呼叫记录页
|
onTap: () => vm.navigateToRecentCalls(context),
|
||||||
),
|
),
|
||||||
_RowConfig(
|
_RowConfig(
|
||||||
icon: Icons.laptop_rounded,
|
icon: Icons.laptop_rounded,
|
||||||
|
|||||||
Reference in New Issue
Block a user