From cce1ab63c07133b76e233389beb5343850ea4ff3 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 23 Apr 2026 12:58:30 +0200 Subject: [PATCH] accordion sections for decisions and tickets views Both views now group items into collapsible accordion sections: - Decisions: CONFIRMED/QUESTIONS/REJECTED with colored dots - Tickets: ACTIVE/BACKLOG/DONE/OTHER with caret toggle Cards show type dot with tooltip, ID in mono, domain badge (decisions), parent reference (tickets), wrapping title, and status badges. Search filter forces all sections open. ACTIVE and BACKLOG default expanded; DONE default collapsed. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../decisions/src/decision_colors.dart | 35 ++++ lib/builtin/decisions/src/decisions_view.dart | 173 +++++++++++++--- lib/builtin/tickets/src/tickets_view.dart | 193 +++++++++++++----- 3 files changed, 321 insertions(+), 80 deletions(-) create mode 100644 lib/builtin/decisions/src/decision_colors.dart 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), + ); + } }