Flutter × OpenHarmony 实战:将闹钟、世界时钟、秒表、计时器四大模块集成至高保真鸿蒙时钟 App

个人主页:ujainu

文章目录

    • 引言
    • [一、为什么选择 Flutter 构建 OpenHarmony 时钟 App?](#一、为什么选择 Flutter 构建 OpenHarmony 时钟 App?)
      • [1. OpenHarmony 的 UI 开发挑战](#1. OpenHarmony 的 UI 开发挑战)
      • [2. Flutter 的跨端优势](#2. Flutter 的跨端优势)
    • [二、整体架构:BottomNavigationBar + IndexedStack](#二、整体架构:BottomNavigationBar + IndexedStack)
      • [为何选择 `IndexedStack`?](#为何选择 IndexedStack?)
    • 三、四大模块融合的关键工程实践
      • [1. 全局主题统一:适配 OpenHarmony 深色/浅色模式](#1. 全局主题统一:适配 OpenHarmony 深色/浅色模式)
      • [2. 定时器资源管理:防止内存泄漏](#2. 定时器资源管理:防止内存泄漏)
      • [3. 模块解耦:零状态共享](#3. 模块解耦:零状态共享)
      • [4. 交互反馈:遵循鸿蒙人因设计](#4. 交互反馈:遵循鸿蒙人因设计)
    • [四、各模块在 OpenHarmony 上的适配亮点](#四、各模块在 OpenHarmony 上的适配亮点)
      • [1. 闹钟模块:精准触发 + 系统通知(未来)](#1. 闹钟模块:精准触发 + 系统通知(未来))
      • [2. 世界时钟:模拟本地时区](#2. 世界时钟:模拟本地时区)
      • [3. 秒表:双表盘绘制](#3. 秒表:双表盘绘制)
      • [4. 计时器:Cupertino 滑轮选择器](#4. 计时器:Cupertino 滑轮选择器)
    • 五、总代码和运行结果
    • [六、总结:Flutter 在 OpenHarmony 生态中的定位](#六、总结:Flutter 在 OpenHarmony 生态中的定位)

引言

随着 OpenHarmony 生态的快速演进,越来越多的开发者开始探索如何在这一新兴操作系统上构建高性能、高体验的应用。而 Flutter 凭借其"一次编写,多端部署"的能力,正成为跨平台开发的重要选择。尤其在 OpenHarmony 3.2 及以上版本中,通过 ArkTS + Flutter 混合开发纯 Flutter 应用容器化部署,已能实现接近原生的流畅体验。

一个完整的系统级应用------时钟(Clock)App,通常包含四大核心功能模块:

  • 闹钟(Alarm):定时提醒;
  • 世界时钟(World Clock):跨时区时间查看;
  • 秒表(Stopwatch):精确计时;
  • 计时器(Timer):倒计时任务。

本文将以一个真实项目为例,详细讲解 如何使用 Flutter 开发这四大模块,并将其无缝集成到一个统一的时钟应用中,最终部署运行于 OpenHarmony 设备 。我们将重点剖析 模块融合架构、状态管理、深色模式适配、性能优化 等关键工程实践,并探讨 Flutter 在 OpenHarmony 生态中的定位与优势。

💡 本文价值:不仅教你"如何写",更告诉你"为何这样设计"------这是从"功能实现"迈向"鸿蒙生态级应用开发"的关键跃迁。


一、为什么选择 Flutter 构建 OpenHarmony 时钟 App?

1. OpenHarmony 的 UI 开发挑战

OpenHarmony 原生使用 ArkUI(声明式 UI 框架),虽强大但学习曲线陡峭,且生态工具链仍在完善中。对于已有 Flutter 技术栈的团队,直接迁移成本高。

2. Flutter 的跨端优势

  • 一套代码,多端运行 :iOS、Android、Web、Desktop,以及 OpenHarmony(通过 Flutter Engine 移植)
  • 高性能渲染:Skia 引擎直绘,60fps 流畅动画;
  • 丰富组件库:Material/Cupertino 风格开箱即用;
  • 热重载(Hot Reload):大幅提升开发效率。

📌 现状说明:截至 2026 年初,社区已有多个成功将 Flutter 应用运行于 OpenHarmony 设备的案例(如华为部分 HarmonyOS NEXT 测试设备支持 Flutter 容器)。本文假设目标设备已具备 Flutter 运行环境。


二、整体架构:BottomNavigationBar + IndexedStack

为确保四大模块在 OpenHarmony 设备上切换流畅、状态持久,我们采用如下架构:

dart 复制代码
class ClockApp extends StatefulWidget {
  @override
  State<ClockApp> createState() => _ClockAppState();
}

class _ClockAppState extends State<ClockApp> {
  int _currentIndex = 0;
  final List<Widget> _pages = [
    const AlarmPage(),        // 闹钟
    const WorldClockPage(),   // 世界时钟
    const StopwatchPage(),    // 秒表
    const TimerMainPage(),    // 计时器
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: IndexedStack(
        index: _currentIndex,
        children: _pages,
      ),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentIndex,
        onTap: (index) => setState(() => _currentIndex = index),
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.alarm), label: '闹钟'),
          BottomNavigationBarItem(icon: Icon(Icons.public), label: '世界时钟'),
          BottomNavigationBarItem(icon: Icon(Icons.watch_later), label: '秒表'),
          BottomNavigationBarItem(icon: Icon(Icons.timer), label: '计时器'),
        ],
      ),
    );
  }
}

为何选择 IndexedStack

  • 状态保持:用户在秒表页面开始计时后,切换到闹钟再返回,秒表继续运行;
  • 避免重建:OpenHarmony 设备内存资源有限,频繁重建页面会增加 GC 压力;
  • 体验一致性:符合鸿蒙"连续性服务"设计理念------用户操作不因页面切换而中断。

三、四大模块融合的关键工程实践

1. 全局主题统一:适配 OpenHarmony 深色/浅色模式

OpenHarmony 系统支持全局深色模式切换。Flutter 应用需主动响应:

dart 复制代码
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '鸿蒙时钟',
      theme: ThemeData(
        brightness: Brightness.light,
        scaffoldBackgroundColor: Colors.white,
        appBarTheme: const AppBarTheme(backgroundColor: Colors.white, foregroundColor: Colors.black),
      ),
      darkTheme: ThemeData(
        brightness: Brightness.dark,
        scaffoldBackgroundColor: Colors.black,
        appBarTheme: const AppBarTheme(backgroundColor: Colors.black, foregroundColor: Colors.white),
      ),
      home: const ClockApp(),
    );
  }
}

各模块内部动态获取主题:

dart 复制代码
final isDark = Theme.of(context).brightness == Brightness.dark;

效果:当用户在 OpenHarmony 设置中开启深色模式,时钟 App 自动切换,视觉体验与系统一致。


2. 定时器资源管理:防止内存泄漏

每个模块都依赖 Timer 刷新 UI。在资源受限的 OpenHarmony 设备上,内存泄漏会导致应用被系统杀掉

正确做法(以秒表为例):
dart 复制代码
class _StopwatchPageState extends State<StopwatchPage> {
  late Timer _timer;

  @override
  void initState() {
    super.initState();
    _timer = Timer.periodic(const Duration(milliseconds: 10), (_) {
      if (_isRunning && mounted) { // 关键:检查 mounted
        setState(() { _elapsedMilliseconds += 10; });
      }
    });
  }

  @override
  void dispose() {
    _timer.cancel(); // 必须释放!
    super.dispose();
  }
}

⚠️ OpenHarmony 特别注意

  • 应用进入后台时,部分设备会限制 Timer 执行;
  • 若需后台持续计时,未来可结合 OpenHarmony 的 后台任务 API(通过 Platform Channel 调用 ArkTS)。

3. 模块解耦:零状态共享

四大模块数据模型完全不同,绝不共享状态

  • 闹钟:List<Alarm> 存储在 AlarmPage 内部;
  • 世界时钟:城市列表由 WorldClockPage 管理;
  • 秒表/计时器:各自维护独立计时逻辑。

🛡️ 优势

  • 修改闹钟逻辑不影响秒表;
  • 单元测试可独立进行;
  • 降低 OpenHarmony 应用包体积(无冗余状态管理库如 Provider/Bloc)。

4. 交互反馈:遵循鸿蒙人因设计

虽然使用 Flutter,但交互应贴近 OpenHarmony 用户习惯:

  • 提示消息 :使用 SnackBar,文案简洁(如"⏰ 闹钟响起");
  • 删除操作 :采用滑动删除(Dismissible),而非弹窗确认;
  • 添加按钮:统一使用蓝色 FAB,位置固定于右下角。

🎯 目标:让用户感觉这是一个"原生鸿蒙应用",而非"移植的 Flutter App"。


四、各模块在 OpenHarmony 上的适配亮点

1. 闹钟模块:精准触发 + 系统通知(未来)

  • 当前使用 SnackBar 模拟提醒;
  • 未来扩展 :通过 Flutter 插件调用 OpenHarmony 的 通知服务(Notification Kit),实现锁屏弹窗。

2. 世界时钟:模拟本地时区

  • 不依赖设备系统时区(OpenHarmony 多设备时区策略复杂);
  • 通过 UTC 时间 + 偏移量计算,确保全球时间准确。

3. 秒表:双表盘绘制

  • 使用 CustomPainter 绘制模拟表盘;
  • 在 OpenHarmony 平板设备上自动适配大屏布局。

4. 计时器:Cupertino 滑轮选择器

  • 虽为 iOS 风格,但在 OpenHarmony 手机上体验良好;
  • 支持预设管理,满足高频场景(如"蒸蛋 10 分钟")。

五、总代码和运行结果

dart 复制代码
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.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 ClockApp(),
    );
  }
}

// ===========================
// 主应用:底部导航容器
// ===========================
class ClockApp extends StatefulWidget {
  const ClockApp({super.key});

  @override
  State<ClockApp> createState() => _ClockAppState();
}

class _ClockAppState extends State<ClockApp> {
  int _currentIndex = 0;

  final List<Widget> _pages = [
    const AlarmPage(),
    const WorldClockPage(),
    const StopwatchPage(),
    const TimerMainPage(),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: IndexedStack(
        index: _currentIndex,
        children: _pages,
      ),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentIndex,
        onTap: (index) {
          setState(() {
            _currentIndex = index;
          });
        },
        type: BottomNavigationBarType.fixed,
        selectedItemColor: Colors.blue,
        unselectedItemColor: Theme.of(context).brightness == Brightness.dark
            ? Colors.grey[400]
            : Colors.grey[600],
        items: const [
          BottomNavigationBarItem(
            icon: Icon(Icons.alarm),
            label: '闹钟',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.public),
            label: '世界时钟',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.watch_later),
            label: '秒表',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.timer),
            label: '计时器',
          ),
        ],
      ),
    );
  }
}

