【Flutter x HarmonyOS 6】设置页面的UI设计

【Flutter x HarmonyOS 6】设置页面的UI设计

前面几篇我们聊了记录页面和挑战页面,这篇来看看导航栏中的最后一个页面------设置页面

设置页面是用户配置应用偏好的入口,包括主题、显示、训练、挑战等多个分组。这篇我们从 UI 设计的角度,看看这个页面是如何组织的。


一、页面整体结构

设置页面同样使用 SectionedPage 包裹,内容区域是一个 ListView

dart 复制代码
class SettingsPage extends StatelessWidget {
  const SettingsPage({super.key});

  @override
  Widget build(BuildContext context) {
    final bottomSafeArea = MediaQuery.of(context).padding.bottom;
    final bottomNavHeight = AdaptiveLayout.bottomNavReservedHeight(context);
    final bottomSpacer = bottomSafeArea + bottomNavHeight + 16;

    return SectionedPage(
      title: '设置',
      subtitle: const Text('配置应用偏好'),
      child: ListView(
        padding: EdgeInsets.only(top: 8, bottom: bottomSpacer),
        children: [
          const _SettingsGroup(
            title: '主题与显示',
            subtitle: '自定义界面与展开图表现',
            children: [
              _ThemeModeSelector(),
              _ChineseNewYearThemeToggle(),
              _ScrambleNetToggle(),
              _BeginnerScrambleToggle(),
              _InspectionToggle(),
            ],
          ),
          const SizedBox(height: 20),
          _SettingsGroup(
            title: '训练',
            subtitle: '配置训练相关偏好',
            children: [
              _DefaultPlanSelector(),
            ],
          ),
          const SizedBox(height: 20),
          const _SettingsGroup(
            title: '挑战',
            subtitle: '配置挑战进行页展示偏好',
            children: [
              _ChallengeTimerOrderToggle(),
            ],
          ),
          const SizedBox(height: 20),
          _SettingsGroup(
            title: '关于应用',
            subtitle: 'LRTimer',
            children: [
              _SettingsActionTile(...),
              const _SettingsActionTile(...),
            ],
          ),
        ],
      ),
    );
  }
}

页面分为四个设置组:

  1. 主题与显示:主题模式、新春主题、展开图显示等。
  2. 训练:默认训练计划。
  3. 挑战:成绩排序方式。
  4. 关于应用:使用教程、版本信息。

二、设置组设计

每个设置组由 _SettingsGroup 组件实现:

dart 复制代码
class _SettingsGroup extends StatelessWidget {
  const _SettingsGroup({
    required this.title,
    required this.subtitle,
    required this.children,
  });

  final String title;
  final String subtitle;
  final List<Widget> children;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 4),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                title,
                style: theme.textTheme.titleMedium
                    ?.copyWith(fontWeight: FontWeight.w800),
              ),
              const SizedBox(height: 4),
              Text(
                subtitle,
                style: theme.textTheme.bodyMedium?.copyWith(
                  color: theme.textTheme.bodyMedium?.color?.withOpacity(0.7),
                ),
              ),
            ],
          ),
        ),
        const SizedBox(height: 12),
        _SettingsGroupCard(children: children),
      ],
    );
  }
}

设置组包含两部分:

  1. 标题区域:组标题 + 副标题说明。
  2. 卡片区域_SettingsGroupCard 包裹的具体设置项。

三、设置组卡片设计

_SettingsGroupCard 是一个带圆角和阴影的卡片:

dart 复制代码
class _SettingsGroupCard extends StatelessWidget {
  const _SettingsGroupCard({required this.children});

  static const EdgeInsets tilePadding =
      EdgeInsets.symmetric(horizontal: 16, vertical: 6);

  final List<Widget> children;

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;
    final borderRadius = BorderRadius.circular(24);

    final separatedChildren = <Widget>[];
    for (var i = 0; i < children.length; i++) {
      separatedChildren.add(children[i]);
      if (i == children.length - 1) {
        continue;
      }
      separatedChildren.add(
        Divider(
          height: 1,
          thickness: 1,
          indent: 16,
          endIndent: 16,
          color: colorScheme.outlineVariant.withOpacity(0.7),
        ),
      );
    }

