Files
pp-bot 8744e2c0b7 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>
2026-03-24 20:30:56 +09:00

555 lines
20 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
/// 我的页(#39 / #40 / #41
///
/// ## 结构iOS SettingsView.swift 对齐)
///
/// - ProfileHeroCard72pt 渐变头像 + 昵称 + @J{uid} handle + 手机号 + bio
/// - AppBarcompact右侧 QR 图标 + 编辑铅笔
/// - 卡片组 1账户我的钱包 / 账户安全
/// - 卡片组 2工具收藏 / 最近呼叫 / 链接设备 / 聊天文件夹
/// - 卡片组 3「偏好设置」通知和声音 / 隐私设置 / 黑名单 / 语言 / 主题
/// - 卡片组 4「关于」用户协议 / 隐私政策 / 版本号
/// - 退出登录(全宽红色按钮)
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
// ── iOS 色板 ──────────────────────────────────────────────────────────────
static const Color _walletColor = Color(0xFFFFAA5B);
static const Color _securityColor = Color(0xFF8A5CF6);
static const Color _favoriteColor = Color(0xFFFFAF45);
static const Color _callColor = Color(0xFF4CB050);
static const Color _deviceColor = Color(0xFF5667FF);
static const Color _folderColor = Color(0xFFF2994A);
static const Color _notifColor = Color(0xFFFF8B5E);
static const Color _privacyColor = Color(0xFF0BB8A9);
static const Color _blockColor = Color(0xFFFF4B4B);
static const Color _langColor = Color(0xFF5667FF);
static const Color _themeColor = Color(0xFF8A5CF6);
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(settingsViewModelProvider);
final vm = ref.read(settingsViewModelProvider.notifier);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLowest,
body: CustomScrollView(
slivers: [
// ── AppBarcompactQR + edit ────────────────────────────────────
SliverAppBar(
title: const Text('我的'),
pinned: true,
automaticallyImplyLeading: false,
actions: [
IconButton(
icon: const Icon(Icons.qr_code_scanner_rounded),
tooltip: '我的二维码',
onPressed: () {}, // TODO: QR code page
),
IconButton(
icon: const Icon(Icons.edit_outlined),
tooltip: '编辑资料',
onPressed: () => vm.navigateToEditProfile(context),
),
const SizedBox(width: 4),
],
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── ProfileHeroCard ───────────────────────────────────────
_ProfileHeroCard(
state: state,
onTap: () => vm.navigateToEditProfile(context),
),
const SizedBox(height: 16),
// ── 卡片组 1账户 ────────────────────────────────────────
_SettingsCard(
items: [
_RowConfig(
icon: Icons.credit_card_rounded,
iconColor: _walletColor,
title: '我的钱包',
onTap: () {}, // TODO: 钱包页
),
_RowConfig(
icon: Icons.shield_rounded,
iconColor: _securityColor,
title: '账户安全',
onTap: () {}, // TODO: 账户安全页
),
],
),
const SizedBox(height: 16),
// ── 卡片组 2工具 ────────────────────────────────────────
_SettingsCard(
items: [
_RowConfig(
icon: Icons.star_rounded,
iconColor: _favoriteColor,
title: '收藏',
onTap: () => context.push(AppRouteName.settingsFavorites.path),
),
_RowConfig(
icon: Icons.phone_rounded,
iconColor: _callColor,
title: '最近呼叫',
onTap: () => vm.navigateToRecentCalls(context),
),
_RowConfig(
icon: Icons.laptop_rounded,
iconColor: _deviceColor,
title: '链接设备',
onTap: () {}, // TODO: 设备管理页
),
_RowConfig(
icon: Icons.folder_rounded,
iconColor: _folderColor,
title: '聊天文件夹',
onTap: () {}, // TODO: Issue #11
),
],
),
const SizedBox(height: 16),
// ── 卡片组 3偏好设置 ────────────────────────────────────
_SectionLabel('偏好设置'),
_SettingsCard(
items: [
_RowConfig(
icon: Icons.notifications_rounded,
iconColor: _notifColor,
title: '通知和声音',
onTap: () {}, // TODO: 通知设置页
),
_RowConfig(
icon: Icons.lock_rounded,
iconColor: _privacyColor,
title: '隐私设置',
onTap: () {}, // TODO: 隐私设置页
),
_RowConfig(
icon: Icons.block_rounded,
iconColor: _blockColor,
title: '黑名单',
onTap: () => vm.navigateToBlocklist(context),
),
_RowConfig(
icon: Icons.language_rounded,
iconColor: _langColor,
title: '语言',
onTap: () => vm.navigateToLanguage(context),
),
_RowConfig(
icon: Icons.palette_rounded,
iconColor: _themeColor,
title: '主题',
onTap: () => vm.navigateToTheme(context),
),
],
),
const SizedBox(height: 16),
// ── 卡片组 4关于 ────────────────────────────────────────
_SectionLabel('关于'),
_SettingsCard(
items: [
_RowConfig(
icon: Icons.description_outlined,
iconColor: Colors.grey,
title: '用户协议',
onTap: () => vm.navigateToAbout(context),
),
_RowConfig(
icon: Icons.privacy_tip_outlined,
iconColor: Colors.grey,
title: '隐私政策',
onTap: () => vm.navigateToAbout(context),
),
_RowConfig(
icon: Icons.info_outline_rounded,
iconColor: Colors.grey,
title: '版本号',
subtitle: AppConfig.appVersion.isEmpty
? '1.0.0'
: AppConfig.appVersion,
onTap: () {},
showChevron: false,
),
],
),
const SizedBox(height: 24),
// ── 退出登录 ───────────────────────────────────────────────
_LogoutButton(
isLoading: state.isLoggingOut,
onTap: () => _confirmLogout(context, ref),
),
const SizedBox(height: 32),
],
),
),
),
],
),
);
}
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('退出登录'),
content: const Text('确定要退出当前账号吗?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('退出'),
),
],
),
);
if (confirmed == true) {
ref.read(settingsViewModelProvider.notifier).logout();
}
}
}
// ── ProfileHeroCard (#39 / #41) ───────────────────────────────────────────────
/// iOS SettingsView.swift ProfileHeroCard 对齐
///
/// 8 色渐变占位uid % 8+ 昵称 + @J{uid} handle + 手机号 + bio
class _ProfileHeroCard extends StatelessWidget {
const _ProfileHeroCard({required this.state, required this.onTap});
final SettingsState state;
final VoidCallback onTap;
// 8 色渐变方案iOS 端 ProfileHeroCard 同款)
static const _gradients = [
[Color(0xFF4776E6), Color(0xFF8E54E9)], // 0: 蓝紫
[Color(0xFF11998E), Color(0xFF38EF7D)], // 1: 青绿
[Color(0xFFFC466B), Color(0xFF3F5EFB)], // 2: 粉蓝
[Color(0xFFF7971E), Color(0xFFFFD200)], // 3: 橙黄
[Color(0xFF56CCF2), Color(0xFF2F80ED)], // 4: 天蓝
[Color(0xFFEB3349), Color(0xFFF45C43)], // 5: 红橙
[Color(0xFF1FA2FF), Color(0xFF12D8FA)], // 6: 蓝青
[Color(0xFF9D50BB), Color(0xFF6E48AA)], // 7: 深紫
];
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_HeroAvatar(
avatarUrl: state.avatarUrl,
uid: state.uid,
isLoading: state.isLoading,
),
const SizedBox(width: 16),
Expanded(
child: state.isLoading
? _ProfileSkeleton()
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.nickname.isEmpty ? '加载中…' : state.nickname,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 2),
if (state.uid > 0)
Text(
'@J${state.uid}',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: cs.onSurfaceVariant),
),
if (state.maskedContact.isNotEmpty)
Text(
state.maskedContact,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: cs.onSurfaceVariant),
),
const SizedBox(height: 2),
Text(
state.bio.isNotEmpty ? state.bio : '添加一句话简介',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: state.bio.isNotEmpty
? cs.onSurfaceVariant
: cs.onSurfaceVariant.withOpacity(0.5),
fontStyle: state.bio.isEmpty
? FontStyle.italic
: FontStyle.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const Icon(Icons.chevron_right, color: Colors.grey),
],
),
),
),
);
}
}
class _HeroAvatar extends StatelessWidget {
const _HeroAvatar({
required this.avatarUrl,
required this.uid,
required this.isLoading,
});
final String? avatarUrl;
final int uid;
final bool isLoading;
@override
Widget build(BuildContext context) {
const radius = 36.0; // 72pt diameter
if (isLoading) {
return const CircleAvatar(
radius: radius,
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
if (avatarUrl != null && avatarUrl!.isNotEmpty) {
return CircleAvatar(
radius: radius,
backgroundImage: NetworkImage(avatarUrl!),
);
}
// 渐变占位uid % 8
final colors = _ProfileHeroCard._gradients[uid.abs() % 8];
return Container(
width: radius * 2,
height: radius * 2,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: colors,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: const Icon(Icons.person, size: 36, color: Colors.white),
);
}
}
class _ProfileSkeleton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 16,
width: 120,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 8),
Container(
height: 12,
width: 80,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(4),
),
),
],
);
}
}
// ── 设置分组 (#40) ─────────────────────────────────────────────────────────────
class _SectionLabel extends StatelessWidget {
const _SectionLabel(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Text(
text,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
);
}
}
class _RowConfig {
const _RowConfig({
required this.icon,
required this.iconColor,
required this.title,
this.subtitle,
required this.onTap,
this.showChevron = true,
});
final IconData icon;
final Color iconColor;
final String title;
final String? subtitle;
final VoidCallback onTap;
final bool showChevron;
}
class _SettingsCard extends StatelessWidget {
const _SettingsCard({required this.items});
final List<_RowConfig> items;
@override
Widget build(BuildContext context) {
return Card(
child: Column(
children: [
for (int i = 0; i < items.length; i++) ...[
if (i > 0)
Divider(
height: 1,
indent: 60,
color: Theme.of(context).dividerColor.withOpacity(0.5),
),
_SettingsRow(config: items[i]),
],
],
),
);
}
}
/// iOS 风格行36pt 彩色圆角正方形图标 + 标题 + 可选副标题 + chevron
class _SettingsRow extends StatelessWidget {
const _SettingsRow({required this.config});
final _RowConfig config;
@override
Widget build(BuildContext context) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
leading: _IconBox(icon: config.icon, color: config.iconColor),
title: Text(config.title),
subtitle: config.subtitle != null
? Text(
config.subtitle!,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: Colors.grey),
)
: null,
trailing: config.showChevron
? const Icon(Icons.chevron_right, size: 18, color: Colors.grey)
: null,
onTap: config.onTap,
);
}
}
/// 36pt 圆角正方形彩色图标盒iOS Settings icon style
class _IconBox extends StatelessWidget {
const _IconBox({required this.icon, required this.color});
final IconData icon;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: Colors.white, size: 20),
);
}
}
// ── 退出登录 ───────────────────────────────────────────────────────────────────
class _LogoutButton extends StatelessWidget {
const _LogoutButton({required this.isLoading, required this.onTap});
final bool isLoading;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: isLoading ? null : onTap,
style: FilledButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2),
)
: const Text('退出登录', style: TextStyle(fontSize: 16)),
),
);
}
}