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 | 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..ca6ac11 --- /dev/null +++ b/lib/features/dashboard/dashboard_view.dart @@ -0,0 +1,740 @@ +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/features/engine/context_vault.dart'; +import 'package:trulana/models/models.dart'; +import 'package:trulana/providers/providers.dart'; + +// --------------------------------------------------------------------------- +// 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}); + + @override + 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: [ + _ContentHeader( + title: 'Dashboard', + trailing: configAsync.whenOrNull( + data: (config) => config != null + ? _PrivacyToggle( + level: config.privacyLevel, + onChanged: (level) { + ref + .read(userConfigProvider.notifier) + .updatePrivacyLevel(level); + }, + ) + : null, + ), + ), + 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( + agentsAsync: agentsAsync, + prefCountAsync: prefCountAsync, + ), + ), + const SizedBox(width: 14), + Expanded( + child: _RedactionStatsCard( + statsAsync: redactionAsync, + ), + ), + ], + ), + const SizedBox(height: 14), + + // Row 2: Connected agents + Recent activity + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _ConnectedAgentsCard(agentsAsync: agentsAsync), + ), + const SizedBox(width: 14), + Expanded( + child: _RecentActivityCard(auditAsync: auditAsync), + ), + ], + ), + const SizedBox(height: 14), + + // Full-width audit trail + _AuditTrailCard(auditAsync: auditAsync), + ], + ), + ), + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Reusable widgets +// --------------------------------------------------------------------------- + +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!, + ], + ), + ); + } +} + +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 _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 — live counts from providers +// --------------------------------------------------------------------------- + +class _VaultStatusCard extends StatelessWidget { + const _VaultStatusCard({ + required this.agentsAsync, + required this.prefCountAsync, + }); + + 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: '$vaultEntries', label: 'Entries'), + _StatColumn(value: '$prefCount', label: 'Preferences'), + _StatColumn(value: '$activeAgents', 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 Stats — computed from audit log +// --------------------------------------------------------------------------- + +class _RedactionStatsCard extends StatelessWidget { + 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: '$total total', + badgeColor: TrulanaColors.primaryCyan, + child: Column( + 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), + ], + ), + ); + } +} + +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.clamp(0.0, 1.0), + 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 — live from agentsProvider +// --------------------------------------------------------------------------- + +class _ConnectedAgentsCard extends StatelessWidget { + const _ConnectedAgentsCard({required this.agentsAsync}); + + final AsyncValue> agentsAsync; + + @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: '$activeCount active', + badgeColor: TrulanaColors.primaryCyan, + 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 { + 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 — top 4 from auditProvider +// --------------------------------------------------------------------------- + +class _RecentActivityCard extends StatelessWidget { + 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: 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(), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Audit Trail — full list from auditProvider +// --------------------------------------------------------------------------- + +class _AuditTrailCard extends StatelessWidget { + 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: 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(), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Shared audit entry row — renders from real AuditLogEntry +// --------------------------------------------------------------------------- + +class _AuditEntryRow extends StatelessWidget { + const _AuditEntryRow({required this.entry, this.compact = false}); + + 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)))), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + 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), + decoration: BoxDecoration( + 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))), + ], + ), + ); + } +} 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/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..09023a1 --- /dev/null +++ b/lib/features/shell/views/agents_view.dart @@ -0,0 +1,246 @@ +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 — live from agentsProvider. +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: agentsAsync.when( + loading: () => const Center( + child: CircularProgressIndicator( + color: TrulanaColors.primaryCyan, + strokeWidth: 2, + ), + ), + 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 SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(24), + child: Column( + children: [ + _AgentCard( + title: 'Active Tokens', + badge: '${active.length} live', + agents: active, + isEmpty: active.isEmpty, + ), + const SizedBox(height: 14), + _AgentCard( + title: 'Expired / Revoked', + agents: inactive, + isEmpty: inactive.isEmpty, + ), + ], + ), + ); + }, + ), + ), + ], + ); + } + + 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)), + ], + ), + ); + } +} + +class _AgentCard extends StatelessWidget { + const _AgentCard({ + required this.title, + required this.agents, + this.badge, + this.isEmpty = false, + }); + + final String title; + final String? badge; + final List agents; + final bool isEmpty; + + @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: 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 _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)), + ], + ), + ); + } +} 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..5d0d0f1 --- /dev/null +++ b/lib/features/shell/views/audit_log_view.dart @@ -0,0 +1,198 @@ +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 — live from auditProvider. +class AuditLogView extends ConsumerWidget { + const AuditLogView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final auditAsync = ref.watch(auditProvider); + + return Column( + children: [ + _header(), + Expanded( + 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]), + ); + }, + ), + ), + ], + ); + } + + 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 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.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)))), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + 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), + decoration: BoxDecoration( + 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.agentId} ${entry.requestIntent}', + ), + ), + ], + ), + ); + } +} + +/// Renders audit detail text with [REDACTED] tags highlighted in cyan. +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)); + } +} 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..fec4feb --- /dev/null +++ b/lib/features/shell/views/context_vault_view.dart @@ -0,0 +1,246 @@ +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 real vault entries run through the +/// Auto-Redact Engine at the user's current privacy level. +class ContextVaultView extends ConsumerWidget { + const ContextVaultView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final vaultAsync = ref.watch(_redactedVaultProvider); + + return Column( + children: [ + _header(), + Expanded( + 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]), + ); + }, + ), + ), + ], + ); + } + + 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(), + 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)), + ), + ], + ), + ); + } +} + +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 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), + _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 _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 new file mode 100644 index 0000000..3a6c80e --- /dev/null +++ b/lib/features/shell/views/preferences_view.dart @@ -0,0 +1,143 @@ +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 from the encrypted database. +final _preferencesProvider = FutureProvider>((ref) async { + return PreferencesService.instance.getAllPreferences(); +}); + +/// Structured Preferences view — live from the database. +class PreferencesView extends ConsumerWidget { + const PreferencesView({super.key}); + + @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) { + 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: prefs.length, + itemBuilder: (context, index) => + _PrefRow(pref: prefs[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 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)))), + 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(scope, + style: TrulanaTheme.monoStyle.copyWith( + fontSize: 9, color: TrulanaColors.textTertiary)), + ), + ], + ), + ); + } + + /// 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/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, + ), + ), + ); + } +} 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.