diff --git a/backend/test_observer/controllers/artefacts/models.py b/backend/test_observer/controllers/artefacts/models.py index 2a89263d..14afdfba 100644 --- a/backend/test_observer/controllers/artefacts/models.py +++ b/backend/test_observer/controllers/artefacts/models.py @@ -19,6 +19,8 @@ # Nadzeya Hutsko from pydantic import BaseModel +from test_observer.data_access.models_enums import TestExecutionStatus + class EnvironmentDTO(BaseModel): id: int @@ -34,6 +36,7 @@ class TestExecutionDTO(BaseModel): jenkins_link: str | None c3_link: str | None environment: EnvironmentDTO + status: TestExecutionStatus class Config: orm_mode = True @@ -41,6 +44,7 @@ class Config: class ArtefactBuildDTO(BaseModel): id: int + architecture: str revision: int | None test_executions: list[TestExecutionDTO] diff --git a/backend/tests/controllers/artefacts/test_artefacts.py b/backend/tests/controllers/artefacts/test_artefacts.py index a0a8de1f..b02bd159 100644 --- a/backend/tests/controllers/artefacts/test_artefacts.py +++ b/backend/tests/controllers/artefacts/test_artefacts.py @@ -43,11 +43,13 @@ def test_get_artefact_builds(db_session: Session, test_client: TestClient): { "id": artefact_build.id, "revision": artefact_build.revision, + "architecture": artefact_build.architecture, "test_executions": [ { "id": test_execution.id, "jenkins_link": test_execution.jenkins_link, "c3_link": test_execution.c3_link, + "status": test_execution.status.value, "environment": { "id": environment.id, "name": environment.name, @@ -77,6 +79,7 @@ def test_get_artefact_builds_only_latest(db_session: Session, test_client: TestC { "id": artefact_build2.id, "revision": artefact_build2.revision, + "architecture": artefact_build2.architecture, "test_executions": [], } ] diff --git a/frontend/analysis_options.yaml b/frontend/analysis_options.yaml index 1d89d184..9d9861b3 100644 --- a/frontend/analysis_options.yaml +++ b/frontend/analysis_options.yaml @@ -5,7 +5,11 @@ include: package:flutter_lints/flutter.yaml analyzer: plugins: - custom_lint - exclude: ["lib/models/*.g.dart", "lib/models/*.freezed.dart"] + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" + errors: + invalid_annotation_target: ignore linter: rules: diff --git a/frontend/lib/models/artefact.dart b/frontend/lib/models/artefact.dart index 185ae20f..c938a573 100644 --- a/frontend/lib/models/artefact.dart +++ b/frontend/lib/models/artefact.dart @@ -6,6 +6,7 @@ part 'artefact.g.dart'; @freezed class Artefact with _$Artefact { const factory Artefact({ + required int id, required String name, required String version, required Map source, diff --git a/frontend/lib/models/artefact_build.dart b/frontend/lib/models/artefact_build.dart new file mode 100644 index 00000000..f87c87ad --- /dev/null +++ b/frontend/lib/models/artefact_build.dart @@ -0,0 +1,33 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'test_execution.dart'; + +part 'artefact_build.freezed.dart'; +part 'artefact_build.g.dart'; + +@freezed +class ArtefactBuild with _$ArtefactBuild { + const ArtefactBuild._(); + + const factory ArtefactBuild({ + required int id, + required String architecture, + required int? revision, + @JsonKey(name: 'test_executions') + required List testExecutions, + }) = _ArtefactBuild; + + factory ArtefactBuild.fromJson(Map json) => + _$ArtefactBuildFromJson(json); + + Map get testExecutionStatusCounts { + final counts = {for (final status in TestExecutionStatus.values) status: 0}; + + for (final testExecution in testExecutions) { + final status = testExecution.status; + counts[status] = (counts[status] ?? 0) + 1; + } + + return counts; + } +} diff --git a/frontend/lib/models/environment.dart b/frontend/lib/models/environment.dart new file mode 100644 index 00000000..0dc9df15 --- /dev/null +++ b/frontend/lib/models/environment.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'environment.freezed.dart'; +part 'environment.g.dart'; + +@freezed +class Environment with _$Environment { + const factory Environment({ + required int id, + required String name, + required String architecture, + }) = _Environment; + + factory Environment.fromJson(Map json) => + _$EnvironmentFromJson(json); +} diff --git a/frontend/lib/models/test_execution.dart b/frontend/lib/models/test_execution.dart new file mode 100644 index 00000000..76ee7067 --- /dev/null +++ b/frontend/lib/models/test_execution.dart @@ -0,0 +1,67 @@ +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'; + +part 'test_execution.freezed.dart'; +part 'test_execution.g.dart'; + +@freezed +class TestExecution with _$TestExecution { + const factory TestExecution({ + required int id, + @JsonKey(name: 'jenkins_link') required String? jenkinsLink, + @JsonKey(name: 'c3_link') required String? c3Link, + required TestExecutionStatus status, + required Environment environment, + }) = _TestExecution; + + factory TestExecution.fromJson(Map json) => + _$TestExecutionFromJson(json); +} + +enum TestExecutionStatus { + @JsonValue('FAILED') + failed, + @JsonValue('NOT_STARTED') + notStarted, + @JsonValue('NOT_TESTED') + notTested, + @JsonValue('IN_PROGRESS') + inProgress, + @JsonValue('PASSED') + passed; + + 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'; + } + } + + Icon get icon { + const size = 20.0; + switch (this) { + case notStarted: + return const Icon(YaruIcons.media_play, size: size); + case inProgress: + return const Icon(YaruIcons.refresh, size: size); + case passed: + return const Icon(YaruIcons.ok, color: YaruColors.success, size: size); + case failed: + return const Icon(YaruIcons.error, color: YaruColors.red, size: size); + case notTested: + return const Icon(YaruIcons.information, size: size); + } + } +} diff --git a/frontend/lib/providers/artefact_builds.dart b/frontend/lib/providers/artefact_builds.dart new file mode 100644 index 00000000..e1695b34 --- /dev/null +++ b/frontend/lib/providers/artefact_builds.dart @@ -0,0 +1,20 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../models/artefact_build.dart'; +import 'dio.dart'; + +part 'artefact_builds.g.dart'; + +@riverpod +Future> artefactBuilds( + ArtefactBuildsRef ref, + int artefactId, +) async { + final dio = ref.watch(dioProvider); + + final response = await dio.get('/v1/artefacts/$artefactId/builds'); + final List artefactBuildsJson = response.data; + final artefactBuilds = + artefactBuildsJson.map((json) => ArtefactBuild.fromJson(json)).toList(); + return artefactBuilds; +} diff --git a/frontend/lib/providers/name_of_selected_stage.dart b/frontend/lib/providers/name_of_selected_stage.dart new file mode 100644 index 00000000..774fb243 --- /dev/null +++ b/frontend/lib/providers/name_of_selected_stage.dart @@ -0,0 +1,10 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'name_of_selected_stage.g.dart'; + +@riverpod +String nameOfSelectedStage(NameOfSelectedStageRef ref) { + throw Exception( + 'Name of selected stage not set yet, need to override provider', + ); +} diff --git a/frontend/lib/providers/names_of_stages.dart b/frontend/lib/providers/names_of_stages.dart new file mode 100644 index 00000000..17424958 --- /dev/null +++ b/frontend/lib/providers/names_of_stages.dart @@ -0,0 +1,8 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'names_of_stages.g.dart'; + +@riverpod +List namesOfStages(NamesOfStagesRef ref) { + throw Exception('Names of stages not set yet, need to override provider'); +} diff --git a/frontend/lib/ui/dashboard/artefact_dialog.dart b/frontend/lib/ui/dashboard/artefact_dialog.dart new file mode 100644 index 00000000..a17a3e51 --- /dev/null +++ b/frontend/lib/ui/dashboard/artefact_dialog.dart @@ -0,0 +1,302 @@ +import 'dart:math'; + +import 'package:dartx/dartx.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intersperse/intersperse.dart'; +import 'package:yaru/yaru.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 '../../providers/name_of_selected_stage.dart'; +import '../../providers/names_of_stages.dart'; +import '../inline_url_text.dart'; +import '../spacing.dart'; + +class ArtefactDialog extends StatelessWidget { + const ArtefactDialog({super.key, required this.artefact}); + + final Artefact artefact; + + @override + Widget build(BuildContext context) { + return SelectionArea( + child: Dialog( + 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, + vertical: Spacing.level3, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ArtefactHeader(title: artefact.name), + const SizedBox(height: Spacing.level4), + _ArtefactInfoSection(artefact: artefact), + const SizedBox(height: Spacing.level4), + Expanded(child: _EnvironmentsSection(artefact: artefact)), + ], + ), + ), + ), + ), + ); + } +} + +class _ArtefactHeader extends StatelessWidget { + const _ArtefactHeader({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: Spacing.level4), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, style: Theme.of(context).textTheme.headlineLarge), + InkWell( + child: const Icon( + YaruIcons.window_close, + size: 60, + ), + onTap: () => Navigator.pop(context), + ) + ], + ), + ); + } +} + +class _ArtefactInfoSection extends StatelessWidget { + const _ArtefactInfoSection({required this.artefact}); + + final Artefact artefact; + + @override + Widget build(BuildContext context) { + final artefactDetails = [ + 'version: ${artefact.version}', + ...artefact.source.entries.map((entry) => '${entry.key}: ${entry.value}'), + ]; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _StagesRow(), + const SizedBox(height: Spacing.level3), + ...artefactDetails + .map( + (detail) => Text( + detail, + style: Theme.of(context).textTheme.bodyLarge, + ), + ) + .intersperse(const SizedBox(height: Spacing.level3)), + ], + ), + 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 + ?.apply(color: YaruColors.warmGrey), + ), + ], + ), + ) + .intersperse(const SizedBox(height: Spacing.level2)) + .toList(), + ), + ], + ); + } +} + +class _StagesRow extends ConsumerWidget { + const _StagesRow(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedStageName = ref.watch(nameOfSelectedStageProvider); + final namesOfStages = ref.watch(namesOfStagesProvider); + + final stageNamesWidgets = []; + bool passedSelectedStage = false; + for (final stageName in namesOfStages) { + Color fontColor = YaruColors.warmGrey; + if (passedSelectedStage) { + fontColor = YaruColors.textGrey; + } else if (stageName == selectedStageName) { + passedSelectedStage = true; + fontColor = YaruColors.orange; + } + + stageNamesWidgets.add( + Text( + stageName.capitalize(), + style: Theme.of(context).textTheme.bodyLarge?.apply(color: fontColor), + ), + ); + } + + return Row( + children: stageNamesWidgets + .intersperse( + Text(' > ', style: Theme.of(context).textTheme.bodyLarge), + ) + .toList(), + ); + } +} + +class _EnvironmentsSection extends ConsumerWidget { + const _EnvironmentsSection({required this.artefact}); + + final Artefact artefact; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final artefactBuilds = ref.watch(artefactBuildsProvider(artefact.id)); + + return artefactBuilds.when( + data: (artefactBuilds) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Environments', style: Theme.of(context).textTheme.titleLarge), + Expanded( + child: ListView.builder( + itemCount: artefactBuilds.length, + itemBuilder: (_, i) => + _ArtefactBuildView(artefactBuild: artefactBuilds[i]), + ), + ), + ], + ), + loading: () => const Center(child: YaruCircularProgressIndicator()), + error: (error, stackTrace) { + return Center(child: Text('Error: $error')); + }, + ); + } +} + +class _ArtefactBuildView extends StatelessWidget { + const _ArtefactBuildView({required this.artefactBuild}); + + final ArtefactBuild artefactBuild; + + @override + Widget build(BuildContext context) { + final revisionText = + artefactBuild.revision == null ? '' : ' (${artefactBuild.revision})'; + + return YaruExpandable( + expandButtonPosition: YaruExpandableButtonPosition.start, + isExpanded: true, + header: Row( + children: [ + Text( + artefactBuild.architecture + revisionText, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(width: Spacing.level4), + ...artefactBuild.testExecutionStatusCounts.entries + .map( + (entry) => Row( + children: [ + entry.key.icon, + const SizedBox(width: Spacing.level2), + Text( + entry.value.toString(), + style: Theme.of(context).textTheme.titleLarge, + ), + ], + ), + ) + .intersperse(const SizedBox(width: Spacing.level4)), + ], + ), + 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) { + final jenkinsLink = testExecution.jenkinsLink; + final c3Link = testExecution.c3Link; + + return YaruExpandable( + header: Row( + children: [ + testExecution.status.icon, + const SizedBox(width: Spacing.level4), + Text( + testExecution.environment.name, + style: Theme.of(context).textTheme.titleLarge, + ), + const Spacer(), + Row( + children: [ + if (jenkinsLink != null) + InlineUrlText( + url: jenkinsLink, + urlText: 'Jenkins', + ), + const SizedBox(width: Spacing.level3), + if (c3Link != null) + InlineUrlText( + url: c3Link, + urlText: 'C3', + ), + ], + ), + ], + ), + expandButtonPosition: YaruExpandableButtonPosition.start, + child: const SizedBox.shrink(), + ); + } +} diff --git a/frontend/lib/ui/dashboard/dashboard_body.dart b/frontend/lib/ui/dashboard/dashboard_body.dart index 3a6a9252..b757e301 100644 --- a/frontend/lib/ui/dashboard/dashboard_body.dart +++ b/frontend/lib/ui/dashboard/dashboard_body.dart @@ -1,8 +1,14 @@ +import 'package:dartx/dartx.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intersperse/intersperse.dart'; import '../../models/artefact.dart'; import '../../models/stage.dart'; +import '../../providers/name_of_selected_stage.dart'; +import '../../providers/names_of_stages.dart'; import '../spacing.dart'; +import 'artefact_dialog.dart'; class DashboardBody extends StatelessWidget { const DashboardBody({Key? key, required this.stages}) : super(key: key); @@ -11,13 +17,20 @@ class DashboardBody extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView.separated( - padding: - const EdgeInsets.symmetric(horizontal: Spacing.pageHorizontalPadding), - scrollDirection: Axis.horizontal, - itemBuilder: (_, i) => _StageColumn(stage: stages[i]), - separatorBuilder: (_, __) => const SizedBox(width: Spacing.level5), - itemCount: stages.length, + return ProviderScope( + overrides: [ + namesOfStagesProvider + .overrideWithValue(stages.map((stage) => stage.name).toList()) + ], + child: ListView.separated( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.pageHorizontalPadding, + ), + scrollDirection: Axis.horizontal, + itemBuilder: (_, i) => _StageColumn(stage: stages[i]), + separatorBuilder: (_, __) => const SizedBox(width: Spacing.level5), + itemCount: stages.length, + ), ); } } @@ -29,72 +42,79 @@ class _StageColumn extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - stage.name, - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: Spacing.level4), - Expanded( - child: SizedBox( - width: _ArtefactCard.width, - child: ListView.separated( - itemBuilder: (_, i) => - _ArtefactCard(artefact: stage.artefacts[i]), - separatorBuilder: (_, __) => - const SizedBox(height: Spacing.level4), - itemCount: stage.artefacts.length, + return ProviderScope( + overrides: [nameOfSelectedStageProvider.overrideWithValue(stage.name)], + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + stage.name.capitalize(), + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: Spacing.level4), + Expanded( + child: SizedBox( + width: _ArtefactCard.width, + child: ListView.separated( + itemBuilder: (_, i) => + _ArtefactCard(artefact: stage.artefacts[i]), + separatorBuilder: (_, __) => + const SizedBox(height: Spacing.level4), + itemCount: stage.artefacts.length, + ), ), ), - ), - ], + ], + ), ); } } -class _ArtefactCard extends StatelessWidget { +class _ArtefactCard extends ConsumerWidget { const _ArtefactCard({Key? key, required this.artefact}) : super(key: key); final Artefact artefact; static const double width = 320; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final artefactDetails = [ 'version: ${artefact.version}', ...artefact.source.entries.map((entry) => '${entry.key}: ${entry.value}'), ]; - return Card( - margin: const EdgeInsets.all(0), - elevation: 0, - shape: RoundedRectangleBorder( - side: BorderSide(color: Theme.of(context).colorScheme.outline), - borderRadius: BorderRadius.circular(2.25), + return GestureDetector( + onTap: () => showDialog( + context: context, + builder: (_) => ProviderScope( + parent: ProviderScope.containerOf(context), + child: ArtefactDialog(artefact: artefact), + ), ), - child: Container( - width: width, - height: 156, - padding: const EdgeInsets.all(Spacing.level4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - artefact.name, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: Spacing.level2), - ...artefactDetails - .expand( - (detail) => [ - Text(detail), - const SizedBox(height: Spacing.level2), - ], - ) - .toList() - ], + child: Card( + margin: const EdgeInsets.all(0), + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide(color: Theme.of(context).colorScheme.outline), + borderRadius: BorderRadius.circular(2.25), + ), + child: Container( + width: width, + height: 156, + padding: const EdgeInsets.all(Spacing.level4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + artefact.name, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: Spacing.level2), + ...artefactDetails + .map((detail) => Text(detail)) + .intersperse(const SizedBox(height: Spacing.level2)), + ], + ), ), ), ); diff --git a/frontend/lib/ui/dashboard/dashboard_header.dart b/frontend/lib/ui/dashboard/dashboard_header.dart index 7603f614..aa773908 100644 --- a/frontend/lib/ui/dashboard/dashboard_header.dart +++ b/frontend/lib/ui/dashboard/dashboard_header.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:yaru/yaru.dart'; -import 'package:yaru_icons/yaru_icons.dart'; +import '../../models/test_execution.dart'; import '../spacing.dart'; class DashboardHeader extends StatelessWidget { @@ -29,22 +28,22 @@ class DashboardHeader extends StatelessWidget { style: Theme.of(context).textTheme.headlineLarge, ), const SizedBox(height: Spacing.level4), - const Row( + Row( children: [ _LegendEntry( - icon: Icon(YaruIcons.ok, color: YaruColors.success), - text: 'Passed', - ), - SizedBox(width: Spacing.level4), - _LegendEntry( - icon: Icon(YaruIcons.error, color: YaruColors.red), + icon: TestExecutionStatus.failed.icon, text: 'Failed', ), - SizedBox(width: Spacing.level4), + const SizedBox(width: Spacing.level4), _LegendEntry( - icon: Icon(YaruIcons.information), + icon: TestExecutionStatus.notTested.icon, text: 'No result', ), + const SizedBox(width: Spacing.level4), + _LegendEntry( + icon: TestExecutionStatus.passed.icon, + text: 'Passed', + ), ], ), ], 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), + ], + ), + ); + } +} diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 6cb75eea..06c61e50 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -217,6 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.1" + dartx: + dependency: "direct main" + description: + name: dartx + sha256: "45d7176701f16c5a5e00a4798791c1964bc231491b879369c818dd9a9c764871" + url: "https://pub.dev" + source: hosted + version: "1.1.0" dependency_validator: dependency: "direct dev" description: @@ -376,6 +384,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + intersperse: + dependency: "direct main" + description: + name: intersperse + sha256: "2f8a905c96f6cbba978644a3d5b31b8d86ddc44917662df7d27a61f3df66a576" + url: "https://pub.dev" + source: hosted + version: "2.0.0" io: dependency: transitive description: @@ -669,6 +685,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.1" + time: + dependency: transitive + description: + name: time + sha256: "83427e11d9072e038364a5e4da559e85869b227cf699a541be0da74f14140124" + url: "https://pub.dev" + source: hosted + version: "2.1.3" timing: dependency: transitive description: diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 595a07b3..77d3e198 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -5,12 +5,14 @@ version: 1.0.0+1 environment: sdk: ">=2.19.0-255.2.beta <3.0.0" dependencies: + dartx: ^1.1.0 dio: ^5.1.2 flutter: sdk: flutter flutter_riverpod: ^2.3.6 freezed_annotation: ^2.2.0 go_router: ^7.0.0 + intersperse: ^2.0.0 json_annotation: ^4.8.1 riverpod_annotation: ^2.1.1 url_launcher: ^6.1.10