    return Container(
      decoration: BoxDecoration(
        borderRadius: borderRadius,
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.04),
            blurRadius: 20,
            offset: const Offset(0, 8),
          ),
        ],
      ),
      child: Material(
        color: colorScheme.surface,
        shape: RoundedRectangleBorder(
          borderRadius: borderRadius,
          side: BorderSide(color: colorScheme.outlineVariant),
        ),
        clipBehavior: Clip.antiAlias,
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: separatedChildren,
        ),
      ),
    );
  }
}

3.1 分隔线处理

卡片内的设置项之间自动插入分隔线:

dart 复制代码
final separatedChildren = <Widget>[];
for (var i = 0; i < children.length; i++) {
  separatedChildren.add(children[i]);
  if (i == children.length - 1) {
    continue;  // 最后一项后不加分隔线
  }
  separatedChildren.add(
    Divider(
      height: 1,
      thickness: 1,
      indent: 16,
      endIndent: 16,
      color: colorScheme.outlineVariant.withOpacity(0.7),
    ),
  );
}

分隔线特点:

  • indent: 16endIndent: 16 让分隔线不贴边。
  • 颜色使用 outlineVariant 并降低透明度。

3.2 卡片样式

dart 复制代码
Container(
  decoration: BoxDecoration(
    borderRadius: borderRadius,
    boxShadow: [
      BoxShadow(
        color: Colors.black.withOpacity(0.04),
        blurRadius: 20,
        offset: const Offset(0, 8),
      ),
    ],
  ),
  child: Material(
    color: colorScheme.surface,
    shape: RoundedRectangleBorder(
      borderRadius: borderRadius,
      side: BorderSide(color: colorScheme.outlineVariant),
    ),
    clipBehavior: Clip.antiAlias,
    child: Column(...),
  ),
)

卡片样式:

  • 圆角 24dp。
  • 轻微阴影(黑色 4% 透明度,模糊半径 20,向下偏移 8)。
  • 边框使用 outlineVariant 颜色。

四、主题模式选择器

主题模式使用 ChoiceChip 实现:

dart 复制代码
class _ThemeModeSelector extends StatelessWidget {
  const _ThemeModeSelector();

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final settingsService = context.watch<AppSettingsService>();
    final selectedPreference = settingsService.themePreference;

    Widget buildChip({
      required ThemePreference value,
      required String label,
      required IconData icon,
    }) {
      final isSelected = selectedPreference == value;
      return ChoiceChip(
        selected: isSelected,
        showCheckmark: false,
        padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
        avatar: Icon(icon, size: 16),
        label: Text(label),
        onSelected: (shouldSelect) {
          if (!shouldSelect || isSelected) {
            return;
          }
          settingsService.setThemePreference(value);
        },
      );
    }

    return Padding(
      padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            '主题模式',
            style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
          ),
          const SizedBox(height: 10),
          Wrap(
            spacing: 10,
            runSpacing: 10,
            children: [
              buildChip(
                value: ThemePreference.system,
                label: '跟随系统',
                icon: Icons.auto_awesome,
              ),
              buildChip(
                value: ThemePreference.light,
                label: '仅亮色',
                icon: Icons.wb_sunny_outlined,
              ),
              buildChip(
                value: ThemePreference.dark,
                label: '仅暗色',
                icon: Icons.bedtime_outlined,
              ),
            ],
          ),
          const SizedBox(height: 8),
          Text(
            '跟随系统时会在系统切换暗/亮模式时自动适配界面。',
            style: theme.textTheme.bodySmall?.copyWith(
              color: theme.textTheme.bodySmall?.color?.withOpacity(0.7),
            ),
          ),
        ],
      ),
    );
  }
}

4.1 ChoiceChip 配置

dart 复制代码
ChoiceChip(
  selected: isSelected,
  showCheckmark: false,  // 不显示勾选图标
  padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  avatar: Icon(icon, size: 16),  // 左侧图标
  label: Text(label),
  onSelected: (shouldSelect) {
    if (!shouldSelect || isSelected) {
      return;
    }
    settingsService.setThemePreference(value);
  },
)