// ===================================================================
// ========== 以下为四个功能页面(按原需求保留并优化) ==========
// ===================================================================

// ===========================
// 1. 闹钟页面
// ===========================
class Alarm {
  final String id;
  final TimeOfDay time;
  final bool enabled;

  Alarm({required this.id, required this.time, this.enabled = true});
}

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

  @override
  State<AlarmPage> createState() => _AlarmPageState();
}

class _AlarmPageState extends State<AlarmPage> {
  final List<Alarm> _alarms = [];
  late Timer _timer;

  @override
  void initState() {
    super.initState();
    _timer = Timer.periodic(const Duration(seconds: 1), (_) => _checkAlarms());
  }

  void _checkAlarms() {
    final now = TimeOfDay.now();
    for (final alarm in _alarms) {
      if (alarm.enabled &&
          alarm.time.hour == now.hour &&
          alarm.time.minute == now.minute &&
          DateTime.now().second == 0) {
        _showAlarmNotification(alarm);
      }
    }
  }

  void _showAlarmNotification(Alarm alarm) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text('⏰ 闹钟: ${alarm.time.format(context)}'),
        duration: const Duration(seconds: 5),
        action: SnackBarAction(
          label: '关闭',
          onPressed: () {
            ScaffoldMessenger.of(context).hideCurrentSnackBar();
          },
        ),
      ),
    );
  }

  void _addAlarm() async {
    final TimeOfDay? picked = await showTimePicker(
      context: context,
      initialTime: TimeOfDay.now(),
    );
    if (picked != null) {
      setState(() {
        _alarms.add(
          Alarm(
            id: DateTime.now().microsecondsSinceEpoch.toString(),
            time: picked,
          ),
        );
      });
    }
  }

  void _deleteAlarm(String id) {
    setState(() {
      _alarms.removeWhere((alarm) => alarm.id == id);
    });
  }

  void _toggleAlarm(String id) {
    setState(() {
      final index = _alarms.indexWhere((a) => a.id == id);
      if (index != -1) {
        _alarms[index] = Alarm(
          id: _alarms[index].id,
          time: _alarms[index].time,
          enabled: !_alarms[index].enabled,
        );
      }
    });
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('闹钟')),
      body: _alarms.isEmpty
          ? Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: const [
                  Icon(Icons.alarm_off, size: 80, color: Colors.grey),
                  SizedBox(height: 16),
                  Text('暂无闹钟', style: TextStyle(fontSize: 18, color: Colors.grey)),
                ],
              ),
            )
          : ListView.builder(
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
              itemCount: _alarms.length,
              itemBuilder: (context, index) {
                final alarm = _alarms[index];
                final isDark = Theme.of(context).brightness == Brightness.dark;
                final activeColor = isDark ? Colors.white : Colors.black;
                final inactiveColor = isDark ? Colors.grey[400]! : Colors.grey[600]!;

                return Dismissible(
                  key: Key(alarm.id),
                  direction: DismissDirection.endToStart,
                  onDismissed: (_) => _deleteAlarm(alarm.id),
                  background: Container(
                    alignment: Alignment.centerRight,
                    padding: const EdgeInsets.only(right: 20),
                    color: Colors.red,
                    child: const Icon(Icons.delete, color: Colors.white),
                  ),
                  child: Card(
                    child: ListTile(
                      leading: Text(
                        alarm.time.format(context),
                        style: TextStyle(
                          fontSize: 24,
                          fontWeight: FontWeight.bold,
                          color: alarm.enabled ? activeColor : inactiveColor,
                        ),
                      ),
                      title: Text(
                        '响铃一次',
                        style: TextStyle(color: inactiveColor),
                      ),
                      trailing: Switch(
                        value: alarm.enabled,
                        activeColor: Colors.blue,
                        onChanged: (value) => _toggleAlarm(alarm.id),
                      ),
                    ),
                  ),
                );
              },
            ),
      floatingActionButton: FloatingActionButton(
        onPressed: _addAlarm,
        backgroundColor: Colors.blue,
        child: const Icon(Icons.add, color: Colors.white),
      ),
    );
  }
}

