From 8744e2c0b7f70b1c1774d9ae7d5d4d845bf16c82 Mon Sep 17 00:00:00 2001 From: pp-bot Date: Tue, 24 Mar 2026 20:30:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(settings):=20=E6=94=B6=E8=97=8F=E5=88=97?= =?UTF-8?q?=E8=A1=A8=20+=20=E6=9C=80=E8=BF=91=E5=91=BC=E5=8F=AB=E5=85=A8?= =?UTF-8?q?=E9=87=8F=E5=AE=9E=E7=8E=B0=EF=BC=88#42~#45=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 收藏(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 --- .../im_app/lib/app/router/app_route_name.dart | 2 + apps/im_app/lib/app/router/app_router.dart | 12 + .../lib/app/router/guards/auth_guard.dart | 2 + .../im_app/lib/core/foundation/api_paths.dart | 9 + .../lib/data/remote/call_log_request.dart | 142 +++++++ .../data/remote/fetch_favorites_request.dart | 166 ++++++++ .../settings/di/settings_providers.dart | 43 +++ .../presentation/favorites_view_model.dart | 101 +++++ .../presentation/recent_calls_view_model.dart | 100 +++++ .../presentation/settings_view_model.dart | 4 + .../usecases/delete_favorite_use_case.dart | 26 ++ .../usecases/fetch_call_logs_usecase.dart | 49 +++ .../usecases/fetch_favorites_use_case.dart | 31 ++ .../settings/view/favorites_page.dart | 360 ++++++++++++++++++ .../settings/view/recent_calls_page.dart | 337 ++++++++++++++++ .../features/settings/view/settings_page.dart | 7 +- 16 files changed, 1389 insertions(+), 2 deletions(-) create mode 100644 apps/im_app/lib/data/remote/call_log_request.dart create mode 100644 apps/im_app/lib/data/remote/fetch_favorites_request.dart create mode 100644 apps/im_app/lib/features/settings/presentation/favorites_view_model.dart create mode 100644 apps/im_app/lib/features/settings/presentation/recent_calls_view_model.dart create mode 100644 apps/im_app/lib/features/settings/usecases/delete_favorite_use_case.dart create mode 100644 apps/im_app/lib/features/settings/usecases/fetch_call_logs_usecase.dart create mode 100644 apps/im_app/lib/features/settings/usecases/fetch_favorites_use_case.dart create mode 100644 apps/im_app/lib/features/settings/view/favorites_page.dart create mode 100644 apps/im_app/lib/features/settings/view/recent_calls_page.dart diff --git a/apps/im_app/lib/app/router/app_route_name.dart b/apps/im_app/lib/app/router/app_route_name.dart index a0757cb..f4a97ba 100644 --- a/apps/im_app/lib/app/router/app_route_name.dart +++ b/apps/im_app/lib/app/router/app_route_name.dart @@ -73,6 +73,8 @@ enum AppRouteName { settingsLanguage('/settings/language'), settingsNetworkDiagnostics('/settings/network-diagnostics'), settingsAbout('/settings/about'), + settingsFavorites('/settings/favorites'), + settingsRecentCalls('/settings/recent-calls'), // ── 全屏页面(无底部导航栏)────────────────────────────────────────────── login('/login'); diff --git a/apps/im_app/lib/app/router/app_router.dart b/apps/im_app/lib/app/router/app_router.dart index e85256b..b56cce3 100644 --- a/apps/im_app/lib/app/router/app_router.dart +++ b/apps/im_app/lib/app/router/app_router.dart @@ -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/network_diagnostics_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/router/app_route_name.dart'; import 'package:im_app/app/router/guards/auth_guard.dart'; @@ -182,6 +184,16 @@ final routerProvider = Provider((ref) { path: AppRouteName.settingsAbout.path, 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( parentNavigatorKey: _rootKey, path: AppRouteName.login.path, diff --git a/apps/im_app/lib/app/router/guards/auth_guard.dart b/apps/im_app/lib/app/router/guards/auth_guard.dart index 81d336b..8918d13 100644 --- a/apps/im_app/lib/app/router/guards/auth_guard.dart +++ b/apps/im_app/lib/app/router/guards/auth_guard.dart @@ -45,6 +45,8 @@ String? authGuard(AuthNotifier authNotifier, GoRouterState state) { case AppRouteName.settingsLanguage: case AppRouteName.settingsNetworkDiagnostics: case AppRouteName.settingsAbout: + case AppRouteName.settingsFavorites: + case AppRouteName.settingsRecentCalls: case AppRouteName.chatDBTest: // 受保护路由 → 未登录跳登录页 return isLoggedIn ? null : AppRouteName.login.path; diff --git a/apps/im_app/lib/core/foundation/api_paths.dart b/apps/im_app/lib/core/foundation/api_paths.dart index 8e4652e..b4ffdfa 100644 --- a/apps/im_app/lib/core/foundation/api_paths.dart +++ b/apps/im_app/lib/core/foundation/api_paths.dart @@ -44,9 +44,18 @@ class ApiPaths { // ── Workspace ── static const workspaceGet = '/workspace/workspace/get'; + // ── Call ── + static const callRecords = '/app/api/call/records'; + // ── Upload ── 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 ── static const wsConnect = '/websock/open'; } diff --git a/apps/im_app/lib/data/remote/call_log_request.dart b/apps/im_app/lib/data/remote/call_log_request.dart new file mode 100644 index 0000000..b403a12 --- /dev/null +++ b/apps/im_app/lib/data/remote/call_log_request.dart @@ -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 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 records; + + const FetchCallLogsResponse({required this.records}); + + factory FetchCallLogsResponse.fromJson(Map json) { + final list = json['list'] ?? json['records'] ?? json['data'] ?? []; + return FetchCallLogsResponse( + records: (list as List) + .map((item) => CallLogDto.fromJson(item as Map)) + .toList(), + ); + } +} + +// ── FetchCallLogsRequest ───────────────────────────────────────────────────── + +/// POST /app/api/call/records — 拉取通话记录 +/// +/// [startFrom] Unix 时间戳(秒),增量拉取用;0 表示全量。 +/// [status] -1 = 全部状态。 +class FetchCallLogsRequest + extends ApiRequestable { + 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 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)) + .toList(), + ); + } + if (data is Map) { + return FetchCallLogsResponse.fromJson(data); + } + return FetchCallLogsResponse(records: []); + } +} diff --git a/apps/im_app/lib/data/remote/fetch_favorites_request.dart b/apps/im_app/lib/data/remote/fetch_favorites_request.dart new file mode 100644 index 0000000..b6516bd --- /dev/null +++ b/apps/im_app/lib/data/remote/fetch_favorites_request.dart @@ -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 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 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 { + 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 get parameters => { + 'page': page, + if (timestamp != null) 'timestamp': timestamp, + }; + + @override + FetchFavoritesResponse? decodeResponse(dynamic response) { + final raw = (response as dynamic).data; + // data 字段:服务端可能返回 {list:[...]} 或直接 [...] + List list; + if (raw is List) { + list = raw; + } else if (raw is Map) { + final l = raw['list']; + list = l is List ? l : []; + } else { + return const FetchFavoritesResponse(items: []); + } + final items = list + .whereType>() + .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 { + final int id; + + const DeleteFavoriteRequest({required this.id}); + + @override + String get path => ApiPaths.favoriteDelete; + + @override + HttpMethod get method => HttpMethod.post; + + @override + Map get parameters => {'id': id}; + + @override + bool? decodeResponse(dynamic response) => true; +} diff --git a/apps/im_app/lib/features/settings/di/settings_providers.dart b/apps/im_app/lib/features/settings/di/settings_providers.dart index 476233d..6482157 100644 --- a/apps/im_app/lib/features/settings/di/settings_providers.dart +++ b/apps/im_app/lib/features/settings/di/settings_providers.dart @@ -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/core/services/socket_manager.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/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/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 装配 /// @@ -42,6 +49,42 @@ final updateProfileUseCaseProvider = Provider((ref) { return UpdateProfileUseCase(client: ref.read(networkSdkApiProvider)); }); +// ── Favorites ───────────────────────────────────────────────────────────────── + +final fetchFavoritesUseCaseProvider = Provider((ref) { + return FetchFavoritesUseCase( + client: ref.read(networkSdkApiProvider), + repository: ref.read(favoriteRepositoryProvider), + ); +}); + +final deleteFavoriteUseCaseProvider = Provider((ref) { + return DeleteFavoriteUseCase( + client: ref.read(networkSdkApiProvider), + repository: ref.read(favoriteRepositoryProvider), + ); +}); + +final favoritesViewModelProvider = + NotifierProvider( + FavoritesViewModel.new, +); + +// ── Call Logs ───────────────────────────────────────────────────────────────── + +/// 通话记录仓储 Provider(Settings feature 内部使用) +final callLogRepositoryProvider = Provider((ref) { + return CallLogRepositoryImpl(ref.read(storageSdkProvider)); +}); + +/// 拉取通话记录用例 Provider +final fetchCallLogsUseCaseProvider = Provider((ref) { + return FetchCallLogsUseCase( + client: ref.read(networkSdkApiProvider), + repo: ref.read(callLogRepositoryProvider), + ); +}); + // ── Auth ─────────────────────────────────────────────────────────────────────── /// 退出登录用例 Provider diff --git a/apps/im_app/lib/features/settings/presentation/favorites_view_model.dart b/apps/im_app/lib/features/settings/presentation/favorites_view_model.dart new file mode 100644 index 0000000..a97b341 --- /dev/null +++ b/apps/im_app/lib/features/settings/presentation/favorites_view_model.dart @@ -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 { + @override + FavoritesState build() { + // 初始化时自动拉取 + Future.microtask(() => _fetchPage(1, isRefresh: true)); + return const FavoritesState(isLoading: true); + } + + Future _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 refresh() => _fetchPage(1, isRefresh: true); + + /// 加载更多(只有 hasMore && !isLoadingMore 时才触发) + Future loadMore() { + if (!state.hasMore || state.isLoadingMore) { + return Future.value(); + } + return _fetchPage(state.page + 1); + } + + /// 删除单条收藏 + Future deleteItem(int id) async { + try { + await ref.read(deleteFavoriteUseCaseProvider).execute(id); + } catch (e) { + state = state.copyWith(error: '删除失败:$e'); + } + } +} diff --git a/apps/im_app/lib/features/settings/presentation/recent_calls_view_model.dart b/apps/im_app/lib/features/settings/presentation/recent_calls_view_model.dart new file mode 100644 index 0000000..38fb07f --- /dev/null +++ b/apps/im_app/lib/features/settings/presentation/recent_calls_view_model.dart @@ -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 { + @override + RecentCallsState build() { + Future.microtask(_init); + return const RecentCallsState(); + } + + Future _init() async { + await Future.wait([loadCallLogs(), markAllRead()]); + } + + // ── Tab ──────────────────────────────────────────────────────────────────── + + void setTab(int index) { + state = state.copyWith(tabIndex: index); + } + + // ── 数据拉取 ─────────────────────────────────────────────────────────────── + + Future 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 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.new, +); diff --git a/apps/im_app/lib/features/settings/presentation/settings_view_model.dart b/apps/im_app/lib/features/settings/presentation/settings_view_model.dart index b66389a..0064195 100644 --- a/apps/im_app/lib/features/settings/presentation/settings_view_model.dart +++ b/apps/im_app/lib/features/settings/presentation/settings_view_model.dart @@ -141,6 +141,10 @@ class SettingsViewModel extends Notifier { void navigateToAbout(BuildContext context) { context.push(AppRouteName.settingsAbout.path); } + + void navigateToRecentCalls(BuildContext context) { + context.push(AppRouteName.settingsRecentCalls.path); + } } /// 我的页面 ViewModel Provider diff --git a/apps/im_app/lib/features/settings/usecases/delete_favorite_use_case.dart b/apps/im_app/lib/features/settings/usecases/delete_favorite_use_case.dart new file mode 100644 index 0000000..78cd705 --- /dev/null +++ b/apps/im_app/lib/features/settings/usecases/delete_favorite_use_case.dart @@ -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 execute(int id) async { + await _client.executeRequest(DeleteFavoriteRequest(id: id)); + await _repository.delete(id); + } +} diff --git a/apps/im_app/lib/features/settings/usecases/fetch_call_logs_usecase.dart b/apps/im_app/lib/features/settings/usecases/fetch_call_logs_usecase.dart new file mode 100644 index 0000000..463b5c1 --- /dev/null +++ b/apps/im_app/lib/features/settings/usecases/fetch_call_logs_usecase.dart @@ -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 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; + } +} diff --git a/apps/im_app/lib/features/settings/usecases/fetch_favorites_use_case.dart b/apps/im_app/lib/features/settings/usecases/fetch_favorites_use_case.dart new file mode 100644 index 0000000..d18da62 --- /dev/null +++ b/apps/im_app/lib/features/settings/usecases/fetch_favorites_use_case.dart @@ -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 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; + } +} diff --git a/apps/im_app/lib/features/settings/view/favorites_page.dart b/apps/im_app/lib/features/settings/view/favorites_page.dart new file mode 100644 index 0000000..4e5dd1b --- /dev/null +++ b/apps/im_app/lib/features/settings/view/favorites_page.dart @@ -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 createState() => _FavoritesPageState(); +} + +class _FavoritesPageState extends ConsumerState { + 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( + 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; + if (list.isEmpty) return null; + final first = list.first as Map; + return first['content'] as String?; + } catch (_) { + return null; + } + } + + List _parseTyp(String typStr) { + try { + final list = jsonDecode(typStr) as List; + return list.whereType().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 _parseTyp(String typStr) { + try { + final list = jsonDecode(typStr) as List; + return list.whereType().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), + ), + ), + ); + } +} diff --git a/apps/im_app/lib/features/settings/view/recent_calls_page.dart b/apps/im_app/lib/features/settings/view/recent_calls_page.dart new file mode 100644 index 0000000..54af12a --- /dev/null +++ b/apps/im_app/lib/features/settings/view/recent_calls_page.dart @@ -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 createState() => _RecentCallsPageState(); +} + +class _RecentCallsPageState extends ConsumerState + 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 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}'; +} diff --git a/apps/im_app/lib/features/settings/view/settings_page.dart b/apps/im_app/lib/features/settings/view/settings_page.dart index e0413c9..cab1b8c 100644 --- a/apps/im_app/lib/features/settings/view/settings_page.dart +++ b/apps/im_app/lib/features/settings/view/settings_page.dart @@ -1,6 +1,9 @@ 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/core/foundation/config.dart'; import 'package:im_app/features/settings/presentation/settings_view_model.dart'; @@ -99,13 +102,13 @@ class SettingsPage extends ConsumerWidget { icon: Icons.star_rounded, iconColor: _favoriteColor, title: '收藏', - onTap: () {}, // TODO: 收藏页 + onTap: () => context.push(AppRouteName.settingsFavorites.path), ), _RowConfig( icon: Icons.phone_rounded, iconColor: _callColor, title: '最近呼叫', - onTap: () {}, // TODO: 呼叫记录页 + onTap: () => vm.navigateToRecentCalls(context), ), _RowConfig( icon: Icons.laptop_rounded,