import 'package:flutter/material.dart'; import '../agent.dart'; import '../setup/autostart.dart'; import '../setup/first_run.dart'; import 'routes.dart'; import 'setup_wizard.dart'; /// 应用外壳:左侧插件菜单 + 右侧插件页面(菜单项来自路由注册表)。 class AppShell extends StatefulWidget { const AppShell({super.key, required this.agent, this.startError}); final Agent agent; final String? startError; @override State createState() => _AppShellState(); } class _AppShellState extends State { int _selectedIndex = 0; bool _autostart = false; bool _autostartLoaded = false; Agent get _agent => widget.agent; @override void initState() { super.initState(); _loadAutostart(); _maybeShowWizard(); } Future _loadAutostart() async { final enabled = await Autostart.isEnabled(); if (mounted) { setState(() { _autostart = enabled; _autostartLoaded = true; }); } } Future _toggleAutostart(bool value) async { setState(() => _autostart = value); try { value ? await Autostart.enable() : await Autostart.disable(); } catch (_) { if (mounted) await _loadAutostart(); // 失败回读真实状态 } } Future _maybeShowWizard() async { if (await FirstRun.isDone()) return; if (!mounted) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; showDialog( context: context, barrierDismissible: false, builder: (_) => SetupWizard(agent: _agent, onFinished: _loadAutostart), ); }); } @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Scaffold( body: Row( children: [ _sidebar(context, scheme), VerticalDivider(width: 1, thickness: 1, color: scheme.outlineVariant), Expanded(child: _content(context, scheme)), ], ), ); } // ---------------- 左侧菜单 ---------------- Widget _sidebar(BuildContext context, ColorScheme scheme) { final serviceOnline = _agent.port != null; return Container( width: 216, color: scheme.surfaceContainerLow, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 产品区 Padding( padding: const EdgeInsets.fromLTRB(16, 18, 16, 14), child: Row( children: [ ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.asset('assets/logo.png', width: 28, height: 28), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '资安信插件助手', style: Theme.of(context) .textTheme .titleSmall ?.copyWith(fontWeight: FontWeight.w700, letterSpacing: 0.2), ), Text('桌面插件', style: Theme.of(context).textTheme.bodySmall), ], ), ), _ServiceDot(online: serviceOnline), ], ), ), Divider(height: 1, thickness: 1, color: scheme.outlineVariant), // 插件菜单(来自路由注册表) Expanded( child: ListView( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), children: [ Padding( padding: const EdgeInsets.fromLTRB(8, 4, 8, 6), child: Text( '插件', style: Theme.of(context) .textTheme .labelSmall ?.copyWith(color: scheme.onSurfaceVariant), ), ), for (var i = 0; i < appRoutes.length; i++) _SidebarItem( route: appRoutes[i], selected: i == _selectedIndex, onTap: () => setState(() => _selectedIndex = i), ), ], ), ), Divider(height: 1, thickness: 1, color: scheme.outlineVariant), // 设置区:开机自启 + 重新运行引导 Padding( padding: const EdgeInsets.fromLTRB(8, 6, 8, 0), child: SwitchListTile( dense: true, contentPadding: const EdgeInsets.symmetric(horizontal: 8), value: _autostartLoaded && _autostart, onChanged: Autostart.isSupported ? _toggleAutostart : null, title: Text('开机自动启动', style: Theme.of(context).textTheme.bodySmall), secondary: Icon(Icons.power_settings_new, size: 18, color: scheme.onSurfaceVariant), ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 8, 4), child: Align( alignment: Alignment.centerLeft, child: TextButton.icon( onPressed: () { showDialog( context: context, barrierDismissible: false, builder: (_) => SetupWizard(agent: _agent, onFinished: _loadAutostart), ); }, icon: const Icon(Icons.restart_alt, size: 14), label: const Text('重新运行引导'), style: TextButton.styleFrom( textStyle: Theme.of(context).textTheme.bodySmall, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), ), ), ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), child: Text( 'v1.0.0', style: Theme.of(context) .textTheme .bodySmall ?.copyWith(color: scheme.onSurfaceVariant), ), ), ], ), ); } // ---------------- 右侧内容 ---------------- Widget _content(BuildContext context, ColorScheme scheme) { final error = widget.startError; if (error != null) { return _ServiceErrorView(message: error); } // IndexedStack 保持各插件页状态,切换菜单不丢页面状态 return IndexedStack( index: _selectedIndex, children: [ for (final route in appRoutes) route.builder(context, _agent), ], ); } } /// 侧栏插件项:图标 + 标题/副标题 + 选中态(高亮底 + 左侧指示条)。 class _SidebarItem extends StatelessWidget { const _SidebarItem({required this.route, required this.selected, required this.onTap}); final AppRoute route; final bool selected; final VoidCallback onTap; static const double _minHeight = 46; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final fg = selected ? scheme.onSurface : scheme.onSurfaceVariant; return Padding( padding: const EdgeInsets.only(bottom: 2), child: Material( color: selected ? scheme.primaryContainer : Colors.transparent, borderRadius: BorderRadius.circular(8), child: InkWell( borderRadius: BorderRadius.circular(8), hoverColor: scheme.primaryContainer.withValues(alpha: 0.35), onTap: onTap, child: Container( constraints: const BoxConstraints(minHeight: _minHeight), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( children: [ Container( width: 3, height: 22, decoration: BoxDecoration( color: selected ? scheme.primary : Colors.transparent, borderRadius: BorderRadius.circular(2), ), ), const SizedBox(width: 10), Icon(route.icon, size: 20, color: selected ? scheme.primary : fg), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( route.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context) .textTheme .bodyMedium ?.copyWith(color: fg, fontWeight: selected ? FontWeight.w600 : null), ), Text( route.subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context) .textTheme .bodySmall ?.copyWith(color: scheme.onSurfaceVariant), ), ], ), ), ], ), ), ), ), ); } } /// 服务在线小圆点(带可达性语义,不只靠颜色)。 class _ServiceDot extends StatelessWidget { const _ServiceDot({required this.online}); final bool online; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Tooltip( message: online ? '本机服务运行中' : '本机服务未运行', child: Icon( online ? Icons.check_circle : Icons.cancel, size: 16, color: online ? scheme.primary : scheme.error, ), ); } } /// 服务启动失败整页提示(引导重试/看日志)。 class _ServiceErrorView extends StatelessWidget { const _ServiceErrorView({required this.message}); final String message; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 460), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.error_outline_rounded, color: scheme.error, size: 32), const SizedBox(width: 12), Text('本机服务启动失败', style: Theme.of(context).textTheme.titleLarge), ], ), const SizedBox(height: 12), Text( message, style: Theme.of(context) .textTheme .bodyMedium ?.copyWith(color: scheme.onSurfaceVariant), ), const SizedBox(height: 16), Text( '常见原因:多个助手实例同时运行占用了端口。关闭其他实例后重新打开本应用即可。', style: Theme.of(context).textTheme.bodySmall, ), ], ), ), ); } }