// ===========================
// 2. 世界时钟页面
// ===========================
final List<CityData> _allCities = [
  CityData('阿姆斯特丹', 1),
  CityData('北京', 8),
  CityData('柏林', 1),
  CityData('布宜诺斯艾利斯', -3),
  CityData('开罗', 2),
  CityData('芝加哥', -6),
  CityData('达拉斯', -6),
  CityData('德里', 5.5),
  CityData('迪拜', 4),
  CityData('都柏林', 0),
  CityData('法兰克福', 1),
  CityData('香港', 8),
  CityData('伊斯坦布尔', 3),
  CityData('雅加达', 7),
  CityData('吉隆坡', 8),
  CityData('伦敦', 0),
  CityData('洛杉矶', -8),
  CityData('马德里', 1),
  CityData('墨尔本', 10),
  CityData('墨西哥城', -6),
  CityData('迈阿密', -5),
  CityData('莫斯科', 3),
  CityData('孟买', 5.5),
  CityData('纽约', -5),
  CityData('奥斯陆', 1),
  CityData('巴黎', 1),
  CityData('里约热内卢', -3),
  CityData('罗马', 1),
  CityData('圣保罗', -3),
  CityData('圣彼得堡', 3),
  CityData('上海', 8),
  CityData('新加坡', 8),
  CityData('斯德哥尔摩', 1),
  CityData('悉尼', 10),
  CityData('东京', 9),
  CityData('多伦多', -5),
  CityData('温哥华', -8),
  CityData('维也纳', 1),
  CityData('苏黎世', 1),
];

class CityData {
  final String name;
  final double utcOffset;

