From e9fcb31896e8818588068fe63ee9284d4772e0cd Mon Sep 17 00:00:00 2001 From: Omar Selo Date: Mon, 26 Jun 2023 16:58:09 +0300 Subject: [PATCH] Progress through artefact dialog UI --- frontend/lib/models/test_execution.dart | 52 ++++++- .../lib/ui/dashboard/artefact_dialog.dart | 133 +++++++++++++++--- frontend/lib/ui/footer.dart | 23 +-- frontend/lib/ui/inline_url_text.dart | 42 ++++++ 4 files changed, 210 insertions(+), 40 deletions(-) create mode 100644 frontend/lib/ui/inline_url_text.dart diff --git a/frontend/lib/models/test_execution.dart b/frontend/lib/models/test_execution.dart index f17933d6..b3c28f88 100644 --- a/frontend/lib/models/test_execution.dart +++ b/frontend/lib/models/test_execution.dart @@ -1,4 +1,7 @@ +import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:yaru/yaru.dart'; +import 'package:yaru_icons/yaru_icons.dart'; import 'environment.dart'; @@ -11,10 +14,57 @@ class TestExecution with _$TestExecution { required int id, @JsonKey(name: 'jenkins_link') required String? jenkinsLink, @JsonKey(name: 'c3_link') required String? c3Link, - required String status, + required TestExecutionStatus status, required Environment environment, }) = _TestExecution; factory TestExecution.fromJson(Map json) => _$TestExecutionFromJson(json); } + +enum TestExecutionStatus { + @JsonValue('NOT_STARTED') + notStarted, + @JsonValue('IN_PROGRESS') + inProgress, + @JsonValue('PASSED') + passed, + @JsonValue('FAILED') + failed, + @JsonValue('NOT_TESTED') + notTested; + + String get name { + switch (this) { + case notStarted: + return 'Not Started'; + case inProgress: + return 'In Progress'; + case passed: + return 'Passed'; + case failed: + return 'Failed'; + case notTested: + return 'Not Tested'; + default: + throw Exception('Unknown TestExecutionStatus: $this'); + } + } + + Icon get icon { + switch (this) { + case notStarted: + return const Icon(YaruIcons.media_play); + case inProgress: + return const Icon(YaruIcons.refresh); + case passed: + return const Icon(YaruIcons.ok, color: YaruColors.success); + case failed: + return const Icon(YaruIcons.error, color: YaruColors.red); + case notTested: + return const Icon(YaruIcons.information); + default: + throw Exception('Unknown TestExecutionStatus: $this'); + } + } +} diff --git a/frontend/lib/ui/dashboard/artefact_dialog.dart b/frontend/lib/ui/dashboard/artefact_dialog.dart index ec01573b..c4ba5d74 100644 --- a/frontend/lib/ui/dashboard/artefact_dialog.dart +++ b/frontend/lib/ui/dashboard/artefact_dialog.dart @@ -1,9 +1,13 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:yaru_icons/yaru_icons.dart'; import 'package:yaru_widgets/widgets.dart'; import '../../models/artefact.dart'; +import '../../models/artefact_build.dart'; +import '../../models/test_execution.dart'; import '../../providers/artefact_builds.dart'; import '../spacing.dart'; @@ -15,9 +19,9 @@ class ArtefactDialog extends StatelessWidget { @override Widget build(BuildContext context) { return Dialog( - child: FractionallySizedBox( - heightFactor: 0.8, - widthFactor: 0.8, + child: SizedBox( + height: min(800, MediaQuery.of(context).size.height * 0.8), + width: min(1200, MediaQuery.of(context).size.width * 0.8), child: Padding( padding: const EdgeInsets.symmetric( horizontal: Spacing.level5, @@ -26,11 +30,11 @@ class ArtefactDialog extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _Header(title: artefact.name), + _ArtefactHeader(title: artefact.name), const SizedBox(height: Spacing.level4), - _Info(artefact: artefact), + _ArtefactInfoSection(artefact: artefact), const SizedBox(height: Spacing.level4), - _Environments(artefact: artefact), + Expanded(child: _EnvironmentsSection(artefact: artefact)), ], ), ), @@ -39,8 +43,8 @@ class ArtefactDialog extends StatelessWidget { } } -class _Header extends StatelessWidget { - const _Header({required this.title}); +class _ArtefactHeader extends StatelessWidget { + const _ArtefactHeader({required this.title}); final String title; @@ -70,8 +74,8 @@ class _Header extends StatelessWidget { } } -class _Info extends StatelessWidget { - const _Info({required this.artefact}); +class _ArtefactInfoSection extends StatelessWidget { + const _ArtefactInfoSection({required this.artefact}); final Artefact artefact; @@ -82,24 +86,47 @@ class _Info extends StatelessWidget { ...artefact.source.entries.map((entry) => '${entry.key}: ${entry.value}'), ]; - return Column( + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ - ...artefactDetails - .expand( - (detail) => [ - Text(detail, style: Theme.of(context).textTheme.bodyLarge), - const SizedBox(height: Spacing.level2), - ], - ) - .toList() + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...artefactDetails + .expand( + (detail) => [ + Text(detail, style: Theme.of(context).textTheme.bodyLarge), + const SizedBox(height: Spacing.level2), + ], + ) + .toList(), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: TestExecutionStatus.values + .map( + (status) => Row( + children: [ + status.icon, + const SizedBox(width: Spacing.level2), + Text( + status.name, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ) + .toList(), + ), ], ); } } -class _Environments extends ConsumerWidget { - const _Environments({required this.artefact}); +class _EnvironmentsSection extends ConsumerWidget { + const _EnvironmentsSection({required this.artefact}); final Artefact artefact; @@ -109,9 +136,16 @@ class _Environments extends ConsumerWidget { return artefactBuilds.when( data: (artefactBuilds) => Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Environments', style: Theme.of(context).textTheme.titleLarge), - ...artefactBuilds.map((build) => Text(build.architecture)) + Expanded( + child: ListView.builder( + itemCount: artefactBuilds.length, + itemBuilder: (_, i) => + _ArtefactBuildView(artefactBuild: artefactBuilds[i]), + ), + ), ], ), loading: () => const YaruCircularProgressIndicator(), @@ -121,3 +155,58 @@ class _Environments extends ConsumerWidget { ); } } + +class _ArtefactBuildView extends StatelessWidget { + const _ArtefactBuildView({required this.artefactBuild}); + + final ArtefactBuild artefactBuild; + + @override + Widget build(BuildContext context) { + return YaruExpandable( + expandButtonPosition: YaruExpandableButtonPosition.start, + isExpanded: true, + header: Text( + artefactBuild.architecture, + style: Theme.of(context).textTheme.titleLarge, + ), + child: Padding( + padding: const EdgeInsets.only(left: Spacing.level4), + child: Column( + children: artefactBuild.testExecutions + .map( + (testExecution) => + _TestExecutionView(testExecution: testExecution), + ) + .toList(), + ), + ), + ); + } +} + +class _TestExecutionView extends StatelessWidget { + const _TestExecutionView({required this.testExecution}); + + final TestExecution testExecution; + + @override + Widget build(BuildContext context) { + return YaruExpandable( + header: Row( + children: [ + testExecution.status.icon, + const SizedBox(width: Spacing.level2), + Text( + testExecution.environment.name, + style: Theme.of(context).textTheme.titleLarge, + ), + const Spacer(), + Text('links'), + ], + ), + expandButtonPosition: YaruExpandableButtonPosition.start, + child: Container(), + ); + } +} diff --git a/frontend/lib/ui/footer.dart b/frontend/lib/ui/footer.dart index b6a97c14..d64996cb 100644 --- a/frontend/lib/ui/footer.dart +++ b/frontend/lib/ui/footer.dart @@ -1,8 +1,8 @@ -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'spacing.dart'; +import 'inline_url_text.dart'; class Footer extends StatelessWidget { const Footer({Key? key}) : super(key: key); @@ -26,22 +26,11 @@ class Footer extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - RichText( - text: TextSpan( - style: fontStyle, - children: [ - const TextSpan(text: 'Powered by '), - TextSpan( - text: 'Canonical Ltd.', - style: fontStyle?.apply( - decoration: TextDecoration.underline, - color: Colors.blue, - ), - recognizer: TapGestureRecognizer() - ..onTap = () => launchUrlString('https://canonical.com/'), - ), - ], - ), + InlineUrlText( + url: 'https://canonical.com/', + fontStyle: fontStyle, + urlText: 'Canonical Ltd.', + leadingText: 'Powered by ', ), const SizedBox(height: Spacing.level3), GestureDetector( diff --git a/frontend/lib/ui/inline_url_text.dart b/frontend/lib/ui/inline_url_text.dart new file mode 100644 index 00000000..2d44074b --- /dev/null +++ b/frontend/lib/ui/inline_url_text.dart @@ -0,0 +1,42 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class InlineUrlText extends StatelessWidget { + const InlineUrlText({ + super.key, + this.fontStyle, + required this.url, + this.trailingText, + this.leadingText, + this.urlText, + }); + + final TextStyle? fontStyle; + final String url; + final String? urlText; + final String? trailingText; + final String? leadingText; + + @override + Widget build(BuildContext context) { + return RichText( + text: TextSpan( + style: fontStyle, + children: [ + if (leadingText != null) TextSpan(text: leadingText), + TextSpan( + text: urlText ?? url, + style: fontStyle?.apply( + decoration: TextDecoration.underline, + color: Colors.blue, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => launchUrlString(url), + ), + if (trailingText != null) TextSpan(text: trailingText), + ], + ), + ); + } +}