Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,61 @@ On snap-ready systems, you can install it on the command-line with:
sudo snap install standup-timer
```

## CI/CD Dashboard

Stand-Up Timer can display the status of CI/CD workflows during the meeting.
Configure it by creating `~/.config/standup-timer/workflows.yaml` or in
`~/snap/standup-timer/current/.config/standup-timer/workflows.yaml` if
running from snap.

### GitHub Actions

```yaml
github_token: ghp_xxxx # optional default token for all GitHub entries

workflows:
- label: "My workflow" # display name (optional)
provider: github
owner: canonical # GitHub organisation or user
repo: my-repo
workflow: ci.yaml # workflow filename
token: ghp_xxxx # per-entry token (overrides github_token)
```

The `token` is only required for private repositories. It can be set once as
`github_token` at the top level and will apply to all GitHub entries that do
not specify their own `token`.

### Jenkins

```yaml
workflows:
- label: "My Jenkins job" # display name (optional)
provider: jenkins
url: https://jenkins.example.com/job/my-job
username: admin # optional
token: abc123 # Jenkins API token (optional)
```

### Mixed example

```yaml
github_token: ghp_xxxx

workflows:
- label: "Checkbox Daily Builds"
provider: github
owner: canonical
repo: checkbox
workflow: checkbox-daily-native-builds.yaml

- label: "Release pipeline"
provider: jenkins
url: https://jenkins.example.com/job/release
username: admin
token: abc123
```

## Community and Support

You can report any issues, bugs, or feature requests on the project's
Expand Down
31 changes: 12 additions & 19 deletions lib/comic.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'widgets/timer_controls.dart';

class Comic {
final String title;
Expand Down Expand Up @@ -55,19 +54,11 @@ Future<Comic> fetchComic() async {
}

class ComicScreen extends StatefulWidget {
final bool showTimerControls;
final bool isRunning;
final bool isDisabled;
final VoidCallback? onToggleTimer;
final VoidCallback? onResetTimer;
final VoidCallback? onNext;

const ComicScreen({
super.key,
this.showTimerControls = false,
this.isRunning = false,
this.isDisabled = false,
this.onToggleTimer,
this.onResetTimer,
this.onNext,
});

@override
Expand Down Expand Up @@ -200,7 +191,7 @@ class _ComicScreenState extends State<ComicScreen> {
Expanded(
child: Container(
width: constraints.maxWidth,
height: constraints.maxHeight - (widget.showTimerControls ? 180 : 120), // Reserve space for controls if needed
height: constraints.maxHeight - (widget.onNext != null ? 180 : 120),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: GestureDetector(
onTap: () => _showImageModal(context, comic),
Expand All @@ -210,7 +201,7 @@ class _ComicScreenState extends State<ComicScreen> {
comic.img,
fit: BoxFit.contain,
width: constraints.maxWidth - 32,
height: constraints.maxHeight - (widget.showTimerControls ? 180 : 120),
height: constraints.maxHeight - (widget.onNext != null ? 180 : 120),
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return const Center(child: CircularProgressIndicator());
Expand All @@ -232,14 +223,16 @@ class _ComicScreenState extends State<ComicScreen> {
textAlign: TextAlign.center,
),
),
if (widget.showTimerControls) ...[
if (widget.onNext != null) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: TimerControls(
isRunning: widget.isRunning,
isDisabled: widget.isDisabled,
onToggleTimer: widget.onToggleTimer ?? () {},
onResetTimer: widget.onResetTimer ?? () {},
child: Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
onPressed: widget.onNext,
icon: const Icon(Icons.dashboard_outlined, size: 16),
label: const Text('View CI Dashboard'),
),
),
),
],
Expand Down
91 changes: 91 additions & 0 deletions lib/providers/workflows_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/ci_provider.dart';
import '../services/config_service.dart';

class WorkflowsState {
final List<CiRun> runs;
final bool isLoading;
final String? configError;
final DateTime? lastFetched;

const WorkflowsState({
this.runs = const [],
this.isLoading = false,
this.configError,
this.lastFetched,
});

WorkflowsState copyWith({
List<CiRun>? runs,
bool? isLoading,
String? configError,
DateTime? lastFetched,
}) {
return WorkflowsState(
runs: runs ?? this.runs,
isLoading: isLoading ?? this.isLoading,
configError: configError ?? this.configError,
lastFetched: lastFetched ?? this.lastFetched,
);
}
}

class WorkflowsNotifier extends Notifier<WorkflowsState> {
Timer? _refreshTimer;
List<CiProvider>? _providers;

@override
WorkflowsState build() {
ref.onDispose(() => _refreshTimer?.cancel());
// Schedule _init after build() returns so that `state` is initialized
// before refresh() tries to read it.
Future.microtask(_init);
return const WorkflowsState(isLoading: true);
}

void _init() {
try {
_providers = ConfigService.loadProviders();
} catch (e) {
state = WorkflowsState(configError: 'Failed to parse config: $e');
return;
}

if (_providers == null) {
state = const WorkflowsState(
configError: 'Create workflows.yaml to monitor CI workflows.',
);
return;
}

if (_providers!.isEmpty) {
state = const WorkflowsState(
configError: 'No workflows listed in workflows.yaml.',
);
return;
}

refresh();
_refreshTimer =
Timer.periodic(const Duration(seconds: 60), (_) => refresh());
}

Future<void> refresh() async {
if (_providers == null) return;
state = state.copyWith(isLoading: true);

final runs = await Future.wait(
_providers!.map((p) => p.fetchLatestRun()),
);

state = state.copyWith(
runs: runs,
isLoading: false,
lastFetched: DateTime.now(),
);
}
}

final workflowsProvider =
NotifierProvider<WorkflowsNotifier, WorkflowsState>(WorkflowsNotifier.new);
Loading