  CityData(this.name, this.utcOffset);
}

class DisplayCity {
  final String name;
  final double utcOffset;

  DisplayCity(this.name, this.utcOffset);

  String getDescription(double mainUtcOffset) {
    final diff = utcOffset - mainUtcOffset;
    if (diff == 0) return '本地时间';
    if (diff > 0) {
      return '早${diff.toStringAsFixed(diff % 1 == 0 ? 0 : 1)}小时';
    } else {
      return '晚${(-diff).toStringAsFixed((-diff) % 1 == 0 ? 0 : 1)}小时';
    }
  }
}

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

  @override
  State<WorldClockPage> createState() => _WorldClockPageState();
}

class _WorldClockPageState extends State<WorldClockPage> {
  late Timer _timer;
  final List<DisplayCity> _displayCities = [
    DisplayCity('北京', 8),
    DisplayCity('伦敦', 0),
    DisplayCity('悉尼', 10),
  ];
  bool _isAnalog = true;
  DisplayCity _mainCity = DisplayCity('北京', 8);

  @override
  void initState() {
    super.initState();
    _timer = Timer.periodic(const Duration(seconds: 1), (_) {
      if (mounted) setState(() {});
    });
  }

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

  void _addCity() async {
    final selected = await showSearch<CityData>(
      context: context,
      delegate: CitySearchDelegate(_allCities),
    );
    if (selected != null) {
      if (!_displayCities.any((city) => city.name == selected.name)) {
        setState(() {
          _displayCities.add(DisplayCity(selected.name, selected.utcOffset));
        });
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('${selected.name} 已添加')),
        );
      } else {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('该城市已在列表中')),
        );
      }
    }
  }

  void _deleteCity(String name) {
    setState(() {
      _displayCities.removeWhere((city) => city.name == name);
      if (_mainCity.name == name && _displayCities.isNotEmpty) {
        _mainCity = _displayCities.first;
      }
    });
  }

  void _setAsMainCity(DisplayCity city) {
    setState(() {
      _mainCity = city;
    });
  }

  void _toggleClockType() {
    setState(() {
      _isAnalog = !_isAnalog;
    });
  }

  String _formatTimeWithSeconds(int hour, int minute, int second) {
    return '${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}:${second.toString().padLeft(2, '0')}';
  }

  DateTime _getTimeByUtcOffset(double utcOffset) {
    final now = DateTime.now();
    final localOffset = now.timeZoneOffset.inMilliseconds;
    final targetTime = DateTime.fromMillisecondsSinceEpoch(
      now.millisecondsSinceEpoch - localOffset + (utcOffset * 3600000).toInt(),
    );
    return targetTime;
  }

  @override
  Widget build(BuildContext context) {
    final isDark = Theme.of(context).brightness == Brightness.dark;
    final mainTime = _getTimeByUtcOffset(_mainCity.utcOffset);
    final hour = mainTime.hour;
    final minute = mainTime.minute;
    final second = mainTime.second;
    final weekdayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];

    return Scaffold(
      appBar: AppBar(title: const Text('世界时钟')),
      body: GestureDetector(
        onTap: _toggleClockType,
        child: Column(
          children: [
            Container(
              alignment: Alignment.center,
              padding: const EdgeInsets.all(20),
              child: _isAnalog
                  ? CustomPaint(
                      painter: AnalogClockPainter(hour, minute, second),
                      size: const Size(300, 300),
                    )
                  : Container(
                      padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
                      decoration: BoxDecoration(
                        color: isDark ? Colors.grey[800] : Colors.white,
                        borderRadius: BorderRadius.circular(16),
                        boxShadow: [
                          BoxShadow(
                            color: isDark ? Colors.black54 : Colors.grey.withOpacity(0.4),
                            blurRadius: 10,
                            offset: const Offset(0, 4),
                          ),
                        ],
                      ),
                      child: Text(
                        _formatTimeWithSeconds(hour, minute, second),
                        style: TextStyle(
                          fontSize: 48,
                          fontWeight: FontWeight.bold,
                          color: isDark ? Colors.white : Colors.black,
                          fontFamily: 'Courier New, monospace',
                        ),
                      ),
                    ),
            ),
            const SizedBox(height: 10),
            Text(
              '${_mainCity.name} | ${mainTime.month}月${mainTime.day}日 ${weekdayNames[mainTime.weekday - 1]}',
              style: const TextStyle(color: Colors.grey, fontSize: 14),
            ),
            const SizedBox(height: 20),
            Expanded(
              child: ListView.builder(
                itemCount: _displayCities.length,
                itemBuilder: (context, index) {
                  final city = _displayCities[index];
                  final cityTime = _getTimeByUtcOffset(city.utcOffset);
                  final timeStr = _formatTimeWithSeconds(cityTime.hour, cityTime.minute, cityTime.second);
                  final isSelected = city.name == _mainCity.name;

                  return Dismissible(
                    key: Key(city.name),
                    direction: DismissDirection.endToStart,
                    onDismissed: (_) => _deleteCity(city.name),
                    background: Container(
                      alignment: Alignment.centerRight,
                      padding: const EdgeInsets.only(right: 20),
                      color: Colors.red,
                      child: const Icon(Icons.delete, color: Colors.white),
                    ),
                    child: Card(
                      margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
                      child: ListTile(
                        title: Text(
                          city.name,
                          style: TextStyle(
                            fontSize: 20,
                            fontWeight: FontWeight.bold,
                            color: isSelected ? Colors.blue : null,
                          ),
                        ),
                        subtitle: Text(city.getDescription(_mainCity.utcOffset)),
                        trailing: Text(timeStr, style: const TextStyle(fontSize: 16)),
                        onTap: () => _setAsMainCity(city),
                      ),
                    ),
                  );
                },
              ),
            ),
            Align(
              alignment: Alignment.bottomCenter,
              child: FloatingActionButton(
                onPressed: _addCity,
                backgroundColor: Colors.blue,
                child: const Icon(Icons.add, size: 30),
              ),
            ),
          ],
        ),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
    );
  }
}

