Initial project

This commit is contained in:
Cody
2026-03-06 14:56:17 +08:00
parent 977b627b15
commit bf9e099747
1180 changed files with 50973 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
/// 主 Tab 容器Shell 层)
///
/// 由 [StatefulShellRoute.indexedStack] 驱动,不持有任何状态。
/// Tab 切换通过 [navigationShell.goBranch] 完成go_router 负责保持各 Tab 的导航栈。
///
/// Tabsindex 顺序):
/// - 0聊天
/// - 1联系人
/// - 2设置
class AppTab extends StatelessWidget {
const AppTab({
super.key,
required this.navigationShell,
});
final StatefulNavigationShell navigationShell;
@override
Widget build(BuildContext context) {
return Scaffold(
body: navigationShell,
bottomNavigationBar: BottomNavigationBar(
currentIndex: navigationShell.currentIndex,
onTap: (index) => navigationShell.goBranch(
index,
// 再次点击已激活的 Tab 时回到该 Tab 的初始路由
initialLocation: index == navigationShell.currentIndex,
),
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.chat_bubble_outline),
activeIcon: Icon(Icons.chat_bubble),
label: '聊天',
),
BottomNavigationBarItem(
icon: Icon(Icons.people_outline),
activeIcon: Icon(Icons.people),
label: '联系人',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings),
label: '设置',
),
],
),
);
}
}

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../app/di/app_providers.dart';
import '../../../app/router/app_route_name.dart';
part 'chat_view_model.g.dart';
/// 聊天页 ViewModel@riverpod 自动生成 `chatViewModelProvider`
///
/// 当前 chat 页面为 Demo无需从服务端加载数据状态为 void。
/// 后续接入会话列表时,将 build() 改为返回会话列表状态,并在此加载数据。
///
/// ## 数据流
///
/// ```
/// ChatPage
/// → ref.read(chatViewModelProvider.notifier).someMethod(context)
/// → ★ ChatViewModel ★ ← 你在这里
/// → 导航 / 业务逻辑
/// ```
@riverpod
class ChatViewModel extends _$ChatViewModel {
@override
void build() {}
// ── 导航Demo 按钮,正式开发后随 UI 一并替换) ──────────────────────────
/// 切换到联系人 Tab。
void goToContact(BuildContext context) {
context.go(AppRouteName.contact.path);
}
/// 带 extra 参数 push 聊天详情页extra 传 Dart Record
void pushChatDetailWithExtra(BuildContext context) {
context.push(
AppRouteName.chatDetail.path,
extra: (conversationId: '42', title: 'extra 传参'),
);
}
/// 带路径参数 push 聊天详情页id 内嵌在 URL 中)。
void pushChatDetailById(BuildContext context) {
context.push(AppRouteName.chatDetailByIdPath('99'));
}
/// 无参 push演示 push 导航)。
void pushSettingsTheme(BuildContext context) {
context.push(AppRouteName.settingsTheme.path);
}
/// 切换到设置 Tab。
void goToSettings(BuildContext context) {
context.go(AppRouteName.settings.path);
}
// ── 业务 ─────────────────────────────────────────────────────────────────
/// 退出登录
///
/// 调用 [AuthNotifier.logout] 清除登录状态go_router 守卫检测到后
/// 自动重定向到登录页,无需手动导航。
void logout() {
ref.read(authNotifierProvider).logout();
}
}

