diff --git a/lib/builtin/decisions/src/decision_colors.dart b/lib/builtin/decisions/src/decision_colors.dart new file mode 100644 index 00000000..f824caa3 --- /dev/null +++ b/lib/builtin/decisions/src/decision_colors.dart @@ -0,0 +1,35 @@ +import 'dart:ui' show Color; + +class DecisionTypeColors { + const DecisionTypeColors({ + required this.confirmed, + required this.question, + required this.rejected, + }); + + final Color confirmed; + final Color question; + final Color rejected; + + Color forType(String? type) => switch (type) { + 'confirmed' => confirmed, + 'question' => question, + 'rejected' => rejected, + _ => confirmed, + }; + + static const dark = DecisionTypeColors( + confirmed: Color(0xFF7DD3A8), + question: Color(0xFFE6C370), + rejected: Color(0xFFE87D7D), + ); + + static const light = DecisionTypeColors( + confirmed: Color(0xFF1D7A4E), + question: Color(0xFFB08A20), + rejected: Color(0xFFC03030), + ); + + static DecisionTypeColors forTheme({required bool dark}) => + dark ? DecisionTypeColors.dark : DecisionTypeColors.light; +} diff --git a/lib/builtin/decisions/src/decisions_view.dart b/lib/builtin/decisions/src/decisions_view.dart index 473c29f6..3f33d737 100644 --- a/lib/builtin/decisions/src/decisions_view.dart +++ b/lib/builtin/decisions/src/decisions_view.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:clide/builtin/decisions/src/decision_colors.dart'; import 'package:clide/kernel/kernel.dart'; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/widgets.dart'; @@ -16,6 +17,7 @@ class _DecisionsViewState extends State { String? _error; bool _loading = true; String _filter = ''; + final Set _expanded = {'confirmed'}; @override void didChangeDependencies() { @@ -47,33 +49,66 @@ class _DecisionsViewState extends State { } } + void _toggleSection(String key) { + setState(() { + if (_expanded.contains(key)) { + _expanded.remove(key); + } else { + _expanded.add(key); + } + }); + } + @override Widget build(BuildContext context) { final tokens = ClideTheme.of(context).surface; - if (_loading) { - return const Center(child: ClideText('Loading decisions...', muted: true)); - } - if (_error != null) { - return Padding( - padding: const EdgeInsets.all(12), - child: ClideText(_error!, muted: true), - ); - } - if (_decisions.isEmpty) { - return const Padding( - padding: EdgeInsets.all(12), - child: ClideText('No decisions found.\nRun `pql decisions sync` to index.', muted: true), - ); - } + if (_loading) return const Center(child: ClideText('Loading decisions...', muted: true)); + if (_error != null) return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true)); + if (_decisions.isEmpty) return const Padding(padding: EdgeInsets.all(12), child: ClideText('No decisions found.\nRun `pql decisions sync` to index.', muted: true)); + final lf = _filter.toLowerCase(); - final filtered = lf.isEmpty ? _decisions : _decisions.where((d) => d.id.toLowerCase().contains(lf) || d.title.toLowerCase().contains(lf) || (d.domain ?? '').toLowerCase().contains(lf)).toList(); + final hasFilter = lf.isNotEmpty; + final filtered = hasFilter ? _decisions.where((d) => d.id.toLowerCase().contains(lf) || d.title.toLowerCase().contains(lf) || (d.domain ?? '').toLowerCase().contains(lf) || (d.type ?? '').contains(lf)).toList() : _decisions; + + final confirmed = filtered.where((d) => d.type == 'confirmed').toList(); + final questions = filtered.where((d) => d.type == 'question').toList(); + final rejected = filtered.where((d) => d.type == 'rejected').toList(); + + final isDark = ClideTheme.of(context).dark; + final typeColors = DecisionTypeColors.forTheme(dark: isDark); + return Column( children: [ ClideFilterBox(hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v)), Expanded( - child: ListView.builder( - itemCount: filtered.length, - itemBuilder: (ctx, i) => _DecisionRow(entry: filtered[i], tokens: tokens), + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (confirmed.isNotEmpty) _AccordionSection( + label: 'CONFIRMED', count: confirmed.length, tokens: tokens, + color: typeColors.confirmed, + expanded: hasFilter || _expanded.contains('confirmed'), + onToggle: () => _toggleSection('confirmed'), + children: [for (final d in confirmed) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors)], + ), + if (questions.isNotEmpty) _AccordionSection( + label: 'QUESTIONS', count: questions.length, tokens: tokens, + color: typeColors.question, + expanded: hasFilter || _expanded.contains('question'), + onToggle: () => _toggleSection('question'), + children: [for (final d in questions) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors)], + ), + if (rejected.isNotEmpty) _AccordionSection( + label: 'REJECTED', count: rejected.length, tokens: tokens, + color: typeColors.rejected, + expanded: hasFilter || _expanded.contains('rejected'), + onToggle: () => _toggleSection('rejected'), + children: [for (final d in rejected) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors)], + ), + ], + ), ), ), ], @@ -82,37 +117,109 @@ class _DecisionsViewState extends State { } class _DecisionEntry { - const _DecisionEntry({required this.id, required this.title, this.domain, this.status}); + const _DecisionEntry({required this.id, required this.title, this.type, this.domain, this.status}); final String id; final String title; + final String? type; final String? domain; final String? status; factory _DecisionEntry.fromJson(Map json) => _DecisionEntry( id: json['id'] as String? ?? '', title: json['title'] as String? ?? '', + type: json['type'] as String?, domain: json['domain'] as String?, status: json['status'] as String?, ); } -class _DecisionRow extends StatelessWidget { - const _DecisionRow({required this.entry, required this.tokens}); - final _DecisionEntry entry; +class _AccordionSection extends StatelessWidget { + const _AccordionSection({ + required this.label, required this.count, required this.tokens, + required this.color, required this.expanded, required this.onToggle, + required this.children, + }); + final String label; + final int count; final SurfaceTokens tokens; + final Color color; + final bool expanded; + final VoidCallback onToggle; + final List children; @override Widget build(BuildContext context) { - return ClideTappable( - builder: (context, hovered, _) => Container( - color: hovered ? tokens.listItemHoverBackground : null, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: Row( - children: [ - ClideText(entry.id, color: tokens.globalTextMuted, fontSize: 12), - const SizedBox(width: 8), - Expanded(child: ClideText(entry.title, fontSize: 13)), - ], + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClideTappable( + onTap: onToggle, + builder: (ctx, hovered, _) => Padding( + padding: const EdgeInsets.only(left: 4, top: 10, bottom: 4), + child: Row( + children: [ + ClideIcon(expanded ? PhosphorIcons.caretDown : PhosphorIcons.caretRight, size: 10, color: tokens.globalTextMuted), + const SizedBox(width: 6), + Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), + const SizedBox(width: 6), + ClideText('$label · $count', fontSize: 11, color: hovered ? tokens.globalForeground : tokens.sidebarSectionHeader, fontFamily: clideMonoFamily), + ], + ), + ), + ), + if (expanded) ...children, + ], + ); + } +} + +class _DecisionCard extends StatelessWidget { + const _DecisionCard({required this.entry, required this.tokens, required this.typeColors}); + final _DecisionEntry entry; + final SurfaceTokens tokens; + final DecisionTypeColors typeColors; + + @override + Widget build(BuildContext context) { + final typeColor = typeColors.forType(entry.type); + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: ClideTappable( + builder: (ctx, hovered, _) => Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: hovered ? tokens.sidebarItemHover : tokens.panelBackground, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: hovered ? tokens.panelActiveBorder : tokens.panelBorder, width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ClideTooltip( + message: entry.type ?? 'confirmed', + child: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle)), + ), + const SizedBox(width: 6), + ClideText(entry.id, fontSize: 11, color: tokens.globalTextMuted, fontFamily: clideMonoFamily), + const Spacer(), + if (entry.domain != null) + ClideText(entry.domain!, fontSize: 10, color: tokens.globalTextMuted, fontFamily: clideMonoFamily), + ], + ), + const SizedBox(height: 4), + ClideText(entry.title, fontSize: 13), + if (entry.status == 'resolved') ...[ + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration(color: tokens.statusSuccess.withAlpha(0x30), borderRadius: BorderRadius.circular(3)), + child: ClideText('resolved', fontSize: 10, color: tokens.statusSuccess, fontFamily: clideMonoFamily), + ), + ], + ], + ), ), ), ); diff --git a/lib/builtin/tickets/src/tickets_view.dart b/lib/builtin/tickets/src/tickets_view.dart index a1d28335..6eb02a44 100644 --- a/lib/builtin/tickets/src/tickets_view.dart +++ b/lib/builtin/tickets/src/tickets_view.dart @@ -17,6 +17,13 @@ class _TicketsViewState extends State { String? _error; bool _loading = true; String _filter = ''; + final Set _expanded = {'active', 'backlog'}; + + void _toggle(String key) { + setState(() { + if (_expanded.contains(key)) _expanded.remove(key); else _expanded.add(key); + }); + } @override void didChangeDependencies() { @@ -50,30 +57,37 @@ class _TicketsViewState extends State { @override Widget build(BuildContext context) { final tokens = ClideTheme.of(context).surface; - if (_loading) { - return const Center(child: ClideText('Loading tickets...', muted: true)); - } - if (_error != null) { - return Padding( - padding: const EdgeInsets.all(12), - child: ClideText(_error!, muted: true), - ); - } - if (_tickets.isEmpty) { - return const Padding( - padding: EdgeInsets.all(12), - child: ClideText('No tickets found.\nRun `pql ticket new` to create one.', muted: true), - ); - } + if (_loading) return const Center(child: ClideText('Loading tickets...', muted: true)); + if (_error != null) return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true)); + if (_tickets.isEmpty) return const Padding(padding: EdgeInsets.all(12), child: ClideText('No tickets.\nRun `pql ticket new` to create one.', muted: true)); + final lf = _filter.toLowerCase(); - final filtered = lf.isEmpty ? _tickets : _tickets.where((t) => t.id.toLowerCase().contains(lf) || t.title.toLowerCase().contains(lf) || (t.status ?? '').toLowerCase().contains(lf)).toList(); + final hasFilter = lf.isNotEmpty; + final filtered = hasFilter ? _tickets.where((t) => t.id.toLowerCase().contains(lf) || t.title.toLowerCase().contains(lf) || (t.status ?? '').contains(lf) || (t.type ?? '').contains(lf)).toList() : _tickets; + + final active = filtered.where((t) => t.status == 'in_progress').toList(); + final backlog = filtered.where((t) => t.status == 'backlog' || t.status == 'ready').toList(); + final done = filtered.where((t) => t.status == 'done').toList(); + final other = filtered.where((t) => t.status == 'cancelled' || t.status == 'review').toList(); + + final isDark = ClideTheme.of(context).dark; + final typeColors = TicketTypeColors.forTheme(dark: isDark); + return Column( children: [ ClideFilterBox(hint: 'Filter tickets…', onChanged: (v) => setState(() => _filter = v)), Expanded( - child: ListView.builder( - itemCount: filtered.length, - itemBuilder: (ctx, i) => _TicketRow(entry: filtered[i], tokens: tokens), + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (active.isNotEmpty) _AccordionSection(label: 'ACTIVE', count: active.length, tokens: tokens, expanded: hasFilter || _expanded.contains('active'), onToggle: () => _toggle('active'), children: [for (final t in active) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors)]), + if (backlog.isNotEmpty) _AccordionSection(label: 'BACKLOG', count: backlog.length, tokens: tokens, expanded: hasFilter || _expanded.contains('backlog'), onToggle: () => _toggle('backlog'), children: [for (final t in backlog) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors)]), + if (done.isNotEmpty) _AccordionSection(label: 'DONE', count: done.length, tokens: tokens, expanded: hasFilter || _expanded.contains('done'), onToggle: () => _toggle('done'), children: [for (final t in done) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors)]), + if (other.isNotEmpty) _AccordionSection(label: 'OTHER', count: other.length, tokens: tokens, expanded: hasFilter || _expanded.contains('other'), onToggle: () => _toggle('other'), children: [for (final t in other) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors)]), + ], + ), ), ), ], @@ -82,12 +96,13 @@ class _TicketsViewState extends State { } class _TicketEntry { - const _TicketEntry({required this.id, required this.title, this.type, this.status, this.priority}); + const _TicketEntry({required this.id, required this.title, this.type, this.status, this.priority, this.parentId}); final String id; final String title; final String? type; final String? status; final String? priority; + final String? parentId; factory _TicketEntry.fromJson(Map json) => _TicketEntry( id: json['id'] as String? ?? '', @@ -95,43 +110,127 @@ class _TicketEntry { type: json['type'] as String?, status: json['status'] as String?, priority: json['priority'] as String?, + parentId: json['parent_id'] as String?, ); } -class _TicketRow extends StatelessWidget { - const _TicketRow({required this.entry, required this.tokens}); - final _TicketEntry entry; +class _AccordionSection extends StatelessWidget { + const _AccordionSection({required this.label, required this.count, required this.tokens, required this.expanded, required this.onToggle, required this.children}); + final String label; + final int count; final SurfaceTokens tokens; + final bool expanded; + final VoidCallback onToggle; + final List children; @override Widget build(BuildContext context) { - final isDark = ClideTheme.of(context).dark; - final typeColors = TicketTypeColors.forTheme(dark: isDark); - final typeColor = typeColors.forType(entry.type); - final statusColor = switch (entry.status) { - 'done' => tokens.statusSuccess, - 'in_progress' => tokens.statusInfo, - 'cancelled' => tokens.statusError, - _ => tokens.globalTextMuted, - }; - return ClideTappable( - builder: (context, hovered, _) => Container( - color: hovered ? tokens.listItemHoverBackground : null, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: Row( - children: [ - ClideText(entry.id, color: typeColor, fontSize: 12, fontFamily: clideMonoFamily), - const SizedBox(width: 6), - Container( - width: 6, - height: 6, - decoration: BoxDecoration(color: statusColor, shape: BoxShape.circle), + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClideTappable( + onTap: onToggle, + builder: (ctx, hovered, _) => Padding( + padding: const EdgeInsets.only(left: 4, top: 10, bottom: 4), + child: Row( + children: [ + ClideIcon(expanded ? PhosphorIcons.caretDown : PhosphorIcons.caretRight, size: 10, color: tokens.globalTextMuted), + const SizedBox(width: 6), + ClideText('$label · $count', fontSize: 11, color: hovered ? tokens.globalForeground : tokens.sidebarSectionHeader, fontFamily: clideMonoFamily), + ], ), - const SizedBox(width: 6), - Expanded(child: ClideText(entry.title, fontSize: 13)), - ], + ), + ), + if (expanded) ...children, + ], + ); + } +} + +class _TicketCard extends StatelessWidget { + const _TicketCard({required this.entry, required this.tokens, required this.typeColors}); + final _TicketEntry entry; + final SurfaceTokens tokens; + final TicketTypeColors typeColors; + + @override + Widget build(BuildContext context) { + final typeColor = typeColors.forType(entry.type); + final statusLabel = _statusLabel(entry.status); + + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: ClideTappable( + builder: (ctx, hovered, _) => Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: hovered ? tokens.sidebarItemHover : tokens.panelBackground, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: hovered ? tokens.panelActiveBorder : tokens.panelBorder, width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ClideTooltip( + message: entry.type ?? 'task', + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle), + ), + ), + const SizedBox(width: 6), + ClideText(entry.id, fontSize: 11, color: tokens.globalTextMuted, fontFamily: clideMonoFamily), + if (entry.parentId != null) ...[ + ClideText(' ← ', fontSize: 11, color: tokens.globalTextMuted), + ClideText(entry.parentId!, fontSize: 11, color: tokens.globalTextMuted, fontFamily: clideMonoFamily), + ], + ], + ), + const SizedBox(height: 4), + ClideText(entry.title, fontSize: 13), + if (statusLabel != null) ...[ + const SizedBox(height: 6), + _StatusBadge(label: statusLabel, tokens: tokens, status: entry.status), + ], + ], + ), ), ), ); } + + static String? _statusLabel(String? status) => switch (status) { + 'in_progress' => 'WIP', + 'review' => 'REVIEW', + 'cancelled' => 'CANCELLED', + _ => null, + }; +} + +class _StatusBadge extends StatelessWidget { + const _StatusBadge({required this.label, required this.tokens, this.status}); + final String label; + final SurfaceTokens tokens; + final String? status; + + @override + Widget build(BuildContext context) { + final color = switch (status) { + 'in_progress' => tokens.statusInfo, + 'review' => tokens.statusWarning, + 'cancelled' => tokens.statusError, + _ => tokens.globalTextMuted, + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withAlpha(0x30), + borderRadius: BorderRadius.circular(3), + ), + child: ClideText(label, fontSize: 10, color: color, fontFamily: clideMonoFamily), + ); + } }