class CitySearchDelegate extends SearchDelegate<CityData> {
  final List<CityData> cities;

  CitySearchDelegate(this.cities);

  @override
  String get searchFieldLabel => '搜索城市';

  @override
  List<Widget>? buildActions(BuildContext context) {
    return [
      if (query.isNotEmpty)
        IconButton(
          icon: const Icon(Icons.clear),
          onPressed: () => query = '',
        ),
    ];
  }

  @override
  Widget? buildLeading(BuildContext context) {
    return IconButton(
      icon: const Icon(Icons.arrow_back),
      onPressed: () => close(context, CityData('', 0)),
    );
  }

  @override
  Widget buildResults(BuildContext context) {
    final results = cities.where((city) {
      return city.name.toLowerCase().contains(query.toLowerCase());
    }).toList();

    return _buildCityList(results);
  }

  @override
  Widget buildSuggestions(BuildContext context) {
    final suggestions = query.isEmpty
        ? cities
        : cities.where((city) {
            return city.name.toLowerCase().startsWith(query.toLowerCase());
          }).toList();

    return _buildCityList(suggestions);
  }

  Widget _buildCityList(List<CityData> list) {
    return ListView.builder(
      itemCount: list.length,
      itemBuilder: (context, index) {
        final city = list[index];
        final offsetStr = city.utcOffset >= 0
            ? '+${city.utcOffset.toStringAsFixed(city.utcOffset % 1 == 0 ? 0 : 1)}'
            : '${city.utcOffset.toStringAsFixed(city.utcOffset % 1 == 0 ? 0 : 1)}';
        return ListTile(
          title: Text(city.name),
          subtitle: Text('UTC $offsetStr'),
          onTap: () => close(context, city),
        );
      },
    );
  }
}

class AnalogClockPainter extends CustomPainter {
  final int hour;
  final int minute;
  final int second;

  AnalogClockPainter(this.hour, this.minute, this.second);

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = size.width / 2 - 10;

    for (int i = 0; i < 60; i++) {
      final angle = i * 6.0;
      final start = center + Offset(cos(angle * pi / 180) * (radius - 10), sin(angle * pi / 180) * (radius - 10));
      final end = center + Offset(cos(angle * pi / 180) * radius, sin(angle * pi / 180) * radius);
      final paint = Paint()
        ..color = i % 5 == 0 ? Colors.black : Colors.grey[400]!
        ..strokeWidth = i % 5 == 0 ? 4 : 2;
      canvas.drawLine(start, end, paint);
    }

    for (int i = 1; i <= 12; i++) {
      final angle = (i - 3) * 30.0;
      final x = center.dx + cos(angle * pi / 180) * (radius - 40);
      final y = center.dy - sin(angle * pi / 180) * (radius - 40);
      final text = TextSpan(text: i.toString(), style: const TextStyle(fontSize: 16));
      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 hourAngle = (hour % 12) * 30.0 + minute * 0.5;
    final hourX = center.dx + cos((hourAngle - 90) * pi / 180) * radius * 0.5;
    final hourY = center.dy + sin((hourAngle - 90) * pi / 180) * radius * 0.5;
    final hourPaint = Paint()..color = Colors.black..strokeWidth = 8;
    canvas.drawLine(center, Offset(hourX, hourY), hourPaint);

    final minuteAngle = minute * 6.0;
    final minX = center.dx + cos((minuteAngle - 90) * pi / 180) * radius * 0.7;
    final minY = center.dy + sin((minuteAngle - 90) * pi / 180) * radius * 0.7;
    final minPaint = Paint()..color = Colors.black..strokeWidth = 5;
    canvas.drawLine(center, Offset(minX, minY), minPaint);

    final secondAngle = second * 6.0;
    final secX = center.dx + cos((secondAngle - 90) * pi / 180) * radius * 0.8;
    final secY = center.dy + sin((secondAngle - 90) * pi / 180) * radius * 0.8;
    final secPaint = Paint()..color = Colors.blue..strokeWidth = 2;
    canvas.drawLine(center, Offset(secX, secY), secPaint);
  }

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

// ===========================
// 3. 秒表页面
// ===========================
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,
                        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);
    }

    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;
}

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;
}

// ===========================
// 4. 计时器页面
// ===========================
class TimerPreset {
  final String name;
  final int hours;
  final int minutes;
  final int seconds;

  TimerPreset({
    required this.name,
    this.hours = 0,
    this.minutes = 0,
    this.seconds = 0,
  });
}

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

  @override
  State<TimerMainPage> createState() => _TimerMainPageState();
}

class _TimerMainPageState extends State<TimerMainPage> {
  int _hours = 0;
  int _minutes = 0;
  int _seconds = 0;
  bool _isRunning = false;
  Timer? _countdownTimer;

  List<TimerPreset> _presets = [
    TimerPreset(name: '刷牙', hours: 0, minutes: 2, seconds: 0),
    TimerPreset(name: '蒸蛋', hours: 0, minutes: 10, seconds: 0),
    TimerPreset(name: '面膜', hours: 0, minutes: 15, seconds: 0),
    TimerPreset(name: '午睡', hours: 0, minutes: 30, seconds: 0),
  ];

