通过网盘分享的文件:flutter1.zip
链接: https://pan.baidu.com/s/1jkLZ9mZXjNm0LgP6FTVRzw 提取码: 2t97

个人中心这个页面,说实话一开始我觉得挺简单的,不就是放几个按钮嘛。但真正做起来才发现,要把它做好还真得花点心思。用户信息怎么展示、统计数据怎么实时更新、菜单列表怎么组织、子页面怎么跳转,每个细节都值得琢磨。

这篇文章会把个人中心模块拆开来讲,从主页面到各个子页面,代码都是项目里实际跑着的。


请添加图片描述

先聊聊个人中心要做什么

打开任何一个App的"我的"页面,你会发现它们的结构都差不多:顶部是用户头像和昵称,中间是一些统计数据,下面是功能入口列表。这种设计已经被验证过无数次了,用户一看就懂,不需要学习成本。

我们的个人中心也采用这个经典布局,但会加入一些自己的特色,比如统计卡片会实时显示收藏和历史记录的数量,主题切换会立即生效等等。


依赖引入

先看看主页面需要引入哪些东西:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

这两个是基础依赖,material.dart 提供 UI 组件,provider 用来做状态管理。

import '../providers/theme_provider.dart';
import '../providers/history_provider.dart';
import '../providers/favorites_provider.dart';

这三个 Provider 分别管理主题设置、观看历史、收藏列表。个人中心需要从它们那里读取数据来显示统计数字。

import 'history_screen.dart';
import 'settings_screen.dart';
import 'about_screen.dart';
import 'help_screen.dart';

这些是子页面的导入,每个 import 对应一个功能入口。实际项目里还有更多,比如排行榜、分类、时间表等,这里就不全列出来了。


页面用 StatelessWidget 还是 StatefulWidget

