feat(app): redesign navigation — floating robot FAB + 4-tab layout
- Add animated robot avatar widget (CustomPainter, 5 states: idle/thinking/executing/speaking/alert) - Add FloatingRobotFab that mirrors chatProvider AgentStatus as robot animation state - Replace 5-tab nav (dashboard/chat/tasks/alerts/settings) with 4-tab (home/my-agents/billing/profile) - Chat is now pushed full-screen from the robot FAB with slide-up transition - HomePage: active agent status card + official agent horizontal scroll + quick tips - MyAgentsPage: empty state with 3-step guide + template grid; shows list when agents exist - ProfilePage: merged settings + prominent billing entry (replaces old SettingsPage as tab) - ChatPage AppBar: robot avatar replaces plain text title, reflects real-time agent state - Add agentConfigs endpoint to ApiEndpoints Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
23daa8e122
commit
d5930ff4c8
|
|
@ -21,6 +21,7 @@ class ApiEndpoints {
|
|||
static const String tasks = '$agent/tasks';
|
||||
static const String sessions = '$agent/sessions';
|
||||
static const String engines = '$agent/engines';
|
||||
static const String agentConfigs = '$agent/configs';
|
||||
|
||||
// Ops
|
||||
static const String opsTasks = '$ops/tasks';
|
||||
|
|
|
|||
|
|
@ -5,19 +5,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import '../updater/update_service.dart';
|
||||
import '../network/connectivity_provider.dart';
|
||||
import '../widgets/offline_banner.dart';
|
||||
import '../widgets/floating_robot_fab.dart';
|
||||
import '../../features/auth/data/providers/auth_provider.dart';
|
||||
import '../../features/auth/presentation/pages/login_page.dart';
|
||||
import '../../features/dashboard/presentation/pages/dashboard_page.dart';
|
||||
import '../../features/chat/presentation/pages/chat_page.dart';
|
||||
import '../../features/tasks/presentation/pages/tasks_page.dart';
|
||||
import '../../features/standing_orders/presentation/pages/standing_orders_page.dart';
|
||||
import '../../features/approvals/presentation/pages/approvals_page.dart';
|
||||
import '../../features/servers/presentation/pages/servers_page.dart';
|
||||
import '../../features/alerts/presentation/pages/alerts_page.dart';
|
||||
import '../../features/settings/presentation/pages/settings_page.dart';
|
||||
import '../../features/terminal/presentation/pages/terminal_page.dart';
|
||||
import '../../features/notifications/presentation/providers/notification_providers.dart';
|
||||
import '../../features/home/presentation/pages/home_page.dart';
|
||||
import '../../features/my_agents/presentation/pages/my_agents_page.dart';
|
||||
import '../../features/billing/presentation/pages/billing_overview_page.dart';
|
||||
import '../../features/profile/presentation/pages/profile_page.dart';
|
||||
import '../../features/notifications/presentation/providers/notification_providers.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final routerProvider = Provider<GoRouter>((ref) {
|
||||
return GoRouter(
|
||||
|
|
@ -35,46 +34,20 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||
builder: (context, state, child) => ScaffoldWithNav(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/dashboard',
|
||||
builder: (context, state) => const DashboardPage(),
|
||||
path: '/home',
|
||||
builder: (context, state) => const HomePage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/chat',
|
||||
builder: (context, state) => const ChatPage(),
|
||||
path: '/my-agents',
|
||||
builder: (context, state) => const MyAgentsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/tasks',
|
||||
builder: (context, state) => const TasksPage(),
|
||||
path: '/billing',
|
||||
builder: (context, state) => const BillingOverviewPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/standing-orders',
|
||||
builder: (context, state) => const StandingOrdersPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/approvals',
|
||||
builder: (context, state) => const ApprovalsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/servers',
|
||||
builder: (context, state) => const ServersPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/terminal',
|
||||
builder: (context, state) => const TerminalPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/alerts',
|
||||
builder: (context, state) => const AlertsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
builder: (context, state) => const SettingsPage(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'billing',
|
||||
builder: (context, state) => const BillingOverviewPage(),
|
||||
),
|
||||
],
|
||||
path: '/profile',
|
||||
builder: (context, state) => const ProfilePage(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -82,16 +55,19 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScaffoldWithNav — 4 tabs + floating robot FAB
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ScaffoldWithNav extends ConsumerStatefulWidget {
|
||||
final Widget child;
|
||||
const ScaffoldWithNav({super.key, required this.child});
|
||||
|
||||
static const routes = [
|
||||
'/dashboard',
|
||||
'/chat',
|
||||
'/tasks',
|
||||
'/alerts',
|
||||
'/settings',
|
||||
'/home',
|
||||
'/my-agents',
|
||||
'/billing',
|
||||
'/profile',
|
||||
];
|
||||
|
||||
@override
|
||||
|
|
@ -106,7 +82,6 @@ class _ScaffoldWithNavState extends ConsumerState<ScaffoldWithNav>
|
|||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
// Check for update after a short delay on first entry
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkForUpdateIfNeeded();
|
||||
});
|
||||
|
|
@ -121,7 +96,6 @@ class _ScaffoldWithNavState extends ConsumerState<ScaffoldWithNav>
|
|||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
// Re-check connectivity immediately on foreground to clear false-offline banner
|
||||
ref.read(connectivityProvider.notifier).check();
|
||||
_checkForUpdateIfNeeded();
|
||||
}
|
||||
|
|
@ -132,7 +106,6 @@ class _ScaffoldWithNavState extends ConsumerState<ScaffoldWithNav>
|
|||
if (!updateService.isInitialized) return;
|
||||
if (updateService.isShowingUpdateDialog) return;
|
||||
|
||||
// Cooldown: 90-300 seconds between checks
|
||||
if (_lastUpdateCheck != null) {
|
||||
final cooldown = 90 + Random().nextInt(210);
|
||||
final elapsed =
|
||||
|
|
@ -141,11 +114,8 @@ class _ScaffoldWithNavState extends ConsumerState<ScaffoldWithNav>
|
|||
}
|
||||
|
||||
_lastUpdateCheck = DateTime.now();
|
||||
|
||||
// Delay 3 seconds to let the UI settle
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
if (!mounted) return;
|
||||
|
||||
await updateService.checkForUpdate(context);
|
||||
}
|
||||
|
||||
|
|
@ -169,38 +139,35 @@ class _ScaffoldWithNavState extends ConsumerState<ScaffoldWithNav>
|
|||
Expanded(child: widget.child),
|
||||
],
|
||||
),
|
||||
floatingActionButton: const FloatingRobotFab(),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: currentIndex,
|
||||
destinations: [
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.dashboard_outlined),
|
||||
selectedIcon: Icon(Icons.dashboard),
|
||||
label: '仪表盘'),
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: '主页',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.chat_outlined),
|
||||
selectedIcon: Icon(Icons.chat),
|
||||
label: '对话'),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.task_outlined),
|
||||
selectedIcon: Icon(Icons.task),
|
||||
label: '任务'),
|
||||
icon: Icon(Icons.smart_toy_outlined),
|
||||
selectedIcon: Icon(Icons.smart_toy),
|
||||
label: '我的创建',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Badge(
|
||||
isLabelVisible: unreadCount > 0,
|
||||
label: Text('$unreadCount'),
|
||||
child: const Icon(Icons.notifications_outlined),
|
||||
child: const Icon(Icons.credit_card_outlined),
|
||||
),
|
||||
selectedIcon: Badge(
|
||||
isLabelVisible: unreadCount > 0,
|
||||
label: Text('$unreadCount'),
|
||||
child: const Icon(Icons.notifications),
|
||||
),
|
||||
label: '告警',
|
||||
selectedIcon: const Icon(Icons.credit_card),
|
||||
label: '账单',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: '设置'),
|
||||
icon: Icon(Icons.person_outline),
|
||||
selectedIcon: Icon(Icons.person),
|
||||
label: '我',
|
||||
),
|
||||
],
|
||||
onDestinationSelected: (index) {
|
||||
if (index != currentIndex) {
|
||||
|
|
@ -212,7 +179,10 @@ class _ScaffoldWithNavState extends ConsumerState<ScaffoldWithNav>
|
|||
}
|
||||
}
|
||||
|
||||
/// Splash page that tries to restore a previous session.
|
||||
// ---------------------------------------------------------------------------
|
||||
// Splash page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _SplashPage extends ConsumerStatefulWidget {
|
||||
const _SplashPage();
|
||||
|
||||
|
|
@ -232,7 +202,7 @@ class _SplashPageState extends ConsumerState<_SplashPage> {
|
|||
final restored = await auth.tryRestoreSession();
|
||||
if (!mounted) return;
|
||||
if (restored) {
|
||||
context.go('/dashboard');
|
||||
context.go('/home');
|
||||
} else {
|
||||
context.go('/login');
|
||||
}
|
||||
|
|
@ -241,9 +211,7 @@ class _SplashPageState extends ConsumerState<_SplashPage> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../features/chat/presentation/pages/chat_page.dart';
|
||||
import '../../features/chat/presentation/providers/chat_providers.dart';
|
||||
import 'robot_avatar.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider: maps AgentStatus → RobotState for the floating button
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final robotStateProvider = Provider<RobotState>((ref) {
|
||||
final chatState = ref.watch(chatProvider);
|
||||
return switch (chatState.agentStatus) {
|
||||
AgentStatus.idle => RobotState.idle,
|
||||
AgentStatus.thinking => RobotState.thinking,
|
||||
AgentStatus.executing => RobotState.executing,
|
||||
AgentStatus.awaitingApproval => RobotState.alert,
|
||||
AgentStatus.error => RobotState.alert,
|
||||
};
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Floating robot FAB
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class FloatingRobotFab extends ConsumerWidget {
|
||||
const FloatingRobotFab({super.key});
|
||||
|
||||
void _openChat(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
const ChatPage(),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
final tween = Tween(
|
||||
begin: const Offset(0, 1),
|
||||
end: Offset.zero,
|
||||
).chain(CurveTween(curve: Curves.easeOutCubic));
|
||||
return SlideTransition(
|
||||
position: animation.drive(tween),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
transitionDuration: const Duration(milliseconds: 350),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final robotState = ref.watch(robotStateProvider);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _openChat(context),
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: const Color(0xFF1E293B),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: _glowColor(robotState).withOpacity(0.5),
|
||||
blurRadius: 16,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: RobotAvatar(
|
||||
state: robotState,
|
||||
size: 52,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _glowColor(RobotState state) => switch (state) {
|
||||
RobotState.idle => const Color(0xFF6366F1),
|
||||
RobotState.thinking => const Color(0xFFF59E0B),
|
||||
RobotState.executing => const Color(0xFFF97316),
|
||||
RobotState.speaking => const Color(0xFF0EA5E9),
|
||||
RobotState.alert => const Color(0xFFEF4444),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'robot_painter.dart';
|
||||
|
||||
export 'robot_painter.dart' show RobotState;
|
||||
|
||||
/// Animated robot avatar widget.
|
||||
/// Automatically drives animations based on [state].
|
||||
class RobotAvatar extends StatefulWidget {
|
||||
final RobotState state;
|
||||
final double size;
|
||||
|
||||
const RobotAvatar({
|
||||
super.key,
|
||||
this.state = RobotState.idle,
|
||||
this.size = 56,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RobotAvatar> createState() => _RobotAvatarState();
|
||||
}
|
||||
|
||||
class _RobotAvatarState extends State<RobotAvatar>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _mainCtrl;
|
||||
late AnimationController _blinkCtrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_mainCtrl = AnimationController(vsync: this)
|
||||
..addListener(() => setState(() {}));
|
||||
_blinkCtrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
)..addListener(() => setState(() {}));
|
||||
|
||||
_startAnimations(widget.state);
|
||||
_scheduleBlink();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(RobotAvatar old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.state != widget.state) {
|
||||
_mainCtrl.stop();
|
||||
_startAnimations(widget.state);
|
||||
}
|
||||
}
|
||||
|
||||
void _startAnimations(RobotState state) {
|
||||
switch (state) {
|
||||
case RobotState.idle:
|
||||
_mainCtrl.duration = const Duration(milliseconds: 2400);
|
||||
_mainCtrl.repeat(reverse: true);
|
||||
case RobotState.thinking:
|
||||
_mainCtrl.duration = const Duration(milliseconds: 900);
|
||||
_mainCtrl.repeat();
|
||||
case RobotState.executing:
|
||||
_mainCtrl.duration = const Duration(milliseconds: 600);
|
||||
_mainCtrl.repeat();
|
||||
case RobotState.speaking:
|
||||
_mainCtrl.duration = const Duration(milliseconds: 700);
|
||||
_mainCtrl.repeat();
|
||||
case RobotState.alert:
|
||||
_mainCtrl.duration = const Duration(milliseconds: 500);
|
||||
_mainCtrl.repeat();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _scheduleBlink() async {
|
||||
while (mounted) {
|
||||
// Wait 2-5 seconds between blinks
|
||||
final pause = 2000 + math.Random().nextInt(3000);
|
||||
await Future.delayed(Duration(milliseconds: pause));
|
||||
if (!mounted) break;
|
||||
// Only blink during non-thinking states
|
||||
if (widget.state != RobotState.thinking &&
|
||||
widget.state != RobotState.executing) {
|
||||
await _blinkCtrl.forward();
|
||||
await Future.delayed(const Duration(milliseconds: 60));
|
||||
if (!mounted) break;
|
||||
await _blinkCtrl.reverse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_mainCtrl.dispose();
|
||||
_blinkCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Apply a subtle breathing scale for idle state
|
||||
final scale = widget.state == RobotState.idle
|
||||
? 1.0 + 0.03 * _mainCtrl.value
|
||||
: widget.state == RobotState.alert
|
||||
? 1.0 + 0.05 * math.sin(_mainCtrl.value * math.pi)
|
||||
: 1.0;
|
||||
|
||||
return Transform.scale(
|
||||
scale: scale,
|
||||
child: SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: CustomPaint(
|
||||
painter: RobotPainter(
|
||||
state: widget.state,
|
||||
anim: _mainCtrl.value,
|
||||
blink: _blinkCtrl.value,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,281 @@
|
|||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Animation state of the robot avatar.
|
||||
enum RobotState {
|
||||
idle, // blue, gentle breathing
|
||||
thinking, // orange, eyes spin
|
||||
executing, // orange+, faster pulse
|
||||
speaking, // cyan, mouth wave
|
||||
alert, // red, flash
|
||||
}
|
||||
|
||||
/// CustomPainter that draws a cute robot face.
|
||||
/// All sizes are expressed as fractions of [size] so it scales cleanly.
|
||||
class RobotPainter extends CustomPainter {
|
||||
final RobotState state;
|
||||
|
||||
/// 0..1 — primary animation progress (breathing, spin, wave, flash)
|
||||
final double anim;
|
||||
|
||||
/// 0..1 — blink progress (0=open, 1=closed)
|
||||
final double blink;
|
||||
|
||||
const RobotPainter({
|
||||
required this.state,
|
||||
required this.anim,
|
||||
required this.blink,
|
||||
});
|
||||
|
||||
// ── colour per state ────────────────────────────────────────────────────
|
||||
Color get _primary => switch (state) {
|
||||
RobotState.idle => const Color(0xFF6366F1),
|
||||
RobotState.thinking => const Color(0xFFF59E0B),
|
||||
RobotState.executing => const Color(0xFFF97316),
|
||||
RobotState.speaking => const Color(0xFF0EA5E9),
|
||||
RobotState.alert => const Color(0xFFEF4444),
|
||||
};
|
||||
|
||||
Color get _glow => _primary.withOpacity(0.35 + 0.2 * anim);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final w = size.width;
|
||||
final h = size.height;
|
||||
final cx = w / 2;
|
||||
|
||||
// ── halo / glow ring ────────────────────────────────────────────────
|
||||
final glowRadius = w * 0.46 + w * 0.06 * anim;
|
||||
canvas.drawCircle(
|
||||
Offset(cx, h * 0.52),
|
||||
glowRadius,
|
||||
Paint()
|
||||
..color = _glow
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12),
|
||||
);
|
||||
|
||||
// ── antenna ─────────────────────────────────────────────────────────
|
||||
final antennaBaseX = cx;
|
||||
final antennaBaseY = h * 0.22;
|
||||
final antennaTipY = h * 0.06;
|
||||
|
||||
// stick
|
||||
canvas.drawLine(
|
||||
Offset(antennaBaseX, antennaBaseY),
|
||||
Offset(antennaBaseX, antennaTipY + w * 0.04),
|
||||
Paint()
|
||||
..color = _primary
|
||||
..strokeWidth = w * 0.04
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
// ball at tip
|
||||
final ballRadius = w * 0.065;
|
||||
final ballGlow = ballRadius * (1.0 + 0.3 * anim);
|
||||
canvas.drawCircle(
|
||||
Offset(antennaBaseX, antennaTipY),
|
||||
ballGlow,
|
||||
Paint()
|
||||
..color = _primary.withOpacity(0.35)
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4),
|
||||
);
|
||||
canvas.drawCircle(
|
||||
Offset(antennaBaseX, antennaTipY),
|
||||
ballRadius,
|
||||
Paint()..color = _primary,
|
||||
);
|
||||
|
||||
// ── head ────────────────────────────────────────────────────────────
|
||||
final headRect = RRect.fromRectAndRadius(
|
||||
Rect.fromCenter(
|
||||
center: Offset(cx, h * 0.40),
|
||||
width: w * 0.70,
|
||||
height: h * 0.34,
|
||||
),
|
||||
Radius.circular(w * 0.15),
|
||||
);
|
||||
canvas.drawRRect(
|
||||
headRect,
|
||||
Paint()..color = const Color(0xFF1E293B),
|
||||
);
|
||||
canvas.drawRRect(
|
||||
headRect,
|
||||
Paint()
|
||||
..color = _primary
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = w * 0.04,
|
||||
);
|
||||
|
||||
// ── eyes ─────────────────────────────────────────────────────────────
|
||||
_drawEyes(canvas, size);
|
||||
|
||||
// ── mouth ────────────────────────────────────────────────────────────
|
||||
_drawMouth(canvas, size);
|
||||
|
||||
// ── body ─────────────────────────────────────────────────────────────
|
||||
final bodyRect = RRect.fromRectAndRadius(
|
||||
Rect.fromCenter(
|
||||
center: Offset(cx, h * 0.80),
|
||||
width: w * 0.62,
|
||||
height: h * 0.25,
|
||||
),
|
||||
Radius.circular(w * 0.10),
|
||||
);
|
||||
canvas.drawRRect(
|
||||
bodyRect,
|
||||
Paint()..color = const Color(0xFF1E293B),
|
||||
);
|
||||
canvas.drawRRect(
|
||||
bodyRect,
|
||||
Paint()
|
||||
..color = _primary.withOpacity(0.6)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = w * 0.03,
|
||||
);
|
||||
|
||||
// little chest dot
|
||||
canvas.drawCircle(
|
||||
Offset(cx, h * 0.80),
|
||||
w * 0.05 * (1.0 + 0.4 * anim),
|
||||
Paint()..color = _primary,
|
||||
);
|
||||
}
|
||||
|
||||
void _drawEyes(Canvas canvas, Size size) {
|
||||
final w = size.width;
|
||||
final h = size.height;
|
||||
|
||||
final eyeY = h * 0.38;
|
||||
final eyeRadius = w * 0.09;
|
||||
final leftX = size.width * 0.34;
|
||||
final rightX = size.width * 0.66;
|
||||
|
||||
for (final ex in [leftX, rightX]) {
|
||||
// eye background
|
||||
canvas.drawCircle(
|
||||
Offset(ex, eyeY),
|
||||
eyeRadius,
|
||||
Paint()..color = const Color(0xFF0F172A),
|
||||
);
|
||||
|
||||
if (state == RobotState.thinking || state == RobotState.executing) {
|
||||
// spinning arc eye
|
||||
final sweepAngle = math.pi * 1.5;
|
||||
final startAngle = 2 * math.pi * anim;
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: Offset(ex, eyeY), radius: eyeRadius * 0.65),
|
||||
startAngle,
|
||||
sweepAngle,
|
||||
false,
|
||||
Paint()
|
||||
..color = _primary
|
||||
..strokeWidth = w * 0.03
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
} else {
|
||||
// normal eye with blink
|
||||
final openFraction = 1.0 - blink;
|
||||
final ey1 = eyeY - eyeRadius * 0.55 * openFraction;
|
||||
final ey2 = eyeY + eyeRadius * 0.55 * openFraction;
|
||||
|
||||
if (openFraction < 0.1) {
|
||||
// fully closed — horizontal line
|
||||
canvas.drawLine(
|
||||
Offset(ex - eyeRadius * 0.5, eyeY),
|
||||
Offset(ex + eyeRadius * 0.5, eyeY),
|
||||
Paint()
|
||||
..color = _primary
|
||||
..strokeWidth = w * 0.025
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
} else {
|
||||
// open eye — vertical oval
|
||||
canvas.drawOval(
|
||||
Rect.fromCenter(
|
||||
center: Offset(ex, eyeY),
|
||||
width: eyeRadius * 0.9,
|
||||
height: (ey2 - ey1).abs().clamp(4.0, eyeRadius * 1.2),
|
||||
),
|
||||
Paint()..color = _primary,
|
||||
);
|
||||
// pupil highlight
|
||||
canvas.drawCircle(
|
||||
Offset(ex + eyeRadius * 0.2, eyeY - eyeRadius * 0.2),
|
||||
eyeRadius * 0.15,
|
||||
Paint()..color = Colors.white.withOpacity(0.8),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _drawMouth(Canvas canvas, Size size) {
|
||||
final w = size.width;
|
||||
final h = size.height;
|
||||
final mouthY = h * 0.47;
|
||||
final mouthLeft = size.width * 0.36;
|
||||
final mouthRight = size.width * 0.64;
|
||||
|
||||
if (state == RobotState.speaking) {
|
||||
// wave mouth
|
||||
final path = Path();
|
||||
path.moveTo(mouthLeft, mouthY);
|
||||
final segments = 4;
|
||||
final segW = (mouthRight - mouthLeft) / segments;
|
||||
for (int i = 0; i < segments; i++) {
|
||||
final x1 = mouthLeft + segW * i;
|
||||
final x2 = mouthLeft + segW * (i + 1);
|
||||
final phase = anim * 2 * math.pi + i * math.pi / 2;
|
||||
final cy1 = mouthY + math.sin(phase) * h * 0.04;
|
||||
final cy2 = mouthY + math.sin(phase + math.pi / 2) * h * 0.04;
|
||||
path.cubicTo(
|
||||
x1 + segW * 0.33, cy1,
|
||||
x2 - segW * 0.33, cy2,
|
||||
x2, mouthY,
|
||||
);
|
||||
}
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint()
|
||||
..color = _primary
|
||||
..strokeWidth = w * 0.035
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
} else if (state == RobotState.alert) {
|
||||
// zigzag / frown
|
||||
final flashOpacity = (0.5 + 0.5 * math.sin(anim * 2 * math.pi)).clamp(0.0, 1.0);
|
||||
canvas.drawLine(
|
||||
Offset(mouthLeft, mouthY),
|
||||
Offset(mouthRight, mouthY),
|
||||
Paint()
|
||||
..color = _primary.withOpacity(flashOpacity)
|
||||
..strokeWidth = w * 0.035
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
} else {
|
||||
// smile / neutral arc
|
||||
final smileDepth = state == RobotState.idle ? h * 0.025 : h * 0.01;
|
||||
final path = Path();
|
||||
path.moveTo(mouthLeft, mouthY);
|
||||
path.quadraticBezierTo(
|
||||
(mouthLeft + mouthRight) / 2,
|
||||
mouthY + smileDepth,
|
||||
mouthRight,
|
||||
mouthY,
|
||||
);
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint()
|
||||
..color = _primary.withOpacity(0.8)
|
||||
..strokeWidth = w * 0.035
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(RobotPainter old) =>
|
||||
old.state != state || old.anim != anim || old.blink != blink;
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/robot_avatar.dart';
|
||||
import '../../../../core/widgets/floating_robot_fab.dart';
|
||||
import '../../domain/entities/chat_message.dart';
|
||||
import '../providers/chat_providers.dart';
|
||||
import '../widgets/timeline_event_node.dart';
|
||||
|
|
@ -517,7 +519,21 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||
return Scaffold(
|
||||
drawer: const ConversationDrawer(),
|
||||
appBar: AppBar(
|
||||
title: const Text('iAgent', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
title: Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final robotState = ref.watch(robotStateProvider);
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RobotAvatar(state: robotState, size: 32),
|
||||
const SizedBox(width: 8),
|
||||
const Text('iAgent',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined, size: 20),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,501 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/robot_avatar.dart';
|
||||
import '../../../auth/data/providers/auth_provider.dart';
|
||||
import '../../../chat/presentation/providers/chat_providers.dart';
|
||||
import '../../../notifications/presentation/providers/notification_providers.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Home page — "主页" Tab
|
||||
// Shows active agent cards + recent activity + welcome guide
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class HomePage extends ConsumerWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final profile = ref.watch(accountProfileProvider);
|
||||
final chatState = ref.watch(chatProvider);
|
||||
final unreadCount = ref.watch(unreadNotificationCountProvider);
|
||||
|
||||
final greeting = _greeting();
|
||||
final name = profile.displayName.isNotEmpty ? profile.displayName : '用户';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// ── AppBar ──────────────────────────────────────────────────────
|
||||
SliverAppBar(
|
||||
expandedHeight: 120,
|
||||
pinned: true,
|
||||
backgroundColor: AppColors.background,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
titlePadding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
|
||||
title: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'$greeting,$name',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
const Text(
|
||||
'iAgent 随时为你服务',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
if (unreadCount > 0)
|
||||
Stack(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Icon(Icons.notifications_outlined,
|
||||
color: AppColors.textSecondary),
|
||||
),
|
||||
Positioned(
|
||||
right: 8,
|
||||
top: 8,
|
||||
child: Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.error,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$unreadCount',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
// ── Active agent status card ─────────────────────────────
|
||||
_AgentStatusCard(chatState: chatState),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── IT0 official agent cards ─────────────────────────────
|
||||
const _SectionHeader(title: 'IT0 官方智能体'),
|
||||
const SizedBox(height: 12),
|
||||
const _OfficialAgentsRow(),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Guide if nothing created yet ─────────────────────────
|
||||
const _SectionHeader(title: '我的智能体'),
|
||||
const SizedBox(height: 12),
|
||||
const _MyAgentsPlaceholder(),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Quick tips ───────────────────────────────────────────
|
||||
const _QuickTipsCard(),
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _greeting() {
|
||||
final hour = DateTime.now().hour;
|
||||
if (hour < 6) return '夜深了';
|
||||
if (hour < 12) return '早上好';
|
||||
if (hour < 14) return '中午好';
|
||||
if (hour < 18) return '下午好';
|
||||
return '晚上好';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent status card — shows current iAgent state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _AgentStatusCard extends StatelessWidget {
|
||||
final ChatState chatState;
|
||||
|
||||
const _AgentStatusCard({required this.chatState});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isActive = chatState.isStreaming;
|
||||
final statusText = switch (chatState.agentStatus) {
|
||||
AgentStatus.idle => '空闲中',
|
||||
AgentStatus.thinking => '正在思考...',
|
||||
AgentStatus.executing => '执行指令中...',
|
||||
AgentStatus.awaitingApproval => '等待审批',
|
||||
AgentStatus.error => '发生错误',
|
||||
};
|
||||
final robotState = switch (chatState.agentStatus) {
|
||||
AgentStatus.idle => RobotState.idle,
|
||||
AgentStatus.thinking => RobotState.thinking,
|
||||
AgentStatus.executing => RobotState.executing,
|
||||
AgentStatus.awaitingApproval => RobotState.alert,
|
||||
AgentStatus.error => RobotState.alert,
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isActive
|
||||
? AppColors.primary.withOpacity(0.5)
|
||||
: AppColors.surfaceLight.withOpacity(0.3),
|
||||
),
|
||||
gradient: isActive
|
||||
? LinearGradient(
|
||||
colors: [
|
||||
AppColors.primary.withOpacity(0.08),
|
||||
AppColors.surface,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
RobotAvatar(state: robotState, size: 64),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'iAgent',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isActive ? AppColors.success : AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
statusText,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isActive
|
||||
? AppColors.success
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (chatState.messages.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'对话中 · ${chatState.messages.length} 条消息',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
// Arrow hint
|
||||
const Icon(Icons.chevron_right, color: AppColors.textMuted),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
|
||||
const _SectionHeader({required this.title});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Official agents row (horizontal scroll cards)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _OfficialAgentsRow extends StatelessWidget {
|
||||
const _OfficialAgentsRow();
|
||||
|
||||
static const _agents = [
|
||||
_AgentCard(
|
||||
name: 'iAgent 运维助手',
|
||||
desc: '服务器管理、SSH 执行、日志分析',
|
||||
icon: Icons.dns_outlined,
|
||||
color: Color(0xFF6366F1),
|
||||
isOfficial: true,
|
||||
),
|
||||
_AgentCard(
|
||||
name: '安全审计助手',
|
||||
desc: '漏洞扫描、权限审查、合规检查',
|
||||
icon: Icons.security_outlined,
|
||||
color: Color(0xFF22C55E),
|
||||
isOfficial: true,
|
||||
),
|
||||
_AgentCard(
|
||||
name: '数据库巡检',
|
||||
desc: '慢查询分析、索引优化、备份验证',
|
||||
icon: Icons.storage_outlined,
|
||||
color: Color(0xFF0EA5E9),
|
||||
isOfficial: true,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 130,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: _agents.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) => _agents[index],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AgentCard extends StatelessWidget {
|
||||
final String name;
|
||||
final String desc;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final bool isOfficial;
|
||||
|
||||
const _AgentCard({
|
||||
required this.name,
|
||||
required this.desc,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
this.isOfficial = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 180,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: color.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 20),
|
||||
),
|
||||
if (isOfficial) ...[
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'官方',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
desc,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// My agents placeholder — guides user to talk to iAgent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _MyAgentsPlaceholder extends StatelessWidget {
|
||||
const _MyAgentsPlaceholder();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppColors.primary.withOpacity(0.2),
|
||||
style: BorderStyle.solid,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.add_circle_outline,
|
||||
size: 40,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'还没有自己的智能体',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'点击下方机器人按钮,告诉 iAgent\n"帮我创建一个 OpenClaw 智能体"',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.5,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quick tips card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _QuickTipsCard extends StatelessWidget {
|
||||
const _QuickTipsCard();
|
||||
|
||||
static const _tips = [
|
||||
'💬 "帮我创建一个监控 GitHub Actions 的智能体"',
|
||||
'🔧 "把我的 OpenClaw 配置导出为 JSON"',
|
||||
'📊 "分析我的服务器最近7天的负载情况"',
|
||||
'🛡️ "帮我设置每天凌晨2点自动备份数据库"',
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'你可以这样说...',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
..._tips.map(
|
||||
(tip) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
tip,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textMuted,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,487 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../../core/config/api_endpoints.dart';
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/utils/date_formatter.dart';
|
||||
import '../../../../core/widgets/empty_state.dart';
|
||||
import '../../../../core/widgets/error_view.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider: fetches agent configs (user-created + official)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final myAgentsProvider =
|
||||
FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
||||
final dio = ref.watch(dioClientProvider);
|
||||
try {
|
||||
final response = await dio.get(ApiEndpoints.agentConfigs);
|
||||
final data = response.data;
|
||||
if (data is List) return data.cast<Map<String, dynamic>>();
|
||||
if (data is Map && data.containsKey('items')) {
|
||||
return (data['items'] as List).cast<Map<String, dynamic>>();
|
||||
}
|
||||
return [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// My Agents Page — "我的创建" Tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class MyAgentsPage extends ConsumerWidget {
|
||||
const MyAgentsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final agentsAsync = ref.watch(myAgentsProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.background,
|
||||
title: const Text('我的创建'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh_outlined),
|
||||
onPressed: () => ref.invalidate(myAgentsProvider),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: agentsAsync.when(
|
||||
data: (agents) => agents.isEmpty
|
||||
? _buildEmptyGuide(context)
|
||||
: _buildAgentList(context, ref, agents),
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => ErrorView(
|
||||
error: e,
|
||||
onRetry: () => ref.invalidate(myAgentsProvider),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyGuide(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 40),
|
||||
// Main illustration
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.smart_toy_outlined,
|
||||
size: 60,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'创建你的专属智能体',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'通过与 iAgent 对话,你可以创建各种智能体:\nOpenClaw 编程助手、运维机器人、数据分析师...',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.6,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 36),
|
||||
|
||||
// Step cards
|
||||
_StepCard(
|
||||
step: '1',
|
||||
title: '点击下方机器人',
|
||||
desc: '打开与 iAgent 的对话窗口',
|
||||
icon: Icons.smart_toy_outlined,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_StepCard(
|
||||
step: '2',
|
||||
title: '描述你想要的智能体',
|
||||
desc: '例如:"帮我创建一个监控 GitHub Actions 的 OpenClaw 智能体"',
|
||||
icon: Icons.record_voice_over_outlined,
|
||||
color: const Color(0xFF0EA5E9),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_StepCard(
|
||||
step: '3',
|
||||
title: 'iAgent 自动完成配置',
|
||||
desc: '智能体会出现在这里,随时可以与它对话',
|
||||
icon: Icons.check_circle_outline,
|
||||
color: AppColors.success,
|
||||
),
|
||||
|
||||
const SizedBox(height: 36),
|
||||
const _TemplatesSection(),
|
||||
const SizedBox(height: 100),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAgentList(
|
||||
BuildContext context, WidgetRef ref, List<Map<String, dynamic>> agents) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(myAgentsProvider),
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
|
||||
itemCount: agents.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, index) {
|
||||
final agent = agents[index];
|
||||
return _AgentListCard(agent: agent);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step guide card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _StepCard extends StatelessWidget {
|
||||
final String step;
|
||||
final String title;
|
||||
final String desc;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _StepCard({
|
||||
required this.step,
|
||||
required this.title,
|
||||
required this.desc,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: color.withOpacity(0.2)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Step number circle
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
step,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
desc,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textMuted,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(icon, color: color, size: 22),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template suggestions section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _TemplatesSection extends StatelessWidget {
|
||||
const _TemplatesSection();
|
||||
|
||||
static const _templates = [
|
||||
_Template(
|
||||
name: 'OpenClaw 编程助手',
|
||||
desc: '代码审查、自动化测试、CI/CD 管理',
|
||||
icon: Icons.code_outlined,
|
||||
color: Color(0xFF8B5CF6),
|
||||
),
|
||||
_Template(
|
||||
name: '运维自动化机器人',
|
||||
desc: '日志分析、故障自动恢复、扩缩容',
|
||||
icon: Icons.auto_fix_high_outlined,
|
||||
color: Color(0xFFF59E0B),
|
||||
),
|
||||
_Template(
|
||||
name: '数据分析助手',
|
||||
desc: '报表生成、异常检测、趋势预测',
|
||||
icon: Icons.bar_chart_outlined,
|
||||
color: Color(0xFF0EA5E9),
|
||||
),
|
||||
_Template(
|
||||
name: '安全巡检机器人',
|
||||
desc: '漏洞扫描、入侵检测、合规审查',
|
||||
icon: Icons.shield_outlined,
|
||||
color: Color(0xFFEF4444),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'热门模板(告诉 iAgent 你想要哪种)',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
GridView.count(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 10,
|
||||
crossAxisSpacing: 10,
|
||||
childAspectRatio: 1.5,
|
||||
children: _templates
|
||||
.map((t) => _TemplateChip(template: t))
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Template {
|
||||
final String name;
|
||||
final String desc;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _Template({
|
||||
required this.name,
|
||||
required this.desc,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
});
|
||||
}
|
||||
|
||||
class _TemplateChip extends StatelessWidget {
|
||||
final _Template template;
|
||||
|
||||
const _TemplateChip({required this.template});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border:
|
||||
Border.all(color: template.color.withOpacity(0.25)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Icon(template.icon, color: template.color, size: 24),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
template.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
template.desc,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent list card (when user has created agents)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _AgentListCard extends StatelessWidget {
|
||||
final Map<String, dynamic> agent;
|
||||
|
||||
const _AgentListCard({required this.agent});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final name = agent['name'] as String? ?? agent['agentName'] as String? ?? '未命名智能体';
|
||||
final desc = agent['description'] as String? ?? '';
|
||||
final engineType = agent['engineType'] as String? ?? '';
|
||||
final createdAt = agent['createdAt'] as String? ?? agent['created_at'] as String?;
|
||||
final timeLabel = createdAt != null
|
||||
? DateFormatter.timeAgo(DateTime.parse(createdAt))
|
||||
: '';
|
||||
|
||||
final typeLabel = switch (engineType) {
|
||||
'claude_agent_sdk' => 'Agent SDK',
|
||||
'claude_api' => 'Claude API',
|
||||
_ => engineType.isNotEmpty ? engineType : 'iAgent',
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: AppColors.primary.withOpacity(0.15),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.smart_toy_outlined,
|
||||
color: AppColors.primary,
|
||||
size: 26,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (desc.isNotEmpty) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
desc,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
typeLabel,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (timeLabel.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
timeLabel,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
color: AppColors.textMuted,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,975 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/updater/update_service.dart';
|
||||
import '../../../auth/data/providers/auth_provider.dart';
|
||||
import '../../../agent_call/presentation/pages/voice_test_page.dart';
|
||||
import '../../../settings/presentation/providers/settings_providers.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile page — "我" Tab
|
||||
// A slimmer version of SettingsPage, always accessible as a Tab.
|
||||
// Billing is linked as a prominent row at the top.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ProfilePage extends ConsumerStatefulWidget {
|
||||
const ProfilePage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ProfilePage> createState() => _ProfilePageState();
|
||||
}
|
||||
|
||||
class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
String _appVersion = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(
|
||||
() => ref.read(accountProfileProvider.notifier).loadProfile());
|
||||
_loadVersion();
|
||||
}
|
||||
|
||||
Future<void> _loadVersion() async {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
if (mounted) {
|
||||
setState(() => _appVersion = 'v${info.version}+${info.buildNumber}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final profile = ref.watch(accountProfileProvider);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final cardColor = isDark ? AppColors.surface : Colors.white;
|
||||
final subtitleColor =
|
||||
isDark ? AppColors.textSecondary : Colors.grey[600];
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.background,
|
||||
title: const Text('我'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
children: [
|
||||
// ── Profile card ────────────────────────────────────────────
|
||||
_buildProfileCard(profile, cardColor, subtitleColor),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Billing (prominent) ─────────────────────────────────────
|
||||
_SettingsGroup(
|
||||
cardColor: cardColor,
|
||||
children: [
|
||||
_SettingsRow(
|
||||
icon: Icons.workspace_premium_outlined,
|
||||
iconBg: const Color(0xFF10B981),
|
||||
title: '订阅套餐与用量',
|
||||
trailing: Text(
|
||||
'Free',
|
||||
style: TextStyle(color: subtitleColor, fontSize: 14),
|
||||
),
|
||||
onTap: () => context.push('/billing'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── General ─────────────────────────────────────────────────
|
||||
_SettingsGroup(
|
||||
cardColor: cardColor,
|
||||
children: [
|
||||
_SettingsRow(
|
||||
icon: Icons.palette_outlined,
|
||||
iconBg: const Color(0xFF6366F1),
|
||||
title: '外观主题',
|
||||
trailing: _ThemeLabel(mode: settings.themeMode),
|
||||
onTap: () => _showThemePicker(settings.themeMode),
|
||||
),
|
||||
_SettingsRow(
|
||||
icon: Icons.language,
|
||||
iconBg: const Color(0xFF3B82F6),
|
||||
title: '语言',
|
||||
trailing: Text('简体中文',
|
||||
style: TextStyle(color: subtitleColor, fontSize: 14)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Voice / Engine ───────────────────────────────────────────
|
||||
_SettingsGroup(
|
||||
cardColor: cardColor,
|
||||
children: [
|
||||
_SettingsRow(
|
||||
icon: Icons.psychology,
|
||||
iconBg: const Color(0xFF7C3AED),
|
||||
title: '对话引擎',
|
||||
trailing: Text(
|
||||
settings.engineType == 'claude_agent_sdk'
|
||||
? 'Agent SDK'
|
||||
: 'Claude API',
|
||||
style: TextStyle(color: subtitleColor, fontSize: 14),
|
||||
),
|
||||
onTap: () => _showEngineTypePicker(settings.engineType),
|
||||
),
|
||||
_SettingsRow(
|
||||
icon: Icons.record_voice_over,
|
||||
iconBg: const Color(0xFF0EA5E9),
|
||||
title: '语音音色',
|
||||
trailing: Text(
|
||||
_voiceDisplayLabel(settings.ttsVoice),
|
||||
style: TextStyle(color: subtitleColor, fontSize: 14),
|
||||
),
|
||||
onTap: () => _showVoicePicker(settings.ttsVoice),
|
||||
),
|
||||
_SettingsRow(
|
||||
icon: Icons.tune,
|
||||
iconBg: const Color(0xFFF97316),
|
||||
title: '语音风格',
|
||||
trailing: Text(
|
||||
_styleDisplayName(settings.ttsStyle),
|
||||
style: TextStyle(color: subtitleColor, fontSize: 14),
|
||||
),
|
||||
onTap: () => _showStylePicker(settings.ttsStyle),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Notifications ────────────────────────────────────────────
|
||||
_SettingsGroup(
|
||||
cardColor: cardColor,
|
||||
children: [
|
||||
_SettingsToggleRow(
|
||||
icon: Icons.notifications_outlined,
|
||||
iconBg: const Color(0xFFEF4444),
|
||||
title: '推送通知',
|
||||
value: settings.notificationsEnabled,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setNotificationsEnabled(v),
|
||||
),
|
||||
_SettingsToggleRow(
|
||||
icon: Icons.volume_up_outlined,
|
||||
iconBg: const Color(0xFFF59E0B),
|
||||
title: '提示音',
|
||||
value: settings.soundEnabled,
|
||||
onChanged: settings.notificationsEnabled
|
||||
? (v) =>
|
||||
ref.read(settingsProvider.notifier).setSoundEnabled(v)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Security ────────────────────────────────────────────────
|
||||
_SettingsGroup(
|
||||
cardColor: cardColor,
|
||||
children: [
|
||||
_SettingsRow(
|
||||
icon: Icons.lock_outline,
|
||||
iconBg: const Color(0xFF8B5CF6),
|
||||
title: '修改密码',
|
||||
onTap: _showChangePasswordSheet,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── About / Dev ──────────────────────────────────────────────
|
||||
_SettingsGroup(
|
||||
cardColor: cardColor,
|
||||
children: [
|
||||
_SettingsRow(
|
||||
icon: Icons.info_outline,
|
||||
iconBg: const Color(0xFF64748B),
|
||||
title: '版本',
|
||||
trailing: Text(
|
||||
_appVersion.isNotEmpty ? _appVersion : 'v1.0.0',
|
||||
style: TextStyle(color: subtitleColor, fontSize: 14),
|
||||
),
|
||||
),
|
||||
_SettingsRow(
|
||||
icon: Icons.system_update_outlined,
|
||||
iconBg: const Color(0xFF22C55E),
|
||||
title: '检查更新',
|
||||
onTap: () => UpdateService().manualCheckUpdate(context),
|
||||
),
|
||||
_SettingsRow(
|
||||
icon: Icons.record_voice_over,
|
||||
iconBg: const Color(0xFF10B981),
|
||||
title: '语音 I/O 测试',
|
||||
trailing: Text('TTS / STT',
|
||||
style: TextStyle(color: subtitleColor, fontSize: 14)),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const VoiceTestPage()),
|
||||
),
|
||||
),
|
||||
if (settings.selectedTenantName != null)
|
||||
_SettingsRow(
|
||||
icon: Icons.business_outlined,
|
||||
iconBg: const Color(0xFF0EA5E9),
|
||||
title: '租户',
|
||||
trailing: Text(settings.selectedTenantName!,
|
||||
style: TextStyle(color: subtitleColor, fontSize: 14)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Logout ──────────────────────────────────────────────────
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: _confirmLogout,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.error,
|
||||
side: const BorderSide(color: AppColors.error),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: const Text('退出登录',
|
||||
style: TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Profile card ──────────────────────────────────────────────────────────
|
||||
|
||||
Widget _buildProfileCard(
|
||||
AccountProfile profile, Color cardColor, Color? subtitleColor) {
|
||||
final initial = profile.displayName.isNotEmpty
|
||||
? profile.displayName[0].toUpperCase()
|
||||
: '?';
|
||||
|
||||
return Card(
|
||||
color: cardColor,
|
||||
elevation: 0,
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () => _showEditNameDialog(profile.displayName),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF6366F1), Color(0xFF818CF8)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
initial,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
profile.displayName.isNotEmpty
|
||||
? profile.displayName
|
||||
: '加载中...',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 18,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
profile.email.isNotEmpty ? profile.email : ' ',
|
||||
style: TextStyle(
|
||||
color: subtitleColor, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right, color: subtitleColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pickers ───────────────────────────────────────────────────────────────
|
||||
|
||||
void _showThemePicker(ThemeMode current) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
_handle(),
|
||||
const SizedBox(height: 12),
|
||||
Text('选择主题',
|
||||
style: Theme.of(ctx).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
)),
|
||||
const SizedBox(height: 8),
|
||||
_ThemeOption(
|
||||
icon: Icons.dark_mode,
|
||||
label: '深色模式',
|
||||
selected: current == ThemeMode.dark,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setThemeMode(ThemeMode.dark);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
_ThemeOption(
|
||||
icon: Icons.light_mode,
|
||||
label: '浅色模式',
|
||||
selected: current == ThemeMode.light,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setThemeMode(ThemeMode.light);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
_ThemeOption(
|
||||
icon: Icons.settings_brightness,
|
||||
label: '跟随系统',
|
||||
selected: current == ThemeMode.system,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setThemeMode(ThemeMode.system);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static const _voices = [
|
||||
('coral', 'Coral', '女 · 温暖'),
|
||||
('nova', 'Nova', '女 · 活泼'),
|
||||
('sage', 'Sage', '女 · 知性'),
|
||||
('shimmer', 'Shimmer', '女 · 柔和'),
|
||||
('ash', 'Ash', '男 · 沉稳'),
|
||||
('echo', 'Echo', '男 · 清朗'),
|
||||
('onyx', 'Onyx', '男 · 低沉'),
|
||||
('alloy', 'Alloy', '中性'),
|
||||
];
|
||||
|
||||
String _voiceDisplayLabel(String voice) {
|
||||
for (final v in _voices) {
|
||||
if (v.$1 == voice) return '${v.$2} · ${v.$3}';
|
||||
}
|
||||
return voice.isNotEmpty
|
||||
? voice[0].toUpperCase() + voice.substring(1)
|
||||
: 'Coral';
|
||||
}
|
||||
|
||||
void _showVoicePicker(String current) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
_handle(),
|
||||
const SizedBox(height: 12),
|
||||
Text('选择语音音色',
|
||||
style: Theme.of(ctx).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
)),
|
||||
const SizedBox(height: 8),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: _voices
|
||||
.map((v) => ListTile(
|
||||
leading: Icon(Icons.record_voice_over,
|
||||
color: current == v.$1
|
||||
? AppColors.primary
|
||||
: null),
|
||||
title: Text(v.$2,
|
||||
style: TextStyle(
|
||||
fontWeight: current == v.$1
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
color: current == v.$1
|
||||
? AppColors.primary
|
||||
: null,
|
||||
)),
|
||||
subtitle: Text(v.$3,
|
||||
style: const TextStyle(fontSize: 12)),
|
||||
trailing: current == v.$1
|
||||
? const Icon(Icons.check_circle,
|
||||
color: AppColors.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTtsVoice(v.$1);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static const _engines = [
|
||||
('claude_agent_sdk', 'Agent SDK', '支持工具审批、技能注入、会话恢复'),
|
||||
('claude_api', 'Claude API', '直连 API,响应更快'),
|
||||
];
|
||||
|
||||
void _showEngineTypePicker(String current) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_handle(),
|
||||
const SizedBox(height: 12),
|
||||
Text('选择对话引擎',
|
||||
style: Theme.of(ctx).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
)),
|
||||
const SizedBox(height: 8),
|
||||
..._engines.map((e) => ListTile(
|
||||
leading: Icon(
|
||||
e.$1 == 'claude_agent_sdk'
|
||||
? Icons.psychology
|
||||
: Icons.api,
|
||||
color: e.$1 == current ? AppColors.primary : null,
|
||||
),
|
||||
title: Text(e.$2,
|
||||
style: TextStyle(
|
||||
fontWeight: e.$1 == current
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
color: e.$1 == current ? AppColors.primary : null,
|
||||
)),
|
||||
subtitle:
|
||||
Text(e.$3, style: const TextStyle(fontSize: 12)),
|
||||
trailing: e.$1 == current
|
||||
? const Icon(Icons.check_circle,
|
||||
color: AppColors.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setEngineType(e.$1);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static const _stylePresets = [
|
||||
('专业干练', '用专业、简洁、干练的语气说话,不拖泥带水。'),
|
||||
('温柔耐心', '用温柔、耐心的语气说话,像一个贴心的朋友。'),
|
||||
('轻松活泼', '用轻松、活泼的语气说话,带一点幽默感。'),
|
||||
('严肃正式', '用严肃、正式的语气说话,像在正式会议中发言。'),
|
||||
('科幻AI', '用科幻电影中AI的语气说话,冷静、理性、略带未来感。'),
|
||||
];
|
||||
|
||||
String _styleDisplayName(String style) {
|
||||
if (style.isEmpty) return '默认';
|
||||
for (final p in _stylePresets) {
|
||||
if (p.$2 == style) return p.$1;
|
||||
}
|
||||
return style.length > 6 ? '${style.substring(0, 6)}...' : style;
|
||||
}
|
||||
|
||||
void _showStylePicker(String current) {
|
||||
final controller = TextEditingController(
|
||||
text: _stylePresets.any((p) => p.$2 == current) ? '' : current,
|
||||
);
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
24, 24, 24, MediaQuery.of(ctx).viewInsets.bottom + 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_handle(),
|
||||
const SizedBox(height: 12),
|
||||
Text('选择语音风格',
|
||||
style: Theme.of(ctx).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
)),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _stylePresets
|
||||
.map((p) => ChoiceChip(
|
||||
label: Text(p.$1),
|
||||
selected: current == p.$2,
|
||||
onSelected: (_) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTtsStyle(p.$2);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: '自定义风格',
|
||||
hintText: '例如:用东北话说话,幽默风趣',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTtsStyle('');
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('恢复默认'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
final text = controller.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTtsStyle(text);
|
||||
}
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('确认'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditNameDialog(String currentName) {
|
||||
final controller = TextEditingController(text: currentName);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: const Text('修改显示名称'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: '显示名称',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final name = controller.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
Navigator.of(ctx).pop();
|
||||
final success = await ref
|
||||
.read(accountProfileProvider.notifier)
|
||||
.updateDisplayName(name);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(success ? '名称已更新' : '更新失败')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showChangePasswordSheet() {
|
||||
final currentCtrl = TextEditingController();
|
||||
final newCtrl = TextEditingController();
|
||||
final confirmCtrl = TextEditingController();
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
24, 24, 24, MediaQuery.of(ctx).viewInsets.bottom + 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_handle(),
|
||||
const SizedBox(height: 12),
|
||||
Text('修改密码',
|
||||
style: Theme.of(ctx)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 20),
|
||||
TextField(
|
||||
controller: currentCtrl,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: '当前密码',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: newCtrl,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: '新密码',
|
||||
prefixIcon: const Icon(Icons.lock_reset),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: confirmCtrl,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: '确认新密码',
|
||||
prefixIcon: const Icon(Icons.lock_reset),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
onPressed: () async {
|
||||
if (newCtrl.text != confirmCtrl.text) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('两次输入的密码不一致')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (newCtrl.text.length < 6) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('新密码至少6个字符')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
Navigator.of(ctx).pop();
|
||||
final result = await ref
|
||||
.read(accountProfileProvider.notifier)
|
||||
.changePassword(
|
||||
currentPassword: currentCtrl.text,
|
||||
newPassword: newCtrl.text,
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(result.success
|
||||
? '密码已修改'
|
||||
: result.message ?? '修改失败'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('确认修改', style: TextStyle(fontSize: 16)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmLogout() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: const Text('退出登录'),
|
||||
content: const Text('确定要退出登录吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
style:
|
||||
FilledButton.styleFrom(backgroundColor: AppColors.error),
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('退出'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await ref.read(authStateProvider.notifier).logout();
|
||||
if (mounted) context.go('/login');
|
||||
}
|
||||
}
|
||||
|
||||
Widget _handle() => Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[400],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Shared settings widgets (copied from settings_page to keep this file self-contained)
|
||||
// =============================================================================
|
||||
|
||||
class _SettingsGroup extends StatelessWidget {
|
||||
final Color cardColor;
|
||||
final List<Widget> children;
|
||||
|
||||
const _SettingsGroup(
|
||||
{required this.cardColor, required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
color: cardColor,
|
||||
elevation: 0,
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < children.length; i++) ...[
|
||||
children[i],
|
||||
if (i < children.length - 1)
|
||||
Divider(
|
||||
height: 1,
|
||||
indent: 56,
|
||||
color: Theme.of(context).dividerColor.withAlpha(80),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color iconBg;
|
||||
final String title;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _SettingsRow({
|
||||
required this.icon,
|
||||
required this.iconBg,
|
||||
required this.title,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: iconBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, color: Colors.white, size: 18),
|
||||
),
|
||||
title: Text(title),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (trailing != null) trailing!,
|
||||
if (onTap != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.chevron_right,
|
||||
size: 20,
|
||||
color: Theme.of(context).hintColor),
|
||||
],
|
||||
],
|
||||
),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsToggleRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color iconBg;
|
||||
final String title;
|
||||
final bool value;
|
||||
final ValueChanged<bool>? onChanged;
|
||||
|
||||
const _SettingsToggleRow({
|
||||
required this.icon,
|
||||
required this.iconBg,
|
||||
required this.title,
|
||||
required this.value,
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SwitchListTile(
|
||||
secondary: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: iconBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, color: Colors.white, size: 18),
|
||||
),
|
||||
title: Text(title),
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeLabel extends StatelessWidget {
|
||||
final ThemeMode mode;
|
||||
const _ThemeLabel({required this.mode});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final label = switch (mode) {
|
||||
ThemeMode.dark => '深色',
|
||||
ThemeMode.light => '浅色',
|
||||
ThemeMode.system => '跟随系统',
|
||||
};
|
||||
return Text(label,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).hintColor, fontSize: 14));
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeOption extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ThemeOption({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: Icon(icon,
|
||||
color: selected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null),
|
||||
title: Text(label,
|
||||
style: TextStyle(
|
||||
fontWeight:
|
||||
selected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: selected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
)),
|
||||
trailing: selected
|
||||
? Icon(Icons.check_circle,
|
||||
color: Theme.of(context).colorScheme.primary)
|
||||
: null,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue