## 收藏(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>
555 lines
20 KiB
Dart
555 lines
20 KiB
Dart
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 对齐)
|
||
///
|
||
/// - ProfileHeroCard:72pt 渐变头像 + 昵称 + @J{uid} handle + 手机号 + bio
|
||
/// - AppBar:compact,右侧 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: [
|
||
// ── AppBar(compact,QR + 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)),
|
||
),
|
||
);
|
||
}
|
||
}
|