| // Copyright 2025 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'package:cocoon_common/rpc_model.dart'; |
| import 'package:collection/collection.dart'; |
| import 'package:flutter/material.dart'; |
| import 'package:url_launcher/url_launcher.dart'; |
| |
| import '../logic/qualified_task.dart'; |
| import '../state/build.dart'; |
| |
| /// Shows details about a test/task and allows modifying its suppression status. |
| class TestDetailsPopover extends StatefulWidget { |
| const TestDetailsPopover({ |
| super.key, |
| required this.qualifiedTask, |
| required this.buildState, |
| required this.showSnackBarCallback, |
| required this.closeCallback, |
| }); |
| |
| final QualifiedTask qualifiedTask; |
| final BuildState buildState; |
| final void Function(SnackBar) showSnackBarCallback; |
| final VoidCallback closeCallback; |
| |
| @override |
| State<TestDetailsPopover> createState() => _TestDetailsPopoverState(); |
| } |
| |
| class _TestDetailsPopoverState extends State<TestDetailsPopover> { |
| SuppressedTest? get suppressedTest { |
| return widget.buildState.suppressedTests.firstWhereOrNull( |
| (s) => s.name == widget.qualifiedTask.task, |
| ); |
| } |
| |
| bool _isPendingUpdate = false; |
| |
| @override |
| Widget build(BuildContext context) { |
| final suppressed = suppressedTest; |
| final isAuthenticated = widget.buildState.authService.isAuthenticated; |
| |
| return Card( |
| elevation: 4, |
| child: Padding( |
| padding: const EdgeInsets.all(16.0), |
| child: IntrinsicWidth( |
| child: Column( |
| crossAxisAlignment: CrossAxisAlignment.start, |
| mainAxisSize: MainAxisSize.min, |
| children: [ |
| SelectableText( |
| widget.qualifiedTask.task, |
| style: Theme.of(context).textTheme.titleLarge, |
| ), |
| const SizedBox(height: 16), |
| switch ((isAuthenticated, suppressed)) { |
| (false, _) => const Text( |
| 'Sign in to change tree blocking status', |
| style: TextStyle(fontStyle: FontStyle.italic), |
| ), |
| (true, null) => ElevatedButton.icon( |
| onPressed: _isPendingUpdate |
| ? null |
| : () => _showSuppressDialog(context), |
| icon: const Icon(Icons.do_not_disturb_on_outlined), |
| label: const Text('Unblock Tree'), |
| style: ElevatedButton.styleFrom( |
| backgroundColor: Colors.amber.shade100, |
| foregroundColor: Colors.amber.shade900, |
| ), |
| ), |
| (true, _) => ElevatedButton.icon( |
| onPressed: _isPendingUpdate |
| ? null |
| : () => _toggleSuppression(false), |
| icon: const Icon(Icons.check_circle_outline), |
| label: const Text('Remove Suppression'), |
| style: ElevatedButton.styleFrom( |
| backgroundColor: Colors.green.shade100, |
| foregroundColor: Colors.green.shade900, |
| ), |
| ), |
| }, |
| if (suppressed?.updates.isNotEmpty == true) ...[ |
| const SizedBox(height: 16), |
| |
| Text( |
| 'Update History:', |
| style: Theme.of(context).textTheme.bodySmall?.copyWith( |
| height: 1.4, |
| fontWeight: FontWeight.bold, |
| ), |
| ), |
| const Divider(), |
| for (var update in suppressed!.updates.sortedBy( |
| (u) => u.updateTimestamp, |
| )) |
| Tooltip( |
| message: |
| '${DateTime.fromMillisecondsSinceEpoch(update.updateTimestamp)}', |
| child: Column( |
| crossAxisAlignment: CrossAxisAlignment.start, |
| children: [ |
| Text( |
| '${update.action} by ${update.user}', |
| style: Theme.of( |
| context, |
| ).textTheme.bodySmall?.copyWith(height: 1.4), |
| ), |
| if (update.note?.isNotEmpty == true) |
| Padding( |
| padding: const EdgeInsets.only(left: 8), |
| child: Text( |
| 'Note: ${update.note}', |
| style: Theme.of( |
| context, |
| ).textTheme.bodySmall?.copyWith(height: 1.4), |
| ), |
| ), |
| ], |
| ), |
| ), |
| |
| const Divider(), |
| ], |
| |
| const SizedBox(height: 16), |
| Column( |
| crossAxisAlignment: CrossAxisAlignment.start, |
| children: [ |
| if (suppressed != null) |
| TextButton.icon( |
| onPressed: () async { |
| await launchUrl(Uri.parse(suppressed.issueLink)); |
| }, |
| icon: const Icon(Icons.open_in_new), |
| label: const Text('Tracking Issue'), |
| ), |
| |
| TextButton.icon( |
| onPressed: () async { |
| await launchUrl( |
| widget.qualifiedTask.sourceConfigurationUrl, |
| ); |
| }, |
| icon: const Icon(Icons.open_in_new), |
| label: const Text('Source Config'), |
| ), |
| ], |
| ), |
| ], |
| ), |
| ), |
| ), |
| ); |
| } |
| |
| Future<void> _showSuppressDialog(BuildContext context) async { |
| final issueLinkController = TextEditingController( |
| text: 'https://github.com/flutter/flutter/issues/', |
| ); |
| final noteController = TextEditingController(); |
| final formKey = GlobalKey<FormState>(); |
| |
| await showDialog<void>( |
| context: context, |
| builder: (BuildContext context) { |
| return AlertDialog( |
| title: const Text('Unblock Tree'), |
| content: Form( |
| key: formKey, |
| child: SingleChildScrollView( |
| child: Column( |
| mainAxisSize: MainAxisSize.min, |
| children: [ |
| const Text( |
| 'Suppressing this test means it will no longer block the tree. ' |
| 'Please provide an issue link tracking this failure.', |
| ), |
| const SizedBox(height: 16), |
| TextFormField( |
| controller: issueLinkController, |
| decoration: const InputDecoration( |
| labelText: 'Issue Link (Required)', |
| hintText: 'https://github.com/flutter/flutter/issues/...', |
| border: OutlineInputBorder(), |
| ), |
| validator: (value) { |
| if (value == null || value.isEmpty) { |
| return 'Please enter an issue link'; |
| } |
| if (!Uri.tryParse(value)!.hasAbsolutePath) { |
| return 'Please enter a valid URL'; |
| } |
| return null; |
| }, |
| ), |
| const SizedBox(height: 16), |
| TextFormField( |
| controller: noteController, |
| decoration: const InputDecoration( |
| labelText: 'Note (Optional)', |
| hintText: 'Reason for suppression...', |
| border: OutlineInputBorder(), |
| ), |
| maxLines: 3, |
| ), |
| ], |
| ), |
| ), |
| ), |
| actions: [ |
| TextButton( |
| onPressed: () => Navigator.of(context).pop(), |
| child: const Text('Cancel'), |
| ), |
| ElevatedButton( |
| onPressed: () async { |
| if (formKey.currentState!.validate()) { |
| Navigator.of(context).pop(); |
| await _toggleSuppression( |
| true, |
| issueLink: issueLinkController.text, |
| note: noteController.text, |
| ); |
| } |
| }, |
| child: const Text('Unblock Tree'), |
| ), |
| ], |
| ); |
| }, |
| ); |
| } |
| |
| Future<void> _toggleSuppression( |
| bool suppress, { |
| String? issueLink, |
| String? note, |
| }) async { |
| if (mounted) { |
| setState(() { |
| _isPendingUpdate = true; |
| }); |
| } |
| final success = await widget.buildState.updateTestSuppression( |
| testName: widget.qualifiedTask.task, |
| suppress: suppress, |
| issueLink: issueLink, |
| note: note, |
| ); |
| if (mounted) { |
| setState(() { |
| _isPendingUpdate = false; |
| }); |
| } |
| if (!success) { |
| widget.showSnackBarCallback( |
| const SnackBar(content: Text('Failed to update test suppression')), |
| ); |
| } |
| } |
| } |