三个选项:

  • 跟随系统auto_awesome 图标。
  • 仅亮色wb_sunny_outlined 图标。
  • 仅暗色bedtime_outlined 图标。

五、开关设置项

多个设置项使用 GripAwareSwitchTile 实现:

5.1 展开图显示

dart 复制代码
class _ScrambleNetToggle extends StatelessWidget {
  const _ScrambleNetToggle();

  @override
  Widget build(BuildContext context) {
    final settingsService = context.watch<AppSettingsService>();
    final theme = Theme.of(context);
    final isEnabled = settingsService.showScrambleNet;

    return GripAwareSwitchTile(
      contentPadding: _SettingsGroupCard.tilePadding,
      value: isEnabled,
      onChanged: settingsService.setShowScrambleNet,
      title: const Text('展开图显示'),
      subtitle: Text(
        '关闭后计时页仅展示打乱文字。',
        style: theme.textTheme.bodySmall?.copyWith(
          color: theme.textTheme.bodySmall?.color?.withOpacity(0.7),
        ),
      ),
    );
  }
}

5.2 新春主题

dart 复制代码
class _ChineseNewYearThemeToggle extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final settingsService = context.watch<AppSettingsService>();
    final theme = Theme.of(context);
    final isEnabled = settingsService.chineseNewYearThemeEnabled;

    return GripAwareSwitchTile(
      contentPadding: _SettingsGroupCard.tilePadding,
      value: isEnabled,
      onChanged: settingsService.setChineseNewYearThemeEnabled,
      title: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          const Text('新春主题'),
          const SizedBox(width: 6),
          Text('🏮', style: TextStyle(fontSize: 16)),
        ],
      ),
      subtitle: Text(
        '开启后应用将使用红金配色,"新增"按钮会变成灯笼。',
        style: theme.textTheme.bodySmall?.copyWith(
          color: theme.textTheme.bodySmall?.color?.withOpacity(0.7),
        ),
      ),
    );
  }
}

标题中加入了灯笼 emoji,增添节日氛围。

5.3 初学者友好打乱

dart 复制代码
class _BeginnerScrambleToggle extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final settingsService = context.watch<AppSettingsService>();
    final theme = Theme.of(context);
    final isEnabled = settingsService.beginnerFriendlyScrambleEnabled;

    return GripAwareSwitchTile(
      contentPadding: _SettingsGroupCard.tilePadding,
      value: isEnabled,
      onChanged: settingsService.setBeginnerFriendlyScrambleEnabled,
      title: const Text('初学者友好打乱'),
      subtitle: Text(
        '开启后在 3×3 打乱下方额外显示中文动作提示(如 R=右上)。',
        style: theme.textTheme.bodySmall?.copyWith(
          color: theme.textTheme.bodySmall?.color?.withOpacity(0.7),
        ),
      ),
    );
  }
}

5.4 15s 观察倒计时

dart 复制代码
class _InspectionToggle extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final settingsService = context.watch<AppSettingsService>();
    final theme = Theme.of(context);
    final isEnabled = settingsService.inspectionCountdownEnabled;

    return GripAwareSwitchTile(
      contentPadding: _SettingsGroupCard.tilePadding,
      value: isEnabled,
      onChanged: settingsService.setInspectionCountdownEnabled,
      title: const Text('15s 观察倒计时'),
      subtitle: Text(
        '开启后长按先进入观察计时,超时自动按 WCA 规则罚时。',
        style: theme.textTheme.bodySmall?.copyWith(
          color: theme.textTheme.bodySmall?.color?.withOpacity(0.7),
        ),
      ),
    );
  }
}

六、操作型设置项

有些设置项需要点击进入下一步操作,使用 _SettingsActionTile

dart 复制代码
class _SettingsActionTile extends StatelessWidget {
  const _SettingsActionTile({
    required this.icon,
    required this.title,
    required this.subtitle,
    this.trailing,
    this.onTap,
  });

