diff --git a/lib/builtin/tickets/src/extension.dart b/lib/builtin/tickets/src/extension.dart index 80d32ba6..6779a56f 100644 --- a/lib/builtin/tickets/src/extension.dart +++ b/lib/builtin/tickets/src/extension.dart @@ -1,3 +1,4 @@ +import 'package:clide/builtin/tickets/src/ticket_detail_view.dart'; import 'package:clide/builtin/tickets/src/tickets_view.dart'; import 'package:clide/extension/extension.dart'; import 'package:clide/kernel/kernel.dart'; @@ -8,7 +9,7 @@ class TicketsExtension extends ClideExtension { @override String get title => 'Tickets'; @override - String get version => '0.1.0'; + String get version => '0.2.0'; @override List get dependsOn => const []; @@ -23,5 +24,12 @@ class TicketsExtension extends ClideExtension { priority: -10, build: (_) => const TicketsView(), ), + TabContribution( + id: 'tickets.detail', + slot: Slots.contextPanel, + title: 'Ticket', + priority: -60, + build: (_) => const TicketDetailView(), + ), ]; } diff --git a/lib/builtin/tickets/src/ticket_detail_controller.dart b/lib/builtin/tickets/src/ticket_detail_controller.dart new file mode 100644 index 00000000..6b06c763 --- /dev/null +++ b/lib/builtin/tickets/src/ticket_detail_controller.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/kernel/src/events/message_bus.dart'; +import 'package:flutter/foundation.dart'; + +class TicketDetail { + const TicketDetail({required this.ticket, this.parents = const [], this.decisions = const []}); + final Map ticket; + final List> parents; + final List> decisions; + + String get id => ticket['id'] as String? ?? ''; + String get title => ticket['title'] as String? ?? ''; + String? get type => ticket['type'] as String?; + String? get status => ticket['status'] as String?; + String? get priority => ticket['priority'] as String?; + String? get description => ticket['description'] as String?; + String? get parentId => ticket['parent_id'] as String?; + String? get decisionRef => ticket['decision_ref'] as String?; + String? get assignedTo => ticket['assigned_to'] as String?; +} + +class TicketDetailController extends ChangeNotifier { + TicketDetailController({required this.ipc, required this.messages, required this.panels}) { + _sub = messages.subscribe(publisher: 'builtin.tickets', channel: 'selection').listen(_onSelection); + } + + final DaemonClient ipc; + final MessageBus messages; + final PanelRegistry panels; + StreamSubscription? _sub; + + TicketDetail? _detail; + TicketDetail? get detail => _detail; + + bool _loading = false; + bool get loading => _loading; + + void _onSelection(Message msg) { + final id = msg.data['id'] as String?; + if (id != null) { + panels.activateTab(Slots.contextPanel, 'tickets.detail'); + unawaited(load(id)); + } + } + + Future load(String id) async { + _loading = true; + notifyListeners(); + + final resp = await ipc.request('pql.tickets.show', args: {'id': id}); + if (!resp.ok) { + _loading = false; + notifyListeners(); + return; + } + + final ticket = resp.data; + final parents = >[]; + final decisions = >[]; + + // Walk parent chain + var pid = ticket['parent_id'] as String?; + while (pid != null) { + final pr = await ipc.request('pql.tickets.show', args: {'id': pid}); + if (!pr.ok) break; + parents.add(pr.data); + pid = pr.data['parent_id'] as String?; + } + + // Collect decision refs from ticket and parents + final refs = {}; + final dr = ticket['decision_ref'] as String?; + if (dr != null) refs.add(dr); + for (final p in parents) { + final pdr = p['decision_ref'] as String?; + if (pdr != null) refs.add(pdr); + } + + for (final ref in refs) { + final dr = await ipc.request('pql.decisions.show', args: {'id': ref}); + if (dr.ok) decisions.add(dr.data); + } + + _detail = TicketDetail(ticket: ticket, parents: parents, decisions: decisions); + _loading = false; + notifyListeners(); + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } +} diff --git a/lib/builtin/tickets/src/ticket_detail_view.dart b/lib/builtin/tickets/src/ticket_detail_view.dart new file mode 100644 index 00000000..bb5c32b7 --- /dev/null +++ b/lib/builtin/tickets/src/ticket_detail_view.dart @@ -0,0 +1,271 @@ +import 'package:clide/builtin/tickets/src/ticket_colors.dart'; +import 'package:clide/builtin/tickets/src/ticket_detail_controller.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; + +class TicketDetailView extends StatefulWidget { + const TicketDetailView({super.key}); + + @override + State createState() => _TicketDetailViewState(); +} + +class _TicketDetailViewState extends State { + TicketDetailController? _controller; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_controller != null) return; + final kernel = ClideKernel.of(context); + _controller = TicketDetailController(ipc: kernel.ipc, messages: kernel.messages, panels: kernel.panels); + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final c = _controller; + if (c == null) return const SizedBox.shrink(); + return ListenableBuilder( + listenable: c, + builder: (ctx, _) { + if (c.loading) return const Center(child: ClideText('Loading…', muted: true)); + final d = c.detail; + if (d == null) return const Padding(padding: EdgeInsets.all(12), child: ClideText('Select a ticket to view details.', muted: true)); + + final tokens = ClideTheme.of(ctx).surface; + final isDark = ClideTheme.of(ctx).dark; + final typeColors = TicketTypeColors.forTheme(dark: isDark); + + return SingleChildScrollView( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _TicketHeader(detail: d, tokens: tokens, typeColors: typeColors), + const SizedBox(height: 12), + _StatusControls(detail: d, tokens: tokens, controller: c), + if (d.description != null && d.description!.isNotEmpty) ...[ + const SizedBox(height: 12), + ClideText(d.description!, muted: true, fontSize: 13), + ], + if (d.parents.isNotEmpty) ...[ + const SizedBox(height: 16), + _SectionLabel(label: 'PARENT TREE', tokens: tokens), + const SizedBox(height: 6), + for (var i = 0; i < d.parents.length; i++) + _CompactCard( + data: d.parents[i], + tokens: tokens, + typeColors: typeColors, + indent: i, + ), + ], + if (d.decisions.isNotEmpty) ...[ + const SizedBox(height: 16), + _SectionLabel(label: 'REFERENCED DECISIONS', tokens: tokens), + const SizedBox(height: 6), + for (final dec in d.decisions) _DecisionRefCard(data: dec, tokens: tokens), + ], + ], + ), + ); + }, + ); + } +} + +class _TicketHeader extends StatelessWidget { + const _TicketHeader({required this.detail, required this.tokens, required this.typeColors}); + final TicketDetail detail; + final SurfaceTokens tokens; + final TicketTypeColors typeColors; + + @override + Widget build(BuildContext context) { + final typeColor = typeColors.forType(detail.type); + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: tokens.panelBackground, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: tokens.panelBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ClideTooltip( + message: detail.type ?? 'task', + child: Container(width: 10, height: 10, decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle)), + ), + const SizedBox(width: 8), + ClideText(detail.id, fontSize: 13, color: typeColor, fontFamily: clideMonoFamily), + const Spacer(), + if (detail.priority != null) + ClideText(detail.priority!, fontSize: 11, color: tokens.globalTextMuted, fontFamily: clideMonoFamily), + ], + ), + const SizedBox(height: 8), + ClideText(detail.title, fontSize: 15, fontWeight: FontWeight.w500), + if (detail.assignedTo != null) ...[ + const SizedBox(height: 6), + ClideText('assigned: ${detail.assignedTo}', muted: true, fontSize: 12, fontFamily: clideMonoFamily), + ], + ], + ), + ); + } +} + +class _StatusControls extends StatelessWidget { + const _StatusControls({required this.detail, required this.tokens, required this.controller}); + final TicketDetail detail; + final SurfaceTokens tokens; + final TicketDetailController controller; + + static const _statuses = ['backlog', 'ready', 'in_progress', 'review', 'done']; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + for (final s in _statuses) ...[ + Expanded( + child: ClideTappable( + onTap: () async { + await controller.ipc.request('pql.tickets.status', args: {'ids': detail.id, 'status': s}); + await controller.load(detail.id); + }, + builder: (ctx, hovered, _) { + final active = detail.status == s; + final color = active ? tokens.statusInfo : (hovered ? tokens.globalForeground : tokens.globalTextMuted); + return Container( + padding: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + color: active ? tokens.statusInfo.withAlpha(0x30) : null, + borderRadius: BorderRadius.circular(3), + border: Border.all(color: active ? tokens.statusInfo : tokens.panelBorder), + ), + alignment: Alignment.center, + child: ClideText(_shortLabel(s), fontSize: 10, color: color, fontFamily: clideMonoFamily), + ); + }, + ), + ), + if (s != _statuses.last) const SizedBox(width: 4), + ], + ], + ); + } + + static String _shortLabel(String s) => switch (s) { + 'backlog' => 'BACKLOG', + 'ready' => 'READY', + 'in_progress' => 'WIP', + 'review' => 'REVIEW', + 'done' => 'DONE', + _ => s.toUpperCase(), + }; +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label, required this.tokens}); + final String label; + final SurfaceTokens tokens; + + @override + Widget build(BuildContext context) { + return ClideText(label, fontSize: 11, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily); + } +} + +class _CompactCard extends StatelessWidget { + const _CompactCard({required this.data, required this.tokens, required this.typeColors, this.indent = 0}); + final Map data; + final SurfaceTokens tokens; + final TicketTypeColors typeColors; + final int indent; + + @override + Widget build(BuildContext context) { + final id = data['id'] as String? ?? ''; + final title = data['title'] as String? ?? ''; + final type = data['type'] as String?; + final typeColor = typeColors.forType(type); + return Padding( + padding: EdgeInsets.only(left: indent * 12.0, bottom: 4), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: tokens.panelBackground, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: tokens.panelBorder), + ), + child: Row( + children: [ + Container(width: 6, height: 6, decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle)), + const SizedBox(width: 6), + ClideText(id, fontSize: 11, color: tokens.globalTextMuted, fontFamily: clideMonoFamily), + const SizedBox(width: 8), + Expanded(child: ClideText(title, fontSize: 12, maxLines: 1, overflow: TextOverflow.ellipsis)), + ], + ), + ), + ); + } +} + +class _DecisionRefCard extends StatelessWidget { + const _DecisionRefCard({required this.data, required this.tokens}); + final Map data; + final SurfaceTokens tokens; + + @override + Widget build(BuildContext context) { + final id = data['id'] as String? ?? ''; + final title = data['title'] as String? ?? ''; + final type = data['type'] as String?; + final domain = data['domain'] as String?; + final color = switch (type) { + 'confirmed' => tokens.statusSuccess, + 'question' => tokens.statusWarning, + 'rejected' => tokens.statusError, + _ => tokens.globalTextMuted, + }; + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: tokens.panelBackground, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: tokens.panelBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container(width: 6, height: 6, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), + const SizedBox(width: 6), + ClideText(id, fontSize: 11, color: color, fontFamily: clideMonoFamily), + const Spacer(), + if (domain != null) ClideText(domain, fontSize: 10, color: tokens.globalTextMuted, fontFamily: clideMonoFamily), + ], + ), + const SizedBox(height: 3), + ClideText(title, fontSize: 12), + ], + ), + ), + ); + } +} diff --git a/lib/builtin/tickets/src/tickets_view.dart b/lib/builtin/tickets/src/tickets_view.dart index 6eb02a44..e18907f9 100644 --- a/lib/builtin/tickets/src/tickets_view.dart +++ b/lib/builtin/tickets/src/tickets_view.dart @@ -161,6 +161,7 @@ class _TicketCard extends StatelessWidget { return Padding( padding: const EdgeInsets.only(bottom: 4), child: ClideTappable( + onTap: () => ClideKernel.of(context).messages.publish('builtin.tickets', 'selection', {'id': entry.id}), builder: (ctx, hovered, _) => Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration(