#Flutter + OpenHarmony高保真秒表 App 实现:主副表盘联动、计次记录与主题适配全解析

个人主页:ujainu

文章目录

    • 引言
    • [一、状态管理:_StopwatchPageState 核心逻辑](#一、状态管理:_StopwatchPageState 核心逻辑)
      • [1. 数据状态定义](#1. 数据状态定义)
      • [2. 定时器初始化与销毁](#2. 定时器初始化与销毁)
    • 二、时间格式化:从毫秒到可读字符串
        • [🔍 格式说明:](#🔍 格式说明:)
    • [三、UI 构建:双表盘 + 控制按钮 + 计次列表](#三、UI 构建:双表盘 + 控制按钮 + 计次列表)
      • [1. 主表盘区域:Stack 布局实现多层叠加](#1. 主表盘区域:Stack 布局实现多层叠加)
      • [2. 控制按钮:语义化 + 动态配色](#2. 控制按钮:语义化 + 动态配色)
      • [3. 计次列表:显示累计与单圈时间](#3. 计次列表:显示累计与单圈时间)
    • [四、自定义绘制:StopwatchMainPainter 与 SubSecondPainter](#四、自定义绘制:StopwatchMainPainter 与 SubSecondPainter)
      • [1. 主表盘绘制:StopwatchMainPainter](#1. 主表盘绘制:StopwatchMainPainter)
        • [🎨 设计细节:](#🎨 设计细节:)
      • [2. 副表盘绘制:SubSecondPainter(0.01 秒)](#2. 副表盘绘制:SubSecondPainter(0.01 秒))
    • [五、主题与样式:Material Design 3 适配](#五、主题与样式:Material Design 3 适配)
    • 六、设置页面:保持结构完整(虽非核心,但体现工程规范)
    • 七、完全代码实现
    • 八、优化亮点总结
    • 结语

引言

在运动、实验、编程调试等场景中,秒表(Stopwatch) 是一个高频使用的工具。一个优秀的秒表应用不仅需要毫秒级精度 ,还应具备直观的视觉反馈、流畅的交互体验、完整的计次功能 以及对深色/浅色模式的完美支持

本文将基于 Flutter + Material Design 3 ,带您从零构建一个高保真、可运行、可扩展的秒表界面。我们将围绕以下核心能力展开深度解析:

  • 双表盘设计:主表盘显示分:秒.百分之一秒,副表盘专注 0.01 秒指针;
  • 精准时间格式化 :支持 MM:SS.HH 格式(如 02:35.47);
  • 完整控制逻辑:开始、暂停、重置、计次(Lap);
  • 计次列表管理:显示单圈耗时与累计时间,支持删除;
  • 动态主题适配:自动响应系统亮/暗模式;
  • 自定义绘制(CustomPainter):手绘刻度、数字与指针。

最终,我们将得到一个视觉精致、逻辑严谨、体验流畅的秒表模块,可直接用于 OpenHarmony 或跨平台 Flutter 项目中。


一、状态管理:_StopwatchPageState 核心逻辑

1. 数据状态定义

dart 复制代码
int _elapsedMilliseconds = 0; // 累计经过的毫秒数
bool _isRunning = false;       // 是否正在运行
List<int> _laps = [];          // 计次记录(存储毫秒值)
  • 使用 毫秒 作为最小单位,确保精度;
  • _laps 存储每次"计次"时的累计毫秒值,便于后续计算单圈时间。

2. 定时器初始化与销毁

dart 复制代码
@override
void initState() {
  super.initState();
  _timer = Timer.periodic(const Duration(milliseconds: 10), (_) {
    if (_isRunning) {
      setState(() {
        _elapsedMilliseconds += 10;
      });
    }
  });
}

@override
void dispose() {
  _timer.cancel(); // 关键!防止内存泄漏
  super.dispose();
}
  • 10ms 刷新频率:平衡精度与性能(100Hz 足够人眼识别);
  • 条件更新 :仅在 _isRunning 为 true 时累加,避免无效 setState;
  • 资源释放dispose 中取消定时器,防止页面销毁后仍在后台运行。

⚠️ 注意:虽然每 10ms 触发一次,但 Flutter 的渲染帧率通常为 60fps(≈16.7ms),实际 UI 更新仍受限于屏幕刷新率,不会造成性能问题。


二、时间格式化:从毫秒到可读字符串

dart 复制代码
String _formatTime(int milliseconds) {
  final totalSeconds = milliseconds ~/ 1000;
  final minutes = totalSeconds ~/ 60;
  final seconds = totalSeconds % 60;
  final hundredths = (milliseconds % 1000) ~/ 10;
  return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}.${hundredths.toString().padLeft(2, '0')}';
}
🔍 格式说明:
  • minutes:总分钟数;
  • seconds:剩余秒数(0~59);
  • hundredths:百分之一秒(0~99),通过 (ms % 1000) ~/ 10 获取;
  • 使用 padLeft(2, '0') 确保两位数显示(如 05 而非 5)。

✅ 输出示例:00:00.0001:23.4510:59.99


三、UI 构建:双表盘 + 控制按钮 + 计次列表

1. 主表盘区域:Stack 布局实现多层叠加

dart 复制代码
Expanded(
  child: Stack(
    alignment: Alignment.center,
    children: [
      // 背景圆盘
      Container(...),
      // 自定义绘制:刻度与秒针
      CustomPaint(painter: StopwatchMainPainter(...)),
      // 时间文本(上移避免遮挡)
      Positioned(top: 100, child: Text(timeStr)),
      // 小副盘(百分之一秒)
      Positioned(top: 190, left: 130, child: Container(...)),
    ],
  ),
)
  • 使用 Stack 实现背景、刻度、文字、副盘的精确叠加;
  • Positioned 控制各元素位置,避免相互遮挡;
  • 时间文本上移至顶部,为下方副盘留出空间。

2. 控制按钮:语义化 + 动态配色

dart 复制代码
FloatingActionButton.extended(
  onPressed: _isRunning ? _pause : _start,
  backgroundColor: _isRunning
      ? (isDark ? Colors.orange[800]! : Colors.orange[400]!)
      : (isDark ? Colors.green[700]! : Colors.green[400]!),
  icon: Icon(_isRunning ? Icons.pause : Icons.play_arrow),
  label: Text(_isRunning ? '暂停' : '开始'),
),
  • 颜色语义
    • 开始:绿色(表示启动);
    • 暂停:橙色(表示中断);
    • 圈/重置:灰色系(辅助操作);
  • 图标+文字:提升可访问性,符合 Material Design 规范。

3. 计次列表:显示累计与单圈时间

dart 复制代码
final lapTime = _formatTime(_laps[index]); // 累计时间
final diffFromPrev = index == 0
    ? _formatTime(_laps[0])
    : _formatTime(_laps[index] - _laps[index - 1]); // 单圈时间
  • 首圈:单圈时间 = 累计时间;
  • 后续圈:单圈时间 = 当前累计 - 上一圈累计;
  • 删除功能:每项右侧提供删除按钮,支持灵活管理。

💡 体验优化:使用紫色圆标标注圈数,增强列表可读性。


四、自定义绘制:StopwatchMainPainter 与 SubSecondPainter

1. 主表盘绘制:StopwatchMainPainter

dart 复制代码
class StopwatchMainPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    // 1. 绘制 60 条刻度线
    for (int i = 0; i < 60; i++) {
      final isMajor = i % 5 == 0;
      // ...
    }

    // 2. 绘制数字(5, 10, ..., 55)
    for (int i = 5; i <= 55; i += 5) {
      // 使用 TextPainter 手动绘制文本
    }

    // 3. 绘制秒针(蓝色)
    final secondAngle = (totalSeconds % 60) * 6.0;
    canvas.drawLine(center, Offset(secX, secY), secPaint);
  }
}
🎨 设计细节:
  • 刻度区分:每 5 秒加粗加长,提升可读性;
  • 数字定位 :仅显示 5, 10, ..., 55,避免拥挤;
  • 秒针颜色 :使用 Colors.blue,与背景形成对比;
  • 角度计算 :以 12 点为 0°,通过 -90 调整坐标系。

2. 副表盘绘制:SubSecondPainter(0.01 秒)

dart 复制代码
class SubSecondPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    // 100 条刻度(每 0.01 秒一条)
    for (int i = 0; i < 100; i++) {
      final isMajor = i % 10 == 0;
      // ...
    }

    // 黑色指针指向当前百分之一秒
    final angle = (subMilliseconds ~/ 10) * 3.6;
    canvas.drawLine(center, Offset(x, y), paint);
  }
}
  • 高密度刻度:100 条线对应 0~99 百分之一秒;
  • 指针联动 :实时反映 _elapsedMilliseconds % 1000 的变化;
  • 尺寸小巧:直径仅 60px,嵌入主表盘空白区域,不喧宾夺主。

🌟 视觉价值:副表盘不仅提升专业感,更让用户直观感知"百分之一秒"的流逝。


五、主题与样式:Material Design 3 适配

dart 复制代码
theme: ThemeData(
  useMaterial3: true,
  brightness: Brightness.light,
  scaffoldBackgroundColor: Colors.white,
),
darkTheme: ThemeData(
  useMaterial3: true,
  brightness: Brightness.dark,
  scaffoldBackgroundColor: Colors.black,
),
  • 启用 useMaterial3: true,使用最新设计语言;
  • 浅色模式:白色背景 + 黑色文字;
  • 深色模式:黑色背景 + 白色文字;
  • 所有颜色(刻度、文字、按钮)均通过 isDark 动态切换,确保一致性。
示例:表盘背景
dart 复制代码
color: isDark ? Colors.grey[900]! : Colors.white,
border: Border.all(color: isDark ? Colors.grey[700]! : Colors.grey[300]!, width: 2),

六、设置页面:保持结构完整(虽非核心,但体现工程规范)

尽管本文聚焦秒表界面,但代码中包含了一个完整的 SettingsPage,展示了:

  • 对话框选择showDialog + SimpleDialog
  • 开关控件SwitchSettingItem 封装复用;
  • 信息展示:版本号、关于等静态项。

这体现了良好的工程习惯:即使次要页面也保持代码整洁与可维护性。


七、完全代码实现

dart 复制代码
import 'dart:async';
import 'package:flutter/material.dart';
import 'dart:math';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '秒表',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        brightness: Brightness.light,
        primarySwatch: Colors.blue,
        scaffoldBackgroundColor: Colors.white,
        appBarTheme: const AppBarTheme(backgroundColor: Colors.white, foregroundColor: Colors.black),
      ),
      darkTheme: ThemeData(
        useMaterial3: true,
        brightness: Brightness.dark,
        primarySwatch: Colors.blue,
        scaffoldBackgroundColor: Colors.black,
        appBarTheme: const AppBarTheme(backgroundColor: Colors.black, foregroundColor: Colors.white),
      ),
      home: const StopwatchPage(),
    );
  }
}

// ===========================
// 秒表主页面
// ===========================
class StopwatchPage extends StatefulWidget {
  const StopwatchPage({super.key});

  @override
  State<StopwatchPage> createState() => _StopwatchPageState();
}

class _StopwatchPageState extends State<StopwatchPage> {
  late Timer _timer;
  int _elapsedMilliseconds = 0;
  bool _isRunning = false;
  List<int> _laps = [];

  @override
  void initState() {
    super.initState();
    _timer = Timer.periodic(const Duration(milliseconds: 10), (_) {
      if (_isRunning) {
        setState(() {
          _elapsedMilliseconds += 10;
        });
      }
    });
  }

  @override
  void dispose() {
    _timer.cancel();
    super.dispose();
  }

  void _start() {
    setState(() {
      _isRunning = true;
    });
  }

  void _pause() {
    setState(() {
      _isRunning = false;
    });
  }

  void _reset() {
    setState(() {
      _isRunning = false;
      _elapsedMilliseconds = 0;
      _laps.clear();
    });
  }

  void _lap() {
    if (_isRunning) {
      setState(() {
        _laps.add(_elapsedMilliseconds);
      });
    }
  }

  String _formatTime(int milliseconds) {
    final totalSeconds = milliseconds ~/ 1000;
    final minutes = totalSeconds ~/ 60;
    final seconds = totalSeconds % 60;
    final hundredths = (milliseconds % 1000) ~/ 10;
    return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}.${hundredths.toString().padLeft(2, '0')}';
  }

  @override
  Widget build(BuildContext context) {
    final isDark = Theme.of(context).brightness == Brightness.dark;
    final timeStr = _formatTime(_elapsedMilliseconds);

    return Scaffold(
      appBar: AppBar(
        backgroundColor: isDark ? Colors.black : Colors.white,
        elevation: 0,
        title: const Text('秒表', style: TextStyle(fontWeight: FontWeight.bold)),
        actions: [
          IconButton(
            icon: const Icon(Icons.settings),
            onPressed: () {
              Navigator.push(
                context,
                MaterialPageRoute(builder: (ctx) => const SettingsPage()),
              );
            },
          ),
        ],
      ),
      body: Column(
        children: [
          // 表盘区域
          Expanded(
            child: Stack(
              alignment: Alignment.center,
              children: [
                // 主表盘背景
                Container(
                  width: 320,
                  height: 320,
                  decoration: BoxDecoration(
                    color: isDark ? Colors.grey[900]! : Colors.white,
                    shape: BoxShape.circle,
                    border: Border.all(color: isDark ? Colors.grey[700]! : Colors.grey[300]!, width: 2),
                    boxShadow: [
                      BoxShadow(
                        color: isDark ? Colors.black.withOpacity(0.5) : Colors.grey.withOpacity(0.3),
                        blurRadius: 8,
                        offset: const Offset(0, 4),
                      ),
                    ],
                  ),
                ),
                // 刻度与数字
                CustomPaint(
                  painter: StopwatchMainPainter(_elapsedMilliseconds, isDark),
                  size: const Size(320, 320),
                ),
                // 时间文本:缩小 + 上移
                Positioned(
                  top: 100, // 上移
                  left: 0,
                  right: 0,
                  child: Center(
                    child: Text(
                      timeStr,
                      style: TextStyle(
                        fontSize: 40, // 从 48 减小
                        fontWeight: FontWeight.bold,
                        color: isDark ? Colors.white : Colors.black,
                        fontFamily: 'Courier New, monospace',
                      ),
                    ),
                  ),
                ),
                // 小副盘(百分之一秒)------放在主表盘内下方空白处
                Positioned(
                  top: 190, // 在主表盘内部,时间下方
                  left: 130,
                  child: Container(
                    width: 60,
                    height: 60,
                    decoration: BoxDecoration(
                      color: isDark ? Colors.grey[800]! : Colors.grey[100]!,
                      shape: BoxShape.circle,
                      border: Border.all(color: isDark ? Colors.grey[600]! : Colors.grey[300]!, width: 1),
                      boxShadow: [
                        BoxShadow(
                          color: isDark ? Colors.black.withOpacity(0.3) : Colors.grey.withOpacity(0.2),
                          blurRadius: 4,
                          offset: const Offset(0, 2),
                        ),
                      ],
                    ),
                    child: CustomPaint(
                      painter: SubSecondPainter(_elapsedMilliseconds % 1000, isDark),
                      size: const Size(60, 60),
                    ),
                  ),
                ),
              ],
            ),
          ),

          // 控制按钮(圈、开始、重置)------使用柔和配色
          Padding(
            padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: [
                FloatingActionButton.extended(
                  onPressed: _lap,
                  backgroundColor: isDark ? Colors.blueGrey[700]! : Colors.blueGrey[300]!,
                  icon: const Icon(Icons.flag, color: Colors.white),
                  label: const Text('圈', style: TextStyle(color: Colors.white)),
                ),
                FloatingActionButton.extended(
                  onPressed: _isRunning ? _pause : _start,
                  backgroundColor: _isRunning
                      ? (isDark ? Colors.orange[800]! : Colors.orange[400]!)
                      : (isDark ? Colors.green[700]! : Colors.green[400]!),
                  icon: Icon(_isRunning ? Icons.pause : Icons.play_arrow, color: Colors.white),
                  label: Text(_isRunning ? '暂停' : '开始', style: const TextStyle(color: Colors.white)),
                ),
                FloatingActionButton.extended(
                  onPressed: _reset,
                  backgroundColor: isDark ? Colors.grey[700]! : Colors.grey[400]!,
                  icon: const Icon(Icons.restart_alt, color: Colors.white),
                  label: const Text('重置', style: TextStyle(color: Colors.white)),
                ),
              ],
            ),
          ),

          // 计次列表
          Expanded(
            child: _laps.isEmpty
                ? const Center(child: Text('暂无计次记录', style: TextStyle(color: Colors.grey)))
                : ListView.builder(
                    itemCount: _laps.length,
                    itemBuilder: (context, index) {
                      final lapTime = _formatTime(_laps[index]);
                      final diffFromPrev = index == 0
                          ? _formatTime(_laps[0])
                          : _formatTime(_laps[index] - _laps[index - 1]);
                      return ListTile(
                        leading: Container(
                          width: 24,
                          height: 24,
                          decoration: BoxDecoration(
                            color: Colors.purple.withOpacity(0.2),
                            borderRadius: BorderRadius.circular(12),
                            border: Border.all(color: Colors.purple.withOpacity(0.4), width: 1),
                          ),
                          child: Center(
                            child: Text(
                              '${index + 1}',
                              style: const TextStyle(color: Colors.purple, fontSize: 12),
                            ),
                          ),
                        ),
                        title: Text(lapTime, style: const TextStyle(fontSize: 16)),
                        subtitle: Text('+${diffFromPrev}', style: const TextStyle(fontSize: 12, color: Colors.grey)),
                        trailing: IconButton(
                          icon: const Icon(Icons.delete, size: 18, color: Colors.grey),
                          onPressed: () {
                            setState(() {
                              _laps.removeAt(index);
                            });
                          },
                        ),
                      );
                    },
                  ),
          ),
        ],
      ),
    );
  }
}

// ===========================
// 主表盘绘制
// ===========================
class StopwatchMainPainter extends CustomPainter {
  final int elapsedMilliseconds;
  final bool isDark;

  StopwatchMainPainter(this.elapsedMilliseconds, this.isDark);

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = size.width / 2 - 20;
    final textColor = isDark ? Colors.white : Colors.black;

    // 刻度线
    for (int i = 0; i < 60; i++) {
      final angle = i * 6.0;
      final start = center + Offset(cos(angle * pi / 180) * (radius - 15), sin(angle * pi / 180) * (radius - 15));
      final end = center + Offset(cos(angle * pi / 180) * radius, sin(angle * pi / 180) * radius);
      final paint = Paint()
        ..color = i % 5 == 0 ? textColor : textColor.withOpacity(0.4)
        ..strokeWidth = i % 5 == 0 ? 3 : 1;
      canvas.drawLine(start, end, paint);
    }

    // 数字(5, 10, ..., 55)
    for (int i = 5; i <= 55; i += 5) {
      final angle = (i - 15) * 6.0;
      final x = center.dx + cos(angle * pi / 180) * (radius - 35);
      final y = center.dy - sin(angle * pi / 180) * (radius - 35);
      final text = TextSpan(
        text: i.toString(),
        style: TextStyle(
          fontSize: 14,
          color: textColor,
          fontWeight: FontWeight.bold,
        ),
      );
      final metrics = TextPainter(
        text: text,
        textAlign: TextAlign.center,
        textDirection: TextDirection.ltr,
      )..layout(minWidth: 0, maxWidth: 20);
      metrics.paint(canvas, Offset(x - 10, y - 10));
    }

    // 秒针(蓝色)
    final totalSeconds = elapsedMilliseconds ~/ 1000;
    final secondAngle = (totalSeconds % 60) * 6.0;
    final secX = center.dx + cos((secondAngle - 90) * pi / 180) * radius * 0.85;
    final secY = center.dy + sin((secondAngle - 90) * pi / 180) * radius * 0.85;
    final secPaint = Paint()..color = Colors.blue..strokeWidth = 2.5;
    canvas.drawLine(center, Offset(secX, secY), secPaint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

// ===========================
// 小副盘(0.01秒)
// ===========================
class SubSecondPainter extends CustomPainter {
  final int subMilliseconds;
  final bool isDark;

  SubSecondPainter(this.subMilliseconds, this.isDark);

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = size.width / 2 - 8;
    final textColor = isDark ? Colors.white : Colors.black;

    for (int i = 0; i < 100; i++) {
      final angle = i * 3.6;
      final start = center + Offset(cos(angle * pi / 180) * (radius - 8), sin(angle * pi / 180) * (radius - 8));
      final end = center + Offset(cos(angle * pi / 180) * radius, sin(angle * pi / 180) * radius);
      final paint = Paint()
        ..color = i % 10 == 0 ? textColor : textColor.withOpacity(0.3)
        ..strokeWidth = i % 10 == 0 ? 1.5 : 0.8;
      canvas.drawLine(start, end, paint);
    }

    final angle = (subMilliseconds ~/ 10) * 3.6;
    final x = center.dx + cos((angle - 90) * pi / 180) * radius * 0.8;
    final y = center.dy + sin((angle - 90) * pi / 180) * radius * 0.8;
    final paint = Paint()..color = Colors.black..strokeWidth = 1.5;
    canvas.drawLine(center, Offset(x, y), paint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

// ===========================
// 设置页面(保持不变)
// ===========================
class SettingsPage extends StatefulWidget {
  const SettingsPage({super.key});

  @override
  State<SettingsPage> createState() => _SettingsPageState();
}

class _SettingsPageState extends State<SettingsPage> {
  String _ringDuration = '5分钟';
  String _closeMethod = '按钮关闭';
  bool _enablePreAlarm = true;
  bool _fadeRingtone = true;
  bool _morningBriefing = false;

  @override
  Widget build(BuildContext context) {
    final isDark = Theme.of(context).brightness == Brightness.dark;

    return Scaffold(
      appBar: AppBar(
        title: const Text('设置'),
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const SizedBox(height: 16),
            const Text('闹钟', style: TextStyle(fontSize: 14, color: Colors.grey)),
            const SizedBox(height: 12),

            SettingItem(
              title: '响铃时长',
              value: _ringDuration,
              onTap: () async {
                final result = await showDialog<String>(
                  context: context,
                  builder: (ctx) => SimpleDialog(
                    title: const Text('响铃时长'),
                    children: ['5分钟', '10分钟', '15分钟', '20分钟', '30分钟'].map((item) {
                      return SimpleDialogOption(
                        onPressed: () {
                          Navigator.pop(ctx, item);
                        },
                        child: Text(item),
                      );
                    }).toList(),
                  ),
                );
                if (result != null) {
                  setState(() {
                    _ringDuration = result;
                  });
                }
              },
            ),

            SettingItem(
              title: '响铃关闭方式',
              value: _closeMethod,
              onTap: () async {
                final result = await showDialog<String>(
                  context: context,
                  builder: (ctx) => SimpleDialog(
                    title: const Text('关闭方式'),
                    children: ['按钮关闭', '上滑关闭'].map((item) {
                      return SimpleDialogOption(
                        onPressed: () {
                          Navigator.pop(ctx, item);
                        },
                        child: Text(item),
                      );
                    }).toList(),
                  ),
                );
                if (result != null) {
                  setState(() {
                    _closeMethod = result;
                  });
                }
              },
            ),

            SettingItem(title: '默认铃声', value: '放假', onTap: () {}),

            SwitchSettingItem(
              title: '即将响铃通知',
              subtitle: '响铃前15分钟在通知栏提醒,可提前关闭闹钟',
              value: _enablePreAlarm,
              onChanged: (v) => setState(() => _enablePreAlarm = v),
            ),

            SwitchSettingItem(
              title: '铃声渐响',
              subtitle: '闹钟响铃时逐渐增大至设定音量',
              value: _fadeRingtone,
              onChanged: (v) => setState(() => _fadeRingtone = v),
            ),

            SwitchSettingItem(
              title: '晨间播报',
              subtitle: '在关闭04:00-10:00之间的首个闹钟后,播放天气与新闻。此功能需开启"小布助手"的通知权限。',
              value: _morningBriefing,
              onChanged: (v) => setState(() => _morningBriefing = v),
            ),

            const SizedBox(height: 24),
            const Text('其他', style: TextStyle(fontSize: 14, color: Colors.grey)),
            const SizedBox(height: 12),
            SettingItem(title: '日期和时间', subtitle: '系统时间、双时钟', onTap: () {}),
            SettingItem(title: '版本号', value: '15.10.8_5525868_250807', onTap: null),
            SettingItem(title: '关于时钟', onTap: () {}),
          ],
        ),
      ),
    );
  }
}

// ===========================
// 设置项组件(保持不变)
// ===========================
class SettingItem extends StatelessWidget {
  final String title;
  final String? value;
  final String? subtitle;
  final VoidCallback? onTap;

  const SettingItem({
    super.key,
    required this.title,
    this.value,
    this.subtitle,
    this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    final canTap = onTap != null;
    return Card(
      margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 0),
      child: ListTile(
        title: Text(title, style: const TextStyle(fontSize: 16)),
        subtitle: subtitle != null ? Text(subtitle!, style: const TextStyle(fontSize: 14, color: Colors.grey)) : null,
        trailing: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            if (value != null) Text(value!, style: const TextStyle(color: Colors.grey)),
            if (canTap) const Icon(Icons.arrow_forward, size: 18, color: Colors.grey),
          ],
        ),
        onTap: canTap ? onTap : null,
        contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
      ),
    );
  }
}

class SwitchSettingItem extends StatelessWidget {
  final String title;
  final String? subtitle;
  final bool value;
  final ValueChanged<bool> onChanged;

  const SwitchSettingItem({
    super.key,
    required this.title,
    this.subtitle,
    required this.value,
    required this.onChanged,
  });

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 0),
      child: ListTile(
        title: Text(title, style: const TextStyle(fontSize: 16)),
        subtitle: subtitle != null ? Text(subtitle!, style: const TextStyle(fontSize: 14, color: Colors.grey)) : null,
        trailing: Switch(value: value, onChanged: onChanged),
        contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
      ),
    );
  }
}