  bool _isEditing = false;

  void _startCountdown() {
    if (_isRunning || (_hours == 0 && _minutes == 0 && _seconds == 0)) return;

    _isRunning = true;
    _countdownTimer?.cancel();
    _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
      if (_seconds > 0) {
        setState(() => _seconds--);
      } else if (_minutes > 0) {
        setState(() {
          _minutes--;
          _seconds = 59;
        });
      } else if (_hours > 0) {
        setState(() {
          _hours--;
          _minutes = 59;
          _seconds = 59;
        });
      } else {
        _isRunning = false;
        timer.cancel();
        ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('时间到!')));
      }
    });
  }

  void _pauseCountdown() {
    _isRunning = false;
    _countdownTimer?.cancel();
  }

  void _usePreset(TimerPreset preset) {
    if (_isRunning) return;
    setState(() {
      _hours = preset.hours;
      _minutes = preset.minutes;
      _seconds = preset.seconds;
    });
  }

  void _goToAdd() async {
    final result = await Navigator.push(
      context,
      MaterialPageRoute(builder: (ctx) => AddTimerPage(
        initialHours: _hours,
        initialMinutes: _minutes,
        initialSeconds: _seconds,
      )),
    );
    if (result is TimerPreset) {
      setState(() {
        _presets.add(result);
      });
    }
  }

  String _formatTime(int h, int m, int s) {
    return '${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
  }

  void _deletePreset(int index) {
    setState(() {
      _presets.removeAt(index);
    });
  }

  void _toggleEdit() {
    setState(() {
      _isEditing = !_isEditing;
    });
  }

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

    return Scaffold(
      appBar: AppBar(
        backgroundColor: isDark ? Colors.black : Colors.white,
        elevation: 0,
        title: const Text('计时器', style: TextStyle(fontWeight: FontWeight.bold)),
        actions: [
          IconButton(
            icon: Icon(_isEditing ? Icons.check : Icons.edit),
            onPressed: _toggleEdit,
          ),
        ],
      ),
      body: Stack(
        children: [
          SingleChildScrollView(
            padding: const EdgeInsets.symmetric(horizontal: 24),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.center,
              children: [
                const SizedBox(height: 32),

                Container(
                  decoration: BoxDecoration(
                    color: isDark ? Colors.grey[800]! : Colors.grey[100]!,
                    borderRadius: BorderRadius.circular(16),
                    boxShadow: [
                      BoxShadow(
                        color: isDark ? Colors.black.withOpacity(0.1) : Colors.grey.withOpacity(0.1),
                        blurRadius: 4,
                      ),
                    ],
                  ),
                  child: Column(
                    children: [
                      Padding(
                        padding: const EdgeInsets.symmetric(vertical: 8),
                        child: Row(
                          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                          children: [
                            Text('23', style: TextStyle(fontSize: 36, color: Colors.grey.withOpacity(0.5))),
                            Text('59', style: TextStyle(fontSize: 36, color: Colors.grey.withOpacity(0.5))),
                            Text('00', style: TextStyle(fontSize: 36, color: Colors.grey.withOpacity(0.5))),
                          ],
                        ),
                      ),

                      GestureDetector(
                        onTap: _isRunning ? null : () => _showTimePicker(),
                        child: Padding(
                          padding: const EdgeInsets.symmetric(vertical: 12),
                          child: AnimatedDefaultTextStyle(
                            duration: const Duration(milliseconds: 200),
                            style: TextStyle(
                              fontSize: 72,
                              fontWeight: FontWeight.bold,
                              fontFamily: 'Courier New',
                              color: _isRunning
                                  ? (isDark ? Colors.white : Colors.black)
                                  : Colors.grey,
                            ),
                            child: Text(timeStr),
                          ),
                        ),
                      ),

                      Padding(
                        padding: const EdgeInsets.symmetric(vertical: 8),
                        child: Row(
                          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                          children: [
                            Text('01', style: TextStyle(fontSize: 36, color: Colors.grey.withOpacity(0.5))),
                            Text('01', style: TextStyle(fontSize: 36, color: Colors.grey.withOpacity(0.5))),
                            Text('02', style: TextStyle(fontSize: 36, color: Colors.grey.withOpacity(0.5))),
                          ],
                        ),
                      ),
                    ],
                  ),
                ),

                const SizedBox(height: 48),

                Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    const Text('常用计时器', style: TextStyle(fontSize: 14, color: Colors.grey)),
                    TextButton(
                      onPressed: _goToAdd,
                      child: const Text('添加', style: TextStyle(color: Colors.blue)),
                    ),
                  ],
                ),

                const SizedBox(height: 12),

                for (var i = 0; i < _presets.length; i++)
                  Container(
                    margin: const EdgeInsets.only(bottom: 12),
                    decoration: BoxDecoration(
                      color: isDark ? Colors.grey[800]! : Colors.grey[100]!,
                      borderRadius: BorderRadius.circular(16),
                      boxShadow: [
                        BoxShadow(
                          color: isDark ? Colors.black.withOpacity(0.1) : Colors.grey.withOpacity(0.1),
                          blurRadius: 4,
                        ),
                      ],
                    ),
                    child: ListTile(
                      contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
                      title: Text(_presets[i].name, style: const TextStyle(fontSize: 18)),
                      subtitle: Text(
                        _formatTime(_presets[i].hours, _presets[i].minutes, _presets[i].seconds),
                        style: const TextStyle(fontSize: 14, color: Colors.grey),
                      ),
                      trailing: _isEditing
                          ? IconButton(
                              icon: const Icon(Icons.delete, size: 16),
                              onPressed: () => _deletePreset(i),
                            )
                          : null,
                      onTap: () => _usePreset(_presets[i]),
                    ),
                  ),

                const SizedBox(height: 120),
              ],
            ),
          ),

          Positioned(
            bottom: 32,
            left: 0,
            right: 0,
            child: Center(
              child: FloatingActionButton(
                onPressed: _isRunning ? _pauseCountdown : _startCountdown,
                backgroundColor: Colors.blue,
                tooltip: _isRunning ? '暂停' : '开始',
                child: Icon(
                  _isRunning ? Icons.pause : Icons.play_arrow,
                  color: Colors.white,
                  size: 28,
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }

  void _showTimePicker() async {
    if (_isRunning) return;

    final result = await showModalBottomSheet<List<int>>(
      context: context,
      builder: (ctx) {
        int h = _hours;
        int m = _minutes;
        int s = _seconds;

        return StatefulBuilder(
          builder: (context, setState) {
            return Container(
              height: 220,
              padding: const EdgeInsets.symmetric(horizontal: 24),
              child: Column(
                children: [
                  const SizedBox(height: 16),
                  const Text('设置计时时间', style: TextStyle(fontSize: 18)),
                  const SizedBox(height: 16),
                  Expanded(
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                      children: [
                        _buildCupertinoPickerInDialog(
                          list: List.generate(24, (i) => i),
                          initialIndex: h,
                          onSelected: (v) => h = v,
                          label: '小时',
                        ),
                        _buildCupertinoPickerInDialog(
                          list: List.generate(60, (i) => i),
                          initialIndex: m,
                          onSelected: (v) => m = v,
                          label: '分钟',
                        ),
                        _buildCupertinoPickerInDialog(
                          list: List.generate(60, (i) => i),
                          initialIndex: s,
                          onSelected: (v) => s = v,
                          label: '秒',
                        ),
                      ],
                    ),
                  ),
                  TextButton(
                    onPressed: () {
                      Navigator.pop(ctx, [h, m, s]);
                    },
                    child: const Text('确定'),
                  ),
                ],
              ),
            );
          },
        );
      },
    );

    if (result != null) {
      setState(() {
        _hours = result[0];
        _minutes = result[1];
        _seconds = result[2];
      });
    }
  }

  Widget _buildCupertinoPickerInDialog({
    required List<int> list,
    required int initialIndex,
    required ValueChanged<int> onSelected,
    required String label,
  }) {
    return Expanded(
      child: Column(
        children: [
          Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey)),
          CupertinoPicker.builder(
            itemExtent: 32,
            backgroundColor: Colors.transparent,
            magnification: 1.0,
            squeeze: 1.0,
            scrollController: FixedExtentScrollController(initialItem: initialIndex),
            itemBuilder: (context, index) {
              return Center(child: Text('${list[index]}'.padLeft(2, '0')));
            },
            childCount: list.length,
            onSelectedItemChanged: onSelected,
          ),
        ],
      ),
    );
  }
}

