feat(notifications): day sheet notification types

Add daySheetId field to NotificationItem model (Brick-managed), extract
notificationTitle/notificationIcon to top-level testable functions, add
five day-sheet notification cases, and wire tap/banner routing for
day-sheet detail and approvals tab.
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-09-28 14:54:19 +08:00
parent 54ce528b02
commit 5af0ce62d9
8 changed files with 219 additions and 38 deletions
@@ -23,6 +23,9 @@ Future<NotificationItem> _$NotificationItemFromSupabase(
? null
: data['announcement_id'] as String?,
messageId: data['message_id'] == null ? null : data['message_id'] as int?,
daySheetId: data['day_sheet_id'] == null
? null
: data['day_sheet_id'] as String?,
type: data['type'] as String,
createdAt: DateTime.parse(data['created_at'] as String),
readAt: data['read_at'] == null
@@ -49,6 +52,7 @@ Future<Map<String, dynamic>> _$NotificationItemToSupabase(
'it_service_request_id': instance.itServiceRequestId,
'announcement_id': instance.announcementId,
'message_id': instance.messageId,
'day_sheet_id': instance.daySheetId,
'type': instance.type,
'created_at': instance.createdAt.toIso8601String(),
'read_at': instance.readAt?.toIso8601String(),
@@ -77,6 +81,9 @@ Future<NotificationItem> _$NotificationItemFromSqlite(
? null
: data['announcement_id'] as String?,
messageId: data['message_id'] == null ? null : data['message_id'] as int?,
daySheetId: data['day_sheet_id'] == null
? null
: data['day_sheet_id'] as String?,
type: data['type'] as String,
createdAt: DateTime.parse(data['created_at'] as String),
readAt: data['read_at'] == null
@@ -103,6 +110,7 @@ Future<Map<String, dynamic>> _$NotificationItemToSqlite(
'it_service_request_id': instance.itServiceRequestId,
'announcement_id': instance.announcementId,
'message_id': instance.messageId,
'day_sheet_id': instance.daySheetId,
'type': instance.type,
'created_at': instance.createdAt.toIso8601String(),
'read_at': instance.readAt?.toIso8601String(),
@@ -160,6 +168,10 @@ class NotificationItemAdapter
association: false,
columnName: 'message_id',
),
'daySheetId': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'day_sheet_id',
),
'type': const RuntimeSupabaseColumnDefinition(
association: false,
columnName: 'type',
@@ -245,6 +257,12 @@ class NotificationItemAdapter
iterable: false,
type: int,
),
'daySheetId': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'day_sheet_id',
iterable: false,
type: String,
),
'type': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'type',
+6
View File
@@ -11,6 +11,12 @@ import 'package:brick_supabase/brick_supabase.dart';
// ignore: unused_import, unused_shown_name, unnecessary_import
import '../utils/app_time.dart';
// ignore: unused_import, unused_shown_name, unnecessary_import
import 'package:tasq/utils/programmer_daily_time.dart';
// ignore: unused_import, unused_shown_name, unnecessary_import
import 'package:tasq/utils/programmer_day_rows.dart';
// ignore: unused_import, unused_shown_name, unnecessary_import
import '../utils/programmer_daily_time.dart';
// ignore: unused_import, unused_shown_name, unnecessary_import
import 'package:tasq/models/team_member.dart';// GENERATED CODE DO NOT EDIT
// ignore: unused_import
import 'dart:convert';
@@ -0,0 +1,36 @@
// GENERATED CODE EDIT WITH CAUTION
// THIS FILE **WILL NOT** BE REGENERATED
// This file should be version controlled and can be manually edited.
part of 'schema.g.dart';
// While migrations are intelligently created, the difference between some commands, such as
// DropTable vs. RenameTable, cannot be determined. For this reason, please review migrations after
// they are created to ensure the correct inference was made.
// The migration version must **always** mirror the file name
const List<MigrationCommand> _migration_20260928065256_up = [
InsertColumn('day_sheet_id', Column.varchar, onTable: 'NotificationItem')
];
const List<MigrationCommand> _migration_20260928065256_down = [
DropColumn('day_sheet_id', onTable: 'NotificationItem')
];
//
// DO NOT EDIT BELOW THIS LINE
//
@Migratable(
version: '20260928065256',
up: _migration_20260928065256_up,
down: _migration_20260928065256_down,
)
class Migration20260928065256 extends Migration {
const Migration20260928065256()
: super(
version: 20260928065256,
up: _migration_20260928065256_up,
down: _migration_20260928065256_down,
);
}
+6 -3
View File
@@ -1,22 +1,24 @@
// GENERATED CODE DO NOT EDIT
// This file should be version controlled
import 'package:brick_sqlite/db.dart';
part '20260927030348.migration.dart';
part '20260928065256.migration.dart';
part '20260411041808.migration.dart';
part '20260411042152.migration.dart';
part '20260926162926.migration.dart';
part '20260927030348.migration.dart';
/// All intelligently-generated migrations from all `@Migratable` classes on disk
final migrations = <Migration>{
const Migration20260927030348(),
const Migration20260928065256(),
const Migration20260411041808(),
const Migration20260411042152(),
const Migration20260926162926(),
const Migration20260927030348(),
};
/// A consumable database structure including the latest generated migration.
final schema = Schema(
20260926162926,
20260927030348,
generatorVersion: 1,
tables: <SchemaTable>{
SchemaTable(
@@ -271,6 +273,7 @@ final schema = Schema(
SchemaColumn('it_service_request_id', Column.varchar),
SchemaColumn('announcement_id', Column.varchar),
SchemaColumn('message_id', Column.integer),
SchemaColumn('day_sheet_id', Column.varchar),
SchemaColumn('type', Column.varchar),
SchemaColumn('created_at', Column.datetime),
SchemaColumn('read_at', Column.datetime),
+4
View File
@@ -19,6 +19,8 @@ class NotificationItem extends OfflineFirstWithSupabaseModel {
final String? itServiceRequestId;
final String? announcementId;
final int? messageId;
@Sqlite(name: 'day_sheet_id')
final String? daySheetId;
final String type;
final DateTime createdAt;
final DateTime? readAt;
@@ -38,6 +40,7 @@ class NotificationItem extends OfflineFirstWithSupabaseModel {
required this.itServiceRequestId,
this.announcementId,
required this.messageId,
this.daySheetId,
required this.type,
required this.createdAt,
required this.readAt,
@@ -55,6 +58,7 @@ class NotificationItem extends OfflineFirstWithSupabaseModel {
itServiceRequestId: map['it_service_request_id'] as String?,
announcementId: map['announcement_id'] as String?,
messageId: map['message_id'] as int?,
daySheetId: map['day_sheet_id'] as String?,
type: map['type'] as String? ?? 'mention',
createdAt: AppTime.parse(map['created_at'] as String),
readAt: map['read_at'] == null
@@ -16,6 +16,48 @@ import '../../widgets/responsive_body.dart';
import '../../theme/app_surfaces.dart';
import '../../theme/m3_motion.dart';
String notificationTitle(String type, String actorName) {
return switch (type) {
'assignment' => '$actorName assigned you',
'created' => '$actorName created a new item',
'swap_request' => '$actorName requested a shift swap',
'swap_update' => '$actorName updated a swap request',
'announcement' => '$actorName posted an announcement',
'announcement_comment' => '$actorName commented on an announcement',
'it_job_reminder' => 'IT Job submission reminder',
'isr_approved' => 'IT Service Request approved',
'isr_status_changed' => 'IT Service Request status updated',
'isr_assigned' => '$actorName assigned you an IT Service Request',
'day_sheet_submitted' => '$actorName submitted a day sheet',
'day_sheet_disapproved' => 'Day sheet disapproved',
'day_sheet_justified' => '$actorName justified a day sheet',
'day_sheet_approved' => 'Day sheet approved',
'day_sheet_digest' => 'Day sheets awaiting review',
_ => '$actorName mentioned you',
};
}
IconData notificationIcon(String type) {
return switch (type) {
'assignment' => Icons.assignment_ind_outlined,
'created' => Icons.campaign_outlined,
'swap_request' => Icons.swap_horiz,
'swap_update' => Icons.update,
'announcement' => Icons.campaign,
'announcement_comment' => Icons.comment_outlined,
'it_job_reminder' => Icons.print,
'isr_approved' => Icons.check_circle_outline,
'isr_status_changed' => Icons.sync_alt_outlined,
'isr_assigned' => Icons.engineering_outlined,
'day_sheet_submitted' => Icons.assignment_turned_in_outlined,
'day_sheet_disapproved' => Icons.report_outlined,
'day_sheet_justified' => Icons.reply_outlined,
'day_sheet_approved' => Icons.verified_outlined,
'day_sheet_digest' => Icons.inbox_outlined,
_ => Icons.alternate_email,
};
}
class NotificationsScreen extends ConsumerStatefulWidget {
const NotificationsScreen({super.key});
@@ -80,42 +122,11 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
};
}
String _notificationTitle(String type, String actorName) {
return switch (type) {
'assignment' => '$actorName assigned you',
'created' => '$actorName created a new item',
'swap_request' => '$actorName requested a shift swap',
'swap_update' => '$actorName updated a swap request',
'announcement' => '$actorName posted an announcement',
'announcement_comment' => '$actorName commented on an announcement',
'it_job_reminder' => 'IT Job submission reminder',
'isr_approved' => 'IT Service Request approved',
'isr_status_changed' => 'IT Service Request status updated',
'isr_assigned' => '$actorName assigned you an IT Service Request',
_ => '$actorName mentioned you',
};
}
IconData _notificationIcon(String type) {
return switch (type) {
'assignment' => Icons.assignment_ind_outlined,
'created' => Icons.campaign_outlined,
'swap_request' => Icons.swap_horiz,
'swap_update' => Icons.update,
'announcement' => Icons.campaign,
'announcement_comment' => Icons.comment_outlined,
'it_job_reminder' => Icons.print,
'isr_approved' => Icons.check_circle_outline,
'isr_status_changed' => Icons.sync_alt_outlined,
'isr_assigned' => Icons.engineering_outlined,
_ => Icons.alternate_email,
};
}
Future<void> _handleTap(BuildContext context, NotificationItem item) async {
final ticketId = item.ticketId;
final taskId = item.taskId;
final isrId = item.itServiceRequestId;
final daySheetId = item.daySheetId;
if (ticketId != null) {
await ref
@@ -136,6 +147,10 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
context.go('/tickets/$ticketId');
} else if (isrId != null) {
context.go('/it-service-requests/$isrId');
} else if (item.type == 'day_sheet_digest') {
context.go('/programmer-tasks?tab=approvals');
} else if (daySheetId != null) {
context.go('/programmer-tasks/day-sheets/$daySheetId');
}
}
@@ -248,8 +263,8 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
animDelay: Duration(
milliseconds: animIndexes[index].clamp(0, 6) * 50,
),
title: _notificationTitle(item.type, actorName),
icon: _notificationIcon(item.type),
title: notificationTitle(item.type, actorName),
icon: notificationIcon(item.type),
iconContainerColor:
_iconContainerColor(context, item.type),
iconForegroundColor:
@@ -331,6 +346,7 @@ class _NotificationCard extends StatelessWidget {
if (item.announcementId != null) return 'Announcement';
if (item.taskId != null) return taskTitle ?? 'Task';
if (item.itServiceRequestId != null) return 'IT Service Request';
if (item.daySheetId != null) return 'Day Sheet';
return ticketSubject ?? 'Ticket';
}
+12 -1
View File
@@ -106,6 +106,11 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
'isr_approved' => 'IT Service Request approved',
'isr_status_changed' => 'IT Service Request updated',
'isr_assigned' => 'New IT Service Request assigned',
'day_sheet_submitted' => 'Day sheet submitted',
'day_sheet_disapproved' => 'Day sheet disapproved',
'day_sheet_justified' => 'Day sheet justified',
'day_sheet_approved' => 'Day sheet approved',
'day_sheet_digest' => 'Day sheets awaiting review',
_ => 'New notification',
};
@@ -121,7 +126,13 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
action: SnackBarAction(
label: 'View',
onPressed: () => _goToDetail(
route: item.announcementId != null ? '/announcements' : null,
route: item.announcementId != null
? '/announcements'
: item.type == 'day_sheet_digest'
? '/programmer-tasks?tab=approvals'
: item.daySheetId != null
? '/programmer-tasks/day-sheets/${item.daySheetId}'
: null,
taskId: item.taskId,
ticketId: item.ticketId,
itServiceRequestId: item.itServiceRequestId,
+87
View File
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tasq/screens/notifications/notifications_screen.dart';
void main() {
group('notificationTitle', () {
test('assignment guard (existing case unchanged)', () {
expect(notificationTitle('assignment', 'Alice'), 'Alice assigned you');
});
test('day_sheet_submitted', () {
expect(
notificationTitle('day_sheet_submitted', 'Bob'),
'Bob submitted a day sheet',
);
});
test('day_sheet_disapproved', () {
expect(
notificationTitle('day_sheet_disapproved', 'Alice'),
'Day sheet disapproved',
);
});
test('day_sheet_justified', () {
expect(
notificationTitle('day_sheet_justified', 'Bob'),
'Bob justified a day sheet',
);
});
test('day_sheet_approved', () {
expect(
notificationTitle('day_sheet_approved', 'Alice'),
'Day sheet approved',
);
});
test('day_sheet_digest', () {
expect(
notificationTitle('day_sheet_digest', 'System'),
'Day sheets awaiting review',
);
});
});
group('notificationIcon', () {
test('assignment guard (existing case unchanged)', () {
expect(notificationIcon('assignment'), Icons.assignment_ind_outlined);
});
test('day_sheet_submitted', () {
expect(
notificationIcon('day_sheet_submitted'),
Icons.assignment_turned_in_outlined,
);
});
test('day_sheet_disapproved', () {
expect(
notificationIcon('day_sheet_disapproved'),
Icons.report_outlined,
);
});
test('day_sheet_justified', () {
expect(
notificationIcon('day_sheet_justified'),
Icons.reply_outlined,
);
});
test('day_sheet_approved', () {
expect(
notificationIcon('day_sheet_approved'),
Icons.verified_outlined,
);
});
test('day_sheet_digest', () {
expect(
notificationIcon('day_sheet_digest'),
Icons.inbox_outlined,
);
});
});
}