From 4a8b1f28dbb62a0d895dc87374f72767e0fb3739 Mon Sep 17 00:00:00 2001 From: lxh2875931338 Date: Sat, 24 Oct 2026 21:28:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E7=AC=AC=E4=B8=80=E7=89=88=EF=BC=88=E7=BC=93=E5=AD=98=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20/=20=E8=AF=8A=E6=96=AD=E5=AF=BC=E5=87=BA=20/=20?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E7=8A=B6=E6=80=81=20/=20=E5=85=B3=E4=BA=8E?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设置页此前没有任何入口,且内容全是占位。现在: - 首页右上角菜单按钮 → 设置页 - 缓存:标签条数、封面数量/占用、临时探测文件占用,一键清理(保留歌单与收藏) - 诊断:导出 版本/连接/缓存/当前播放/最近错误 到 txt,经 FileProvider 走系统分享 - 连接:显示 WebDAV 地址与用户,支持测试连接、退出登录 - 关于:版本、构建号、播放内核与 UI 许可说明 - 删除未被引用的 FullPlayerPage(与 player_page 重复) 真机验证:设置页正常打开,诊断导出成功调起系统分享面板。 --- lib/pages/home_page.dart | 10 +- lib/pages/settings_page.dart | 404 ++++++++++++++++++++++++++---- lib/widgets/full_player_page.dart | 116 --------- 3 files changed, 365 insertions(+), 165 deletions(-) delete mode 100644 lib/widgets/full_player_page.dart diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 7bd4341..3516894 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -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 { ), IconButton( icon: const Icon(Icons.menu, color: Colors.white54), - onPressed: () {}, + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const SettingsPage(), + ), + ); + }, ), ], ), diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index dc5764e..11b6692 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -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 createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + 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 _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 _clearMetadataCache() async { + final confirmed = await showDialog( + 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 _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( + 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 _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 _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, - ); -} diff --git a/lib/widgets/full_player_page.dart b/lib/widgets/full_player_page.dart deleted file mode 100644 index 537aa10..0000000 --- a/lib/widgets/full_player_page.dart +++ /dev/null @@ -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(); - 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().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, - ), - ], - ) - ], - ), - ), - ); - } -}