Flutter for OpenHarmony高级闹钟App实战:铃声选择器实现
本文介绍了Flutter闹钟应用中铃声选择器的实现方案。通过定义铃声数据模型(Ringtone类)和分类枚举(RingtoneType),建立完整的铃声数据结构。采用GetX状态管理实现铃声控制器,处理铃声加载、播放控制和状态监听,支持内置铃声与自定义铃声。UI部分实现带筛选功能的铃声选择页面,包括分类筛选、播放预览和选中状态显示。整体设计注重用户体验,通过响应式编程确保UI与数据同步,并提供错误
铃声选择器是闹钟应用中很重要的一个功能。用户设置闹钟时,能选择自己喜欢的铃声,这直接影响起床体验。咱们这次要实现一个功能完整的铃声选择器,不仅能选择内置铃声,还能预览播放、调节音量,甚至支持导入自定义铃声。
说实话,做这个功能的时候,我一直在想怎么让用户能快速找到合适的铃声。最后决定用分类筛选、搜索、预览播放这几个功能组合起来,让选择过程既快速又直观。
铃声数据模型
首先定义铃声的数据结构,这是整个功能的基础。
/// 铃声模型
class Ringtone {
final String id;
final String name;
final String path;
final RingtoneType type;
final int duration;
final bool isCustom;
const Ringtone({
required this.id,
required this.name,
required this.path,
required this.type,
required this.duration,
this.isCustom = false,
});
}
enum RingtoneType {
gentle, energetic, nature, classic, custom,
}
模型字段设计:id用来唯一标识铃声,name是显示给用户的名称,path是音频文件路径。type字段把铃声分成轻柔、活力、自然、经典、自定义几个类别,方便用户按喜好筛选。duration记录铃声时长,isCustom标记是否为用户导入的铃声。
枚举类型的使用:用enum定义铃声类型比用字符串更安全,编译时就能检查错误,而且IDE能提供自动补全。
铃声控制器基础
接下来实现铃声管理的控制器,负责加载和播放铃声。
import 'package:get/get.dart';
import 'package:audioplayers/audioplayers.dart';
import '../models/ringtone.dart';
class RingtoneController extends GetxController {
final _audioPlayer = AudioPlayer();
final ringtones = <Ringtone>[].obs;
final customRingtones = <Ringtone>[].obs;
final currentPlayingId = Rxn<String>();
final isPlaying = false.obs;
GetX控制器:继承GetxController让咱们能用GetX的依赖注入和生命周期管理。
响应式变量:ringtones和customRingtones用obs包裹,UI能自动响应数据变化。currentPlayingId用Rxn表示可空的响应式变量,记录当前播放的铃声ID。isPlaying标记播放状态,UI可以据此显示播放或停止图标。
AudioPlayer实例:用audioplayers包来处理音频播放,这个包在Flutter中很成熟,支持多种音频格式。
void onInit() {
super.onInit();
_loadBuiltInRingtones();
_loadCustomRingtones();
_setupAudioPlayerListeners();
}
void _setupAudioPlayerListeners() {
_audioPlayer.onPlayerStateChanged.listen((state) {
isPlaying.value = state == PlayerState.playing;
if (state == PlayerState.completed) {
currentPlayingId.value = null;
}
});
}
初始化流程:onInit在控制器创建时调用,这里加载内置铃声和自定义铃声,然后设置播放器监听。
播放状态监听:监听播放器状态变化,更新isPlaying变量。当播放完成时,清除currentPlayingId,这样UI上的播放按钮会恢复到初始状态。这种自动状态同步让代码更简洁。
void _loadBuiltInRingtones() {
ringtones.value = [
const Ringtone(
id: 'gentle_wake',
name: '轻柔唤醒',
path: 'assets/ringtones/gentle_wake.mp3',
type: RingtoneType.gentle,
duration: 30,
),
const Ringtone(
id: 'morning_breeze',
name: '晨风',
内置铃声列表:预定义几个内置铃声,涵盖不同类型。path指向assets目录,这些文件需要在pubspec.yaml中声明。
const构造函数:Ringtone用const构造,这些对象在编译时就创建好了,运行时不需要重新分配内存,性能更好。
播放控制实现
实现铃声的播放和停止功能。
<function_calls>
articles/10_flutter_for_openharmony高级闹钟app实战+铃声选择器实现/flutter_for_openharmony高级闹钟app实战+铃声选择器实现.md
Future<void> playRingtone(String ringtoneId, {double volume = 0.7}) async {
try {
if (currentPlayingId.value == ringtoneId) {
await stopRingtone();
return;
}
await stopRingtone();
final ringtone = _findRingtoneById(ringtoneId);
if (ringtone == null) return;
播放逻辑设计:如果点击的是正在播放的铃声,就停止播放,这样用户可以通过再次点击来停止。否则先停止当前播放,再播放新铃声,避免多个铃声同时响。
查找铃声:_findRingtoneById在内置和自定义铃声中查找,找不到就返回null。这种防御性编程能避免因数据不一致导致的崩溃。
await _audioPlayer.setVolume(volume);
await _audioPlayer.play(AssetSource(ringtone.path));
currentPlayingId.value = ringtoneId;
} catch (e) {
Get.snackbar('错误', '播放铃声失败: $e');
}
}
Future<void> stopRingtone() async {
await _audioPlayer.stop();
currentPlayingId.value = null;
}
音量设置:先设置音量再播放,volume参数默认0.7,这是个比较舒适的预览音量。
错误处理:用try-catch捕获播放异常,通过snackbar提示用户。在音频播放中,文件不存在、格式不支持等问题都可能导致异常。
停止播放:stopRingtone停止播放并清除currentPlayingId,这个方法会被多处调用,所以单独提取出来。
铃声选择器UI
现在实现铃声选择器的界面部分。
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '../../controllers/ringtone_controller.dart';
class RingtonePickerPage extends StatefulWidget {
final String? selectedRingtoneId;
const RingtonePickerPage({super.key, this.selectedRingtoneId});
State<RingtonePickerPage> createState() => _RingtonePickerPageState();
}
页面参数:selectedRingtoneId传入当前选中的铃声ID,这样可以在列表中高亮显示当前选择。
StatefulWidget的选择:虽然用GetX也能管理状态,但对于筛选类型这种临时的UI状态,用StatefulWidget的setState更直接。
class _RingtonePickerPageState extends State<RingtonePickerPage> {
final _controller = Get.find<RingtoneController>();
late String? _selectedId;
RingtoneType? _filterType;
void initState() {
super.initState();
_selectedId = widget.selectedRingtoneId;
}
状态变量:_selectedId记录用户选择的铃声,初始值从widget参数获取。_filterType用于筛选铃声类型,null表示显示全部。
Get.find获取控制器:RingtoneController应该在应用启动时就注册好了,这里直接find获取实例。
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('选择铃声'),
actions: [
TextButton(
onPressed: () => Get.back(result: _selectedId),
child: const Text('确定', style: TextStyle(color: Colors.white)),
),
],
),
body: Column(
children: [
AppBar设计:右上角的确定按钮返回选中的铃声ID,用Get.back的result参数传递结果。
布局结构:用Column垂直布局,上面是筛选条件,下面是铃声列表。这种结构很常见,用户一眼就能理解。
筛选功能实现
添加铃声类型的筛选功能。
Widget _buildFilterChips() {
return Container(
padding: EdgeInsets.all(16.w),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
FilterChip(
label: const Text('全部'),
selected: _filterType == null,
onSelected: (_) => setState(() => _filterType = null),
),
SizedBox(width: 8.w),
FilterChip组件:Material Design的筛选芯片,selected状态会改变样式。点击后用setState更新_filterType并刷新UI。
横向滚动:用SingleChildScrollView包裹,scrollDirection设为horizontal,这样筛选项多了也不会挤在一起。
FilterChip(
label: const Text('轻柔'),
selected: _filterType == RingtoneType.gentle,
onSelected: (_) => setState(() => _filterType = RingtoneType.gentle),
),
SizedBox(width: 8.w),
FilterChip(
label: const Text('活力'),
selected: _filterType == RingtoneType.energetic,
onSelected: (_) => setState(() => _filterType = RingtoneType.energetic),
),
筛选选项:每个类型对应一个FilterChip,selected根据_filterType判断。onSelected回调中更新筛选类型。
间距处理:芯片之间用SizedBox分隔,8.w的宽度让视觉上有呼吸感。
铃声列表展示
实现铃声列表的构建逻辑。
Widget _buildRingtoneList() {
return Obx(() {
final allRingtones = [
..._controller.ringtones,
..._controller.customRingtones,
];
final filteredRingtones = _filterType == null
? allRingtones
: allRingtones.where((r) => r.type == _filterType).toList();
Obx响应式构建:用Obx包裹让列表能响应ringtones和customRingtones的变化,比如用户添加了自定义铃声。
合并列表:用展开运算符合并内置和自定义铃声,这样用户能在一个列表中看到所有铃声。
筛选逻辑:_filterType为null时显示全部,否则用where过滤出匹配类型的铃声。
if (filteredRingtones.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.music_off, size: 64.sp, color: Colors.grey),
SizedBox(height: 16.h),
Text('暂无铃声', style: TextStyle(color: Colors.grey, fontSize: 16.sp)),
],
),
);
}
空状态处理:筛选后列表为空时,显示友好的提示。用music_off图标和灰色文字,让用户知道这不是错误,只是没有匹配的铃声。
居中布局:用Center和Column让空状态提示在屏幕中央,视觉上更平衡。
return ListView.builder(
padding: EdgeInsets.symmetric(horizontal: 16.w),
itemCount: filteredRingtones.length,
itemBuilder: (context, index) {
final ringtone = filteredRingtones[index];
return _buildRingtoneItem(ringtone);
},
);
});
}
ListView.builder:用builder模式构建列表,只创建可见的item,性能更好。即使有上百个铃声也不会卡顿。
padding设置:左右各16.w的padding让列表内容不会紧贴屏幕边缘,视觉上更舒适。
铃声项设计
实现单个铃声项的UI。
Widget _buildRingtoneItem(Ringtone ringtone) {
final isSelected = _selectedId == ringtone.id;
final isPlaying = _controller.currentPlayingId.value == ringtone.id;
return Card(
margin: EdgeInsets.only(bottom: 12.h),
elevation: isSelected ? 4 : 1,
child: ListTile(
contentPadding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
leading: Container(
width: 48.w,
height: 48.w,
状态判断:isSelected判断是否为当前选中的铃声,isPlaying判断是否正在播放。这两个状态会影响UI样式。
Card的elevation:选中的铃声elevation更高,产生浮起的视觉效果,让用户清楚知道当前选择。
ListTile布局:ListTile是Material Design的标准列表项组件,自动处理点击波纹、间距等细节。
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).primaryColor.withOpacity(0.1)
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(24.r),
),
child: Icon(
_getRingtoneIcon(ringtone.type),
color: isSelected ? Theme.of(context).primaryColor : Colors.grey,
),
),
leading图标设计:用圆形容器包裹图标,选中时用主题色背景,未选中时用灰色。这种视觉反馈很直观。
图标选择:_getRingtoneIcon根据铃声类型返回不同图标,轻柔用月亮,活力用闪电,自然用树叶,让用户一眼识别类型。
title: Text(
ringtone.name,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
subtitle: Text('${ringtone.duration}秒 · ${_getTypeLabel(ringtone.type)}'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (ringtone.isCustom)
Icon(Icons.star, size: 16.sp, color: Colors.amber),
标题样式:选中的铃声用粗体显示,增强视觉区分度。
副标题信息:显示时长和类型,用中点分隔,信息密度适中。
自定义标记:如果是用户导入的铃声,显示星标图标,让用户知道这是自己的铃声。
SizedBox(width: 8.w),
IconButton(
icon: Icon(isPlaying ? Icons.stop_circle : Icons.play_circle),
color: Theme.of(context).primaryColor,
onPressed: () => _controller.playRingtone(ringtone.id),
),
],
),
onTap: () => setState(() => _selectedId = ringtone.id),
),
);
}
播放按钮:根据isPlaying状态切换图标,播放时显示停止图标,停止时显示播放图标。点击调用控制器的playRingtone方法。
点击选择:点击整个ListTile会选中这个铃声,更新_selectedId并刷新UI。这种大面积的点击区域比只能点按钮更友好。
音量预览组件
添加音量调节功能,让用户能调整预览音量。
class VolumePreviewWidget extends StatefulWidget {
final double initialVolume;
final ValueChanged<double>? onVolumeChanged;
const VolumePreviewWidget({
super.key,
this.initialVolume = 0.7,
this.onVolumeChanged,
});
State<VolumePreviewWidget> createState() => _VolumePreviewWidgetState();
}
组件参数:initialVolume设置初始音量,默认0.7。onVolumeChanged回调让父组件能响应音量变化。
独立组件:把音量控制做成独立组件,可以在多个地方复用,比如铃声选择器、闹钟编辑器等。
class _VolumePreviewWidgetState extends State<VolumePreviewWidget> {
late double _volume;
void initState() {
super.initState();
_volume = widget.initialVolume;
}
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.all(16.w),
状态管理:_volume记录当前音量值,初始化时从widget参数获取。
Card容器:用Card包裹让音量控制区域有明显的视觉边界,和其他内容区分开。
child: Padding(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.volume_up, size: 24.sp),
SizedBox(width: 8.w),
Text('预览音量', style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold)),
const Spacer(),
Text('${(_volume * 100).toInt()}%', style: TextStyle(fontSize: 16.sp, color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold)),
],
),
标题行设计:左侧音量图标和文字,右侧显示百分比。Spacer让两边内容分别靠左右对齐。
百分比显示:把0-1的音量值转换成0-100的百分比,用主题色高亮显示,让用户清楚知道当前音量。
SizedBox(height: 8.h),
SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 4.h,
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 8.r),
overlayShape: RoundSliderOverlayShape(overlayRadius: 16.r),
),
child: Slider(
value: _volume,
min: 0.0,
max: 1.0,
divisions: 20,
Slider定制:用SliderTheme定制滑块样式,trackHeight设置轨道高度,thumbShape设置滑块大小,overlayShape设置点击波纹大小。
divisions参数:设为20意味着有21个档位(0-20),每个档位对应5%的音量变化,这个粒度对预览来说刚好。
onChanged: (value) {
setState(() => _volume = value);
widget.onVolumeChanged?.call(value);
},
),
),
],
),
),
);
}
}
音量变化处理:onChanged回调中更新_volume并刷新UI,同时调用父组件的回调。这样父组件能实时获取音量变化,比如立即应用到正在播放的铃声。
可选回调:用?.call确保onVolumeChanged为null时不会报错,这让组件使用更灵活。
自定义铃声导入
实现用户导入自己音乐作为铃声的功能。
import 'package:file_picker/file_picker.dart';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
extension RingtoneControllerCustom on RingtoneController {
Future<void> importCustomRingtone() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.audio,
allowMultiple: false,
);
Extension扩展:用extension给RingtoneController添加方法,而不是直接修改类,这样代码组织更清晰。
FilePicker使用:type设为FileType.audio确保只能选择音频文件,allowMultiple设为false表示一次只能选一个。
if (result == null || result.files.isEmpty) return;
final file = result.files.first;
if (file.path == null) {
Get.snackbar('错误', '无法读取文件');
return;
}
final fileSize = File(file.path!).lengthSync();
if (fileSize > 10 * 1024 * 1024) {
Get.snackbar('错误', '文件大小不能超过10MB');
return;
}
文件验证:检查用户是否选择了文件,path是否为null。然后检查文件大小,限制在10MB以内,避免占用太多存储空间。
用户提示:验证失败时用snackbar提示用户,说明具体原因,而不是静默失败。
final id = 'custom_${DateTime.now().millisecondsSinceEpoch}';
final name = file.name.replaceAll(RegExp(r'\.[^.]+$'), '');
final appDir = await getApplicationDocumentsDirectory();
final customDir = Directory('${appDir.path}/ringtones');
if (!await customDir.exists()) {
await customDir.create(recursive: true);
}
final newPath = '${customDir.path}/$id.${file.extension}';
await File(file.path!).copy(newPath);
ID生成:用时间戳生成唯一ID,前缀custom_表示这是自定义铃声。
文件名处理:用正则表达式去掉扩展名,得到铃声名称。
文件复制:把用户选择的文件复制到应用目录,而不是直接引用原路径。这样即使用户删除了原文件,铃声也能正常使用。recursive: true确保父目录不存在时会自动创建。
final ringtone = Ringtone(
id: id,
name: name,
path: newPath,
type: RingtoneType.custom,
duration: 30,
isCustom: true,
);
customRingtones.add(ringtone);
await _saveCustomRingtones();
Get.snackbar('成功', '铃声已导入');
} catch (e) {
Get.snackbar('错误', '导入失败: $e');
}
}
创建铃声对象:用复制后的路径创建Ringtone对象,type设为custom,isCustom设为true。duration默认30秒,实际可以通过音频解析获取准确时长。
保存和提示:添加到customRingtones列表,调用_saveCustomRingtones持久化,最后提示用户导入成功。整个过程用try-catch包裹,任何异常都会被捕获并提示。
铃声持久化
实现自定义铃声的保存和加载。
Future<void> _saveCustomRingtones() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonList = customRingtones.map((r) => r.toJson()).toList();
await prefs.setString('custom_ringtones', jsonEncode(jsonList));
} catch (e) {
debugPrint('保存自定义铃声失败: $e');
}
}
Future<void> loadCustomRingtones() async {
try {
final prefs = await SharedPreferences.getInstance();
SharedPreferences存储:用SharedPreferences保存铃声列表,这是Flutter中最简单的持久化方案,适合存储少量数据。
JSON序列化:把Ringtone对象列表转换成JSON字符串存储,加载时再反序列化。这要求Ringtone类实现toJson和fromJson方法。
final jsonString = prefs.getString('custom_ringtones');
if (jsonString == null) return;
final jsonList = jsonDecode(jsonString) as List;
customRingtones.value = jsonList
.map((json) => Ringtone.fromJson(json as Map<String, dynamic>))
.toList();
} catch (e) {
debugPrint('加载自定义铃声失败: $e');
}
}
}
加载逻辑:从SharedPreferences读取JSON字符串,解码成List,然后map转换成Ringtone对象列表。
错误处理:加载失败时只打印日志,不影响应用启动。这样即使数据损坏,用户也能正常使用内置铃声。
铃声删除功能
添加删除自定义铃声的功能。
Future<void> deleteCustomRingtone(String id) async {
try {
final ringtone = customRingtones.firstWhere((r) => r.id == id);
final file = File(ringtone.path);
if (await file.exists()) {
await file.delete();
}
customRingtones.removeWhere((r) => r.id == id);
await _saveCustomRingtones();
Get.snackbar('成功', '铃声已删除');
} catch (e) {
Get.snackbar('错误', '删除失败: $e');
}
}
删除流程:先找到铃声对象,删除文件,从列表移除,保存更新后的列表,最后提示用户。
文件删除:删除实际的音频文件,避免留下垃圾文件占用存储空间。先检查文件是否存在,避免文件已被删除时报错。
列表更新:用removeWhere从列表中移除,这会触发UI更新。然后调用_saveCustomRingtones持久化变更。
在闹钟编辑器中集成
把铃声选择器集成到闹钟编辑器中。
// 在AlarmEditorPage中修改铃声选择部分
Widget _buildRingtoneSelector() {
return Card(
child: ListTile(
leading: const Icon(Icons.music_note),
title: const Text('铃声'),
subtitle: Obx(() {
final controller = Get.find<RingtoneController>();
final ringtone = controller.ringtones
.firstWhereOrNull((r) => r.id == _ringtoneId);
return Text(ringtone?.name ?? _ringtoneId);
}),
Obx响应式显示:用Obx包裹subtitle,这样铃声列表变化时能自动更新显示的名称。
查找铃声名称:根据_ringtoneId查找对应的铃声对象,显示name字段。找不到时显示ID本身,这是一种降级处理。
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Obx(() {
final controller = Get.find<RingtoneController>();
final isPlaying = controller.currentPlayingId.value == _ringtoneId;
return IconButton(
icon: Icon(isPlaying ? Icons.stop : Icons.play_arrow),
onPressed: () => controller.playRingtone(_ringtoneId),
);
}),
const Icon(Icons.chevron_right),
],
),
预览按钮:在trailing添加播放按钮,让用户不进入选择器也能预览当前铃声。根据播放状态切换图标。
导航图标:chevron_right提示用户可以点击进入选择器,这是Material Design的常见模式。
onTap: () async {
final result = await Get.to<String>(
() => RingtonePickerPage(selectedRingtoneId: _ringtoneId),
);
if (result != null) {
setState(() => _ringtoneId = result);
}
},
),
);
}
导航和结果处理:用Get.to打开铃声选择器,传入当前选中的ID。await等待用户选择,result不为null时更新_ringtoneId。
类型安全:Get.to指定返回类型,这样result的类型是String?,编译器能做类型检查。
总结
这篇文章咱们实现了一个功能完整的铃声选择器。从数据模型到UI组件,从音频播放到文件管理,每个环节都考虑得很周到。铃声分类、搜索、预览、导入这些功能让用户体验很完整。
说实话,做这个功能让我对音频处理有了更深的理解。audioplayers包虽然简单,但足够应对大多数场景。文件管理要注意权限和存储空间,用户导入的文件要复制到应用目录更可靠。UI设计要考虑各种状态,播放中、选中、空列表都要有合适的视觉反馈。
如果你也在做类似的功能,建议重点关注音频资源的管理和释放,这直接影响应用性能。另外用户体验的细节,比如预览音量、播放状态指示、友好的错误提示,这些看似小事,实际上决定了产品的品质。
欢迎加入OpenHarmony跨平台开发社区交流:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)