class ProfileScreen extends StatelessWidget {
  const ProfileScreen({super.key});

个人中心主页面用的是 StatelessWidget。你可能会问,页面上不是有统计数据吗,那不是状态吗?

确实是状态,但这些状态不是由这个页面管理的,而是由 Provider 管理的。页面只是"读取"这些数据并显示出来,当 Provider 里的数据变化时,Consumer 会自动触发重建。所以页面本身不需要维护任何状态,用 StatelessWidget 就够了。

这个设计思路很重要:把状态放到该放的地方,页面只负责展示


整体布局结构

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('我的')),
      body: SingleChildScrollView(
        child: Column(
          children: [

Scaffold 是 Flutter 页面的标准骨架,提供了 AppBar、body、floatingActionButton 等插槽。

SingleChildScrollView 包裹整个内容区域,这样当内容超出屏幕高度时可以滚动。有些同学喜欢用 ListView,但 ListView 更适合列表场景,这里用 Column 组织不同类型的内容块更合适。


用户头像区域

头像用一个渐变色的圆形容器来实现:

Container(
  width: 80,
  height: 80,
  decoration: BoxDecoration(
    shape: BoxShape.circle,
    gradient: LinearGradient(
      colors: [
        Theme.of(context).primaryColor,
        Theme.of(context).colorScheme.secondary,
      ],
    ),
  ),

BoxDecorationshape: BoxShape.circle 让容器变成圆形。渐变色用 LinearGradient,从主题的主色渐变到次色。

为什么要用主题色而不是写死颜色值?因为这样头像颜色会跟随主题变化。用户切换到深色模式时,头像的渐变色也会自动适配,不需要额外处理。

  child: const Icon(
    Icons.person,
    size: 40,
    color: Colors.white,
  ),
),

头像里放一个人形图标,白色的,在渐变背景上很显眼。如果后续要接入用户系统,可以把这个 Icon 换成 Image.network 加载用户真实头像。


用户昵称和欢迎语

const SizedBox(height: 16),
const Text(
  '动漫迷',
  style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),

昵称用 20号字体加粗,在页面上比较突出。SizedBox(height: 16) 是头像和昵称之间的间距,比用 Padding 更简洁。

const SizedBox(height: 8),
const Text(
  '欢迎来到微动漫',
  style: TextStyle(color: Colors.grey),
),

欢迎语用灰色小字,作为昵称的补充说明。这种"大字+小字"的组合在 UI 设计里很常见,能形成视觉层次。


统计卡片的实现

统计卡片是个人中心的亮点,能让用户一眼看到自己的数据:

Widget _buildStatCard(BuildContext context) {
  return Consumer2<FavoritesProvider, HistoryProvider>(
    builder: (context, favProvider, histProvider, _) {

这里用了 Consumer2,它可以同时监听两个 Provider。为什么不用两个嵌套的 Consumer?因为那样代码会很丑,Consumer2 就是为这种场景设计的。

当收藏列表或历史记录有变化时,这个 builder 函数会被重新调用,卡片上的数字会自动更新。用户在详情页点了收藏,回到个人中心,数字已经变了,不需要手动刷新。

      return Card(
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            children: [
              _buildStat('收藏', favProvider.favorites.length.toString()),
              _buildStat('历史', histProvider.history.length.toString()),
              _buildStat('追番', '0'),
            ],
          ),
        ),
      );
    },
  );
}

Card 组件自带圆角和阴影,不需要额外设置。Row 配合 MainAxisAlignment.spaceAround 让三个统计项均匀分布,视觉效果很平衡。

favorites.lengthhistory.length 直接从 Provider 读取,转成字符串显示。"追番"功能还没做,先写个 0 占位。


单个统计项的构建

Widget _buildStat(String label, String value) {
  return Column(
    children: [
      Text(
        value,
        style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
      ),

数字用 大号粗体,这是最重要的信息,要最显眼。

      const SizedBox(height: 4),
      Text(label, style: const TextStyle(color: Colors.grey, fontSize: 12)),
    ],
  );
}

标签用 小号灰色字体,作为数字的说明。这种"数字在上、标签在下"的布局是统计卡片的标准设计,用户一眼就能看到关键数据。


功能菜单列表

菜单列表是个人中心的核心,提供各种功能的入口:

Widget _buildMenuSection(BuildContext context) {
  return Column(
    children: [
      _buildMenuItem(
        context,
        icon: Icons.trending_up,
        title: '趋势',
        onTap: () => Navigator.push(
          context,
          MaterialPageRoute(builder: (_) => const TrendingScreen()),
        ),
      ),

每个菜单项都是一个 _buildMenuItem 调用,传入三个参数:图标标题点击回调

点击后用 Navigator.push 跳转到对应的子页面。这是 Flutter 最基本的页面跳转方式,简单直接。

      _buildMenuItem(
        context,
        icon: Icons.history,
        title: '观看历史',
        onTap: () => Navigator.push(
          context,
          MaterialPageRoute(builder: (_) => const HistoryScreen()),
        ),
      ),

观看历史是用户常用的功能,放在比较靠前的位置。

      _buildMenuItem(
        context,
        icon: Icons.settings,
        title: '设置',
        onTap: () => Navigator.push(
          context,
          MaterialPageRoute(builder: (_) => const SettingsScreen()),
        ),
      ),

设置页面提供主题切换等功能,是个人中心的标配。

      _buildMenuItem(
        context,
        icon: Icons.help,
        title: '帮助',
        onTap: () => Navigator.push(
          context,
          MaterialPageRoute(builder: (_) => const HelpScreen()),
        ),
      ),
      _buildMenuItem(
        context,
        icon: Icons.info,
        title: '关于',
        onTap: () => Navigator.push(
          context,
          MaterialPageRoute(builder: (_) => const AboutScreen()),
        ),
      ),
    ],
  );
}

帮助和关于放在最后,这两个功能使用频率较低,但不能没有。


菜单项组件的实现

Widget _buildMenuItem(
  BuildContext context, {
  required IconData icon,
  required String title,
  required VoidCallback onTap,
}) {
  return Card(
    margin: const EdgeInsets.only(bottom: 8),

每个菜单项用 Card 包裹,自带圆角和阴影效果。margin: const EdgeInsets.only(bottom: 8) 让卡片之间有间距,不会挤在一起。

    child: ListTile(
      leading: Icon(icon, color: Theme.of(context).primaryColor),
      title: Text(title),
      trailing: const Icon(Icons.chevron_right),
      onTap: onTap,
    ),
  );
}

ListTile 是 Material Design 的标准列表项组件,自带 leading、title、trailing 三个插槽,正好对应我们需要的"图标-标题-箭头"布局。

左侧图标用主题色,保持视觉统一。右侧箭头提示用户这是可点击的,点击后会跳转到新页面。


设置页面的实现

设置页面主要提供主题切换功能,需要管理选中状态:

class SettingsScreen extends StatefulWidget {
  const SettingsScreen({super.key});

  
  State<SettingsScreen> createState() => _SettingsScreenState();
}

这里用 StatefulWidget,因为需要维护当前选中的主题选项。

class _SettingsScreenState extends State<SettingsScreen> {
  String _selectedTheme = 'system';

_selectedTheme 存储当前选中的主题,默认是"跟随系统"。这个变量用来控制 RadioListTile 的选中状态。


初始化时同步主题状态

页面打开时,需要从 Provider 获取当前主题,保证显示的选中状态和实际主题一致:


void initState() {
  super.initState();
  WidgetsBinding.instance.addPostFrameCallback((_) {
    final themeProvider = context.read<ThemeProvider>();

为什么要用 addPostFrameCallback?因为 initState 里不能直接访问 context,会报错。这个回调会在第一帧渲染完成后执行,那时候 context 已经可用了。

    setState(() {
      switch (themeProvider.themeMode) {
        case ThemeMode.light:
          _selectedTheme = 'light';
          break;
        case ThemeMode.dark:
          _selectedTheme = 'dark';
          break;
        default:
          _selectedTheme = 'system';
      }
    });
  });
}

根据 ThemeMode 枚举值设置对应的字符串。这样 RadioListTile 才能正确显示选中状态,用户一进页面就能看到当前用的是哪个主题。


主题切换的处理

void _changeTheme(String? theme) {
  if (theme == null) return;
  setState(() {
    _selectedTheme = theme;
  });
  context.read<ThemeProvider>().setTheme(theme);
}

这个方法做两件事:

  1. 更新本地状态,让 RadioListTile 立即显示新的选中状态
  2. 调用 Provider 的 setTheme 方法,真正切换主题并持久化

先 setState 再调 Provider,这样用户点击后能立即看到选中状态变化,体验更流畅。


主题选项的 UI

RadioListTile<String>(
  title: const Text('浅色主题'),
  value: 'light',
  groupValue: _selectedTheme,
  onChanged: _changeTheme,
),

RadioListTile 是带单选按钮的列表项,非常适合这种互斥选择的场景。

value 是这个选项代表的值,groupValue 是当前选中的值。当 value == groupValue 时,这个选项显示为选中状态。

RadioListTile<String>(
  title: const Text('深色主题'),
  value: 'dark',
  groupValue: _selectedTheme,
  onChanged: _changeTheme,
),
RadioListTile<String>(
  title: const Text('跟随系统'),
  value: 'system',
  groupValue: _selectedTheme,
  onChanged: _changeTheme,
),

三个选项覆盖了用户的主要需求:强制浅色强制深色跟随系统。大多数用户会选择跟随系统,这样白天自动浅色、晚上自动深色。


ThemeProvider 的实现

主题切换的核心是 ThemeProvider:

class ThemeProvider extends ChangeNotifier {
  ThemeMode _themeMode = ThemeMode.system;

  ThemeMode get themeMode => _themeMode;

继承 ChangeNotifier 让这个类具备通知监听者的能力。_themeMode 存储当前主题模式,对外只暴露 getter,不允许直接修改。

  ThemeProvider() {
    _loadTheme();
  }

构造函数里调用 _loadTheme,从本地存储加载之前保存的主题设置。这样 App 重启后主题不会丢失。


加载保存的主题

Future<void> _loadTheme() async {
  try {
    await StorageService.instance.init();
    final theme = StorageService.instance.getString('theme') ?? 'system';
    _themeMode = _getThemeMode(theme);
    notifyListeners();
  } catch (e) {
    print('加载主题错误: $e');
  }
}

从本地存储读取 theme 这个 key,如果没有就默认 system。然后转换成 ThemeMode 枚举,调用 notifyListeners 通知监听者。


保存主题设置

void setTheme(String theme) {
  _themeMode = _getThemeMode(theme);
  StorageService.instance.setString('theme', theme);
  notifyListeners();
}

这个方法同时做三件事:更新内存状态写入本地存储通知监听者。调用后整个 App 的主题会立即切换。

ThemeMode _getThemeMode(String theme) {
  switch (theme) {
    case 'light':
      return ThemeMode.light;
    case 'dark':
      return ThemeMode.dark;
    default:
      return ThemeMode.system;
  }
}

这个辅助方法把字符串转换成 ThemeMode 枚举,在加载和保存时都会用到。


观看历史页面

历史记录页面展示用户浏览过的动漫:

class HistoryScreen extends StatelessWidget {
  const HistoryScreen({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('观看历史'),
        actions: [

AppBar 右侧要放一个清空按钮,但只在有历史记录时才显示。

          Consumer<HistoryProvider>(
            builder: (context, provider, _) => provider.history.isNotEmpty
                ? IconButton(
                    icon: const Icon(Icons.delete_sweep),
                    onPressed: () {

Consumer 监听 HistoryProvider,根据 history.isNotEmpty 决定是否显示按钮。这样当历史记录被清空后,按钮会自动消失。


清空确认对话框

清空是个危险操作,需要二次确认:

showDialog(
  context: context,
  builder: (_) => AlertDialog(
    title: const Text('清空历史'),
    content: const Text('确定要清空所有观看历史吗?'),

AlertDialog 是 Material Design 的标准对话框组件,包含标题和内容。

    actions: [
      TextButton(
        onPressed: () => Navigator.pop(context),
        child: const Text('取消'),
      ),
      TextButton(
        onPressed: () {
          provider.clearHistory();
          Navigator.pop(context);
        },
        child: const Text('确定'),
      ),
    ],
  ),
);

两个按钮:取消和确定。点取消只是关闭对话框,点确定会调用 clearHistory 清空数据,然后关闭对话框。


历史记录列表

body: Consumer<HistoryProvider>(
  builder: (context, provider, _) {
    if (provider.history.isEmpty) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.history, size: 64, color: Colors.grey[400]),
            const SizedBox(height: 16),
            Text(
              '还没有观看历史',
              style: TextStyle(color: Colors.grey[600], fontSize: 16),
            ),
          ],
        ),
      );
    }

空状态要有友好的提示,让用户知道这里会显示什么内容。一个大图标加一行文字,简洁明了。

    return ListView.builder(
      padding: const EdgeInsets.all(8),
      itemCount: provider.history.length,
      itemBuilder: (_, i) => AnimeListTile(
        anime: provider.history[i],
        onDelete: () => provider.removeFromHistory(provider.history[i].malId),
      ),
    );
  },
),

有数据时用 ListView.builder 展示。每个列表项传入 onDelete 回调,支持滑动删除单条记录。


HistoryProvider 的添加逻辑

Future<void> addToHistory(Anime anime) async {
  try {
    _history.removeWhere((e) => e.malId == anime.malId);
    _history.insert(0, anime);

先移除已存在的相同记录,再插入到列表开头。这样做有两个好处:避免重复最近浏览的排最前

    if (_history.length > 50) {
      _history = _history.sublist(0, 50);
    }

限制最多保存 50条,超出的自动删除。这个数量可以根据实际需求调整,太多会占用存储空间,太少又不够用。

    await StorageService.instance.setStringList(
      'history',
      _history.map((e) => json.encode(e.toJson())).toList(),
    );
    notifyListeners();
  } catch (e) {
    print('添加历史记录错误: $e');
  }
}

每次修改都同步到本地存储,保证数据不丢失。用 json.encode 把 Anime 对象转成 JSON 字符串存储。


帮助页面的实现

帮助页面用 FAQ 形式展示常见问题:

class HelpScreen extends StatelessWidget {
  const HelpScreen({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('帮助')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          _buildFaqItem(
            '如何收藏动漫?',
            '在动漫详情页面点击❤️图标即可收藏。收藏的动漫会保存在"我的收藏"中。',
          ),

每个问题用 _buildFaqItem 方法构建,传入问题和答案。这种方式让代码结构清晰,添加新问题也很方便。

          _buildFaqItem(
            '如何切换主题?',
            '进入"我的" > "设置",选择浅色、深色或跟随系统主题。',
          ),
          _buildFaqItem(
            '应用支持离线使用吗?',
            '不支持。应用需要网络连接才能获取动漫数据。',
          ),
        ],
      ),
    );
  }

FAQ 要覆盖用户最常问的问题,写的时候要站在用户角度思考。


可折叠的问答项

Widget _buildFaqItem(String question, String answer) {
  return ExpansionTile(
    title: Text(
      question,
      style: const TextStyle(fontWeight: FontWeight.w600),
    ),

ExpansionTile 是 Flutter 提供的可折叠列表项组件,默认只显示标题,点击后展开显示内容。

    children: [
      Padding(
        padding: const EdgeInsets.all(16),
        child: Text(
          answer,
          style: const TextStyle(height: 1.6),
        ),
      ),
    ],
  );
}

答案放在 children 里,展开后才显示。height: 1.6 设置行高,让文字更易读。

这种设计让页面不会太长,用户可以快速浏览所有问题,找到自己关心的再展开查看。


小结

个人中心看起来简单,实际涉及的知识点不少:页面布局设计Provider 数据监听Consumer 和 Consumer2 的使用主题切换和持久化对话框交互可折叠列表等等。

代码结构上,主页面负责展示和导航,子页面负责具体功能,Provider 负责数据管理。这种职责分离的架构便于后期扩展,比如要加新功能,只需要新建一个子页面,然后在主页面加个入口就行。

个人中心是用户使用频率很高的页面,值得花时间把它做好。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