import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../theme/app_theme.dart'; import '../models/backfill_sync_item.dart'; import '../models/sync_run_event.dart'; import '../utils/sync_run_formatters.dart'; import 'sync_run_status_chip.dart'; class SyncRunExpansionTile extends StatelessWidget { const SyncRunExpansionTile({ super.key, required this.event, this.now, this.emphasizeError = false, this.onRetry, }); final SyncRunEvent event; final DateTime? now; final bool emphasizeError; final VoidCallback? onRetry; @override Widget build(BuildContext context) { final Color iconColor = severityColor(event.severity); return Card( key: Key('sync-run-${event.id}'), color: emphasizeError ? AppColors.surfaceElevated : AppColors.surface, margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Theme( data: Theme.of(context).copyWith(dividerColor: Colors.transparent), child: ExpansionTile( leading: Icon(severityIcon(event.severity), color: iconColor), title: Text( '${event.displayTitle} — ${shortStatusLabel(event)}', style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), subtitle: Text( event.summary, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), ), trailing: Text( formatRelativeTime(event.startedAt, now: now), style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), ), children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SyncRunStatusChip(event: event), const SizedBox(height: 12), ..._detailRows(context), ], ), ), ], ), ), ); } List _detailRows(BuildContext context) { final List rows = [ _detailRow('Run ID', '${event.id}'), _detailRow('Kind', event.kind), _detailRow('Started', formatLocalTimestamp(event.startedAt)), _detailRow('Finished', formatLocalTimestamp(event.finishedAt)), _detailRow('Duration', formatDurationMs(event.durationMs)), ]; if (event.rowsWritten > 0) { rows.add(_detailRow('Rows written', '${event.rowsWritten}')); } if (event.rowsRemoved > 0) { rows.add(_detailRow('Rows removed', '${event.rowsRemoved}')); } if (event.kind == 'backfill' && event.backfillItems.isNotEmpty) { rows.add( const Padding( padding: EdgeInsets.only(top: 8, bottom: 4), child: Text( 'Backfill fetches (Alpaca start / raw.slot_start)', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.textSecondary, ), ), ), ); for (final BackfillSyncItem item in event.backfillItems) { rows.add(_backfillFetchRow(item)); } } if (event.status == SyncRunStatus.success && event.error == null) { rows.add(_detailRow('Errors', 'No errors')); } if (event.error != null && event.error!.trim().isNotEmpty) { final String? httpStatus = parseHttpStatus(event.error); if (httpStatus != null) { rows.add(_detailRow('HTTP status', httpStatus)); } rows.add( Padding( padding: const EdgeInsets.only(top: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Error', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.textSecondary, ), ), const SizedBox(height: 4), ConstrainedBox( constraints: const BoxConstraints(maxHeight: 240), child: SingleChildScrollView( child: SelectableText( event.error!, key: Key('sync-run-error-${event.id}'), style: const TextStyle( fontSize: 13, color: AppColors.textPrimary, ), ), ), ), TextButton.icon( onPressed: () { Clipboard.setData(ClipboardData(text: event.error!)); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Error copied')), ); }, icon: const Icon(Icons.copy, size: 16), label: const Text('Copy error'), ), if (event.severity == SyncRunSeverity.rateLimit || event.status == SyncRunStatus.failed) const Text( 'Will retry on next scheduled run.', style: TextStyle( fontSize: 12, color: AppColors.textSecondary, ), ), if (onRetry != null && (event.status == SyncRunStatus.failed || event.status == SyncRunStatus.partial || event.severity == SyncRunSeverity.rateLimit)) TextButton.icon( key: Key('retry-run-${event.id}'), onPressed: onRetry, icon: const Icon(Icons.replay, size: 16), label: const Text('Retry now'), ), ], ), ), ); } if (event.status == SyncRunStatus.inProgress) { rows.add( const Padding( padding: EdgeInsets.only(top: 8), child: Row( children: [ SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ), SizedBox(width: 8), Text( 'Run in progress…', style: TextStyle(fontSize: 12, color: AppColors.textSecondary), ), ], ), ), ); } return rows; } Widget _backfillFetchRow(BackfillSyncItem item) { final String wire = item.fetchSlotStartWire; return Padding( padding: const EdgeInsets.only(bottom: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SelectableText( wire, key: Key('backfill-slot-$wire'), style: const TextStyle( fontSize: 12, fontFamily: 'monospace', color: AppColors.textPrimary, ), ), const SizedBox(height: 2), Text( '${item.symbols.length} assets: ${item.symbols.join(', ')}', style: const TextStyle( fontSize: 12, color: AppColors.textSecondary, ), ), ], ), ); } Widget _detailRow(String label, String value) { return Padding( padding: const EdgeInsets.only(bottom: 6), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 110, child: Text( label, style: const TextStyle( fontSize: 12, color: AppColors.textSecondary, ), ), ), Expanded( child: Text( value, style: const TextStyle( fontSize: 12, color: AppColors.textPrimary, ), ), ), ], ), ); } }