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:
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_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,
|
||||
|
||||
Reference in New Issue
Block a user