class AddTimerPage extends StatefulWidget {
  final int initialHours;
  final int initialMinutes;
  final int initialSeconds;

  const AddTimerPage({
    super.key,
    this.initialHours = 0,
    this.initialMinutes = 0,
    this.initialSeconds = 0,
  });

  @override
  State<AddTimerPage> createState() => _AddTimerPageState();
}

class _AddTimerPageState extends State<AddTimerPage> {
  late int _hours;
  late int _minutes;
  late int _seconds;
  String _name = '';

  @override
  void initState() {
    super.initState();
    _hours = widget.initialHours;
    _minutes = widget.initialMinutes;
    _seconds = widget.initialSeconds;
  }

  Widget _buildCupertinoPicker({
    required List<int> list,
    required int initialIndex,
    required ValueChanged<int> onSelected,
    required String label,
  }) {
    return Expanded(
      child: Column(
        children: [
          Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey)),
          CupertinoPicker.builder(
            itemExtent: 32,
            backgroundColor: Colors.transparent,
            magnification: 1.0,
            squeeze: 1.0,
            scrollController: FixedExtentScrollController(initialItem: initialIndex),
            itemBuilder: (context, index) {
              return Center(child: Text('${list[index]}'.padLeft(2, '0')));
            },
            childCount: list.length,
            onSelectedItemChanged: onSelected,
          ),
        ],
      ),
    );
  }

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

    return Scaffold(
      appBar: AppBar(
        backgroundColor: isDark ? Colors.black : Colors.white,
        elevation: 0,
        title: const Text('添加计时器'),
        leading: IconButton(
          icon: const Icon(Icons.close),
          onPressed: () => Navigator.pop(context),
        ),
        actions: [
          IconButton(
            icon: const Icon(Icons.check),
            onPressed: () {
              if (_name.isEmpty) return;
              final preset = TimerPreset(
                name: _name,
                hours: _hours,
                minutes: _minutes,
                seconds: _seconds,
              );
              Navigator.pop(context, preset);
            },
          ),
        ],
      ),
      body: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Container(
              decoration: BoxDecoration(
                color: isDark ? Colors.grey[800]! : Colors.grey[100]!,
                borderRadius: BorderRadius.circular(16),
                boxShadow: [
                  BoxShadow(
                    color: isDark ? Colors.black.withOpacity(0.1) : Colors.grey.withOpacity(0.1),
                    blurRadius: 4,
                  ),
                ],
              ),
              child: Column(
                children: [
                  Padding(
                    padding: const EdgeInsets.symmetric(vertical: 8),
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                      children: [
                        Text('23', style: TextStyle(fontSize: 36, color: Colors.grey.withOpacity(0.5))),
                        Text('59', style: TextStyle(fontSize: 36, color: Colors.grey.withOpacity(0.5))),
                        Text('00', style: TextStyle(fontSize: 36, color: Colors.grey.withOpacity(0.5))),
                      ],
                    ),
                  ),

                  SizedBox(
                    height: 120,
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                      children: [
                        _buildCupertinoPicker(
                          list: List.generate(24, (i) => i),
                          initialIndex: _hours,
                          onSelected: (v) => setState(() => _hours = v),
                          label: '小时',
                        ),
                        _buildCupertinoPicker(
                          list: List.generate(60, (i) => i),
                          initialIndex: _minutes,
                          onSelected: (v) => setState(() => _minutes = v),
                          label: '分钟',
                        ),
                        _buildCupertinoPicker(
                          list: List.generate(60, (i) => i),
                          initialIndex: _seconds,
                          onSelected: (v) => setState(() => _seconds = v),
                          label: '秒',
                        ),
                      ],
                    ),
                  ),

                  Padding(
                    padding: const EdgeInsets.symmetric(vertical: 8),
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                      children: [
                        Text('01', style: TextStyle(fontSize: 36, color: Colors.grey.withOpacity(0.5))),
                        Text('01', style: TextStyle(fontSize: 36, color: Colors.grey.withOpacity(0.5))),
                        Text('02', style: TextStyle(fontSize: 36, color: Colors.grey.withOpacity(0.5))),
                      ],
                    ),
                  ),
                ],
              ),
            ),

            const SizedBox(height: 20),

            Container(
              decoration: BoxDecoration(
                color: isDark ? Colors.grey[800]! : Colors.grey[100]!,
                borderRadius: BorderRadius.circular(16),
                boxShadow: [
                  BoxShadow(
                    color: isDark ? Colors.black.withOpacity(0.1) : Colors.grey.withOpacity(0.1),
                    blurRadius: 4,
                  ),
                ],
              ),
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
              child: TextField(
                decoration: InputDecoration(
                  hintText: '计时器名称',
                  border: InputBorder.none,
                  hintStyle: TextStyle(color: Colors.grey.withOpacity(0.5)),
                ),
                onChanged: (v) => setState(() => _name = v),
              ),
            ),
            const SizedBox(height: 20),

            Container(
              decoration: BoxDecoration(
                color: isDark ? Colors.grey[800]! : Colors.grey[100]!,
                borderRadius: BorderRadius.circular(16),
                boxShadow: [
                  BoxShadow(
                    color: isDark ? Colors.black.withOpacity(0.1) : Colors.grey.withOpacity(0.1),
                    blurRadius: 4,
                  ),
                ],
              ),
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  const Text('铃声', style: TextStyle(fontSize: 16)),
                  const Text('默认铃声', style: TextStyle(fontSize: 14, color: Colors.grey)),
                  const Icon(Icons.arrow_forward_ios, size: 14, color: Colors.grey),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// ===========================
// 设置页面(来自秒表)
// ===========================
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),
      ),
    );
  }
}

