diff --git a/it0_app/lib/core/config/api_endpoints.dart b/it0_app/lib/core/config/api_endpoints.dart index e07aa41..5994156 100644 --- a/it0_app/lib/core/config/api_endpoints.dart +++ b/it0_app/lib/core/config/api_endpoints.dart @@ -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'; diff --git a/it0_app/lib/core/router/app_router.dart b/it0_app/lib/core/router/app_router.dart index 0215821..b6c843c 100644 --- a/it0_app/lib/core/router/app_router.dart +++ b/it0_app/lib/core/router/app_router.dart @@ -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((ref) { return GoRouter( @@ -35,46 +34,20 @@ final routerProvider = Provider((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((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 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 @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 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 } _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 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 } } -/// 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()), ); } } diff --git a/it0_app/lib/core/widgets/floating_robot_fab.dart b/it0_app/lib/core/widgets/floating_robot_fab.dart new file mode 100644 index 0000000..d776668 --- /dev/null +++ b/it0_app/lib/core/widgets/floating_robot_fab.dart @@ -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((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), + }; +} diff --git a/it0_app/lib/core/widgets/robot_avatar.dart b/it0_app/lib/core/widgets/robot_avatar.dart new file mode 100644 index 0000000..60d00e4 --- /dev/null +++ b/it0_app/lib/core/widgets/robot_avatar.dart @@ -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 createState() => _RobotAvatarState(); +} + +class _RobotAvatarState extends State + 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 _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, + ), + ), + ), + ); + } +} diff --git a/it0_app/lib/core/widgets/robot_painter.dart b/it0_app/lib/core/widgets/robot_painter.dart new file mode 100644 index 0000000..2035bb5 --- /dev/null +++ b/it0_app/lib/core/widgets/robot_painter.dart @@ -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; +} \ No newline at end of file diff --git a/it0_app/lib/features/chat/presentation/pages/chat_page.dart b/it0_app/lib/features/chat/presentation/pages/chat_page.dart index 2e9e6fd..cbf0c67 100644 --- a/it0_app/lib/features/chat/presentation/pages/chat_page.dart +++ b/it0_app/lib/features/chat/presentation/pages/chat_page.dart @@ -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 { 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), diff --git a/it0_app/lib/features/home/presentation/pages/home_page.dart b/it0_app/lib/features/home/presentation/pages/home_page.dart new file mode 100644 index 0000000..335cbaf --- /dev/null +++ b/it0_app/lib/features/home/presentation/pages/home_page.dart @@ -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, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/it0_app/lib/features/my_agents/presentation/pages/my_agents_page.dart b/it0_app/lib/features/my_agents/presentation/pages/my_agents_page.dart new file mode 100644 index 0000000..390633f --- /dev/null +++ b/it0_app/lib/features/my_agents/presentation/pages/my_agents_page.dart @@ -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>>((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>(); + if (data is Map && data.containsKey('items')) { + return (data['items'] as List).cast>(); + } + 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> 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 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, + ), + ], + ), + ); + } +} diff --git a/it0_app/lib/features/profile/presentation/pages/profile_page.dart b/it0_app/lib/features/profile/presentation/pages/profile_page.dart new file mode 100644 index 0000000..ae2ca58 --- /dev/null +++ b/it0_app/lib/features/profile/presentation/pages/profile_page.dart @@ -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 createState() => _ProfilePageState(); +} + +class _ProfilePageState extends ConsumerState { + String _appVersion = ''; + + @override + void initState() { + super.initState(); + Future.microtask( + () => ref.read(accountProfileProvider.notifier).loadProfile()); + _loadVersion(); + } + + Future _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( + 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 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? 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, + ); + } +}