3cb980b629
Security — enforce write authorization server-side (was UI/RPC-only): - it_service_requests RLS: block cross-office read/edit + self-approve (QA-015) - pass_slips RLS: owner can complete but not self-approve (QA-046) - swap_requests RLS: scope select/update to participants + admin (QA-047) - storage: tighten it_service_attachments + task_attachments write/delete (QA-027) - admin_user_management edge function: allow programmers to manage users (QA-016) Fixes: - workforce generator "uncovered shifts" false alarms (QA-043/044) - network-map VLAN + New-location dialog validation, disabled-until-valid (QA-048) - de-flake time-of-day-dependent dashboard metrics test (QA-045) Toolchain: - upgrade to Flutter 3.47.5 / Dart 3.13.4; font_awesome_flutter 11.0.0, flutter_quill 11.6.0, pdfrx 2.6.5; clear resulting deprecations (QA-002) analyze clean; 139 tests pass; web build succeeds. Report + evidence in docs/qa/. Note: also carries the in-progress Brick model cleanup already present in the working tree. QA-001 (AI keys public in the build) is deferred by owner decision. Co-Authored-By: claude-flow <ruv@ruv.net>
254 lines
8.2 KiB
Dart
254 lines
8.2 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../models/announcement_comment.model.dart';
|
|
import '../../providers/announcements_provider.dart';
|
|
import '../../providers/profile_provider.dart';
|
|
import '../../utils/app_time.dart';
|
|
import '../../utils/snackbar.dart';
|
|
import '../../widgets/profile_avatar.dart';
|
|
|
|
/// Inline, collapsible comments section for an announcement card.
|
|
class AnnouncementCommentsSection extends ConsumerStatefulWidget {
|
|
const AnnouncementCommentsSection({
|
|
super.key,
|
|
required this.announcementId,
|
|
});
|
|
|
|
final String announcementId;
|
|
|
|
@override
|
|
ConsumerState<AnnouncementCommentsSection> createState() =>
|
|
_AnnouncementCommentsSectionState();
|
|
}
|
|
|
|
class _AnnouncementCommentsSectionState
|
|
extends ConsumerState<AnnouncementCommentsSection> {
|
|
final _controller = TextEditingController();
|
|
bool _sending = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
final text = _controller.text.trim();
|
|
if (text.isEmpty || _sending) return;
|
|
|
|
setState(() => _sending = true);
|
|
try {
|
|
await ref.read(announcementsControllerProvider).addComment(
|
|
announcementId: widget.announcementId,
|
|
body: text,
|
|
);
|
|
_controller.clear();
|
|
if (mounted) showSuccessSnackBar(context, 'Comment posted.');
|
|
} on AnnouncementNotificationException {
|
|
// Comment was posted; only push notification delivery failed.
|
|
_controller.clear();
|
|
if (mounted) {
|
|
showInfoSnackBar(
|
|
context,
|
|
'Comment posted, but notifications may not have been sent.',
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
if (isOfflineSaveError(e)) {
|
|
_controller.clear();
|
|
showSuccessSnackBar(context, 'Comment saved offline — will sync when connected.');
|
|
} else {
|
|
showErrorSnackBar(context, 'Failed to post comment: $e');
|
|
}
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _sending = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final commentsAsync =
|
|
ref.watch(announcementCommentsProvider(widget.announcementId));
|
|
final profiles = ref.watch(profilesProvider).valueOrNull ?? [];
|
|
final cs = Theme.of(context).colorScheme;
|
|
final tt = Theme.of(context).textTheme;
|
|
final currentUserId = ref.watch(currentUserIdProvider);
|
|
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.5)),
|
|
commentsAsync.when(
|
|
loading: () => const Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
|
),
|
|
error: (e, _) => Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Text('Error loading comments',
|
|
style: tt.bodySmall?.copyWith(color: cs.error)),
|
|
),
|
|
data: (comments) => comments.isEmpty
|
|
? Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16, vertical: 12),
|
|
child: Text(
|
|
'No comments yet',
|
|
style: tt.bodySmall
|
|
?.copyWith(color: cs.onSurfaceVariant),
|
|
),
|
|
)
|
|
: ListView.separated(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
itemCount: comments.length,
|
|
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
|
itemBuilder: (context, index) {
|
|
final comment = comments[index];
|
|
return _CommentTile(
|
|
comment: comment,
|
|
profiles: profiles,
|
|
currentUserId: currentUserId,
|
|
onDelete: () async {
|
|
await ref
|
|
.read(announcementsControllerProvider)
|
|
.deleteComment(comment.id);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
// Input row
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _controller,
|
|
maxLines: null,
|
|
minLines: 1,
|
|
keyboardType: TextInputType.multiline,
|
|
textInputAction: TextInputAction.newline,
|
|
decoration: InputDecoration(
|
|
hintText: 'Write a comment...',
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 12, vertical: 10),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide(color: cs.outlineVariant),
|
|
),
|
|
),
|
|
style: tt.bodyMedium,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton(
|
|
onPressed: _sending ? null : _submit,
|
|
icon: _sending
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child:
|
|
CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Icon(Icons.send, color: cs.primary),
|
|
tooltip: 'Send',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CommentTile extends StatelessWidget {
|
|
const _CommentTile({
|
|
required this.comment,
|
|
required this.profiles,
|
|
required this.currentUserId,
|
|
required this.onDelete,
|
|
});
|
|
|
|
final AnnouncementComment comment;
|
|
final List profiles;
|
|
final String? currentUserId;
|
|
final VoidCallback onDelete;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
final tt = Theme.of(context).textTheme;
|
|
|
|
// Resolve author name
|
|
String authorName = 'Unknown';
|
|
String? avatarUrl;
|
|
for (final p in profiles) {
|
|
if (p.id == comment.authorId) {
|
|
authorName = p.fullName;
|
|
avatarUrl = p.avatarUrl;
|
|
break;
|
|
}
|
|
}
|
|
|
|
final isOwn = comment.authorId == currentUserId;
|
|
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
ProfileAvatar(fullName: authorName, avatarUrl: avatarUrl, radius: 14),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
authorName,
|
|
style: tt.labelMedium
|
|
?.copyWith(fontWeight: FontWeight.w600),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Flexible(
|
|
child: Text(
|
|
AppTime.relative(comment.createdAt),
|
|
style: tt.labelSmall
|
|
?.copyWith(color: cs.onSurfaceVariant),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(comment.body, style: tt.bodySmall),
|
|
],
|
|
),
|
|
),
|
|
if (isOwn)
|
|
IconButton(
|
|
icon: Icon(Icons.close, size: 16, color: cs.onSurfaceVariant),
|
|
onPressed: onDelete,
|
|
tooltip: 'Delete',
|
|
visualDensity: VisualDensity.compact,
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|