feat(ui): 设置页第一版(缓存管理 / 诊断导出 / 连接状态 / 关于)
设置页此前没有任何入口,且内容全是占位。现在: - 首页右上角菜单按钮 → 设置页 - 缓存:标签条数、封面数量/占用、临时探测文件占用,一键清理(保留歌单与收藏) - 诊断:导出 版本/连接/缓存/当前播放/最近错误 到 txt,经 FileProvider 走系统分享 - 连接:显示 WebDAV 地址与用户,支持测试连接、退出登录 - 关于:版本、构建号、播放内核与 UI 许可说明 - 删除未被引用的 FullPlayerPage(与 player_page 重复) 真机验证:设置页正常打开,诊断导出成功调起系统分享面板。
This commit is contained in:
@@ -4,6 +4,7 @@ import '../services/audio_service.dart';
|
||||
import '../services/webdav_service.dart';
|
||||
import '../widgets/magnetic_scroll_physics.dart';
|
||||
import 'webdav_setup_page.dart';
|
||||
import 'settings_page.dart';
|
||||
import 'webdav_file_list_page.dart';
|
||||
import '../constants/ui_constants.dart';
|
||||
import '../base/base_state.dart';
|
||||
@@ -365,7 +366,14 @@ class _HomePageState extends BaseState<HomePage> {
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.menu, color: Colors.white54),
|
||||
onPressed: () {},
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const SettingsPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+353
-45
@@ -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),
|
||||
),
|
||||
_buildTile(
|
||||
icon: Icons.cloud_outlined,
|
||||
title: 'WebDAV 云盘',
|
||||
subtitle: '连接你的 NAS 或网盘',
|
||||
onTap: () {
|
||||
// 🟢 这里调用你现成的 WebDAV 通道
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('待接入 WebDAV 逻辑')),
|
||||
);
|
||||
},
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
_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')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
// lib/widgets/full_player_page.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/audio_service.dart';
|
||||
|
||||
class FullPlayerPage extends StatelessWidget {
|
||||
const FullPlayerPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final service = context.watch<AudioService>();
|
||||
final song = service.currentSong!;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF0E1211),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.playlist_play_outlined),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 280,
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: const Color(0xFF2A3332),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF7C9A9E).withOpacity(0.2),
|
||||
blurRadius: 60,
|
||||
spreadRadius: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
child:
|
||||
const Icon(Icons.music_note, size: 80, color: Colors.white24),
|
||||
),
|
||||
const SizedBox(height: 60),
|
||||
Text(
|
||||
song.title,
|
||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
song.artist,
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[400]),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
Slider(
|
||||
value: 0.3,
|
||||
onChanged: (v) {},
|
||||
activeColor: const Color(0xFFB8D4D0),
|
||||
inactiveColor: Colors.grey[800],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.skip_previous, size: 32),
|
||||
color: Colors.white60,
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
CircleAvatar(
|
||||
radius: 32,
|
||||
backgroundColor: const Color(0xFF7C9A9E),
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
service.isPlaying ? Icons.pause : Icons.play_arrow,
|
||||
color: Colors.black87,
|
||||
size: 32,
|
||||
),
|
||||
onPressed: () => context.read<AudioService>().togglePlay(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.skip_next, size: 32),
|
||||
color: Colors.white60,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.volume_up_outlined),
|
||||
color: Colors.white38,
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user