运行界面:



六、总结:Flutter 在 OpenHarmony 生态中的定位

通过本次实践,我们验证了 Flutter 完全有能力构建符合 OpenHarmony 体验标准的系统级应用。其优势在于:

  • 开发效率高:一套代码覆盖多端,加速鸿蒙生态应用供给;
  • UI 表现力强:自绘引擎突破原生组件限制,实现高保真设计;
  • 社区生态成熟:大量插件可复用,降低开发成本。

当然,也需正视挑战:

  • 后台能力依赖 Platform Channel;
  • 部分鸿蒙特有 API(如分布式能力)需原生桥接;
  • 包体积略大于纯 ArkTS 应用。

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

相关推荐
2501_944525544 小时前
Flutter for OpenHarmony 个人理财管理App实战 - 预算详情页面
android·开发语言·前端·javascript·flutter·ecmascript
雨季6664 小时前
Flutter 三端应用实战:OpenHarmony 简易“动态主题切换卡片”交互模式
flutter·ui·交互·dart
熊猫钓鱼>_>4 小时前
【开源鸿蒙跨平台开发先锋训练营】Day 19: 开源鸿蒙React Native动效体系构建与混合开发复盘
react native·华为·开源·harmonyos·鸿蒙·openharmony
向哆哆4 小时前
构建健康档案管理快速入口:Flutter × OpenHarmony 跨端开发实战
flutter·开源·鸿蒙·openharmony·开源鸿蒙
2601_949593654 小时前
基础入门 React Native 鸿蒙跨平台开发:BackHandler 返回键控制
react native·react.js·harmonyos
mocoding5 小时前
使用Flutter强大的图标库fl_chart优化鸿蒙版天气预报温度、降水量、湿度展示
flutter·华为·harmonyos
向哆哆5 小时前
构建智能健康档案管理与预约挂号系统:Flutter × OpenHarmony 跨端开发实践
flutter·开源·鸿蒙·openharmony·开源鸿蒙
Cobboo5 小时前
i单词上架鸿蒙应用市场之路:一次从 Android 到 HarmonyOS 的完整实战
android·华为·harmonyos
Swift社区5 小时前
Flutter 路由系统,对比 RN / Web / iOS 有什么本质不同?
前端·flutter·ios
kirk_wang5 小时前
Flutter艺术探索-Flutter依赖注入:get_it与provider组合使用
flutter·移动开发·flutter教程·移动开发教程