  final IconData icon;
  final String title;
  final String subtitle;
  final Widget? trailing;
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final colorScheme = theme.colorScheme;

    return ListTile(
      onTap: onTap,
      contentPadding: _SettingsGroupCard.tilePadding,
      leading: Container(
        width: 40,
        height: 40,
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(14),
          color: colorScheme.primary.withOpacity(0.12),
        ),
        child: Icon(icon, color: colorScheme.primary),
      ),
      title: Text(
        title,
        style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
      ),
      subtitle: Text(
        subtitle,
        style: theme.textTheme.bodyMedium?.copyWith(
          color: theme.textTheme.bodyMedium?.color?.withOpacity(0.75),
        ),
      ),
      trailing: trailing,
    );
  }
}

6.1 图标容器

dart 复制代码
leading: Container(
  width: 40,
  height: 40,
  decoration: BoxDecoration(
    borderRadius: BorderRadius.circular(14),
    color: colorScheme.primary.withOpacity(0.12),
  ),
  child: Icon(icon, color: colorScheme.primary),
),

图标放在圆角矩形容器中:

  • 尺寸 40×40。
  • 圆角 14dp。
  • 背景色为主色 12% 透明度。
  • 图标颜色为主色。

6.2 默认训练计划选择

dart 复制代码
class _DefaultPlanSelector extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final settingsService = context.watch<AppSettingsService>();
    final planRepository = context.read<TrainingPlanRepository>();
    final plans = planRepository.fetchPlans();
    final defaultPlanId = settingsService.settings.defaultTrainingPlanId;
    final defaultPlan =
        defaultPlanId != null ? planRepository.findPlan(defaultPlanId) : null;

    return _SettingsActionTile(
      icon: Icons.playlist_add_check_circle_outlined,
      title: '默认训练计划',
      subtitle: defaultPlan?.name ?? '未设置',
      trailing: const Icon(Icons.arrow_forward_ios, size: 16),
      onTap: () {
        final service = context.read<AppSettingsService>();
        _showPlanSelectionDialog(
          context,
          plans,
          service.settings.defaultTrainingPlanId,
          service,
        );
      },
    );
  }
}

点击后弹出选择对话框:

dart 复制代码
void _showPlanSelectionDialog(
  BuildContext context,
  List<TrainingPlan> plans,
  String? currentPlanId,
  AppSettingsService settingsService,
) {
  showDialog<void>(
    context: context,
    builder: (context) {
      return AlertDialog(
        title: const Text('选择默认训练计划'),
        content: SizedBox(
          width: double.maxFinite,
          child: ListView(
            shrinkWrap: true,
            children: [
              RadioListTile<String?>(
                title: const Text('无'),
                value: null,
                groupValue: currentPlanId,
                onChanged: (value) {
                  settingsService.setDefaultTrainingPlan(value);
                  Navigator.of(context).pop();
                },
              ),
              ...plans.map((plan) {
                return RadioListTile<String?>(
                  title: Text(plan.name),
                  value: plan.id,
                  groupValue: currentPlanId,
                  onChanged: (value) {
                    settingsService.setDefaultTrainingPlan(value);
                    Navigator.of(context).pop();
                  },
                );
              }),
            ],
          ),
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.of(context).pop(),
            child: const Text('取消'),
          ),
        ],
      );
    },
  );
}

使用 RadioListTile 展示可选计划,选择后自动关闭对话框。


七、设置数据管理

设置数据通过 AppSettingsService 管理:

dart 复制代码
class AppSettingsService extends ChangeNotifier {
  late Box<AppSettings> _settingsBox;
  static const String _settingsKey = 'appSettings';

  AppSettings get settings => _settingsBox.get(_settingsKey) ?? AppSettings();

  Future<void> init() async {
    if (!Hive.isAdapterRegistered(ThemePreferenceAdapter().typeId)) {
      Hive.registerAdapter(ThemePreferenceAdapter());
    }
    if (!Hive.isAdapterRegistered(AppSettingsAdapter().typeId)) {
      Hive.registerAdapter(AppSettingsAdapter());
    }
    _settingsBox = await Hive.openBox<AppSettings>('app_settings');
    if (_settingsBox.isEmpty) {
      await _settingsBox.put(_settingsKey, AppSettings());
    }
  }
}

