Skip to content
Open
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
62 changes: 62 additions & 0 deletions frontend/lib/models/user_notification.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright 2024 Canonical Ltd.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License version 3, as
// published by the Free Software Foundation.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-FileCopyrightText: Copyright 2024 Canonical Ltd.
// SPDX-License-Identifier: GPL-3.0-only

import 'package:freezed_annotation/freezed_annotation.dart';

part 'user_notification.freezed.dart';
part 'user_notification.g.dart';

enum NotificationType {
@JsonValue('USER_ASSIGNED_ARTEFACT_REVIEW')
userAssignedArtefactReview,
@JsonValue('USER_ASSIGNED_ENVIRONMENT_REVIEW')
userAssignedEnvironmentReview,
}

@freezed
abstract class UserNotification with _$UserNotification {
const factory UserNotification({
required int id,
@JsonKey(name: 'user_id') required int userId,
@JsonKey(name: 'notification_type') required NotificationType notificationType,
@JsonKey(name: 'target_url') String? targetUrl,
@JsonKey(name: 'created_at') required DateTime createdAt,
@JsonKey(name: 'dismissed_at') DateTime? dismissedAt,
}) = _UserNotification;

factory UserNotification.fromJson(Map<String, Object?> json) =>
_$UserNotificationFromJson(json);
}

@freezed
abstract class UserNotifications with _$UserNotifications {
const factory UserNotifications({
required List<UserNotification> notifications,
}) = _UserNotifications;

factory UserNotifications.fromJson(Map<String, Object?> json) =>
_$UserNotificationsFromJson(json);
}

extension NotificationTypeExtension on NotificationType {
String get displayTitle {
switch (this) {
case NotificationType.userAssignedArtefactReview:
return 'Artefact Review Assigned';
case NotificationType.userAssignedEnvironmentReview:
return 'Environment Review Assigned';
}
}
}
32 changes: 32 additions & 0 deletions frontend/lib/providers/notifications.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright 2024 Canonical Ltd.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License version 3, as
// published by the Free Software Foundation.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-FileCopyrightText: Copyright 2024 Canonical Ltd.
// SPDX-License-Identifier: GPL-3.0-only

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../models/user_notification.dart';
import 'api.dart';

part 'notifications.g.dart';

@riverpod
Future<UserNotifications> notifications(Ref ref) async {
return ref.watch(apiProvider).getNotifications();
}

@riverpod
Future<int> unreadNotificationCount(Ref ref) async {
return ref.watch(apiProvider).getUnreadNotificationCount();
}
17 changes: 17 additions & 0 deletions frontend/lib/repositories/api_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import '../models/test_issue.dart';
import '../models/test_result.dart';
import '../models/test_results_filters.dart';
import '../models/user.dart';
import '../models/user_notification.dart';

class ApiRepository {
final Dio dio;
Expand Down Expand Up @@ -583,4 +584,20 @@ class ApiRepository {
);
return IssueWithContext.fromJson(response.data);
}

Future<UserNotifications> getNotifications() async {
final response = await dio.get('/v1/notifications');
return UserNotifications.fromJson(response.data);
}

Future<int> getUnreadNotificationCount() async {
final response = await dio.get('/v1/notifications/unread-count');
return response.data as int;
}

Future<UserNotification> markNotificationAsRead(int notificationId) async {
final response =
await dio.patch('/v1/notifications/$notificationId/read');
return UserNotification.fromJson(response.data);
}
}
9 changes: 8 additions & 1 deletion frontend/lib/routing.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ import 'models/family_name.dart';
import 'frontend_config.dart';
import 'ui/artefact_page/artefact_page.dart';
import 'ui/dashboard/dashboard.dart';
import 'ui/issue_page/issue_page.dart';
import 'ui/issues_page/issues_page.dart';
import 'ui/notifications_page/notifications_page.dart';
import 'ui/skeleton.dart';
import 'ui/test_results_page/test_results_page.dart';
import 'ui/issue_page/issue_page.dart';

final appRouter = GoRouter(
routes: [
Expand Down Expand Up @@ -110,6 +111,12 @@ final appRouter = GoRouter(
),
),
),
GoRoute(
path: '/notifications',
pageBuilder: (_, __) => const NoTransitionPage(
child: NotificationsPage(),
),
),
],
),
],
Expand Down
64 changes: 64 additions & 0 deletions frontend/lib/ui/navbar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import 'package:yaru/yaru.dart';

import '../providers/api.dart';
import '../providers/current_user.dart';
import '../providers/notifications.dart';
import '../routing.dart';
import '../frontend_config.dart';
import 'spacing.dart';
Expand Down Expand Up @@ -88,6 +89,7 @@ class Navbar extends ConsumerWidget {
title: 'Issues',
route: '/issues',
),
if (user != null) const _NotificationBellIcon(),
_NavbarDropdownEntry(
label: 'Help',
dropdownChildren: [
Expand Down Expand Up @@ -278,3 +280,65 @@ class _NavbarButton extends StatelessWidget {
);
}
}

class _NotificationBellIcon extends ConsumerWidget {
const _NotificationBellIcon();

@override
Widget build(BuildContext context, WidgetRef ref) {
final unreadCountAsync = ref.watch(unreadNotificationCountProvider);

return InkWell(
onTap: () => context.go('/notifications'),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.level4,
vertical: Spacing.level3,
),
child: Stack(
clipBehavior: Clip.none,
children: [
Icon(
YaruIcons.notification,
color: Colors.white,
size: 20,
),
unreadCountAsync.when(
data: (count) {
if (count == 0) return const SizedBox.shrink();
return Positioned(
right: -8,
top: -8,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error,
shape: BoxShape.circle,
),
constraints: const BoxConstraints(
minWidth: 18,
minHeight: 18,
),
child: Center(
child: Text(
count > 99 ? '99+' : count.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
);
},
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
),
],
Comment on lines +291 to +338
),
),
);
}
}

2 changes: 1 addition & 1 deletion frontend/lib/ui/notification.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ void showNotification(
content: Text(message, style: TextStyle(color: textColor)),
duration: duration,
width: 400,
backgroundColor: backgroundColor ?? Colors.green,
backgroundColor: Colors.white,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
Loading
Loading