View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import '../../../../core/ui/base/context_theme_ext.dart';
/// 会话详情页(路由传参 Demo
///
/// 通过 go_router 的 `extra` 接收上一页传入的数据,
/// 由 [app_router.dart] 的 builder 解包后以构造参数注入,
/// 本页不感知 GoRouter 任何实现细节。
///
/// ## 正式开发
///
/// 将 [conversationId] 传给对应的 Riverpod `.family` provider 加载完整会话数据。
/// 构造参数保持不变,数据来源从 `extra` 换成 provider 即可。
class ChatDetailPage extends StatelessWidget {
const ChatDetailPage({
super.key,
required this.conversationId,
required this.title,
});
final String conversationId;
final String title;
@override
Widget build(BuildContext context) {
final s = context.styles;
return Scaffold(
appBar: AppBar(title: Text(title)),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Text('会话 ID', style: s.labelMuted),
Text(conversationId, style: s.headlineSmall),
],
),
),
);
}
}

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/ui/components/app_button.dart';
import '../presentation/chat_view_model.dart';
/// 聊天页Demo 按钮)
///
/// 包含五个演示按钮,覆盖 go_router 的常见导航场景:
/// - 「切换 Tab」 — go替换历史不可返回
/// - 「有参 pushextra」 — push + extraDart Record可返回
/// - 「有参 push路径参数」— push + URL 内嵌 id可返回
/// - 「无参 push」 — push可返回
/// - 「退出登录」 — 守卫自动重定向到 /login
///
/// 所有操作通过 [ChatViewModel] 处理View 不直接调用路由。
/// 正式开发后替换为会话列表,按钮相关代码一并清除。
class ChatPage extends ConsumerWidget {
const ChatPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final vm = ref.read(chatViewModelProvider.notifier);
return Scaffold(
appBar: AppBar(title: const Text('聊天')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 16,
children: [
// 切换 Tab用 go替换整个历史栈不可返回
AppButton.inverse(
label: '切换 Tabgo',
onPressed: () => vm.goToContact(context),
),
// 带参数 pushextra 传 Dart Record适合已有对象的场景
AppButton.inverse(
label: '有参 pushextra',
onPressed: () => vm.pushChatDetailWithExtra(context),
),
// 带参数 pushid 内嵌在路径中,适合需要深链接 / 分享的场景
AppButton.inverse(
label: '有参 push路径参数',
onPressed: () => vm.pushChatDetailById(context),
),
// 无参 push压栈自动显示返回按钮不切 Tab
AppButton.inverse(
label: '无参 push',
onPressed: () => vm.pushSettingsTheme(context),
),
// 无参 go替换历史切换到对应 TabTabBar 可见,不可返回
AppButton.inverse(
label: '无参 go',
onPressed: () => vm.goToSettings(context),
),
AppButton.secondary(
label: '退出登录',
fullWidth: false,
onPressed: () => vm.logout(),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
/// 联系人页占位
///
/// 待 contact 功能开发后替换为实际内容。
class ContactPage extends StatelessWidget {
const ContactPage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold();
}
}

View File

@@ -0,0 +1,64 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/di/network_provider.dart';
import '../../../app/di/db_provider.dart';
import '../../../data/repositories/auth_repository_impl.dart';
import '../../../domain/repositories/auth_repository.dart';
import '../usecases/login_usecase.dart';
/// ## DI 装配Auth Feature 层
///
/// di/ 目录只放**需要手动装配的 Provider**(构造注入、回调组合等)。
/// ViewModel Provider 由 `@riverpod` 注解自动生成,不在此文件中。
///
/// Auth 模块的 DI 链路Repository → UseCase按需
/// app/di/ 只提供 SDK 基础设施apiConfig / apiClient / socketManager / storageApi
/// 业务模块的 Provider 内聚在 features/{模块}/di/ 下。
///
/// ```
/// LoginViewModel ← @riverpod 自动生成
/// → ref.read(loginUseCaseProvider) ← di/ 手动装配
/// → ref.read(authRepositoryProvider) ← di/ 手动装配
/// → ref.read(socketManagerProvider) ← app/di/ 手动装配
/// → ref.read(apiConfigProvider) ← app/di/ 手动装配
/// → ref.read(apiClientProvider) ← app/di/ 手动装配
/// → ref.read(storageSdkProvider) ← app/di/ 手动装配
/// ```
// ── Repository ────────────────────────────────────────────────────────────────
/// 认证 Repository Provider
///
/// 注入 domain 接口类型 [AuthRepository]
/// ViewModel 通过此 Provider 获取依赖,不感知具体实现。
///
/// [onTokenUpdate] 是复合回调:
/// 1. 写入 ApiConfig 内存 → 后续请求自动携带 token
/// 2. TODO: 持久化到安全存储crypto_sdk→ App 重启后恢复
/// 两个 SDK 互不依赖,由 App 层在此组合。
final authRepositoryProvider = Provider<AuthRepository>((ref) {
final apiConfig = ref.read(apiConfigProvider);
// TODO: final secureStorage = ref.read(secureStorageProvider);
return AuthRepositoryImpl(
client: ref.read(networkSdkApiProvider), // 直接注入 ApiClient
onTokenUpdate: (token) {
apiConfig.updateToken(token); // 内存network_sdk
// TODO: secureStorage.saveToken(token); // 持久化crypto_sdk
},
);
});
// ── UseCase ───────────────────────────────────────────────────────────────────
/// 登录用例 Provider
///
/// 多步编排:格式校验 → 调接口 → 写 Token → 连接 WebSocket → 打开数据库
final loginUseCaseProvider = Provider<LoginUseCase>((ref) {
return LoginUseCase(
authRepository: ref.read(authRepositoryProvider),
socketManager: ref.read(socketManagerProvider),
apiConfig: ref.read(apiConfigProvider),
storageApi: ref.read(storageSdkProvider),
);
});

View File

@@ -0,0 +1,33 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../domain/entities/user.dart';
part 'login_state.freezed.dart';
/// 登录页面状态(@freezed 自动生成 copyWith / == / toString
///
/// ViewModel 通过 `state = state.copyWith(...)` 更新状态,
/// View 通过 `ref.watch(loginViewModelProvider)` 自动响应变化。
///
/// ## 状态流转
///
/// ```
/// 初始 → LoginState() isLoading: false, user: null, error: null
/// 点击登录 → state.copyWith(isLoading: true) isLoading: true
/// 登录成功 → state.copyWith(user: user) isLoading: false, user: User
/// 格式错误 → state.copyWith(error: '邮箱格式不正确') isLoading: false, error: String
/// 网络错误 → state.copyWith(error: '网络错误') isLoading: false, error: String
/// ```
@freezed
sealed class LoginState with _$LoginState {
const factory LoginState({
/// 登录成功后的用户信息null = 未登录)
User? user,
/// 是否正在请求中(控制 loading 状态 / 按钮禁用)
@Default(false) bool isLoading,
/// 错误信息null = 无错误)
String? error,
}) = _LoginState;
}

View File

@@ -0,0 +1,96 @@
import 'package:flutter/foundation.dart';
import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/app/di/db_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:storage_sdk/storage_sdk.dart';
import '../../../app/di/app_providers.dart';
import '../di/auth_providers.dart';
import 'login_state.dart';
part 'login_view_model.g.dart';
/// 登录 ViewModel@riverpod 自动生成 `loginViewModelProvider`
///
/// `@riverpod` 注解 → build_runner 自动生成 `login_view_model.g.dart`
/// 其中包含 `loginViewModelProvider`。View 层直接使用:
///
/// ```dart
/// // View 层读取状态
/// final state = ref.watch(loginViewModelProvider);
///
/// // View 层调用方法
/// ref.read(loginViewModelProvider.notifier).login(email, password);
/// ```
///
/// ## 手动 vs 自动 Provider 对比
///
/// ```
/// loginViewModelProvider ← @riverpod 自动生成(本文件)
/// → ref.read(loginUseCaseProvider) ← di/ 手动装配
/// → ref.read(authRepositoryProvider) ← di/ 手动装配
/// → ref.read(apiClientProvider) ← app/di/ 手动装配
/// ```
///
/// ## 数据流位置
///
/// ```
/// View: ref.read(loginViewModelProvider.notifier).login(email, password)
/// → ★ LoginViewModel.login() ★ ← 你在这里
/// → LoginUseCase.execute() ← 格式校验 + 调 Repository
/// → AuthRepository.login()
/// → _client.executeRequest(LoginRequest)
/// ← LoginData → User
/// ← User
/// → state = state.copyWith(user: user) ← 更新状态
/// View: ref.watch → 自动 rebuild ← UI 刷新
/// ```
@riverpod
class LoginViewModel extends _$LoginViewModel {
@override
LoginState build() => const LoginState();
/// Demo 登录(跳过 API直接设置登录状态
///
/// 仅用于演示路由守卫行为,正式 UI 就绪后删除此方法,改用 [login]。
/// 正式 [login] 成功后同样需要调用 [AuthNotifier.login] 更新守卫状态。
Future<void> demoLogin() async {
final storageApi = ref.read(storageSdkProvider);
///TODO: StorageSDKLifeCycle 需要只在主项目暴露
final storageLifeCycle = storageApi as StorageSdkLifecycle;
ref.read(authNotifierProvider).login();
await storageLifeCycle.openDatabase(1234567);
final rows = await storageApi.rawQuery("PRAGMA table_info('user')");
for (final row in rows) {
debugPrint('Schema: ${row.data}');
}
}
/// 执行登录
///
/// 1. 设置 loading 状态UI 显示加载指示器、禁用按钮)
/// 2. 调 UseCase格式校验 → 登录 → 返回 User
/// 3. 成功:写入 user失败写入 error
Future<void> login(String email, String password) async {
state = state.copyWith(isLoading: true, error: null);
try {
final user = await ref.read(loginUseCaseProvider).execute(
email: email,
password: password,
);
state = state.copyWith(user: user, isLoading: false);
} on FormatException catch (e) {
// 格式校验失败UseCase 层抛出)
state = state.copyWith(error: e.message, isLoading: false);
} on ApiError catch (e) {
// 网络 / 服务端错误Repository → SDK 透传)
state = state.copyWith(error: e.displayMessage, isLoading: false);
} catch (e) {
// 兜底:防止未预期的异常导致 isLoading 死锁
state = state.copyWith(error: e.toString(), isLoading: false);
}
}
}

View File

@@ -0,0 +1,117 @@
import 'package:networks_sdk/networks_sdk.dart';
import 'package:storage_sdk/storage_sdk.dart';
import '../../../core/services/socket_manager.dart';
import '../../../domain/entities/user.dart';
import '../../../domain/repositories/auth_repository.dart';
/// 登录用例
///
/// 封装登录的完整业务流程:
/// 格式校验 → 调 Repository 登录 → 初始化 WebSocket → 打开本地数据库 → 返回 User
///
/// ## 为什么需要 UseCase
///
/// ViewModel 直接调 Repository 也能跑通,但登录有明确的多步业务规则:
/// - 格式校验(不发无效请求,省流量、减少服务端压力)
/// - 登录后初始化 WebSocket 连接
/// - 登录后按 user id 打开对应的本地数据库
///
/// 把这些规则封装在 UseCase 里ViewModel 只需一行调用。
///
/// ## 数据流位置
///
/// ```
/// LoginViewModel.login(email, password)
/// → ★ LoginUseCase.execute() ★ ← 你在这里
/// → 格式校验(邮箱 + 密码)
/// → AuthRepository.login()
/// → AuthRepositoryImpl.login()
/// → _client.executeRequest(LoginRequest)
/// ← LoginDataDTO
/// → _onTokenUpdate(token) ← 回调写入 Token内存 + 持久化,由 Provider 层组合)
/// ← LoginData.toEntity() → User
/// → SocketManager.connect(token) ← 登录后连接 WebSocket
/// → StorageSdkApi.openDatabase(user.id) ← 按用户 id 打开本地库
/// ← User
/// ```
class LoginUseCase {
final AuthRepository _authRepository;
final SocketManager _socketManager;
final ApiConfig _apiConfig;
final StorageSdkApi _storageApi;
StorageSdkLifecycle get _storageLifeCycle => _storageApi as StorageSdkLifecycle;
LoginUseCase({
required AuthRepository authRepository,
required SocketManager socketManager,
required ApiConfig apiConfig,
required StorageSdkApi storageApi,
}) : _authRepository = authRepository,
_socketManager = socketManager,
_apiConfig = apiConfig,
_storageApi = storageApi;
/// 执行登录
///
/// 1. 格式校验 → 不合法直接抛 [FormatException]
/// 2. 调 Repository 登录 → 拿到 Usertoken 写入由 Repository 处理)
/// 3. 用已存入 ApiConfig 的 token 连接 WebSocket
/// 4. 按 user id 打开本地数据库
///
/// 抛出:
/// - [FormatException] — 邮箱或密码格式不合法
/// - [ApiError] — 网络/服务端错误(由 Repository 透传)
Future<User> execute({
required String email,
required String password,
}) async {
// ── 1. 格式校验 ──
_validateEmail(email);
_validatePassword(password);
// ── 2. 登录 ──
final user = await _authRepository.login(
email: email,
password: password,
);
// ── 3. 连接 WebSocket ──
// token 在 Repository 的 _onTokenUpdate 回调中已写入 ApiConfig
// 此处直接读取,避免改动现有接口。
final token = _apiConfig.token;
if (token != null && token.isNotEmpty) {
await _socketManager.connect(token: token);
}
// ── 4. 打开数据库 ──
// TODO: 当服务端返回整型 uid 时,换成 user.uid目前用 hashCode 作为临时标识。
await _storageLifeCycle.openDatabase(user.hashCode);
// TODO: 后续扩展点
// - 同步联系人列表
// - 注册推送 token
return user;
}
void _validateEmail(String email) {
if (email.trim().isEmpty) {
throw const FormatException('邮箱不能为空'); // TODO: 接入国际化
}
final emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
if (!emailRegex.hasMatch(email.trim())) {
throw const FormatException('邮箱格式不正确'); // TODO: 接入国际化
}
}
void _validatePassword(String password) {
if (password.isEmpty) {
throw const FormatException('密码不能为空'); // TODO: 接入国际化
}
if (password.length < 6) {
throw const FormatException('密码长度不能少于 6 位'); // TODO: 接入国际化
}
}
}

View File

@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/ui/base/context_theme_ext.dart';
import '../presentation/login_view_model.dart';
/// 登录页Demo
///
/// 演示 go_router 登录守卫:点击「登录」后经由 [LoginViewModel.demoLogin]
/// 触发 [GoRouter.refreshListenable],守卫重新执行并重定向到 /chat。
///
/// 正式实现时替换为完整登录流程email/password 输入 → LoginViewModel.login
class LoginPage extends ConsumerWidget {
const LoginPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final s = context.styles;
return Scaffold(
appBar: AppBar(
title: const Text('登录'),
automaticallyImplyLeading: false,
),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('IM_Demo', style: s.titleMedium),
const SizedBox(height: 8),
Text(
'未登录时任意路由均被重定向到此页 \n 主要是为了展示路由守卫的功能 \n 后续路由守卫专门处理各种跳转前的逻辑判断',
style: s.bodySmall,
),
const SizedBox(height: 32),
FilledButton(
onPressed: () => ref.read(loginViewModelProvider.notifier).demoLogin(),
child: const Text('登录'),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,21 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../usecases/set_theme_usecase.dart';
/// Settings feature DI 装配
///
/// 手动装配 UseCase ProviderViewModel 通过此处获取依赖。
///
/// ```
/// ThemeViewModel
/// → ref.read(setThemeUseCaseProvider) ← 此处装配
/// → SetThemeUseCase幂等校验
/// → onApply → ThemeModeNotifier.setMode()(内存 + 持久化 TODO
/// ```
// ── UseCase ───────────────────────────────────────────────────────────────────
/// 设置主题用例 Provider
final setThemeUseCaseProvider = Provider<SetThemeUseCase>(
(_) => const SetThemeUseCase(),
);

View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../app/router/app_route_name.dart';
part 'settings_view_model.g.dart';
/// 设置页 ViewModel
///
/// ## 数据流位置
///
/// ```
/// SettingsPage
/// → ref.read(settingsViewModelProvider.notifier).navigateToTheme(context)
/// → ★ SettingsViewModel.navigateToTheme() ★ ← 你在这里
/// → context.push(AppRouteName.settingsTheme.path)
/// ```
///
/// 导航意图由 ViewModel 统一管理View 不直接调用路由。
@riverpod
class SettingsViewModel extends _$SettingsViewModel {
@override
void build() {}
/// 跳转到主题设置页。
void navigateToTheme(BuildContext context) {
context.push(AppRouteName.settingsTheme.path);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../app/di/app_providers.dart';
import '../di/settings_providers.dart';
part 'theme_view_model.g.dart';
/// 主题 ViewModel
///
/// View 层只感知此 ViewModel不直接依赖 app 级 Provider。
///
/// ## 数据流
///
/// ```
/// ThemeView
/// → ref.watch(themeViewModelProvider) ← 当前 ThemeMode
/// → ref.read(themeViewModelProvider.notifier).setMode(mode)
/// → ★ ThemeViewModel.setMode() ★ ← 你在这里
/// → SetThemeUseCase.execute()
/// → 幂等校验(相同模式直接返回)
/// → onApply → ThemeModeNotifier.setMode() ← 更新内存状态
/// → TODO: 持久化storage_sdk
/// ```
@riverpod
class ThemeViewModel extends _$ThemeViewModel {
@override
ThemeMode build() => ref.watch(themeModeProvider);
void setMode(ThemeMode mode) {
ref.read(setThemeUseCaseProvider).execute(
current: state,
requested: mode,
onApply: (m) => ref.read(themeModeProvider.notifier).setMode(m),
);
}
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
/// 设置主题用例
///
/// 职责:幂等校验——当前模式与目标模式相同时直接返回,不触发任何变更。
///
/// 持久化由 [ThemeModeNotifier.setMode] 负责(在 `onApply` 被调用后执行),
/// UseCase 不感知存储细节。
///
/// ## 数据流
///
/// ```
/// ThemeViewModel.setMode(mode)
/// → ★ SetThemeUseCase.execute() ★ ← 你在这里
/// → 幂等校验(相同模式 → 直接返回)
/// → onApply(mode)
/// → ThemeModeNotifier.setMode() ← 更新内存 + 写入持久化TODO
/// ```
class SetThemeUseCase {
const SetThemeUseCase();
/// 执行主题切换
///
/// [current] 当前生效的主题模式
/// [requested] 用户选择的目标模式
/// [onApply] 校验通过后回调,由 ViewModel 负责调用 ThemeModeNotifier
void execute({
required ThemeMode current,
required ThemeMode requested,
required void Function(ThemeMode mode) onApply,
}) {
if (current == requested) return;
onApply(requested);
}
}

View File

@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../presentation/settings_view_model.dart';
/// 设置页
///
/// 所有用户操作通过 [SettingsViewModel] 处理View 不直接调用路由。
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: const Text('设置'),
),
body: ListView(
children: [
ListTile(
title: const Text('主题'),
trailing: const Icon(Icons.chevron_right),
onTap: () => ref
.read(settingsViewModelProvider.notifier)
.navigateToTheme(context),
),
],
),
);
}
}

View File

@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../presentation/theme_view_model.dart';
import 'widgets/settings_section_header.dart';
import 'widgets/theme_option_tile.dart';
/// 主题选择页
///
/// 通过 [ThemeViewModel] 读写主题状态,不直接感知 app 级 Provider。
class ThemeView extends ConsumerWidget {
const ThemeView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final current = ref.watch(themeViewModelProvider);
return Scaffold(
appBar: AppBar(
title: const Text('主题'),
),
body: ListView(
children: [
const SettingsSectionHeader(title: '外观'),
ThemeOptionTile(
label: '跟随系统',
mode: ThemeMode.system,
current: current,
onTap: () => ref
.read(themeViewModelProvider.notifier)
.setMode(ThemeMode.system),
),
ThemeOptionTile(
label: '黑色模式',
mode: ThemeMode.dark,
current: current,
onTap: () => ref
.read(themeViewModelProvider.notifier)
.setMode(ThemeMode.dark),
),
ThemeOptionTile(
label: '白色模式',
mode: ThemeMode.light,
current: current,
onTap: () => ref
.read(themeViewModelProvider.notifier)
.setMode(ThemeMode.light),
),
],
),
);
}
}

View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import '../../../../../core/ui/base/context_theme_ext.dart';
/// 设置页分组标题
///
/// 用于在列表中区分配置分组,如「外观」、「通知」。
/// 文字颜色跟随 [ColorScheme.primary],自带上下留白。
///
/// 用法:
/// ```dart
/// const SettingsSectionHeader(title: '外观')
/// ```
class SettingsSectionHeader extends StatelessWidget {
const SettingsSectionHeader({
super.key,
required this.title,
});
final String title;
@override
Widget build(BuildContext context) {
final s = context.styles;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 4),
child: Text(title, style: s.sectionLabel),
);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import '../../../../../core/ui/base/context_theme_ext.dart';
/// 单个主题选项行
///
/// 纯展示 + 事件透传,不感知任何 Provider。
/// 由父级传入 [current] 判断选中状态,[onTap] 处理切换。
///
/// 用法:
/// ```dart
/// ThemeOptionTile(
/// label: '黑色模式',
/// mode: ThemeMode.dark,
/// current: current,
/// onTap: () => ref.read(themeViewModelProvider.notifier).setMode(ThemeMode.dark),
/// )
/// ```
class ThemeOptionTile extends StatelessWidget {
const ThemeOptionTile({
super.key,
required this.label,
required this.mode,
required this.current,
required this.onTap,
});
final String label;
final ThemeMode mode;
final ThemeMode current;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final s = context.styles;
final isSelected = current == mode;
return ListTile(
title: Text(label),
trailing: isSelected ? Icon(Icons.check, color: s.primary) : null,
onTap: onTap,
);
}
}