7.1 设置项定义

dart 复制代码
@HiveType(typeId: 3)
class AppSettings extends HiveObject {
  @HiveField(0)
  String? defaultTrainingPlanId;

  @HiveField(1)
  ThemePreference themePreference;

  @HiveField(2)
  bool showScrambleNet;

  @HiveField(3)
  bool enableInspectionCountdown;

  @HiveField(4)
  bool hasSeenUsageTutorial;

  @HiveField(5)
  bool enableBeginnerFriendlyScramble;

  @HiveField(6)
  bool challengeAttemptDetailNewestFirst;

  @HiveField(7)
  bool enableChineseNewYearTheme;

  AppSettings({
    this.defaultTrainingPlanId,
    this.themePreference = ThemePreference.system,
    this.showScrambleNet = true,
    this.enableInspectionCountdown = false,
    this.hasSeenUsageTutorial = false,
    this.enableBeginnerFriendlyScramble = false,
    this.challengeAttemptDetailNewestFirst = true,
    this.enableChineseNewYearTheme = true,
  });
}

使用 Hive 的 @HiveType@HiveField 注解,自动生成序列化代码。

7.2 设置变更方法

dart 复制代码
Future<void> setThemePreference(ThemePreference preference) async {
  final currentSettings = settings;
  currentSettings.themePreference = preference;
  await _settingsBox.put(_settingsKey, currentSettings);
  notifyListeners();
}

Future<void> setShowScrambleNet(bool value) async {
  final currentSettings = settings;
  currentSettings.showScrambleNet = value;
  await _settingsBox.put(_settingsKey, currentSettings);
  notifyListeners();
}

每个设置项都有对应的 setter 方法:

  1. 获取当前设置对象。
  2. 修改对应字段。
  3. 持久化到 Hive。
  4. 调用 notifyListeners() 通知 UI 刷新。

八、总结

这篇我们从 UI 设计的角度,梳理了设置页面的整体结构:

  1. 页面结构:SectionedPage + ListView,分为四个设置组。
  2. 设置组设计:标题区域 + 卡片区域,卡片带圆角和阴影。
  3. 分隔线处理:设置项之间自动插入分隔线。
  4. 主题选择器:使用 ChoiceChip 实现三选一。
  5. 开关设置项:使用 GripAwareSwitchTile,带标题和说明。
  6. 操作型设置项:使用 ListTile,带图标容器和箭头。
  7. 数据管理:AppSettingsService + Hive 持久化。

设置页面的设计,体现了分组组织、卡片化呈现、清晰的层级关系,让用户能够快速找到和调整所需配置。

相关推荐
ZC跨境爬虫2 小时前
跟着 MDN 学CSS day_31:(精通链接样式,从伪类到导航菜单)
前端·javascript·css·ui·交互
大雷神2 小时前
第21篇|侧边导航:平板和 2in1 为什么不照搬手机布局
harmonyos
G_dou_2 小时前
Flutter+OpenHarmony实战:XMB Tracke
flutter·harmonyos·鸿蒙
lzp07913 小时前
元数据驱动开发 - 面向对象编程思想的补充(上)
spring boot·后端·ui
脑极体10 小时前
点亮星河AI+鸿蒙,一座艺术场馆的日神觉醒
人工智能·华为·harmonyos
●VON10 小时前
鸿蒙Flutter实战:分类管理页BottomSheet CRUD
数据库·flutter·华为·harmonyos·鸿蒙
GitCode官方14 小时前
开源鸿蒙 PC 直播回顾|从环境搭建到真机验证:鸿蒙 PC 命令行迁移全链路。
华为·开源·harmonyos
想你依然心痛14 小时前
HarmonyOS 6(API 23)智能体驱动的沉浸式AR文化遗产数字修复工坊
华为·ar·harmonyos·智能体