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:
hailin 2026-03-07 09:42:17 -08:00
parent 23daa8e122
commit d5930ff4c8
9 changed files with 2517 additions and 81 deletions

View File

@ -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';

View File

@ -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()),
);
}
}

View File

@ -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),
};
}

View File

@ -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,
),
),
),
);
}
}

View File

@ -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;
}

View File

@ -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),

View File

@ -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,
),
),
),
),
],
),
);
}
}

View File

@ -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,
),
],
),
);
}
}

View File

@ -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,
);
}
}