feat(ui): 设置页第一版(缓存管理 / 诊断导出 / 连接状态 / 关于)

设置页此前没有任何入口,且内容全是占位。现在:
- 首页右上角菜单按钮 → 设置页
- 缓存:标签条数、封面数量/占用、临时探测文件占用,一键清理(保留歌单与收藏)
- 诊断:导出 版本/连接/缓存/当前播放/最近错误 到 txt,经 FileProvider 走系统分享
- 连接:显示 WebDAV 地址与用户,支持测试连接、退出登录
- 关于:版本、构建号、播放内核与 UI 许可说明
- 删除未被引用的 FullPlayerPage(与 player_page 重复)

真机验证:设置页正常打开,诊断导出成功调起系统分享面板。
This commit is contained in:
2026-10-24 21:28:22 +08:00
parent d07086aaa0
commit 4a8b1f28db
3 changed files with 365 additions and 165 deletions
+356 -48
View File
@@ -1,59 +1,367 @@
import 'package:flutter/material.dart';
// lib/pages/settings_page.dart
import 'dart:io';
class SettingsPage extends StatelessWidget {
import 'package:android_intent_plus/android_intent.dart';
import 'package:android_intent_plus/flag.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import '../database/song_database.dart';
import '../services/audio_service.dart';
import '../services/webdav_service.dart';
import '../utils/file_provider_utils.dart';
/// 与 pubspec.yaml 的 version 保持同步(发版时一起改)
const String kAppVersion = '1.1.0-dev.1';
const int kAppBuild = 2;
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('设置')),
body: ListView(
children: [
const SizedBox(height: 20),
_buildTile(
icon: Icons.folder_open_outlined,
title: '本地文件 (SAF)',
subtitle: '通过系统文件选择器导入',
onTap: () {
// 🟢 这里粘贴你旧项目的 SAF 逻辑
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('待接入 SAF 逻辑')),
);
},
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
bool _loading = true;
int _songRows = 0;
int _metadataRows = 0;
int _favoriteRows = 0;
int _artworkCount = 0;
int _artworkBytes = 0;
int _tempCount = 0;
int _tempBytes = 0;
@override
void initState() {
super.initState();
_loadStats();
}
Future<void> _loadStats() async {
setState(() => _loading = true);
try {
final db = await SongDatabase().database;
final songRows =
(await db.rawQuery('SELECT COUNT(*) AS c FROM songs')).first['c'];
final metaRows = (await db.rawQuery(
'SELECT COUNT(*) AS c FROM metadata_cache'))
.first['c'];
final favRows =
(await db.rawQuery('SELECT COUNT(*) AS c FROM favorites')).first['c'];
var artworkCount = 0, artworkBytes = 0;
final docs = await getApplicationDocumentsDirectory();
final artDir = Directory('${docs.path}/artworks');
if (await artDir.exists()) {
await for (final entity in artDir.list()) {
if (entity is File) {
artworkCount++;
artworkBytes += await entity.length();
}
}
}
var tempCount = 0, tempBytes = 0;
final tmp = await getTemporaryDirectory();
if (await tmp.exists()) {
await for (final entity in tmp.list()) {
if (entity is File &&
entity.path.contains('/metadata_') &&
entity.path.endsWith('.part')) {
tempCount++;
tempBytes += await entity.length();
}
}
}
if (!mounted) return;
setState(() {
_songRows = songRows as int? ?? 0;
_metadataRows = metaRows as int? ?? 0;
_favoriteRows = favRows as int? ?? 0;
_artworkCount = artworkCount;
_artworkBytes = artworkBytes;
_tempCount = tempCount;
_tempBytes = tempBytes;
_loading = false;
});
} catch (e) {
debugPrint('⚠️ [Settings] 统计失败: $e');
if (mounted) setState(() => _loading = false);
}
}
Future<void> _clearMetadataCache() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1A1F1E),
title: const Text('清理元数据缓存', style: TextStyle(color: Colors.white)),
content: const Text(
'会删除歌曲标签缓存和封面文件,歌单与收藏不受影响。\n下次播放时重新抓取。',
style: TextStyle(color: Colors.grey),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
_buildTile(
icon: Icons.cloud_outlined,
title: 'WebDAV 云盘',
subtitle: '连接你的 NAS 或网盘',
onTap: () {
// 🟢 这里调用你现成的 WebDAV 通道
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('待接入 WebDAV 逻辑')),
);
},
),
_buildTile(
icon: Icons.equalizer_outlined,
title: '均衡器',
subtitle: '简单低音增强 (后续)',
onTap: () {},
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('清理', style: TextStyle(color: Colors.redAccent)),
),
],
),
);
if (confirmed != true) return;
try {
final db = await SongDatabase().database;
await db.delete('songs');
await db.delete('metadata_cache');
final docs = await getApplicationDocumentsDirectory();
final artDir = Directory('${docs.path}/artworks');
if (await artDir.exists()) {
await artDir.delete(recursive: true);
}
final tmp = await getTemporaryDirectory();
if (await tmp.exists()) {
await for (final entity in tmp.list()) {
if (entity is File &&
entity.path.contains('/metadata_') &&
entity.path.endsWith('.part')) {
await entity.delete();
}
}
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('元数据缓存已清理')),
);
}
await _loadStats();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('清理失败: $e')));
}
}
}
Future<void> _exportDiagnostics() async {
try {
final now = DateTime.now();
final docs = await getApplicationDocumentsDirectory();
final file =
File('${docs.path}/diagnostics-${now.millisecondsSinceEpoch}.txt');
final song = AudioService().currentSong;
final buffer = StringBuffer()
..writeln('清听 诊断信息')
..writeln('时间: ${now.toIso8601String()}')
..writeln('版本: $kAppVersion ($kAppBuild)')
..writeln('平台: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}')
..writeln()
..writeln('[连接]')
..writeln('baseUrl: ${WebDAVService.instance.baseUrl ?? '(未配置)'}')
..writeln('username: ${WebDAVService.instance.username ?? '(未配置)'}')
..writeln('connected: ${WebDAVService.instance.isConnected}')
..writeln()
..writeln('[数据]')
..writeln('songs: $_songRows')
..writeln('metadata_cache: $_metadataRows')
..writeln('favorites: $_favoriteRows')
..writeln('artwork 文件: $_artworkCount 个 / ${_formatBytes(_artworkBytes)}')
..writeln('临时探测文件: $_tempCount 个 / ${_formatBytes(_tempBytes)}')
..writeln()
..writeln('[当前播放]')
..writeln('song: ${song?.title ?? '(无)'} - ${song?.artist ?? ''}')
..writeln('path: ${song?.id ?? ''}')
..writeln('position: ${AudioService().position}')
..writeln('duration: ${AudioService().duration}')
..writeln('lastError: ${AudioService().lastPlaybackError ?? '(无)'}');
await file.writeAsString(buffer.toString());
if (!mounted) return;
final uri = await FileProviderUtils.getContentUri(file);
if (uri != null) {
try {
final intent = AndroidIntent(
action: 'android.intent.action.SEND',
type: 'text/plain',
arguments: {
'android.intent.extra.STREAM': uri.toString(),
'android.intent.extra.SUBJECT': '清听诊断信息',
},
flags: const [Flag.FLAG_GRANT_READ_URI_PERMISSION],
);
await intent.launch();
return;
} catch (e) {
debugPrint('⚠️ [Settings] 分享失败: $e');
}
}
if (mounted) {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1A1F1E),
title: const Text('诊断信息已导出', style: TextStyle(color: Colors.white)),
content: SelectableText(
file.path,
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('好'),
),
],
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('导出失败: $e')));
}
}
}
Future<void> _testConnection() async {
try {
await WebDAVService.instance.listDirectory(path: '/');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('连接正常')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('连接失败: $e')),
);
}
}
}
Future<void> _logout() async {
await WebDAVService.instance.clearCredentials();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已退出登录,下次进入会要求重新配置')),
);
}
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB';
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0E1211),
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: const Text('设置'),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
_sectionTitle('缓存'),
ListTile(
leading: const Icon(Icons.cleaning_services_outlined,
color: Color(0xFF7C9A9E)),
title: const Text('元数据缓存'),
subtitle: Text(
'标签 $_songRows 首 · 封面 $_artworkCount 张 '
'(${_formatBytes(_artworkBytes)}) · 临时文件 ${_formatBytes(_tempBytes)}',
style: const TextStyle(fontSize: 12),
),
trailing: const Icon(Icons.chevron_right, color: Colors.grey),
onTap: _clearMetadataCache,
),
_sectionTitle('诊断'),
ListTile(
leading: const Icon(Icons.bug_report_outlined,
color: Color(0xFF7C9A9E)),
title: const Text('导出诊断信息'),
subtitle: const Text(
'版本、连接状态、缓存统计、当前播放与最近错误',
style: TextStyle(fontSize: 12),
),
trailing: const Icon(Icons.ios_share, color: Colors.grey),
onTap: _exportDiagnostics,
),
_sectionTitle('连接'),
ListTile(
leading: const Icon(Icons.cloud_outlined,
color: Color(0xFF7C9A9E)),
title: Text(WebDAVService.instance.baseUrl ?? '(未配置)'),
subtitle: Text(
'用户 ${WebDAVService.instance.username ?? '-'} · '
'${WebDAVService.instance.isConnected ? '已连接' : '未连接'}',
style: const TextStyle(fontSize: 12),
),
),
ListTile(
leading: const Icon(Icons.wifi_tethering,
color: Color(0xFF7C9A9E)),
title: const Text('测试连接'),
onTap: _testConnection,
),
ListTile(
leading:
const Icon(Icons.logout, color: Colors.redAccent),
title: const Text('退出登录'),
subtitle: const Text(
'清除本机保存的 WebDAV 账号',
style: TextStyle(fontSize: 12),
),
onTap: _logout,
),
_sectionTitle('关于'),
ListTile(
leading:
const Icon(Icons.info_outline, color: Color(0xFF7C9A9E)),
title: const Text('清听'),
subtitle: const Text(
'版本 $kAppVersion ($kAppBuild)\n'
'播放内核 media_kit (libmpv, LGPL/GPL) · UI Flutter (BSD)',
style: TextStyle(fontSize: 12),
),
),
const SizedBox(height: 24),
],
),
);
}
Widget _sectionTitle(String text) {
return Padding(
padding: const EdgeInsets.fromLTRB(4, 20, 4, 8),
child: Text(
text,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFFB8D4D0),
letterSpacing: 1.2,
),
),
);
}
}
Widget _buildTile(
{required IconData icon,
required String title,
required String subtitle,
required VoidCallback onTap}) {
return ListTile(
leading: Icon(icon, color: const Color(0xFF7C9A9E)),
title: Text(title),
subtitle: Text(subtitle, style: const TextStyle(fontSize: 12)),
trailing: const Icon(Icons.chevron_right, color: Colors.grey),
onTap: onTap,
);
}