From 452c9766fc992465f0d4b0a62cbbc9a1813eca08 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 02:58:24 +0000 Subject: [PATCH 1/3] feat: add desktop sidebar navigation with 6 views from HTML mockups Replaces the single-screen scrollable dashboard with a macOS desktop shell featuring persistent sidebar navigation and dedicated views: Dashboard, Context Vault, Preferences, Agents, Audit Log, and Settings. https://claude.ai/code/session_01ViQtUW7jVVA1JGPDkvvUEA --- lib/core/router/app_router.dart | 4 +- lib/features/dashboard/dashboard_view.dart | 607 ++++++++++++++++++ lib/features/shell/app_shell.dart | 288 +++++++++ lib/features/shell/views/agents_view.dart | 287 +++++++++ lib/features/shell/views/audit_log_view.dart | 240 +++++++ .../shell/views/context_vault_view.dart | 252 ++++++++ .../shell/views/preferences_view.dart | 179 ++++++ lib/features/shell/views/settings_view.dart | 344 ++++++++++ 8 files changed, 2199 insertions(+), 2 deletions(-) create mode 100644 lib/features/dashboard/dashboard_view.dart create mode 100644 lib/features/shell/app_shell.dart create mode 100644 lib/features/shell/views/agents_view.dart create mode 100644 lib/features/shell/views/audit_log_view.dart create mode 100644 lib/features/shell/views/context_vault_view.dart create mode 100644 lib/features/shell/views/preferences_view.dart create mode 100644 lib/features/shell/views/settings_view.dart diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index d09e8d6..d5748fd 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:trulana/features/dashboard/dashboard_screen.dart'; import 'package:trulana/features/onboarding/onboarding_screen.dart'; +import 'package:trulana/features/shell/app_shell.dart'; import 'package:trulana/providers/user_config_provider.dart'; /// Central [GoRouter] instance wired to Riverpod so route guards @@ -40,7 +40,7 @@ final appRouterProvider = Provider((Ref ref) { GoRoute( path: '/dashboard', builder: (BuildContext context, GoRouterState state) => - const DashboardScreen(), + const AppShell(), ), GoRoute( path: '/gatekeeper', diff --git a/lib/features/dashboard/dashboard_view.dart b/lib/features/dashboard/dashboard_view.dart new file mode 100644 index 0000000..9832aec --- /dev/null +++ b/lib/features/dashboard/dashboard_view.dart @@ -0,0 +1,607 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:trulana/core/theme/theme.dart'; +import 'package:trulana/core/ui/ui.dart'; +import 'package:trulana/models/models.dart'; +import 'package:trulana/providers/providers.dart'; + +/// Dashboard view matching the desktop HTML mockup. +/// +/// Grid layout: vault stats, redaction bars, connected agents, +/// recent activity, and full audit trail. +class DashboardView extends ConsumerWidget { + const DashboardView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final configAsync = ref.watch(userConfigProvider); + final agentsAsync = ref.watch(agentsProvider); + + return Column( + children: [ + // — Header + _ContentHeader( + title: 'Dashboard', + trailing: configAsync.whenOrNull( + data: (config) => config != null + ? _PrivacyToggle( + level: config.privacyLevel, + onChanged: (level) { + ref + .read(userConfigProvider.notifier) + .updatePrivacyLevel(level); + }, + ) + : null, + ), + ), + + // — Scrollable grid content + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Row 1: Vault status + Redaction stats + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _VaultStatusCard(configAsync: configAsync)), + const SizedBox(width: 14), + const Expanded(child: _RedactionStatsCard()), + ], + ), + const SizedBox(height: 14), + + // Row 2: Connected agents + Recent activity + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _ConnectedAgentsCard(agentsAsync: agentsAsync)), + const SizedBox(width: 14), + const Expanded(child: _RecentActivityCard()), + ], + ), + const SizedBox(height: 14), + + // Full-width audit trail + const _AuditTrailCard(), + ], + ), + ), + ), + ], + ); + } +} + +/// Reusable content header bar. +class _ContentHeader extends StatelessWidget { + const _ContentHeader({required this.title, this.trailing}); + + final String title; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(24, 18, 24, 14), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: Color(0xFF14142A)), + ), + ), + child: Row( + children: [ + Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: TrulanaColors.textPrimary, + ), + ), + const Spacer(), + if (trailing != null) trailing!, + ], + ), + ); + } +} + +/// Privacy level toggle: Standard / Strict / Paranoid. +class _PrivacyToggle extends StatelessWidget { + const _PrivacyToggle({required this.level, required this.onChanged}); + + final PrivacyLevel level; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: PrivacyLevel.values.map((pl) { + final isActive = pl == level; + return GestureDetector( + onTap: () => onChanged(pl), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: isActive + ? TrulanaColors.primaryCyan.withValues(alpha: 0.12) + : Colors.transparent, + ), + child: Text( + pl.name[0].toUpperCase() + pl.name.substring(1), + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 9, + color: isActive + ? TrulanaColors.primaryCyan + : TrulanaColors.textTertiary, + ), + ), + ), + ); + }).toList(), + ), + ); + } +} + +/// Card wrapper matching the HTML mockup card style. +class _DashCard extends StatelessWidget { + const _DashCard({ + required this.title, + required this.child, + this.badge, + this.badgeColor, + }); + + final String title; + final Widget child; + final String? badge; + final Color? badgeColor; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: const Color(0xFF0E0E1A), + border: Border.all(color: const Color(0xFF14142A)), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: Color(0xFF14142A)), + ), + ), + child: Row( + children: [ + Text( + title.toUpperCase(), + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 10, + letterSpacing: 0.8, + color: TrulanaColors.textTertiary, + ), + ), + const Spacer(), + if (badge != null) + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: (badgeColor ?? TrulanaColors.primaryCyan) + .withValues(alpha: 0.25), + ), + ), + child: Text( + badge!, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 9, + color: badgeColor ?? TrulanaColors.primaryCyan, + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(14), + child: child, + ), + ], + ), + ); + } +} + +/// Vault status card with entry counts. +class _VaultStatusCard extends StatelessWidget { + const _VaultStatusCard({required this.configAsync}); + + final AsyncValue configAsync; + + @override + Widget build(BuildContext context) { + return _DashCard( + title: 'Vault Status', + badge: 'encrypted', + badgeColor: TrulanaColors.successGreen, + child: Row( + children: [ + _StatColumn(value: '6', label: 'Entries'), + _StatColumn(value: '12', label: 'Preferences'), + _StatColumn(value: '2', label: 'Agents'), + ], + ), + ); + } +} + +class _StatColumn extends StatelessWidget { + const _StatColumn({required this.value, required this.label}); + + final String value; + final String label; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Column( + children: [ + Text( + value, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 22, + fontWeight: FontWeight.w500, + color: TrulanaColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + label, + style: const TextStyle( + fontSize: 10, + color: TrulanaColors.textTertiary, + ), + ), + ], + ), + ); + } +} + +/// Redaction statistics with colored bars. +class _RedactionStatsCard extends StatelessWidget { + const _RedactionStatsCard(); + + @override + Widget build(BuildContext context) { + return _DashCard( + title: 'Redactions Today', + badge: '47 total', + badgeColor: TrulanaColors.primaryCyan, + child: Column( + children: const [ + _RedactionBar(label: 'PII', percent: 0.72, count: '18', color: Color(0xFFF05858)), + SizedBox(height: 10), + _RedactionBar(label: 'Entity', percent: 0.56, count: '14', color: TrulanaColors.primaryCyan), + SizedBox(height: 10), + _RedactionBar(label: 'General', percent: 0.44, count: '11', color: Color(0xFFE0C85C)), + SizedBox(height: 10), + _RedactionBar(label: 'Blocked', percent: 0.16, count: '4', color: TrulanaColors.successGreen), + ], + ), + ); + } +} + +class _RedactionBar extends StatelessWidget { + const _RedactionBar({ + required this.label, + required this.percent, + required this.count, + required this.color, + }); + + final String label; + final double percent; + final String count; + final Color color; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + SizedBox( + width: 60, + child: Text( + label, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 10, + color: TrulanaColors.textTertiary, + ), + ), + ), + Expanded( + child: Container( + height: 6, + decoration: BoxDecoration( + color: TrulanaColors.primaryCyan.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(3), + ), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: percent, + child: Container( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(3), + ), + ), + ), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 30, + child: Text( + count, + textAlign: TextAlign.right, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 10, + color: TrulanaColors.textSecondary, + ), + ), + ), + ], + ); + } +} + +/// Connected agents card with status dots. +class _ConnectedAgentsCard extends StatelessWidget { + const _ConnectedAgentsCard({required this.agentsAsync}); + + final AsyncValue> agentsAsync; + + @override + Widget build(BuildContext context) { + return _DashCard( + title: 'Connected Agents', + badge: '2 active', + badgeColor: TrulanaColors.primaryCyan, + child: Column( + children: const [ + _AgentRow(name: 'cursor-mcp', scope: 'context.read', ttl: '11:42', isLive: true), + _AgentRow(name: 'claude-desktop', scope: 'context.read', ttl: '8:15', isLive: true), + _AgentRow(name: 'demo-agent', scope: 'context.read', ttl: 'expired', isLive: false), + ], + ), + ); + } +} + +class _AgentRow extends StatelessWidget { + const _AgentRow({ + required this.name, + required this.scope, + required this.ttl, + required this.isLive, + }); + + final String name; + final String scope; + final String ttl; + final bool isLive; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: Color(0x05FFFFFF)), + ), + ), + child: Row( + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isLive ? TrulanaColors.successGreen : TrulanaColors.textTertiary, + boxShadow: isLive + ? [BoxShadow(color: TrulanaColors.successGreen.withValues(alpha: 0.5), blurRadius: 4)] + : null, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + name, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 12, + color: TrulanaColors.textSecondary, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + border: Border.all(color: const Color(0xFF14142A)), + ), + child: Text( + scope, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 9, + color: TrulanaColors.textTertiary, + ), + ), + ), + const SizedBox(width: 8), + Text( + ttl, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 10, + color: isLive ? const Color(0xFF2A8A84) : TrulanaColors.textTertiary, + ), + ), + ], + ), + ); + } +} + +/// Recent activity card with color-coded action tags. +class _RecentActivityCard extends StatelessWidget { + const _RecentActivityCard(); + + @override + Widget build(BuildContext context) { + return _DashCard( + title: 'Recent Activity', + child: Column( + children: const [ + _AuditEntry(time: '14:32:01', action: 'redact', detail: 'cursor-mcp \u00b7 7 redactions', isRedactHighlight: true), + _AuditEntry(time: '14:31:58', action: 'query', detail: 'cursor-mcp \u00b7 "work schedule"'), + _AuditEntry(time: '14:28:44', action: 'auth', detail: 'claude-desktop \u00b7 granted ttl=900s'), + _AuditEntry(time: '14:22:10', action: 'deny', detail: 'unknown-app \u00b7 no valid token'), + ], + ), + ); + } +} + +/// Full-width audit trail card. +class _AuditTrailCard extends StatelessWidget { + const _AuditTrailCard(); + + @override + Widget build(BuildContext context) { + return _DashCard( + title: 'Audit Trail', + badge: 'encrypted \u00b7 tamper-evident', + badgeColor: TrulanaColors.successGreen, + child: Column( + children: const [ + _AuditEntry( + time: '14:32:01', + action: 'redact', + detail: 'cursor-mcp queried "work schedule" \u2192 [PERSON] [EMAIL] [LOCATION] stripped', + isRedactHighlight: true, + ), + _AuditEntry( + time: '14:28:44', + action: 'auth', + detail: 'claude-desktop handshake \u00b7 scopes: context.read \u00b7 token issued \u00b7 ttl=900s', + ), + _AuditEntry( + time: '14:22:10', + action: 'deny', + detail: 'unknown-app attempted query without token \u00b7 request rejected', + ), + _AuditEntry( + time: '14:15:33', + action: 'query', + detail: 'cursor-mcp queried "morning routine" \u2192 matched 1 vault entry \u00b7 12 redactions', + ), + ], + ), + ); + } +} + +/// Single audit log entry row used across dashboard cards. +class _AuditEntry extends StatelessWidget { + const _AuditEntry({ + required this.time, + required this.action, + required this.detail, + this.isRedactHighlight = false, + }); + + final String time; + final String action; + final String detail; + final bool isRedactHighlight; + + Color get _actionColor => switch (action) { + 'auth' => TrulanaColors.primaryCyan, + 'query' => TrulanaColors.successGreen, + 'redact' => const Color(0xFFE0C85C), + 'deny' => const Color(0xFFF05858), + _ => TrulanaColors.textTertiary, + }; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 7), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: Color(0x05FFFFFF))), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 65, + child: Text( + time, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 11, + color: TrulanaColors.textTertiary, + ), + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: _actionColor.withValues(alpha: 0.1), + ), + child: Text( + action.toUpperCase(), + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 9, + letterSpacing: 0.3, + color: _actionColor, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + detail, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 11, + color: TrulanaColors.textSecondary, + height: 1.5, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/shell/app_shell.dart b/lib/features/shell/app_shell.dart new file mode 100644 index 0000000..139f1c8 --- /dev/null +++ b/lib/features/shell/app_shell.dart @@ -0,0 +1,288 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:trulana/core/theme/theme.dart'; +import 'package:trulana/core/ui/ui.dart'; +import 'package:trulana/features/dashboard/dashboard_view.dart'; +import 'package:trulana/features/shell/views/context_vault_view.dart'; +import 'package:trulana/features/shell/views/preferences_view.dart'; +import 'package:trulana/features/shell/views/agents_view.dart'; +import 'package:trulana/features/shell/views/audit_log_view.dart'; +import 'package:trulana/features/shell/views/settings_view.dart'; + +/// Provider tracking which sidebar item is selected. +final selectedNavProvider = StateProvider((ref) => 0); + +/// macOS desktop app shell with persistent sidebar navigation. +/// +/// Mirrors the desktop HTML mockup: a fixed sidebar on the left with +/// navigation items, and a content area on the right that swaps views. +class AppShell extends ConsumerWidget { + const AppShell({super.key}); + + static const List<_NavItem> _navItems = [ + _NavItem(icon: Icons.dashboard_rounded, label: 'Dashboard'), + _NavItem(icon: Icons.hexagon_outlined, label: 'Context Vault'), + _NavItem(icon: Icons.tune_rounded, label: 'Preferences'), + _NavItem(icon: Icons.hub_outlined, label: 'Agents'), + _NavItem(icon: Icons.receipt_long_rounded, label: 'Audit Log'), + ]; + + static const List<_NavItem> _systemItems = [ + _NavItem(icon: Icons.settings_rounded, label: 'Settings'), + ]; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final int selected = ref.watch(selectedNavProvider); + + return Scaffold( + backgroundColor: TrulanaColors.background, + body: Row( + children: [ + // — Sidebar + _Sidebar( + navItems: _navItems, + systemItems: _systemItems, + selected: selected, + onSelect: (int index) { + ref.read(selectedNavProvider.notifier).state = index; + }, + ), + + // — Content area + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: _buildView(selected), + ), + ), + ], + ), + ); + } + + Widget _buildView(int index) { + return switch (index) { + 0 => const DashboardView(key: ValueKey('dashboard')), + 1 => const ContextVaultView(key: ValueKey('vault')), + 2 => const PreferencesView(key: ValueKey('prefs')), + 3 => const AgentsView(key: ValueKey('agents')), + 4 => const AuditLogView(key: ValueKey('audit')), + 5 => const SettingsView(key: ValueKey('settings')), + _ => const DashboardView(key: ValueKey('dashboard')), + }; + } +} + +class _NavItem { + final IconData icon; + final String label; + + const _NavItem({required this.icon, required this.label}); +} + +/// Fixed sidebar with brand header, nav items, and server status footer. +class _Sidebar extends StatelessWidget { + const _Sidebar({ + required this.navItems, + required this.systemItems, + required this.selected, + required this.onSelect, + }); + + final List<_NavItem> navItems; + final List<_NavItem> systemItems; + final int selected; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + return Container( + width: 220, + decoration: const BoxDecoration( + color: Color(0xFF0B0B14), + border: Border( + right: BorderSide(color: TrulanaColors.surfaceLight), + ), + ), + child: Column( + children: [ + // — Brand header + Container( + padding: const EdgeInsets.fromLTRB(18, 18, 18, 14), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: Color(0xFF14142A)), + ), + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(9), + child: Image.asset( + 'assets/images/trulana-icon.png', + width: 36, + height: 36, + ), + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Trulana', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: TrulanaColors.textPrimary, + letterSpacing: 0.04, + ), + ), + Text( + 'v1.0.0-mvp', + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 9, + color: TrulanaColors.textTertiary, + letterSpacing: 0.04, + ), + ), + ], + ), + ], + ), + ), + + // — Navigation items + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...List.generate(navItems.length, (int i) { + return _NavButton( + item: navItems[i], + isActive: selected == i, + onTap: () => onSelect(i), + ); + }), + Padding( + padding: const EdgeInsets.fromLTRB(10, 16, 10, 6), + child: Text( + 'SYSTEM', + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 9, + letterSpacing: 1.0, + color: TrulanaColors.textTertiary, + ), + ), + ), + ...List.generate(systemItems.length, (int i) { + final int globalIndex = navItems.length + i; + return _NavButton( + item: systemItems[i], + isActive: selected == globalIndex, + onTap: () => onSelect(globalIndex), + ); + }), + const Spacer(), + ], + ), + ), + ), + + // — Server status footer + Container( + padding: const EdgeInsets.all(18), + decoration: const BoxDecoration( + border: Border( + top: BorderSide(color: Color(0xFF14142A)), + ), + ), + child: Row( + children: [ + const PulseIndicator( + size: 7, + color: TrulanaColors.successGreen, + ), + const SizedBox(width: 8), + Text( + 'Server running on ', + style: TextStyle( + fontSize: 11, + color: TrulanaColors.textTertiary, + ), + ), + Text( + ':8432', + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 11, + color: const Color(0xFF2A8A84), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// Individual sidebar navigation button. +class _NavButton extends StatelessWidget { + const _NavButton({ + required this.item, + required this.isActive, + required this.onTap, + }); + + final _NavItem item; + final bool isActive; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: isActive + ? TrulanaColors.primaryCyan.withValues(alpha: 0.08) + : Colors.transparent, + ), + child: Row( + children: [ + Icon( + item.icon, + size: 18, + color: isActive + ? TrulanaColors.primaryCyan + : TrulanaColors.textTertiary, + ), + const SizedBox(width: 10), + Text( + item.label, + style: TextStyle( + fontSize: 13, + color: isActive + ? TrulanaColors.primaryCyan + : TrulanaColors.textSecondary, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/shell/views/agents_view.dart b/lib/features/shell/views/agents_view.dart new file mode 100644 index 0000000..e2791bb --- /dev/null +++ b/lib/features/shell/views/agents_view.dart @@ -0,0 +1,287 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:trulana/core/theme/theme.dart'; +import 'package:trulana/models/models.dart'; +import 'package:trulana/providers/providers.dart'; + +/// Agents view matching the desktop HTML mockup. +/// +/// Shows active tokens with TTL remaining and expired/revoked section. +class AgentsView extends ConsumerWidget { + const AgentsView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final agentsAsync = ref.watch(agentsProvider); + + return Column( + children: [ + _header(), + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(24), + child: agentsAsync.when( + loading: () => const Center( + child: CircularProgressIndicator( + color: TrulanaColors.primaryCyan, + strokeWidth: 2, + ), + ), + error: (e, s) => Text( + 'Failed to load agents', + style: TextStyle(color: TrulanaColors.dangerRed), + ), + data: (agents) { + final active = agents + .where((a) => a.status == AgentStatus.active) + .toList(); + final inactive = agents + .where((a) => a.status != AgentStatus.active) + .toList(); + + return Column( + children: [ + _AgentCard( + title: 'Active Tokens', + badge: '${active.length} live', + agents: active.isNotEmpty + ? active + .map((a) => _AgentDisplayRow( + name: a.agentName, + scope: a.grantedScopes.join(', '), + ttl: _formatTtl(a.expirationDate), + isLive: true, + )) + .toList() + : [ + const _AgentDisplayRow( + name: 'cursor-mcp', + scope: 'context.read', + ttl: '11:42 remaining', + isLive: true, + ), + const _AgentDisplayRow( + name: 'claude-desktop', + scope: 'context.read', + ttl: '8:15 remaining', + isLive: true, + ), + ], + ), + const SizedBox(height: 14), + _AgentCard( + title: 'Expired / Revoked', + agents: inactive.isNotEmpty + ? inactive + .map((a) => _AgentDisplayRow( + name: a.agentName, + scope: a.grantedScopes.join(', '), + ttl: 'expired', + isLive: false, + )) + .toList() + : [ + const _AgentDisplayRow( + name: 'demo-agent', + scope: 'context.read', + ttl: 'expired 12m ago', + isLive: false, + ), + const _AgentDisplayRow( + name: 'test-runner', + scope: 'context.read', + ttl: 'expired 2h ago', + isLive: false, + ), + ], + ), + ], + ); + }, + ), + ), + ), + ], + ); + } + + Widget _header() { + return Container( + padding: const EdgeInsets.fromLTRB(24, 18, 24, 14), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: Color(0xFF14142A))), + ), + child: const Row( + children: [ + Text( + 'Agents', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: TrulanaColors.textPrimary, + ), + ), + ], + ), + ); + } + + String _formatTtl(DateTime expiration) { + final remaining = expiration.difference(DateTime.now()); + if (remaining.isNegative) return 'expired'; + final min = remaining.inMinutes; + final sec = remaining.inSeconds % 60; + return '$min:${sec.toString().padLeft(2, '0')} remaining'; + } +} + +class _AgentCard extends StatelessWidget { + const _AgentCard({ + required this.title, + required this.agents, + this.badge, + }); + + final String title; + final String? badge; + final List<_AgentDisplayRow> agents; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: const Color(0xFF0E0E1A), + border: Border.all(color: const Color(0xFF14142A)), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: Color(0xFF14142A))), + ), + child: Row( + children: [ + Text( + title.toUpperCase(), + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 10, + letterSpacing: 0.8, + color: TrulanaColors.textTertiary, + ), + ), + const Spacer(), + if (badge != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: TrulanaColors.primaryCyan.withValues(alpha: 0.25), + ), + ), + child: Text( + badge!, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 9, + color: TrulanaColors.primaryCyan, + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(14), + child: Column(children: agents), + ), + ], + ), + ); + } +} + +class _AgentDisplayRow extends StatelessWidget { + const _AgentDisplayRow({ + required this.name, + required this.scope, + required this.ttl, + required this.isLive, + }); + + final String name; + final String scope; + final String ttl; + final bool isLive; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: Color(0x05FFFFFF))), + ), + child: Row( + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isLive + ? TrulanaColors.successGreen + : TrulanaColors.textTertiary, + boxShadow: isLive + ? [ + BoxShadow( + color: + TrulanaColors.successGreen.withValues(alpha: 0.5), + blurRadius: 4, + ), + ] + : null, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + name, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 12, + color: TrulanaColors.textSecondary, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + border: Border.all(color: const Color(0xFF14142A)), + ), + child: Text( + scope, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 9, + color: TrulanaColors.textTertiary, + ), + ), + ), + const SizedBox(width: 8), + Text( + ttl, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 10, + color: isLive + ? const Color(0xFF2A8A84) + : TrulanaColors.textTertiary, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/shell/views/audit_log_view.dart b/lib/features/shell/views/audit_log_view.dart new file mode 100644 index 0000000..c2fbb68 --- /dev/null +++ b/lib/features/shell/views/audit_log_view.dart @@ -0,0 +1,240 @@ +import 'package:flutter/material.dart'; + +import 'package:trulana/core/theme/theme.dart'; + +/// Audit Log view matching the desktop HTML mockup. +/// +/// Detailed event log with timestamps, color-coded action types, +/// and redaction details. +class AuditLogView extends StatelessWidget { + const AuditLogView({super.key}); + + static const List<_AuditDisplayEntry> _entries = [ + _AuditDisplayEntry( + time: '14:32:01', + action: 'redact', + detail: + 'cursor-mcp "work schedule" \u2192 [PERSON] [EMAIL] [LOCATION] [TIME] stripped \u00b7 7 total', + ), + _AuditDisplayEntry( + time: '14:28:44', + action: 'auth', + detail: + 'claude-desktop handshake \u00b7 scopes: [context.read] \u00b7 intent: "code assistance" \u00b7 ttl=900s', + ), + _AuditDisplayEntry( + time: '14:22:10', + action: 'deny', + detail: 'unknown-app \u00b7 no bearer token \u00b7 rejected', + ), + _AuditDisplayEntry( + time: '14:15:33', + action: 'redact', + detail: + 'cursor-mcp "morning routine" \u2192 [PERSON] [EMAIL] [PHONE] [LOCATION] \u00d72 [ORG] \u00b7 12 total', + ), + _AuditDisplayEntry( + time: '14:15:30', + action: 'auth', + detail: + 'cursor-mcp handshake \u00b7 scopes: [context.read] \u00b7 intent: "IDE context" \u00b7 ttl=900s', + ), + _AuditDisplayEntry( + time: '14:02:17', + action: 'redact', + detail: + 'demo-agent "financial info" \u2192 [CARD] [SSN] [AMOUNT] \u00d72 \u00b7 9 total', + ), + _AuditDisplayEntry( + time: '13:58:01', + action: 'auth', + detail: + 'demo-agent handshake \u00b7 scopes: [context.read] \u00b7 ttl=900s', + ), + _AuditDisplayEntry( + time: '13:45:00', + action: 'query', + detail: + 'server started \u00b7 vault unlocked \u00b7 REST bound to 127.0.0.1:8432', + ), + ]; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _header(), + Expanded( + child: ListView.builder( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), + itemCount: _entries.length, + itemBuilder: (context, index) => + _AuditRow(entry: _entries[index]), + ), + ), + ], + ); + } + + Widget _header() { + return Container( + padding: const EdgeInsets.fromLTRB(24, 18, 24, 14), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: Color(0xFF14142A))), + ), + child: Row( + children: [ + const Text( + 'Audit Log', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: TrulanaColors.textPrimary, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all(color: TrulanaColors.surfaceLight), + ), + child: Text( + 'Export', + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 10, + color: TrulanaColors.textSecondary, + ), + ), + ), + ], + ), + ); + } +} + +class _AuditRow extends StatelessWidget { + const _AuditRow({required this.entry}); + + final _AuditDisplayEntry entry; + + Color get _actionColor => switch (entry.action) { + 'auth' => TrulanaColors.primaryCyan, + 'query' => TrulanaColors.successGreen, + 'redact' => const Color(0xFFE0C85C), + 'deny' => const Color(0xFFF05858), + _ => TrulanaColors.textTertiary, + }; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 7), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: Color(0x05FFFFFF))), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 65, + child: Text( + entry.time, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 11, + color: TrulanaColors.textTertiary, + ), + ), + ), + const SizedBox(width: 10), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: _actionColor.withValues(alpha: 0.1), + ), + child: Text( + entry.action.toUpperCase(), + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 9, + letterSpacing: 0.3, + color: _actionColor, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: _HighlightedDetail(text: entry.detail), + ), + ], + ), + ); + } +} + +/// Renders audit detail text with [REDACTED] tags highlighted. +class _HighlightedDetail extends StatelessWidget { + const _HighlightedDetail({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + final pattern = RegExp(r'\[([A-Z]+)\]'); + final spans = []; + int lastEnd = 0; + + final baseStyle = TrulanaTheme.monoStyle.copyWith( + fontSize: 11, + color: TrulanaColors.textSecondary, + height: 1.5, + ); + + for (final match in pattern.allMatches(text)) { + if (match.start > lastEnd) { + spans.add(TextSpan( + text: text.substring(lastEnd, match.start), + style: baseStyle, + )); + } + spans.add(WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + color: TrulanaColors.primaryCyan.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(2), + ), + child: Text( + match.group(0)!, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 10, + color: TrulanaColors.primaryCyan, + ), + ), + ), + )); + lastEnd = match.end; + } + + if (lastEnd < text.length) { + spans.add(TextSpan(text: text.substring(lastEnd), style: baseStyle)); + } + + return RichText(text: TextSpan(children: spans)); + } +} + +class _AuditDisplayEntry { + final String time; + final String action; + final String detail; + + const _AuditDisplayEntry({ + required this.time, + required this.action, + required this.detail, + }); +} diff --git a/lib/features/shell/views/context_vault_view.dart b/lib/features/shell/views/context_vault_view.dart new file mode 100644 index 0000000..6698dbd --- /dev/null +++ b/lib/features/shell/views/context_vault_view.dart @@ -0,0 +1,252 @@ +import 'package:flutter/material.dart'; + +import 'package:trulana/core/theme/theme.dart'; + +/// Context Vault view — shows vault entries with redacted PII markers. +/// +/// Mirrors the desktop HTML mockup "Context Vault" sidebar view with +/// category tags, dates, and redacted text previews. +class ContextVaultView extends StatelessWidget { + const ContextVaultView({super.key}); + + static const List<_VaultDisplayEntry> _entries = [ + _VaultDisplayEntry( + category: 'routine', + date: 'Mar 28', + preview: + 'Wakes up at [TIME], takes the Caltrain from [LOCATION] to [LOCATION]. ' + 'Goes to [ORG] for a 45-min session, picks up coffee. ' + 'Contact: [PERSON] at [EMAIL].', + ), + _VaultDisplayEntry( + category: 'work', + date: 'Mar 28', + preview: + 'Works at [ORG] as a senior PM. Reports: [PERSON] ([EMAIL]) and ' + '[PERSON] ([EMAIL]). Weekly 1:1 with VP [PERSON] at [TIME]. ' + 'Badge ID tied to [SSN].', + ), + _VaultDisplayEntry( + category: 'financial', + date: 'Mar 27', + preview: + 'Primary checking at [ORG], card [CARD]. Monthly take-home [AMOUNT]. ' + 'Rent [AMOUNT]/mo in [LOCATION].', + ), + _VaultDisplayEntry( + category: 'health', + date: 'Mar 27', + preview: + 'PCP is Dr. [PERSON] at [ORG], [LOCATION]. [AGE], blood type O+. ' + 'Rx atorvastatin. Portal: [EMAIL]. IP: [IP].', + ), + _VaultDisplayEntry( + category: 'travel', + date: 'Mar 26', + preview: + 'Trip to [LOCATION]. Delta DL1847 from [LOCATION] at [TIME]. ' + 'Hotel under [PERSON], [EMAIL]. SSN on file: [SSN].', + ), + _VaultDisplayEntry( + category: 'personal', + date: 'Mar 25', + preview: + 'Lives with [PERSON] in [LOCATION]. [PERSON] works at [ORG]. ' + 'Two kids at [LOCATION] Elementary. Savings goal: [AMOUNT].', + ), + ]; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _header(), + Expanded( + child: ListView.builder( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), + itemCount: _entries.length, + itemBuilder: (context, index) => _EntryTile(entry: _entries[index]), + ), + ), + ], + ); + } + + Widget _header() { + return Container( + padding: const EdgeInsets.fromLTRB(24, 18, 24, 14), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: Color(0xFF14142A))), + ), + child: Row( + children: [ + const Text( + 'Context Vault', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: TrulanaColors.textPrimary, + ), + ), + const Spacer(), + _HeaderButton(label: '+ Add Entry', isPrimary: true), + ], + ), + ); + } +} + +class _EntryTile extends StatelessWidget { + const _EntryTile({required this.entry}); + + final _VaultDisplayEntry entry; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: Color(0x08FFFFFF))), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: TrulanaColors.primaryCyan.withValues(alpha: 0.08), + ), + child: Text( + entry.category.toUpperCase(), + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 9, + letterSpacing: 0.4, + color: TrulanaColors.primaryCyan, + ), + ), + ), + const SizedBox(width: 8), + Text( + entry.date, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 10, + color: TrulanaColors.textTertiary, + ), + ), + ], + ), + const SizedBox(height: 6), + _RedactedText(text: entry.preview), + ], + ), + ); + } +} + +/// Renders text with [REDACTED_TAG] markers highlighted in cyan. +class _RedactedText extends StatelessWidget { + const _RedactedText({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + final pattern = RegExp(r'\[([A-Z]+)\]'); + final spans = []; + int lastEnd = 0; + + for (final match in pattern.allMatches(text)) { + if (match.start > lastEnd) { + spans.add(TextSpan( + text: text.substring(lastEnd, match.start), + style: const TextStyle( + fontSize: 13, + color: TrulanaColors.textSecondary, + height: 1.5, + ), + )); + } + spans.add(WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + color: TrulanaColors.primaryCyan.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(2), + ), + child: Text( + match.group(0)!, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 11, + color: TrulanaColors.primaryCyan, + ), + ), + ), + )); + lastEnd = match.end; + } + + if (lastEnd < text.length) { + spans.add(TextSpan( + text: text.substring(lastEnd), + style: const TextStyle( + fontSize: 13, + color: TrulanaColors.textSecondary, + height: 1.5, + ), + )); + } + + return RichText(text: TextSpan(children: spans)); + } +} + +class _HeaderButton extends StatelessWidget { + const _HeaderButton({required this.label, this.isPrimary = false}); + + final String label; + final bool isPrimary; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isPrimary + ? const Color(0xFF2A8A84) + : TrulanaColors.surfaceLight, + ), + color: isPrimary + ? TrulanaColors.primaryCyan.withValues(alpha: 0.1) + : Colors.transparent, + ), + child: Text( + label, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 10, + color: isPrimary ? TrulanaColors.primaryCyan : TrulanaColors.textSecondary, + ), + ), + ); + } +} + +class _VaultDisplayEntry { + final String category; + final String date; + final String preview; + + const _VaultDisplayEntry({ + required this.category, + required this.date, + required this.preview, + }); +} diff --git a/lib/features/shell/views/preferences_view.dart b/lib/features/shell/views/preferences_view.dart new file mode 100644 index 0000000..e00e2d7 --- /dev/null +++ b/lib/features/shell/views/preferences_view.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:trulana/core/theme/theme.dart'; +import 'package:trulana/core/services/preferences_service.dart'; +import 'package:trulana/models/models.dart'; + +/// Provider for loading all preferences. +final _preferencesProvider = FutureProvider>((ref) async { + return PreferencesService.instance.getAllPreferences(); +}); + +/// Structured Preferences view matching the desktop HTML mockup. +/// +/// Displays key-value pairs with scope tags in a clean list layout. +class PreferencesView extends ConsumerWidget { + const PreferencesView({super.key}); + + /// Fallback display data matching the HTML mockup when DB preferences + /// don't have scope tags. + static const List<_PrefDisplay> _fallbackPrefs = [ + _PrefDisplay(key: 'preferred_coffee', value: 'Philz, iced mint mojito', scope: 'routine'), + _PrefDisplay(key: 'response_style', value: 'concise', scope: 'work'), + _PrefDisplay(key: 'commute', value: 'Caltrain from San Francisco', scope: 'routine'), + _PrefDisplay(key: 'workout_time', value: '6:30 AM', scope: 'health'), + _PrefDisplay(key: 'work_style', value: 'deep focus mornings, meetings after 2 PM', scope: 'work'), + _PrefDisplay(key: 'diet', value: 'Dairy-free, Mediterranean', scope: 'health'), + _PrefDisplay(key: 'travel_airline', value: 'Delta, window, SkyMiles', scope: 'travel'), + _PrefDisplay(key: 'meeting_default', value: '25 min, agenda-first', scope: 'work'), + ]; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final prefsAsync = ref.watch(_preferencesProvider); + + return Column( + children: [ + _header(), + Expanded( + child: prefsAsync.when( + loading: () => const Center( + child: CircularProgressIndicator( + color: TrulanaColors.primaryCyan, + strokeWidth: 2, + ), + ), + error: (e, s) => Center( + child: Text('Failed to load preferences', + style: TextStyle(color: TrulanaColors.dangerRed)), + ), + data: (prefs) { + // Use DB prefs if available, fall back to display data + final displayList = prefs.isNotEmpty + ? prefs + .map((p) => _PrefDisplay( + key: p.key, + value: p.value, + scope: p.tags.isNotEmpty ? p.tags.first : 'general', + )) + .toList() + : _fallbackPrefs; + + return ListView.builder( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), + itemCount: displayList.length, + itemBuilder: (context, index) => + _PrefRow(pref: displayList[index]), + ); + }, + ), + ), + ], + ); + } + + Widget _header() { + return Container( + padding: const EdgeInsets.fromLTRB(24, 18, 24, 14), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: Color(0xFF14142A))), + ), + child: Row( + children: [ + const Text( + 'Structured Preferences', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: TrulanaColors.textPrimary, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFF2A8A84)), + color: TrulanaColors.primaryCyan.withValues(alpha: 0.1), + ), + child: Text( + '+ Add', + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 10, + color: TrulanaColors.primaryCyan, + ), + ), + ), + ], + ), + ); + } +} + +class _PrefRow extends StatelessWidget { + const _PrefRow({required this.pref}); + + final _PrefDisplay pref; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: Color(0x05FFFFFF))), + ), + child: Row( + children: [ + SizedBox( + width: 140, + child: Text( + pref.key, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 11, + color: const Color(0xFF2A8A84), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + pref.value, + style: const TextStyle( + fontSize: 13, + color: TrulanaColors.textSecondary, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + border: Border.all(color: const Color(0xFF14142A)), + ), + child: Text( + pref.scope, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 9, + color: TrulanaColors.textTertiary, + ), + ), + ), + ], + ), + ); + } +} + +class _PrefDisplay { + final String key; + final String value; + final String scope; + + const _PrefDisplay({ + required this.key, + required this.value, + required this.scope, + }); +} diff --git a/lib/features/shell/views/settings_view.dart b/lib/features/shell/views/settings_view.dart new file mode 100644 index 0000000..7cb281e --- /dev/null +++ b/lib/features/shell/views/settings_view.dart @@ -0,0 +1,344 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:trulana/core/theme/theme.dart'; +import 'package:trulana/models/models.dart'; +import 'package:trulana/providers/providers.dart'; + +/// Settings view matching the desktop HTML mockup. +/// +/// Privacy level, biometric config, server settings, and data management. +class SettingsView extends ConsumerWidget { + const SettingsView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final configAsync = ref.watch(userConfigProvider); + + return Column( + children: [ + _header(), + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(24), + child: configAsync.when( + loading: () => const Center( + child: CircularProgressIndicator( + color: TrulanaColors.primaryCyan, + strokeWidth: 2, + ), + ), + error: (e, s) => Text( + 'Failed to load settings', + style: TextStyle(color: TrulanaColors.dangerRed), + ), + data: (config) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SettingGroup( + title: 'Privacy', + children: [ + _SettingRow( + label: 'Default privacy level', + subtitle: 'Applied to all new queries', + trailing: _PrivacyToggle( + level: config?.privacyLevel ?? PrivacyLevel.strict, + onChanged: (level) { + ref + .read(userConfigProvider.notifier) + .updatePrivacyLevel(level); + }, + ), + ), + _SettingRow( + label: 'Require Touch ID on launch', + subtitle: 'Biometric gate every session', + trailing: const _ToggleSwitch(isOn: true), + ), + ], + ), + _SettingGroup( + title: 'Server', + children: [ + _SettingRow( + label: 'Auto-start REST server', + subtitle: 'Binds to localhost:8432', + trailing: const _ToggleSwitch(isOn: true), + ), + _SettingRow( + label: 'MCP stdio adapter', + subtitle: 'For Claude Desktop / Cursor', + trailing: const _ToggleSwitch(isOn: true), + ), + _SettingRow( + label: 'Token TTL', + subtitle: 'Agent token validity period', + trailing: Text( + '15 min', + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 13, + color: const Color(0xFF2A8A84), + ), + ), + ), + ], + ), + _SettingGroup( + title: 'Data', + children: [ + _SettingRow( + label: 'Export vault (encrypted)', + subtitle: 'Backup your context vault', + trailing: _ActionButton(label: 'Export'), + ), + _SettingRow( + label: 'Wipe all data', + subtitle: 'Permanently destroy everything', + trailing: _ActionButton( + label: 'Wipe', + isDanger: true, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ], + ); + } + + Widget _header() { + return Container( + padding: const EdgeInsets.fromLTRB(24, 18, 24, 14), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: Color(0xFF14142A))), + ), + child: const Row( + children: [ + Text( + 'Settings', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: TrulanaColors.textPrimary, + ), + ), + ], + ), + ); + } +} + +class _SettingGroup extends StatelessWidget { + const _SettingGroup({required this.title, required this.children}); + + final String title; + final List children; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + title.toUpperCase(), + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 10, + letterSpacing: 0.8, + color: TrulanaColors.textTertiary, + ), + ), + ), + ...children, + ], + ), + ); + } +} + +class _SettingRow extends StatelessWidget { + const _SettingRow({ + required this.label, + this.subtitle, + required this.trailing, + }); + + final String label; + final String? subtitle; + final Widget trailing; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: Color(0x05FFFFFF))), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 13, + color: TrulanaColors.textSecondary, + ), + ), + if (subtitle != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + subtitle!, + style: const TextStyle( + fontSize: 11, + color: TrulanaColors.textTertiary, + ), + ), + ), + ], + ), + ), + trailing, + ], + ), + ); + } +} + +class _ToggleSwitch extends StatefulWidget { + const _ToggleSwitch({required this.isOn}); + + final bool isOn; + + @override + State<_ToggleSwitch> createState() => _ToggleSwitchState(); +} + +class _ToggleSwitchState extends State<_ToggleSwitch> { + late bool _on; + + @override + void initState() { + super.initState(); + _on = widget.isOn; + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => setState(() => _on = !_on), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 36, + height: 20, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: _on ? const Color(0xFF2A8A84) : TrulanaColors.surfaceLight, + ), + child: AnimatedAlign( + duration: const Duration(milliseconds: 200), + alignment: _on ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + width: 14, + height: 14, + margin: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _on + ? TrulanaColors.primaryCyan + : TrulanaColors.textTertiary, + ), + ), + ), + ), + ); + } +} + +class _PrivacyToggle extends StatelessWidget { + const _PrivacyToggle({required this.level, required this.onChanged}); + + final PrivacyLevel level; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: PrivacyLevel.values.map((pl) { + final isActive = pl == level; + return GestureDetector( + onTap: () => onChanged(pl), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: isActive + ? TrulanaColors.primaryCyan.withValues(alpha: 0.12) + : Colors.transparent, + ), + child: Text( + pl.name[0].toUpperCase() + pl.name.substring(1), + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 9, + color: isActive + ? TrulanaColors.primaryCyan + : TrulanaColors.textTertiary, + ), + ), + ), + ); + }).toList(), + ), + ); + } +} + +class _ActionButton extends StatelessWidget { + const _ActionButton({required this.label, this.isDanger = false}); + + final String label; + final bool isDanger; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isDanger + ? const Color(0xFFF05858).withValues(alpha: 0.3) + : TrulanaColors.surfaceLight, + ), + ), + child: Text( + label, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 10, + color: isDanger + ? const Color(0xFFF05858) + : TrulanaColors.textSecondary, + ), + ), + ); + } +} From 0f882c2eef2ea612616be0096960a12da3837d15 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 20:10:34 +0000 Subject: [PATCH 2/3] feat: wire all sidebar views to live providers and seed demo data Replace all hardcoded/mock data with real Riverpod providers and database queries: - Dashboard: vault stats from ContextVault.entryCount, PreferencesService count, and agentsProvider active count. Redaction stats computed from audit log entries. Agents and audit trail from live providers. - Context Vault: entries run through AutoRedactEngine at user's current privacy level, with live redaction counts per entry. - Preferences: loaded from PreferencesService with inferred scope tags. - Agents: fully driven by agentsProvider with live TTL computation. - Audit Log: rendered from auditProvider with highlighted [REDACTED] tags. - Settings: already wired to userConfigProvider (unchanged). Seed demo agents (cursor-mcp, claude-desktop, demo-agent) and 8 audit log entries on first launch so the UI is populated out of the box. Enriched seed preferences to match HTML mockup (added diet, airline, meeting_default). https://claude.ai/code/session_01ViQtUW7jVVA1JGPDkvvUEA --- lib/features/dashboard/dashboard_view.dart | 621 +++++++++++------- lib/features/engine/context_vault.dart | 9 + lib/features/shell/views/agents_view.dart | 249 +++---- lib/features/shell/views/audit_log_view.dart | 224 +++---- .../shell/views/context_vault_view.dart | 256 ++++---- .../shell/views/preferences_view.dart | 130 ++-- lib/main.dart | 112 +++- 7 files changed, 864 insertions(+), 737 deletions(-) diff --git a/lib/features/dashboard/dashboard_view.dart b/lib/features/dashboard/dashboard_view.dart index 9832aec..ca6ac11 100644 --- a/lib/features/dashboard/dashboard_view.dart +++ b/lib/features/dashboard/dashboard_view.dart @@ -1,15 +1,91 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:trulana/core/database/database_helper.dart'; +import 'package:trulana/core/services/preferences_service.dart'; import 'package:trulana/core/theme/theme.dart'; -import 'package:trulana/core/ui/ui.dart'; +import 'package:trulana/features/engine/context_vault.dart'; import 'package:trulana/models/models.dart'; import 'package:trulana/providers/providers.dart'; -/// Dashboard view matching the desktop HTML mockup. -/// -/// Grid layout: vault stats, redaction bars, connected agents, -/// recent activity, and full audit trail. +// --------------------------------------------------------------------------- +// Computed providers for dashboard stats +// --------------------------------------------------------------------------- + +/// Live preference count from the database. +final _prefCountProvider = FutureProvider((ref) async { + final prefs = await PreferencesService.instance.getAllPreferences(); + return prefs.length; +}); + +/// Counts redaction events by type from audit log entries. +/// Parses the requestIntent text to extract redaction counts. +final _redactionStatsProvider = + FutureProvider<_RedactionStats>((ref) async { + final entries = await DatabaseHelper.instance.getAuditEntries(limit: 100); + int pii = 0; + int entity = 0; + int general = 0; + int blocked = 0; + + for (final entry in entries) { + if (entry.actionTaken == AuditAction.blocked) { + blocked++; + continue; + } + if (entry.actionTaken == AuditAction.redacted) { + final match = RegExp(r'(\d+) redaction').firstMatch(entry.requestIntent); + if (match != null) { + final count = int.tryParse(match.group(1)!) ?? 0; + // Distribute across categories based on tag presence + final text = entry.requestIntent; + if (text.contains('[SSN]') || + text.contains('[CARD]') || + text.contains('[EMAIL]') || + text.contains('[PHONE]') || + text.contains('[IP]')) { + pii += (count * 0.4).ceil(); + } + if (text.contains('[PERSON]') || + text.contains('[ORG]') || + text.contains('[LOCATION]')) { + entity += (count * 0.35).ceil(); + } + general += (count * 0.25).ceil(); + } + } + } + + return _RedactionStats( + pii: pii, + entity: entity, + general: general, + blocked: blocked, + ); +}); + +class _RedactionStats { + final int pii; + final int entity; + final int general; + final int blocked; + + const _RedactionStats({ + required this.pii, + required this.entity, + required this.general, + required this.blocked, + }); + + int get total => pii + entity + general + blocked; +} + +// --------------------------------------------------------------------------- +// Dashboard view +// --------------------------------------------------------------------------- + class DashboardView extends ConsumerWidget { const DashboardView({super.key}); @@ -17,10 +93,12 @@ class DashboardView extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final configAsync = ref.watch(userConfigProvider); final agentsAsync = ref.watch(agentsProvider); + final auditAsync = ref.watch(auditProvider); + final prefCountAsync = ref.watch(_prefCountProvider); + final redactionAsync = ref.watch(_redactionStatsProvider); return Column( children: [ - // — Header _ContentHeader( title: 'Dashboard', trailing: configAsync.whenOrNull( @@ -36,8 +114,6 @@ class DashboardView extends ConsumerWidget { : null, ), ), - - // — Scrollable grid content Expanded( child: SingleChildScrollView( physics: const BouncingScrollPhysics(), @@ -48,9 +124,18 @@ class DashboardView extends ConsumerWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded(child: _VaultStatusCard(configAsync: configAsync)), + Expanded( + child: _VaultStatusCard( + agentsAsync: agentsAsync, + prefCountAsync: prefCountAsync, + ), + ), const SizedBox(width: 14), - const Expanded(child: _RedactionStatsCard()), + Expanded( + child: _RedactionStatsCard( + statsAsync: redactionAsync, + ), + ), ], ), const SizedBox(height: 14), @@ -59,15 +144,19 @@ class DashboardView extends ConsumerWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded(child: _ConnectedAgentsCard(agentsAsync: agentsAsync)), + Expanded( + child: _ConnectedAgentsCard(agentsAsync: agentsAsync), + ), const SizedBox(width: 14), - const Expanded(child: _RecentActivityCard()), + Expanded( + child: _RecentActivityCard(auditAsync: auditAsync), + ), ], ), const SizedBox(height: 14), // Full-width audit trail - const _AuditTrailCard(), + _AuditTrailCard(auditAsync: auditAsync), ], ), ), @@ -77,7 +166,10 @@ class DashboardView extends ConsumerWidget { } } -/// Reusable content header bar. +// --------------------------------------------------------------------------- +// Reusable widgets +// --------------------------------------------------------------------------- + class _ContentHeader extends StatelessWidget { const _ContentHeader({required this.title, this.trailing}); @@ -89,20 +181,15 @@ class _ContentHeader extends StatelessWidget { return Container( padding: const EdgeInsets.fromLTRB(24, 18, 24, 14), decoration: const BoxDecoration( - border: Border( - bottom: BorderSide(color: Color(0xFF14142A)), - ), + border: Border(bottom: BorderSide(color: Color(0xFF14142A))), ), child: Row( children: [ - Text( - title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: TrulanaColors.textPrimary, - ), - ), + Text(title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: TrulanaColors.textPrimary)), const Spacer(), if (trailing != null) trailing!, ], @@ -111,7 +198,6 @@ class _ContentHeader extends StatelessWidget { } } -/// Privacy level toggle: Standard / Strict / Paranoid. class _PrivacyToggle extends StatelessWidget { const _PrivacyToggle({required this.level, required this.onChanged}); @@ -133,7 +219,8 @@ class _PrivacyToggle extends StatelessWidget { return GestureDetector( onTap: () => onChanged(pl), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), color: isActive @@ -157,7 +244,6 @@ class _PrivacyToggle extends StatelessWidget { } } -/// Card wrapper matching the HTML mockup card style. class _DashCard extends StatelessWidget { const _DashCard({ required this.title, @@ -183,72 +269,77 @@ class _DashCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: const BoxDecoration( - border: Border( - bottom: BorderSide(color: Color(0xFF14142A)), - ), + border: Border(bottom: BorderSide(color: Color(0xFF14142A))), ), child: Row( children: [ - Text( - title.toUpperCase(), - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 10, - letterSpacing: 0.8, - color: TrulanaColors.textTertiary, - ), - ), + Text(title.toUpperCase(), + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 10, + letterSpacing: 0.8, + color: TrulanaColors.textTertiary)), const Spacer(), if (badge != null) Container( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), border: Border.all( - color: (badgeColor ?? TrulanaColors.primaryCyan) - .withValues(alpha: 0.25), - ), - ), - child: Text( - badge!, - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 9, - color: badgeColor ?? TrulanaColors.primaryCyan, - ), + color: (badgeColor ?? TrulanaColors.primaryCyan) + .withValues(alpha: 0.25)), ), + child: Text(badge!, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 9, + color: + badgeColor ?? TrulanaColors.primaryCyan)), ), ], ), ), - Padding( - padding: const EdgeInsets.all(14), - child: child, - ), + Padding(padding: const EdgeInsets.all(14), child: child), ], ), ); } } -/// Vault status card with entry counts. +// --------------------------------------------------------------------------- +// Vault Status — live counts from providers +// --------------------------------------------------------------------------- + class _VaultStatusCard extends StatelessWidget { - const _VaultStatusCard({required this.configAsync}); + const _VaultStatusCard({ + required this.agentsAsync, + required this.prefCountAsync, + }); - final AsyncValue configAsync; + final AsyncValue> agentsAsync; + final AsyncValue prefCountAsync; @override Widget build(BuildContext context) { + final vaultEntries = ContextVault.instance.entryCount; + final prefCount = + prefCountAsync.whenOrNull(data: (c) => c) ?? 0; + final activeAgents = agentsAsync.whenOrNull( + data: (list) => + list.where((a) => a.status == AgentStatus.active).length) ?? + 0; + return _DashCard( title: 'Vault Status', badge: 'encrypted', badgeColor: TrulanaColors.successGreen, child: Row( children: [ - _StatColumn(value: '6', label: 'Entries'), - _StatColumn(value: '12', label: 'Preferences'), - _StatColumn(value: '2', label: 'Agents'), + _StatColumn(value: '$vaultEntries', label: 'Entries'), + _StatColumn(value: '$prefCount', label: 'Preferences'), + _StatColumn(value: '$activeAgents', label: 'Agents'), ], ), ); @@ -266,47 +357,68 @@ class _StatColumn extends StatelessWidget { return Expanded( child: Column( children: [ - Text( - value, - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 22, - fontWeight: FontWeight.w500, - color: TrulanaColors.textPrimary, - ), - ), + Text(value, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 22, + fontWeight: FontWeight.w500, + color: TrulanaColors.textPrimary)), const SizedBox(height: 2), - Text( - label, - style: const TextStyle( - fontSize: 10, - color: TrulanaColors.textTertiary, - ), - ), + Text(label, + style: const TextStyle( + fontSize: 10, color: TrulanaColors.textTertiary)), ], ), ); } } -/// Redaction statistics with colored bars. +// --------------------------------------------------------------------------- +// Redaction Stats — computed from audit log +// --------------------------------------------------------------------------- + class _RedactionStatsCard extends StatelessWidget { - const _RedactionStatsCard(); + const _RedactionStatsCard({required this.statsAsync}); + + final AsyncValue<_RedactionStats> statsAsync; @override Widget build(BuildContext context) { + final stats = statsAsync.whenOrNull(data: (s) => s) ?? + const _RedactionStats(pii: 0, entity: 0, general: 0, blocked: 0); + final total = stats.total; + final maxVal = [stats.pii, stats.entity, stats.general, stats.blocked] + .reduce(max); + final denom = maxVal > 0 ? maxVal.toDouble() : 1.0; + return _DashCard( title: 'Redactions Today', - badge: '47 total', + badge: '$total total', badgeColor: TrulanaColors.primaryCyan, child: Column( - children: const [ - _RedactionBar(label: 'PII', percent: 0.72, count: '18', color: Color(0xFFF05858)), - SizedBox(height: 10), - _RedactionBar(label: 'Entity', percent: 0.56, count: '14', color: TrulanaColors.primaryCyan), - SizedBox(height: 10), - _RedactionBar(label: 'General', percent: 0.44, count: '11', color: Color(0xFFE0C85C)), - SizedBox(height: 10), - _RedactionBar(label: 'Blocked', percent: 0.16, count: '4', color: TrulanaColors.successGreen), + children: [ + _RedactionBar( + label: 'PII', + percent: stats.pii / denom, + count: '${stats.pii}', + color: const Color(0xFFF05858)), + const SizedBox(height: 10), + _RedactionBar( + label: 'Entity', + percent: stats.entity / denom, + count: '${stats.entity}', + color: TrulanaColors.primaryCyan), + const SizedBox(height: 10), + _RedactionBar( + label: 'General', + percent: stats.general / denom, + count: '${stats.general}', + color: const Color(0xFFE0C85C)), + const SizedBox(height: 10), + _RedactionBar( + label: 'Blocked', + percent: stats.blocked / denom, + count: '${stats.blocked}', + color: TrulanaColors.successGreen), ], ), ); @@ -331,52 +443,41 @@ class _RedactionBar extends StatelessWidget { return Row( children: [ SizedBox( - width: 60, - child: Text( - label, - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 10, - color: TrulanaColors.textTertiary, - ), - ), - ), + width: 60, + child: Text(label, + style: TrulanaTheme.monoStyle + .copyWith(fontSize: 10, color: TrulanaColors.textTertiary))), Expanded( child: Container( height: 6, decoration: BoxDecoration( - color: TrulanaColors.primaryCyan.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(3), - ), + color: TrulanaColors.primaryCyan.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(3)), child: FractionallySizedBox( alignment: Alignment.centerLeft, - widthFactor: percent, + widthFactor: percent.clamp(0.0, 1.0), child: Container( - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(3), - ), - ), + decoration: BoxDecoration( + color: color, borderRadius: BorderRadius.circular(3))), ), ), ), const SizedBox(width: 8), SizedBox( - width: 30, - child: Text( - count, - textAlign: TextAlign.right, - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 10, - color: TrulanaColors.textSecondary, - ), - ), - ), + width: 30, + child: Text(count, + textAlign: TextAlign.right, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 10, color: TrulanaColors.textSecondary))), ], ); } } -/// Connected agents card with status dots. +// --------------------------------------------------------------------------- +// Connected Agents — live from agentsProvider +// --------------------------------------------------------------------------- + class _ConnectedAgentsCard extends StatelessWidget { const _ConnectedAgentsCard({required this.agentsAsync}); @@ -384,19 +485,44 @@ class _ConnectedAgentsCard extends StatelessWidget { @override Widget build(BuildContext context) { + final agents = agentsAsync.valueOrNull ?? []; + final activeCount = + agents.where((a) => a.status == AgentStatus.active).length; + return _DashCard( title: 'Connected Agents', - badge: '2 active', + badge: '$activeCount active', badgeColor: TrulanaColors.primaryCyan, - child: Column( - children: const [ - _AgentRow(name: 'cursor-mcp', scope: 'context.read', ttl: '11:42', isLive: true), - _AgentRow(name: 'claude-desktop', scope: 'context.read', ttl: '8:15', isLive: true), - _AgentRow(name: 'demo-agent', scope: 'context.read', ttl: 'expired', isLive: false), - ], - ), + child: agents.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text('No agents registered', + style: TextStyle( + fontSize: 12, color: TrulanaColors.textTertiary)), + ) + : Column( + children: agents.map((agent) { + final isLive = agent.status == AgentStatus.active; + final ttl = _formatTtl(agent); + return _AgentRow( + name: agent.agentName, + scope: agent.grantedScopes.join(', '), + ttl: ttl, + isLive: isLive, + ); + }).toList(), + ), ); } + + String _formatTtl(AuthorizedAgent agent) { + if (agent.status != AgentStatus.active) return 'expired'; + final remaining = agent.expirationDate.difference(DateTime.now()); + if (remaining.isNegative) return 'expired'; + final min = remaining.inMinutes; + final sec = remaining.inSeconds % 60; + return '$min:${sec.toString().padLeft(2, '0')}'; + } } class _AgentRow extends StatelessWidget { @@ -417,10 +543,7 @@ class _AgentRow extends StatelessWidget { return Container( padding: const EdgeInsets.symmetric(vertical: 8), decoration: const BoxDecoration( - border: Border( - bottom: BorderSide(color: Color(0x05FFFFFF)), - ), - ), + border: Border(bottom: BorderSide(color: Color(0x05FFFFFF)))), child: Row( children: [ Container( @@ -428,178 +551,188 @@ class _AgentRow extends StatelessWidget { height: 7, decoration: BoxDecoration( shape: BoxShape.circle, - color: isLive ? TrulanaColors.successGreen : TrulanaColors.textTertiary, + color: isLive + ? TrulanaColors.successGreen + : TrulanaColors.textTertiary, boxShadow: isLive - ? [BoxShadow(color: TrulanaColors.successGreen.withValues(alpha: 0.5), blurRadius: 4)] + ? [ + BoxShadow( + color: TrulanaColors.successGreen + .withValues(alpha: 0.5), + blurRadius: 4) + ] : null, ), ), const SizedBox(width: 10), Expanded( - child: Text( - name, - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 12, - color: TrulanaColors.textSecondary, - ), - ), - ), + child: Text(name, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 12, color: TrulanaColors.textSecondary))), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(3), - border: Border.all(color: const Color(0xFF14142A)), - ), - child: Text( - scope, - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 9, - color: TrulanaColors.textTertiary, - ), - ), + borderRadius: BorderRadius.circular(3), + border: Border.all(color: const Color(0xFF14142A))), + child: Text(scope, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 9, color: TrulanaColors.textTertiary)), ), const SizedBox(width: 8), - Text( - ttl, - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 10, - color: isLive ? const Color(0xFF2A8A84) : TrulanaColors.textTertiary, - ), - ), + Text(ttl, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 10, + color: isLive + ? const Color(0xFF2A8A84) + : TrulanaColors.textTertiary)), ], ), ); } } -/// Recent activity card with color-coded action tags. +// --------------------------------------------------------------------------- +// Recent Activity — top 4 from auditProvider +// --------------------------------------------------------------------------- + class _RecentActivityCard extends StatelessWidget { - const _RecentActivityCard(); + const _RecentActivityCard({required this.auditAsync}); + + final AsyncValue> auditAsync; @override Widget build(BuildContext context) { + final entries = auditAsync.valueOrNull ?? []; + final recent = entries.take(4).toList(); + return _DashCard( title: 'Recent Activity', - child: Column( - children: const [ - _AuditEntry(time: '14:32:01', action: 'redact', detail: 'cursor-mcp \u00b7 7 redactions', isRedactHighlight: true), - _AuditEntry(time: '14:31:58', action: 'query', detail: 'cursor-mcp \u00b7 "work schedule"'), - _AuditEntry(time: '14:28:44', action: 'auth', detail: 'claude-desktop \u00b7 granted ttl=900s'), - _AuditEntry(time: '14:22:10', action: 'deny', detail: 'unknown-app \u00b7 no valid token'), - ], - ), + child: recent.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text('No activity yet', + style: TextStyle( + fontSize: 12, color: TrulanaColors.textTertiary)), + ) + : Column( + children: recent + .map((e) => _AuditEntryRow(entry: e, compact: true)) + .toList(), + ), ); } } -/// Full-width audit trail card. +// --------------------------------------------------------------------------- +// Audit Trail — full list from auditProvider +// --------------------------------------------------------------------------- + class _AuditTrailCard extends StatelessWidget { - const _AuditTrailCard(); + const _AuditTrailCard({required this.auditAsync}); + + final AsyncValue> auditAsync; @override Widget build(BuildContext context) { + final entries = auditAsync.valueOrNull ?? []; + return _DashCard( title: 'Audit Trail', badge: 'encrypted \u00b7 tamper-evident', badgeColor: TrulanaColors.successGreen, - child: Column( - children: const [ - _AuditEntry( - time: '14:32:01', - action: 'redact', - detail: 'cursor-mcp queried "work schedule" \u2192 [PERSON] [EMAIL] [LOCATION] stripped', - isRedactHighlight: true, - ), - _AuditEntry( - time: '14:28:44', - action: 'auth', - detail: 'claude-desktop handshake \u00b7 scopes: context.read \u00b7 token issued \u00b7 ttl=900s', - ), - _AuditEntry( - time: '14:22:10', - action: 'deny', - detail: 'unknown-app attempted query without token \u00b7 request rejected', - ), - _AuditEntry( - time: '14:15:33', - action: 'query', - detail: 'cursor-mcp queried "morning routine" \u2192 matched 1 vault entry \u00b7 12 redactions', - ), - ], - ), + child: entries.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text('No audit entries', + style: TextStyle( + fontSize: 12, color: TrulanaColors.textTertiary)), + ) + : Column( + children: entries + .map((e) => _AuditEntryRow(entry: e, compact: false)) + .toList(), + ), ); } } -/// Single audit log entry row used across dashboard cards. -class _AuditEntry extends StatelessWidget { - const _AuditEntry({ - required this.time, - required this.action, - required this.detail, - this.isRedactHighlight = false, - }); +// --------------------------------------------------------------------------- +// Shared audit entry row — renders from real AuditLogEntry +// --------------------------------------------------------------------------- + +class _AuditEntryRow extends StatelessWidget { + const _AuditEntryRow({required this.entry, this.compact = false}); - final String time; - final String action; - final String detail; - final bool isRedactHighlight; - - Color get _actionColor => switch (action) { - 'auth' => TrulanaColors.primaryCyan, - 'query' => TrulanaColors.successGreen, - 'redact' => const Color(0xFFE0C85C), - 'deny' => const Color(0xFFF05858), - _ => TrulanaColors.textTertiary, + final AuditLogEntry entry; + final bool compact; + + String get _actionLabel => switch (entry.actionTaken) { + AuditAction.approved => 'auth', + AuditAction.blocked => 'deny', + AuditAction.redacted => 'redact', + AuditAction.negotiated => 'query', + }; + + Color get _actionColor => switch (entry.actionTaken) { + AuditAction.approved => TrulanaColors.primaryCyan, + AuditAction.blocked => const Color(0xFFF05858), + AuditAction.redacted => const Color(0xFFE0C85C), + AuditAction.negotiated => TrulanaColors.successGreen, }; + String get _timeStr { + final t = entry.timestamp; + return '${t.hour.toString().padLeft(2, '0')}:' + '${t.minute.toString().padLeft(2, '0')}:' + '${t.second.toString().padLeft(2, '0')}'; + } + + String get _detail { + if (compact) { + return '${entry.agentId} \u00b7 ${_truncate(entry.requestIntent, 40)}'; + } + return '${entry.agentId} ${entry.requestIntent}'; + } + + String _truncate(String s, int maxLen) { + if (s.length <= maxLen) return s; + return '${s.substring(0, maxLen)}...'; + } + @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(vertical: 7), decoration: const BoxDecoration( - border: Border(bottom: BorderSide(color: Color(0x05FFFFFF))), - ), + border: Border(bottom: BorderSide(color: Color(0x05FFFFFF)))), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( - width: 65, - child: Text( - time, - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 11, - color: TrulanaColors.textTertiary, - ), - ), - ), + width: 65, + child: Text(_timeStr, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 11, color: TrulanaColors.textTertiary))), const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(3), - color: _actionColor.withValues(alpha: 0.1), - ), - child: Text( - action.toUpperCase(), - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 9, - letterSpacing: 0.3, - color: _actionColor, - ), - ), + borderRadius: BorderRadius.circular(3), + color: _actionColor.withValues(alpha: 0.1)), + child: Text(_actionLabel.toUpperCase(), + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 9, + letterSpacing: 0.3, + color: _actionColor)), ), const SizedBox(width: 10), Expanded( - child: Text( - detail, - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 11, - color: TrulanaColors.textSecondary, - height: 1.5, - ), - ), - ), + child: Text(_detail, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 11, + color: TrulanaColors.textSecondary, + height: 1.5))), ], ), ); diff --git a/lib/features/engine/context_vault.dart b/lib/features/engine/context_vault.dart index d7fe963..75f50e1 100644 --- a/lib/features/engine/context_vault.dart +++ b/lib/features/engine/context_vault.dart @@ -63,6 +63,15 @@ class ContextVault { ), ]; + /// Number of vault entries available for querying. + int get entryCount => _entries.length; + + /// Returns all raw (unredacted) vault entries for display views. + /// The caller must run results through [AutoRedactEngine] before display. + List queryAll() { + return _entries.map((e) => e.raw).toList(); + } + /// Returns raw (unredacted) context matching the query keywords. /// The caller is responsible for running the result through /// [AutoRedactEngine] before it leaves the device. diff --git a/lib/features/shell/views/agents_view.dart b/lib/features/shell/views/agents_view.dart index e2791bb..09023a1 100644 --- a/lib/features/shell/views/agents_view.dart +++ b/lib/features/shell/views/agents_view.dart @@ -5,9 +5,7 @@ import 'package:trulana/core/theme/theme.dart'; import 'package:trulana/models/models.dart'; import 'package:trulana/providers/providers.dart'; -/// Agents view matching the desktop HTML mockup. -/// -/// Shows active tokens with TTL remaining and expired/revoked section. +/// Agents view — live from agentsProvider. class AgentsView extends ConsumerWidget { const AgentsView({super.key}); @@ -19,88 +17,46 @@ class AgentsView extends ConsumerWidget { children: [ _header(), Expanded( - child: SingleChildScrollView( - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.all(24), - child: agentsAsync.when( - loading: () => const Center( - child: CircularProgressIndicator( - color: TrulanaColors.primaryCyan, - strokeWidth: 2, - ), - ), - error: (e, s) => Text( - 'Failed to load agents', - style: TextStyle(color: TrulanaColors.dangerRed), + child: agentsAsync.when( + loading: () => const Center( + child: CircularProgressIndicator( + color: TrulanaColors.primaryCyan, + strokeWidth: 2, ), - data: (agents) { - final active = agents - .where((a) => a.status == AgentStatus.active) - .toList(); - final inactive = agents - .where((a) => a.status != AgentStatus.active) - .toList(); + ), + error: (e, s) => Center( + child: Text('Failed to load agents', + style: TextStyle(color: TrulanaColors.dangerRed)), + ), + data: (agents) { + final active = agents + .where((a) => a.status == AgentStatus.active) + .toList(); + final inactive = agents + .where((a) => a.status != AgentStatus.active) + .toList(); - return Column( + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(24), + child: Column( children: [ _AgentCard( title: 'Active Tokens', badge: '${active.length} live', - agents: active.isNotEmpty - ? active - .map((a) => _AgentDisplayRow( - name: a.agentName, - scope: a.grantedScopes.join(', '), - ttl: _formatTtl(a.expirationDate), - isLive: true, - )) - .toList() - : [ - const _AgentDisplayRow( - name: 'cursor-mcp', - scope: 'context.read', - ttl: '11:42 remaining', - isLive: true, - ), - const _AgentDisplayRow( - name: 'claude-desktop', - scope: 'context.read', - ttl: '8:15 remaining', - isLive: true, - ), - ], + agents: active, + isEmpty: active.isEmpty, ), const SizedBox(height: 14), _AgentCard( title: 'Expired / Revoked', - agents: inactive.isNotEmpty - ? inactive - .map((a) => _AgentDisplayRow( - name: a.agentName, - scope: a.grantedScopes.join(', '), - ttl: 'expired', - isLive: false, - )) - .toList() - : [ - const _AgentDisplayRow( - name: 'demo-agent', - scope: 'context.read', - ttl: 'expired 12m ago', - isLive: false, - ), - const _AgentDisplayRow( - name: 'test-runner', - scope: 'context.read', - ttl: 'expired 2h ago', - isLive: false, - ), - ], + agents: inactive, + isEmpty: inactive.isEmpty, ), ], - ); - }, - ), + ), + ); + }, ), ), ], @@ -115,26 +71,15 @@ class AgentsView extends ConsumerWidget { ), child: const Row( children: [ - Text( - 'Agents', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: TrulanaColors.textPrimary, - ), - ), + Text('Agents', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: TrulanaColors.textPrimary)), ], ), ); } - - String _formatTtl(DateTime expiration) { - final remaining = expiration.difference(DateTime.now()); - if (remaining.isNegative) return 'expired'; - final min = remaining.inMinutes; - final sec = remaining.inSeconds % 60; - return '$min:${sec.toString().padLeft(2, '0')} remaining'; - } } class _AgentCard extends StatelessWidget { @@ -142,11 +87,13 @@ class _AgentCard extends StatelessWidget { required this.title, required this.agents, this.badge, + this.isEmpty = false, }); final String title; final String? badge; - final List<_AgentDisplayRow> agents; + final List agents; + final bool isEmpty; @override Widget build(BuildContext context) { @@ -159,20 +106,18 @@ class _AgentCard extends StatelessWidget { child: Column( children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: const BoxDecoration( border: Border(bottom: BorderSide(color: Color(0xFF14142A))), ), child: Row( children: [ - Text( - title.toUpperCase(), - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 10, - letterSpacing: 0.8, - color: TrulanaColors.textTertiary, - ), - ), + Text(title.toUpperCase(), + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 10, + letterSpacing: 0.8, + color: TrulanaColors.textTertiary)), const Spacer(), if (badge != null) Container( @@ -181,32 +126,60 @@ class _AgentCard extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), border: Border.all( - color: TrulanaColors.primaryCyan.withValues(alpha: 0.25), - ), - ), - child: Text( - badge!, - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 9, - color: TrulanaColors.primaryCyan, - ), + color: TrulanaColors.primaryCyan + .withValues(alpha: 0.25)), ), + child: Text(badge!, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 9, + color: TrulanaColors.primaryCyan)), ), ], ), ), Padding( padding: const EdgeInsets.all(14), - child: Column(children: agents), + child: isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text('None', + style: TextStyle( + fontSize: 12, + color: TrulanaColors.textTertiary)), + ) + : Column( + children: agents.map((agent) { + final isLive = agent.status == AgentStatus.active; + return _AgentRow( + name: agent.agentName, + scope: agent.grantedScopes.join(', '), + ttl: _formatTtl(agent), + isLive: isLive, + ); + }).toList(), + ), ), ], ), ); } + + String _formatTtl(AuthorizedAgent agent) { + if (agent.status != AgentStatus.active) { + final ago = DateTime.now().difference(agent.expirationDate); + if (ago.inHours > 0) return 'expired ${ago.inHours}h ago'; + return 'expired ${ago.inMinutes}m ago'; + } + final remaining = agent.expirationDate.difference(DateTime.now()); + if (remaining.isNegative) return 'expired'; + final min = remaining.inMinutes; + final sec = remaining.inSeconds % 60; + return '$min:${sec.toString().padLeft(2, '0')} remaining'; + } } -class _AgentDisplayRow extends StatelessWidget { - const _AgentDisplayRow({ +class _AgentRow extends StatelessWidget { + const _AgentRow({ required this.name, required this.scope, required this.ttl, @@ -223,8 +196,7 @@ class _AgentDisplayRow extends StatelessWidget { return Container( padding: const EdgeInsets.symmetric(vertical: 8), decoration: const BoxDecoration( - border: Border(bottom: BorderSide(color: Color(0x05FFFFFF))), - ), + border: Border(bottom: BorderSide(color: Color(0x05FFFFFF)))), child: Row( children: [ Container( @@ -238,48 +210,35 @@ class _AgentDisplayRow extends StatelessWidget { boxShadow: isLive ? [ BoxShadow( - color: - TrulanaColors.successGreen.withValues(alpha: 0.5), - blurRadius: 4, - ), + color: TrulanaColors.successGreen + .withValues(alpha: 0.5), + blurRadius: 4) ] : null, ), ), const SizedBox(width: 10), Expanded( - child: Text( - name, - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 12, - color: TrulanaColors.textSecondary, - ), - ), - ), + child: Text(name, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 12, color: TrulanaColors.textSecondary))), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(3), - border: Border.all(color: const Color(0xFF14142A)), - ), - child: Text( - scope, - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 9, - color: TrulanaColors.textTertiary, - ), - ), + borderRadius: BorderRadius.circular(3), + border: Border.all(color: const Color(0xFF14142A))), + child: Text(scope, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 9, color: TrulanaColors.textTertiary)), ), const SizedBox(width: 8), - Text( - ttl, - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 10, - color: isLive - ? const Color(0xFF2A8A84) - : TrulanaColors.textTertiary, - ), - ), + Text(ttl, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 10, + color: isLive + ? const Color(0xFF2A8A84) + : TrulanaColors.textTertiary)), ], ), ); diff --git a/lib/features/shell/views/audit_log_view.dart b/lib/features/shell/views/audit_log_view.dart index c2fbb68..5d0d0f1 100644 --- a/lib/features/shell/views/audit_log_view.dart +++ b/lib/features/shell/views/audit_log_view.dart @@ -1,76 +1,51 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:trulana/core/theme/theme.dart'; +import 'package:trulana/models/models.dart'; +import 'package:trulana/providers/providers.dart'; -/// Audit Log view matching the desktop HTML mockup. -/// -/// Detailed event log with timestamps, color-coded action types, -/// and redaction details. -class AuditLogView extends StatelessWidget { +/// Audit Log view — live from auditProvider. +class AuditLogView extends ConsumerWidget { const AuditLogView({super.key}); - static const List<_AuditDisplayEntry> _entries = [ - _AuditDisplayEntry( - time: '14:32:01', - action: 'redact', - detail: - 'cursor-mcp "work schedule" \u2192 [PERSON] [EMAIL] [LOCATION] [TIME] stripped \u00b7 7 total', - ), - _AuditDisplayEntry( - time: '14:28:44', - action: 'auth', - detail: - 'claude-desktop handshake \u00b7 scopes: [context.read] \u00b7 intent: "code assistance" \u00b7 ttl=900s', - ), - _AuditDisplayEntry( - time: '14:22:10', - action: 'deny', - detail: 'unknown-app \u00b7 no bearer token \u00b7 rejected', - ), - _AuditDisplayEntry( - time: '14:15:33', - action: 'redact', - detail: - 'cursor-mcp "morning routine" \u2192 [PERSON] [EMAIL] [PHONE] [LOCATION] \u00d72 [ORG] \u00b7 12 total', - ), - _AuditDisplayEntry( - time: '14:15:30', - action: 'auth', - detail: - 'cursor-mcp handshake \u00b7 scopes: [context.read] \u00b7 intent: "IDE context" \u00b7 ttl=900s', - ), - _AuditDisplayEntry( - time: '14:02:17', - action: 'redact', - detail: - 'demo-agent "financial info" \u2192 [CARD] [SSN] [AMOUNT] \u00d72 \u00b7 9 total', - ), - _AuditDisplayEntry( - time: '13:58:01', - action: 'auth', - detail: - 'demo-agent handshake \u00b7 scopes: [context.read] \u00b7 ttl=900s', - ), - _AuditDisplayEntry( - time: '13:45:00', - action: 'query', - detail: - 'server started \u00b7 vault unlocked \u00b7 REST bound to 127.0.0.1:8432', - ), - ]; - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final auditAsync = ref.watch(auditProvider); + return Column( children: [ _header(), Expanded( - child: ListView.builder( - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), - itemCount: _entries.length, - itemBuilder: (context, index) => - _AuditRow(entry: _entries[index]), + child: auditAsync.when( + loading: () => const Center( + child: CircularProgressIndicator( + color: TrulanaColors.primaryCyan, + strokeWidth: 2, + ), + ), + error: (e, s) => Center( + child: Text('Failed to load audit log', + style: TextStyle(color: TrulanaColors.dangerRed)), + ), + data: (entries) { + if (entries.isEmpty) { + return Center( + child: Text('No audit entries yet', + style: TextStyle( + fontSize: 14, + color: TrulanaColors.textTertiary)), + ); + } + return ListView.builder( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 20), + itemCount: entries.length, + itemBuilder: (context, index) => + _AuditRow(entry: entries[index]), + ); + }, ), ), ], @@ -85,28 +60,22 @@ class AuditLogView extends StatelessWidget { ), child: Row( children: [ - const Text( - 'Audit Log', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: TrulanaColors.textPrimary, - ), - ), + const Text('Audit Log', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: TrulanaColors.textPrimary)), const Spacer(), Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 6), decoration: BoxDecoration( borderRadius: BorderRadius.circular(6), border: Border.all(color: TrulanaColors.surfaceLight), ), - child: Text( - 'Export', - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 10, - color: TrulanaColors.textSecondary, - ), - ), + child: Text('Export', + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 10, color: TrulanaColors.textSecondary)), ), ], ), @@ -117,55 +86,61 @@ class AuditLogView extends StatelessWidget { class _AuditRow extends StatelessWidget { const _AuditRow({required this.entry}); - final _AuditDisplayEntry entry; + final AuditLogEntry entry; + + String get _actionLabel => switch (entry.actionTaken) { + AuditAction.approved => 'auth', + AuditAction.blocked => 'deny', + AuditAction.redacted => 'redact', + AuditAction.negotiated => 'query', + }; - Color get _actionColor => switch (entry.action) { - 'auth' => TrulanaColors.primaryCyan, - 'query' => TrulanaColors.successGreen, - 'redact' => const Color(0xFFE0C85C), - 'deny' => const Color(0xFFF05858), - _ => TrulanaColors.textTertiary, + Color get _actionColor => switch (entry.actionTaken) { + AuditAction.approved => TrulanaColors.primaryCyan, + AuditAction.blocked => const Color(0xFFF05858), + AuditAction.redacted => const Color(0xFFE0C85C), + AuditAction.negotiated => TrulanaColors.successGreen, }; + String get _timeStr { + final t = entry.timestamp; + return '${t.hour.toString().padLeft(2, '0')}:' + '${t.minute.toString().padLeft(2, '0')}:' + '${t.second.toString().padLeft(2, '0')}'; + } + @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(vertical: 7), decoration: const BoxDecoration( - border: Border(bottom: BorderSide(color: Color(0x05FFFFFF))), - ), + border: Border(bottom: BorderSide(color: Color(0x05FFFFFF)))), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( - width: 65, - child: Text( - entry.time, - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 11, - color: TrulanaColors.textTertiary, - ), - ), - ), + width: 65, + child: Text(_timeStr, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 11, color: TrulanaColors.textTertiary))), const SizedBox(width: 10), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(3), - color: _actionColor.withValues(alpha: 0.1), - ), - child: Text( - entry.action.toUpperCase(), - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 9, - letterSpacing: 0.3, - color: _actionColor, - ), - ), + borderRadius: BorderRadius.circular(3), + color: _actionColor.withValues(alpha: 0.1)), + child: Text(_actionLabel.toUpperCase(), + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 9, + letterSpacing: 0.3, + color: _actionColor)), ), const SizedBox(width: 10), Expanded( - child: _HighlightedDetail(text: entry.detail), + child: _HighlightedDetail( + text: '${entry.agentId} ${entry.requestIntent}', + ), ), ], ), @@ -173,7 +148,7 @@ class _AuditRow extends StatelessWidget { } } -/// Renders audit detail text with [REDACTED] tags highlighted. +/// Renders audit detail text with [REDACTED] tags highlighted in cyan. class _HighlightedDetail extends StatelessWidget { const _HighlightedDetail({required this.text}); @@ -194,9 +169,7 @@ class _HighlightedDetail extends StatelessWidget { for (final match in pattern.allMatches(text)) { if (match.start > lastEnd) { spans.add(TextSpan( - text: text.substring(lastEnd, match.start), - style: baseStyle, - )); + text: text.substring(lastEnd, match.start), style: baseStyle)); } spans.add(WidgetSpan( alignment: PlaceholderAlignment.baseline, @@ -207,34 +180,19 @@ class _HighlightedDetail extends StatelessWidget { color: TrulanaColors.primaryCyan.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(2), ), - child: Text( - match.group(0)!, - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 10, - color: TrulanaColors.primaryCyan, - ), - ), + child: Text(match.group(0)!, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 10, color: TrulanaColors.primaryCyan)), ), )); lastEnd = match.end; } if (lastEnd < text.length) { - spans.add(TextSpan(text: text.substring(lastEnd), style: baseStyle)); + spans.add( + TextSpan(text: text.substring(lastEnd), style: baseStyle)); } return RichText(text: TextSpan(children: spans)); } } - -class _AuditDisplayEntry { - final String time; - final String action; - final String detail; - - const _AuditDisplayEntry({ - required this.time, - required this.action, - required this.detail, - }); -} diff --git a/lib/features/shell/views/context_vault_view.dart b/lib/features/shell/views/context_vault_view.dart index 6698dbd..fec4feb 100644 --- a/lib/features/shell/views/context_vault_view.dart +++ b/lib/features/shell/views/context_vault_view.dart @@ -1,72 +1,92 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:trulana/core/theme/theme.dart'; +import 'package:trulana/features/engine/auto_redact_engine.dart'; +import 'package:trulana/features/engine/context_vault.dart'; +import 'package:trulana/features/engine/models/redaction_result.dart'; +import 'package:trulana/models/models.dart'; +import 'package:trulana/providers/providers.dart'; + +/// Category labels for the 6 vault entries (matches ContextVault order). +const _categories = ['routine', 'work', 'financial', 'health', 'travel', 'personal']; + +/// Provider that runs every vault entry through the Auto-Redact Engine +/// at the user's current privacy level, producing display-ready text. +final _redactedVaultProvider = + FutureProvider>((ref) async { + final config = await ref.watch(userConfigProvider.future); + final level = config?.privacyLevel ?? PrivacyLevel.strict; + + final engine = AutoRedactEngine(); + final allRaw = ContextVault.instance.queryAll(); + + final List<_VaultDisplayEntry> results = []; + for (int i = 0; i < allRaw.length; i++) { + final result = await engine.process(allRaw[i], level); + results.add(_VaultDisplayEntry( + category: i < _categories.length ? _categories[i] : 'general', + date: _recentDate(i), + preview: result.sanitizedText, + redactionCount: result.totalRedactions, + )); + } + return results; +}); + +/// Generates staggered recent dates for display. +String _recentDate(int index) { + final now = DateTime.now(); + final d = now.subtract(Duration(days: index)); + const months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' + ]; + return '${months[d.month - 1]} ${d.day}'; +} -/// Context Vault view — shows vault entries with redacted PII markers. -/// -/// Mirrors the desktop HTML mockup "Context Vault" sidebar view with -/// category tags, dates, and redacted text previews. -class ContextVaultView extends StatelessWidget { +/// Context Vault view — shows real vault entries run through the +/// Auto-Redact Engine at the user's current privacy level. +class ContextVaultView extends ConsumerWidget { const ContextVaultView({super.key}); - static const List<_VaultDisplayEntry> _entries = [ - _VaultDisplayEntry( - category: 'routine', - date: 'Mar 28', - preview: - 'Wakes up at [TIME], takes the Caltrain from [LOCATION] to [LOCATION]. ' - 'Goes to [ORG] for a 45-min session, picks up coffee. ' - 'Contact: [PERSON] at [EMAIL].', - ), - _VaultDisplayEntry( - category: 'work', - date: 'Mar 28', - preview: - 'Works at [ORG] as a senior PM. Reports: [PERSON] ([EMAIL]) and ' - '[PERSON] ([EMAIL]). Weekly 1:1 with VP [PERSON] at [TIME]. ' - 'Badge ID tied to [SSN].', - ), - _VaultDisplayEntry( - category: 'financial', - date: 'Mar 27', - preview: - 'Primary checking at [ORG], card [CARD]. Monthly take-home [AMOUNT]. ' - 'Rent [AMOUNT]/mo in [LOCATION].', - ), - _VaultDisplayEntry( - category: 'health', - date: 'Mar 27', - preview: - 'PCP is Dr. [PERSON] at [ORG], [LOCATION]. [AGE], blood type O+. ' - 'Rx atorvastatin. Portal: [EMAIL]. IP: [IP].', - ), - _VaultDisplayEntry( - category: 'travel', - date: 'Mar 26', - preview: - 'Trip to [LOCATION]. Delta DL1847 from [LOCATION] at [TIME]. ' - 'Hotel under [PERSON], [EMAIL]. SSN on file: [SSN].', - ), - _VaultDisplayEntry( - category: 'personal', - date: 'Mar 25', - preview: - 'Lives with [PERSON] in [LOCATION]. [PERSON] works at [ORG]. ' - 'Two kids at [LOCATION] Elementary. Savings goal: [AMOUNT].', - ), - ]; - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final vaultAsync = ref.watch(_redactedVaultProvider); + return Column( children: [ _header(), Expanded( - child: ListView.builder( - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), - itemCount: _entries.length, - itemBuilder: (context, index) => _EntryTile(entry: _entries[index]), + child: vaultAsync.when( + loading: () => const Center( + child: CircularProgressIndicator( + color: TrulanaColors.primaryCyan, + strokeWidth: 2, + ), + ), + error: (e, s) => Center( + child: Text('Failed to load vault entries', + style: TextStyle(color: TrulanaColors.dangerRed)), + ), + data: (entries) { + if (entries.isEmpty) { + return Center( + child: Text('Vault is empty', + style: TextStyle( + fontSize: 14, + color: TrulanaColors.textTertiary)), + ); + } + return ListView.builder( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 20), + itemCount: entries.length, + itemBuilder: (context, index) => + _EntryTile(entry: entries[index]), + ); + }, ), ), ], @@ -81,16 +101,24 @@ class ContextVaultView extends StatelessWidget { ), child: Row( children: [ - const Text( - 'Context Vault', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: TrulanaColors.textPrimary, + const Text('Context Vault', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: TrulanaColors.textPrimary)), + const Spacer(), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFF2A8A84)), + color: TrulanaColors.primaryCyan.withValues(alpha: 0.1), ), + child: Text('+ Add Entry', + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 10, color: TrulanaColors.primaryCyan)), ), - const Spacer(), - _HeaderButton(label: '+ Add Entry', isPrimary: true), ], ), ); @@ -107,8 +135,7 @@ class _EntryTile extends StatelessWidget { return Container( padding: const EdgeInsets.symmetric(vertical: 12), decoration: const BoxDecoration( - border: Border(bottom: BorderSide(color: Color(0x08FFFFFF))), - ), + border: Border(bottom: BorderSide(color: Color(0x08FFFFFF)))), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -119,25 +146,32 @@ class _EntryTile extends StatelessWidget { const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( borderRadius: BorderRadius.circular(3), - color: TrulanaColors.primaryCyan.withValues(alpha: 0.08), - ), - child: Text( - entry.category.toUpperCase(), - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 9, - letterSpacing: 0.4, - color: TrulanaColors.primaryCyan, - ), + color: + TrulanaColors.primaryCyan.withValues(alpha: 0.08), ), + child: Text(entry.category.toUpperCase(), + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 9, + letterSpacing: 0.4, + color: TrulanaColors.primaryCyan)), ), const SizedBox(width: 8), - Text( - entry.date, - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 10, - color: TrulanaColors.textTertiary, + Text(entry.date, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 10, color: TrulanaColors.textTertiary)), + const Spacer(), + if (entry.redactionCount > 0) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: const Color(0xFFE0C85C).withValues(alpha: 0.1), + ), + child: Text('${entry.redactionCount} redacted', + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 9, color: const Color(0xFFE0C85C))), ), - ), ], ), const SizedBox(height: 6), @@ -156,7 +190,7 @@ class _RedactedText extends StatelessWidget { @override Widget build(BuildContext context) { - final pattern = RegExp(r'\[([A-Z]+)\]'); + final pattern = RegExp(r'\[([A-Z_ ]+)\]'); final spans = []; int lastEnd = 0; @@ -165,10 +199,7 @@ class _RedactedText extends StatelessWidget { spans.add(TextSpan( text: text.substring(lastEnd, match.start), style: const TextStyle( - fontSize: 13, - color: TrulanaColors.textSecondary, - height: 1.5, - ), + fontSize: 13, color: TrulanaColors.textSecondary, height: 1.5), )); } spans.add(WidgetSpan( @@ -180,13 +211,9 @@ class _RedactedText extends StatelessWidget { color: TrulanaColors.primaryCyan.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(2), ), - child: Text( - match.group(0)!, - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 11, - color: TrulanaColors.primaryCyan, - ), - ), + child: Text(match.group(0)!, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 11, color: TrulanaColors.primaryCyan)), ), )); lastEnd = match.end; @@ -196,10 +223,7 @@ class _RedactedText extends StatelessWidget { spans.add(TextSpan( text: text.substring(lastEnd), style: const TextStyle( - fontSize: 13, - color: TrulanaColors.textSecondary, - height: 1.5, - ), + fontSize: 13, color: TrulanaColors.textSecondary, height: 1.5), )); } @@ -207,46 +231,16 @@ class _RedactedText extends StatelessWidget { } } -class _HeaderButton extends StatelessWidget { - const _HeaderButton({required this.label, this.isPrimary = false}); - - final String label; - final bool isPrimary; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: isPrimary - ? const Color(0xFF2A8A84) - : TrulanaColors.surfaceLight, - ), - color: isPrimary - ? TrulanaColors.primaryCyan.withValues(alpha: 0.1) - : Colors.transparent, - ), - child: Text( - label, - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 10, - color: isPrimary ? TrulanaColors.primaryCyan : TrulanaColors.textSecondary, - ), - ), - ); - } -} - class _VaultDisplayEntry { final String category; final String date; final String preview; + final int redactionCount; const _VaultDisplayEntry({ required this.category, required this.date, required this.preview, + required this.redactionCount, }); } diff --git a/lib/features/shell/views/preferences_view.dart b/lib/features/shell/views/preferences_view.dart index e00e2d7..3a6c80e 100644 --- a/lib/features/shell/views/preferences_view.dart +++ b/lib/features/shell/views/preferences_view.dart @@ -5,30 +5,15 @@ import 'package:trulana/core/theme/theme.dart'; import 'package:trulana/core/services/preferences_service.dart'; import 'package:trulana/models/models.dart'; -/// Provider for loading all preferences. +/// Provider for loading all preferences from the encrypted database. final _preferencesProvider = FutureProvider>((ref) async { return PreferencesService.instance.getAllPreferences(); }); -/// Structured Preferences view matching the desktop HTML mockup. -/// -/// Displays key-value pairs with scope tags in a clean list layout. +/// Structured Preferences view — live from the database. class PreferencesView extends ConsumerWidget { const PreferencesView({super.key}); - /// Fallback display data matching the HTML mockup when DB preferences - /// don't have scope tags. - static const List<_PrefDisplay> _fallbackPrefs = [ - _PrefDisplay(key: 'preferred_coffee', value: 'Philz, iced mint mojito', scope: 'routine'), - _PrefDisplay(key: 'response_style', value: 'concise', scope: 'work'), - _PrefDisplay(key: 'commute', value: 'Caltrain from San Francisco', scope: 'routine'), - _PrefDisplay(key: 'workout_time', value: '6:30 AM', scope: 'health'), - _PrefDisplay(key: 'work_style', value: 'deep focus mornings, meetings after 2 PM', scope: 'work'), - _PrefDisplay(key: 'diet', value: 'Dairy-free, Mediterranean', scope: 'health'), - _PrefDisplay(key: 'travel_airline', value: 'Delta, window, SkyMiles', scope: 'travel'), - _PrefDisplay(key: 'meeting_default', value: '25 min, agenda-first', scope: 'work'), - ]; - @override Widget build(BuildContext context, WidgetRef ref) { final prefsAsync = ref.watch(_preferencesProvider); @@ -49,23 +34,21 @@ class PreferencesView extends ConsumerWidget { style: TextStyle(color: TrulanaColors.dangerRed)), ), data: (prefs) { - // Use DB prefs if available, fall back to display data - final displayList = prefs.isNotEmpty - ? prefs - .map((p) => _PrefDisplay( - key: p.key, - value: p.value, - scope: p.tags.isNotEmpty ? p.tags.first : 'general', - )) - .toList() - : _fallbackPrefs; - + if (prefs.isEmpty) { + return Center( + child: Text('No preferences set', + style: TextStyle( + fontSize: 14, + color: TrulanaColors.textTertiary)), + ); + } return ListView.builder( physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), - itemCount: displayList.length, + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 20), + itemCount: prefs.length, itemBuilder: (context, index) => - _PrefRow(pref: displayList[index]), + _PrefRow(pref: prefs[index]), ); }, ), @@ -82,29 +65,23 @@ class PreferencesView extends ConsumerWidget { ), child: Row( children: [ - const Text( - 'Structured Preferences', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: TrulanaColors.textPrimary, - ), - ), + const Text('Structured Preferences', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: TrulanaColors.textPrimary)), const Spacer(), Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 6), decoration: BoxDecoration( borderRadius: BorderRadius.circular(6), border: Border.all(color: const Color(0xFF2A8A84)), color: TrulanaColors.primaryCyan.withValues(alpha: 0.1), ), - child: Text( - '+ Add', - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 10, - color: TrulanaColors.primaryCyan, - ), - ), + child: Text('+ Add', + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 10, color: TrulanaColors.primaryCyan)), ), ], ), @@ -115,65 +92,52 @@ class PreferencesView extends ConsumerWidget { class _PrefRow extends StatelessWidget { const _PrefRow({required this.pref}); - final _PrefDisplay pref; + final Preference pref; @override Widget build(BuildContext context) { + final scope = pref.tags.isNotEmpty ? pref.tags.first : _inferScope(pref.key); + return Container( padding: const EdgeInsets.symmetric(vertical: 10), decoration: const BoxDecoration( - border: Border(bottom: BorderSide(color: Color(0x05FFFFFF))), - ), + border: Border(bottom: BorderSide(color: Color(0x05FFFFFF)))), child: Row( children: [ SizedBox( width: 140, - child: Text( - pref.key, - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 11, - color: const Color(0xFF2A8A84), - ), - ), + child: Text(pref.key, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 11, color: const Color(0xFF2A8A84))), ), const SizedBox(width: 12), Expanded( - child: Text( - pref.value, - style: const TextStyle( - fontSize: 13, - color: TrulanaColors.textSecondary, - ), - ), + child: Text(pref.value, + style: const TextStyle( + fontSize: 13, color: TrulanaColors.textSecondary)), ), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( borderRadius: BorderRadius.circular(3), border: Border.all(color: const Color(0xFF14142A)), ), - child: Text( - pref.scope, - style: TrulanaTheme.monoStyle.copyWith( - fontSize: 9, - color: TrulanaColors.textTertiary, - ), - ), + child: Text(scope, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 9, color: TrulanaColors.textTertiary)), ), ], ), ); } -} -class _PrefDisplay { - final String key; - final String value; - final String scope; - - const _PrefDisplay({ - required this.key, - required this.value, - required this.scope, - }); + /// Infers a scope tag from the preference key when no tags are set. + String _inferScope(String key) { + if (key.contains('coffee') || key.contains('commute')) return 'routine'; + if (key.contains('work') || key.contains('response') || key.contains('meeting')) return 'work'; + if (key.contains('workout') || key.contains('diet')) return 'health'; + if (key.contains('travel') || key.contains('airline')) return 'travel'; + return 'general'; + } } diff --git a/lib/main.dart b/lib/main.dart index 0d60066..edb0734 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -18,6 +18,8 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); await _seedDefaultConfig(); await _seedDefaultPreferences(); + await _seedDefaultAgents(); + await _seedDefaultAuditEntries(); final bool mcpMode = Platform.environment['TRULANA_MCP'] == '1' || @@ -53,11 +55,14 @@ Future _seedDefaultPreferences() async { if (existing.isNotEmpty) return; const Map seeds = { - 'preferred_coffee': 'Philz', + 'preferred_coffee': 'Philz, iced mint mojito', 'response_style': 'concise', 'commute': 'Caltrain from San Francisco', 'workout_time': '6:30 AM', 'work_style': 'deep focus mornings, meetings after 2 PM', + 'diet': 'Dairy-free, Mediterranean', + 'travel_airline': 'Delta, window, SkyMiles', + 'meeting_default': '25 min, agenda-first', }; for (final MapEntry entry in seeds.entries) { @@ -65,6 +70,111 @@ Future _seedDefaultPreferences() async { } } +/// Seeds demo agents on first launch so the UI isn't empty. +Future _seedDefaultAgents() async { + final DatabaseHelper db = DatabaseHelper.instance; + final List existing = await db.getAgents(); + if (existing.isNotEmpty) return; + + const Uuid uuid = Uuid(); + final DateTime now = DateTime.now(); + + await db.insertAgent(AuthorizedAgent( + agentId: uuid.v4(), + agentName: 'cursor-mcp', + grantedScopes: const ['context.read'], + status: AgentStatus.active, + expirationDate: now.add(const Duration(minutes: 12)), + )); + await db.insertAgent(AuthorizedAgent( + agentId: uuid.v4(), + agentName: 'claude-desktop', + grantedScopes: const ['context.read'], + status: AgentStatus.active, + expirationDate: now.add(const Duration(minutes: 8)), + )); + await db.insertAgent(AuthorizedAgent( + agentId: uuid.v4(), + agentName: 'demo-agent', + grantedScopes: const ['context.read'], + status: AgentStatus.revoked, + expirationDate: now.subtract(const Duration(minutes: 12)), + )); +} + +/// Seeds demo audit log entries on first launch. +Future _seedDefaultAuditEntries() async { + final DatabaseHelper db = DatabaseHelper.instance; + final List existing = await db.getAuditEntries(limit: 1); + if (existing.isNotEmpty) return; + + const Uuid uuid = Uuid(); + final DateTime now = DateTime.now(); + + final List seeds = [ + AuditLogEntry( + logId: uuid.v4(), + agentId: 'cursor-mcp', + requestIntent: 'query "work schedule" \u2192 [PERSON] [EMAIL] [LOCATION] [TIME] stripped \u00b7 7 redactions', + actionTaken: AuditAction.redacted, + timestamp: now.subtract(const Duration(minutes: 2)), + ), + AuditLogEntry( + logId: uuid.v4(), + agentId: 'claude-desktop', + requestIntent: 'handshake \u00b7 scopes: [context.read] \u00b7 intent: "code assistance" \u00b7 ttl=900s', + actionTaken: AuditAction.approved, + timestamp: now.subtract(const Duration(minutes: 5)), + ), + AuditLogEntry( + logId: uuid.v4(), + agentId: 'unknown-app', + requestIntent: 'no bearer token \u00b7 rejected', + actionTaken: AuditAction.blocked, + timestamp: now.subtract(const Duration(minutes: 12)), + ), + AuditLogEntry( + logId: uuid.v4(), + agentId: 'cursor-mcp', + requestIntent: 'query "morning routine" \u2192 [PERSON] [EMAIL] [PHONE] [LOCATION] \u00d72 [ORG] \u00b7 12 redactions', + actionTaken: AuditAction.redacted, + timestamp: now.subtract(const Duration(minutes: 18)), + ), + AuditLogEntry( + logId: uuid.v4(), + agentId: 'cursor-mcp', + requestIntent: 'handshake \u00b7 scopes: [context.read] \u00b7 intent: "IDE context" \u00b7 ttl=900s', + actionTaken: AuditAction.approved, + timestamp: now.subtract(const Duration(minutes: 19)), + ), + AuditLogEntry( + logId: uuid.v4(), + agentId: 'demo-agent', + requestIntent: 'query "financial info" \u2192 [CARD] [SSN] [AMOUNT] \u00d72 \u00b7 9 redactions', + actionTaken: AuditAction.redacted, + timestamp: now.subtract(const Duration(minutes: 32)), + ), + AuditLogEntry( + logId: uuid.v4(), + agentId: 'demo-agent', + requestIntent: 'handshake \u00b7 scopes: [context.read] \u00b7 ttl=900s', + actionTaken: AuditAction.approved, + timestamp: now.subtract(const Duration(minutes: 36)), + ), + AuditLogEntry( + logId: uuid.v4(), + agentId: 'system', + requestIntent: 'server started \u00b7 vault unlocked \u00b7 REST bound to 127.0.0.1:8432', + actionTaken: AuditAction.approved, + timestamp: now.subtract(const Duration(minutes: 49)), + ), + ]; + + for (final entry in seeds) { + await db.insertAuditEntry(entry); + } +} + /// App shell that wires Riverpod state to a GoRouter-driven /// MaterialApp. All routing decisions (onboarding gate, dashboard /// redirect) live in [appRouterProvider] so this widget stays thin. From a634224b33d642c8869bc83c0f18b22e313c122f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 21:25:34 +0000 Subject: [PATCH 3/3] docs: update test counts from 117 to 134 across all docs README, CONTRIBUTING, and SECURITY all referenced 117 tests but the actual count is 134 (89 engine + 31 integration + 11 security + 3 widget). Updated badge, test commands, directory breakdown, and security coverage table to match. https://claude.ai/code/session_01ViQtUW7jVVA1JGPDkvvUEA --- CONTRIBUTING.md | 12 ++++++------ README.md | 10 +++++----- SECURITY.md | 3 ++- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2db5b9e..e987bd6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ Trulana is a working macOS MVP with two verified transports: - **REST** — 38 verification checks passing (`scripts/demo_client.sh`) - **MCP** — 18 verification checks passing (`scripts/test_mcp.sh`) -- **117 unit + integration tests** passing (`flutter test`) +- **134 unit + integration tests** passing (`flutter test`) The full auth → token → vault query → redact → response → audit log loop works end-to-end on both transports. The three items below are the documented gaps between the current MVP and a production-ready release. @@ -38,7 +38,7 @@ These are ordered by impact. Each one maps directly to a "Current Limits" entry **Why it matters:** The dashboard shows pending lease requests and the backend logs them, but the two action buttons in the UI are stubbed out. **Where to start:** -- `lib/features/dashboard/widgets/monetization_card.dart` — lines 199 and 207 have the TODO comments +- `lib/features/dashboard/widgets/monetization_card.dart` — the Approve/Deny buttons have TODO comments - Wire the "Approve" button to accept the lease and record it in the Ledger table - Wire the "Deny" button to reject the lease and log the denial in the Audit Log - Add tests to verify the approve/deny flow updates the database correctly @@ -58,7 +58,7 @@ These are larger efforts that build on top of the MVP: git clone https://github.com/AdamsLocal/trulana.git cd trulana flutter pub get -flutter test # run all 117 tests +flutter test # run all 134 tests flutter test --reporter expanded # verbose output ``` @@ -88,10 +88,10 @@ flutter build macos --release ``` test/ -├── engine/ # 72 tests — Auto-Redact pipeline (regex, NER, privacy filter, full pipeline) +├── engine/ # 89 tests — Auto-Redact pipeline (regex, NER, privacy filter, full pipeline, vault) ├── security/ # 11 tests — log hygiene, auth consistency across transports -├── integration/ # 14 tests — full service loop E2E (auth → query → redact → audit) -└── widget_test.dart # widget smoke tests +├── integration/ # 31 tests — full service loop E2E + preference model (auth → query → redact → audit) +└── widget_test.dart # 3 tests — model round-trip smoke tests ``` When adding a new feature, add tests in the matching directory. Engine changes go in `test/engine/`, auth/security changes go in `test/security/`, and end-to-end flows go in `test/integration/`. diff --git a/README.md b/README.md index b76fc9b..358e2ed 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Trulana turns any device into a private context server for AI tools. Apps query over localhost or MCP — every response is redacted on-device before it leaves. MCP-native. Cross-platform. Zero cloud. -![Tests](https://img.shields.io/badge/tests-117%20passing-3DFFD0?style=flat-square) ![Flutter](https://img.shields.io/badge/Flutter-Dart%203.x-3DFFD0?style=flat-square) ![License](https://img.shields.io/badge/license-BUSL--1.1-3DFFD0?style=flat-square) ![Platform](https://img.shields.io/badge/platform-macOS%20·%20Windows%20·%20Linux%20·%20iOS%20·%20Android-0A0820?style=flat-square&labelColor=1ABFA0) +![Tests](https://img.shields.io/badge/tests-134%20passing-3DFFD0?style=flat-square) ![Flutter](https://img.shields.io/badge/Flutter-Dart%203.x-3DFFD0?style=flat-square) ![License](https://img.shields.io/badge/license-BUSL--1.1-3DFFD0?style=flat-square) ![Platform](https://img.shields.io/badge/platform-macOS%20·%20Windows%20·%20Linux%20·%20iOS%20·%20Android-0A0820?style=flat-square&labelColor=1ABFA0) --- @@ -113,10 +113,10 @@ See [SECURITY.md](SECURITY.md) for the full security model and trust boundaries. ## Tests ```bash -flutter test # all 117 tests -flutter test test/engine/ # redaction pipeline -flutter test test/security/ # log hygiene + auth consistency -flutter test test/integration/ # full service loop E2E +flutter test # all 134 tests +flutter test test/engine/ # redaction pipeline (89 tests) +flutter test test/security/ # log hygiene + auth consistency (11 tests) +flutter test test/integration/ # full service loop + preferences E2E (31 tests) ``` --- diff --git a/SECURITY.md b/SECURITY.md index 56442d7..4de0cac 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -126,8 +126,9 @@ Before distributing the Trulana binary: | Area | Tests | What Is Asserted | |---|---|---| -| Redaction pipeline | 72 unit tests | All PII types stripped at all privacy levels | +| Redaction pipeline | 89 unit tests | All PII types stripped at all privacy levels | | REST API | 38 integration checks | Auth, rejection, redaction, PII leak detection, token expiry | | MCP protocol | 18 checks (release) | Stdout cleanliness, tool discovery, auth, redaction, rejection | +| Service loop + models | 31 integration tests | Full auth → query → redact → audit E2E + preference model | | Log hygiene | 6 tests | No tokens, PII, keys, or DB paths in log output | | Auth consistency | 5 tests | Singleton identity, identical validation across transports |