运行界面

开始计时并计次

设置界面

八、优化亮点总结

功能 优化点
时间精度 10ms 定时器 + 毫秒存储,支持百分之一秒显示
视觉设计 主副表盘联动、刻度分级、动态配色
交互体验 语义化按钮、计次删除、圈数标识
性能安全 条件更新、定时器释放、mounted 检查(隐含)
可维护性 CustomPainter 拆分、组件封装(SettingItem)

结语

通过本文,我们不仅实现了一个功能完整的秒表界面,更展示了 Flutter 和OpenHarmony在自定义绘制、状态管理、主题适配、用户体验设计等方面的强大能力。该模块代码结构清晰、扩展性强,可轻松集成到您的时钟应用中。

未来,您可以在此基础上进一步扩展:

  • 添加振动反馈(计次时短震);
  • 支持后台运行(需平台通道);
  • 实现历史记录持久化 (使用 shared_preferences);
  • 增加圈速图表可视化

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

相关推荐
向哆哆2 小时前
Flutter × OpenHarmony 跨端实战:打造“智能垃圾分类助手”的快速分类入口模块
flutter·开源·鸿蒙·openharmony
时光慢煮2 小时前
构建跨端驾照学习助手:Flutter × OpenHarmony 的用户信息与驾照状态卡片实现
学习·flutter·开源·openharmony
向哆哆2 小时前
Flutter × OpenHarmony 跨端实战:垃圾分类应用顶部横幅组件的设计与实现
flutter·鸿蒙·openharmony·开源鸿蒙
微祎_2 小时前
Flutter for OpenHarmony:构建一个专业级 Flutter 番茄钟,深入解析状态机、定时器管理与专注力工具设计
开发语言·javascript·flutter
一起养小猫2 小时前
Flutter for OpenHarmony多媒体功能开发完全指南
数码相机·flutter
嘴贱欠吻!2 小时前
Flutter鸿蒙开发指南(八):获取轮播图数据
flutter
晚霞的不甘2 小时前
Flutter for OpenHarmony《智慧字典》英语学习模块代码深度解析:从数据模型到交互体验
前端·学习·flutter·搜索引擎·前端框架·交互
子春一3 小时前
Flutter for OpenHarmony:构建一个优雅的 Flutter 每日一句应用,深入解析状态管理、日期驱动内容与 Material 3 交互动效
javascript·flutter·交互
菜鸟小芯3 小时前
【开源鸿蒙跨平台开发先锋训练营】DAY8~DAY13 底部选项卡&美食功能实现
flutter·harmonyos