Add Clean Air Forum selfie filter, live camera guide, and IG sticker (mobile)#3755
Conversation
…nd IG sticker Lets Clean Air Forum attendees generate a branded selfie filter or transparent Instagram sticker from their live AQI reading and optionally submit it to the conference wall display, so the feature drives visible engagement at the booth. - Filter/sticker/share-card templates match the approved Figma spec exactly (proportional scaling off a 1080px reference so text/branding stay pixel-accurate at any capture resolution) - Live camera screen overlays the filter chrome so users can frame their shot before capturing, falling back to the system camera or gallery - Share sheet, selfie-source picker, and consent toggle reuse the Exposure/ Learn design tokens (buttons, switch, drag-handle chrome) for visual consistency with the rest of the app instead of one-off styling - Copies the sticker straight to the clipboard (via `pasteboard`) so it can be pasted into an Instagram Story without a separate save/share step - Card and filter shares now include the app link in the share text for conversion tracking Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fixes a CodeRabbit finding: didChangeAppLifecycleState disposed the camera controller on AppLifecycleState.inactive but never cleared the field, so a rebuild before _openCamera() finished on resume could still try to paint CameraPreview against an already-disposed controller. Now nulls the field (and rebuilds) before disposing. Also sends the new x-clean-air-forum-secret header (from CLEAN_AIR_FORUM_API_SECRET) on wall submissions, matching the website's new shared-secret check. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdds Clean Air Forum selfie sharing to the mobile app with new camera capture, branded filter and sticker renderers, shared AQI/text utilities, upload/submission services, a multi-tab share sheet, and Android support for camera and file sharing. ChangesClean Air Forum Sharing Feature
Estimated code review effort: 4 (Complex) | ~75 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant ShareSheet as AirQualityShareSheet
participant CameraScreen as CleanAirForumCameraScreen
participant ShareService as AirQualityShareService
participant SubmissionService as CleanAirForumSubmissionService
participant Cloudinary
participant ForumWall as Clean Air Forum wall
User->>ShareSheet: select filter tab and tap Take selfie
ShareSheet->>CameraScreen: push camera screen
CameraScreen->>CameraScreen: request permission and open camera
User->>CameraScreen: tap shutter
CameraScreen->>ShareSheet: return captured File
ShareSheet->>ShareService: shareCleanAirForumFilter(...)
alt consent enabled
ShareSheet->>SubmissionService: submitSelfie(imageBytes, measurement)
SubmissionService->>Cloudinary: upload image bytes
Cloudinary-->>SubmissionService: secure_url
SubmissionService->>ForumWall: POST selfie payload
end
ShareSheet-->>User: inline success or error message
Possibly related PRs
Suggested labels: Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Warning Review ran into problems🔥 ProblemsErrors were encountered while retrieving linked issues. Errors (1)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@copilot |
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (5)
src/mobile/lib/src/app/dashboard/utils/clean_air_forum_branding.dart (1)
48-66: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueNitpick:
sizereads as square dimensions but only sets width.Height is derived from
aspectRatio, so callers passingsizeget a non-square render. Consider renaming towidthfor clarity, or document inline that height is auto-derived.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/mobile/lib/src/app/dashboard/utils/clean_air_forum_branding.dart` around lines 48 - 66, The AirQoIconMark widget’s size parameter is misleading because it only controls width while height is derived from aspectRatio in build(). Update the API in AirQoIconMark to use a clearer name like width, or add inline documentation in the constructor/build method explaining that height is automatically calculated from the intrinsic aspect ratio so callers don’t assume a square render.src/mobile/lib/src/app/dashboard/widgets/air_quality_share_card.dart (1)
120-146: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winPill background still uses the old translucent style; text already switched to the new design.
The
aqiPillBackground()helper was added specifically to replace this exact translucent-over-background pattern with the "solid pale bg + solid saturated text" style, and this file already adopted the text half (color: categoryColorat line 138) but not the background half — line 128 still doescategoryColor.withValues(alpha: 0.18)directly instead ofaqiPillBackground(categoryColor). Looks like a partial migration; worth aligning both halves for visual consistency across share/filter/sticker cards.♻️ Proposed fix
decoration: BoxDecoration( - color: categoryColor.withValues(alpha: 0.18), + color: aqiPillBackground(categoryColor), borderRadius: BorderRadius.circular(999), ),🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/mobile/lib/src/app/dashboard/widgets/air_quality_share_card.dart` around lines 120 - 146, The AQI pill in the share card is only partially migrated to the new design: the text already uses the saturated category color, but the background still uses the old translucent alpha-based style. Update the pill in air_quality_share_card.dart so the Container decoration uses aqiPillBackground(categoryColor) instead of categoryColor.withValues(alpha: 0.18), keeping the styling consistent with the other cards that already use the shared helper.src/mobile/lib/src/app/exposure/widgets/exposure_place_name_text_field.dart (1)
39-57: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low valueConsider handling
focusNodereassignment viadidUpdateWidget.The
_focusNodegetter resolveswidget.focusNodelazily and the listener is only attached once ininitState. If a parent ever swaps theFocusNodeinstance passed in after the first build, the listener stays on the stale node and the new node never gets one, silently breaking the focus-glow styling. None of the current call sites passfocusNode(controller and theme flags only, no focusNode), (controller/hint/isDark, without providing any focusNode), so this isn't triggered today, but it's a gap worth closing before other consumers (e.g. the new share sheet) start passing one in.🛡️ Optional defensive fix
`@override` void initState() { super.initState(); _focusNode.addListener(_handleFocusChange); } + `@override` + void didUpdateWidget(covariant ExposurePlaceNameTextField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.focusNode != oldWidget.focusNode) { + final oldNode = oldWidget.focusNode ?? _internalFocusNode; + oldNode?.removeListener(_handleFocusChange); + if (oldWidget.focusNode == null) { + _internalFocusNode?.dispose(); + _internalFocusNode = null; + } + _focusNode.addListener(_handleFocusChange); + } + } + void _handleFocusChange() { setState(() => _focused = _focusNode.hasFocus); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/mobile/lib/src/app/exposure/widgets/exposure_place_name_text_field.dart` around lines 39 - 57, The focus listener setup in exposure_place_name_text_field.dart only happens in initState, so if the widget’s focusNode changes later the old FocusNode keeps the listener and the new one never gets it. Update the ExposurePlaceNameTextField state to handle FocusNode replacement in didUpdateWidget by comparing the previous and current widget.focusNode, removing _handleFocusChange from the old node, attaching it to the new node, and keeping the existing dispose cleanup aligned with the _focusNode getter and _handleFocusChange listener.src/mobile/lib/src/app/shared/services/clean_air_forum_submission_service.dart (1)
91-96: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winValidate Cloudinary config before building the request.
If
NEXT_PUBLIC_CLOUDINARY_NAME/_PRESETare missing/misconfigured,cloudinaryUrlbecomes malformed (e.g..../v1_1//image/upload) and the failure surfaces only as an opaque HTTP error rather than a clear config error.♻️ Proposed fix
Future<String> _uploadToCloudinary(Uint8List imageBytes) async { final cloudName = dotenv.env['NEXT_PUBLIC_CLOUDINARY_NAME'] ?? ''; final uploadPreset = dotenv.env['NEXT_PUBLIC_CLOUDINARY_PRESET'] ?? ''; + if (cloudName.isEmpty || uploadPreset.isEmpty) { + throw Exception('Cloudinary is not configured for Clean Air Forum uploads.'); + } final cloudinaryUrl = 'https://api.cloudinary.com/v1_1/$cloudName/image/upload';🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/mobile/lib/src/app/shared/services/clean_air_forum_submission_service.dart` around lines 91 - 96, Validate the Cloudinary configuration in _uploadToCloudinary before constructing cloudinaryUrl. If NEXT_PUBLIC_CLOUDINARY_NAME or NEXT_PUBLIC_CLOUDINARY_PRESET are missing or empty, fail fast with a clear configuration error instead of continuing to build the request; keep the check near the dotenv lookups in clean_air_forum_submission_service.dart so the malformed URL is never created.src/mobile/lib/src/app/dashboard/widgets/clean_air_forum_filter_card.dart (1)
62-64: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winConsider an
errorBuilderfor the selfie image.
selfieFilecan originate from the gallery picker, so a corrupt/unsupported file would fall through to Flutter's default broken-image placeholder inside the branded card. AnerrorBuilderfalling back to_SelfiePlaceholderwould keep the card looking intentional in that edge case.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/mobile/lib/src/app/dashboard/widgets/clean_air_forum_filter_card.dart` around lines 62 - 64, The selfie image in the card currently only handles the null case, so a corrupt or unsupported gallery file can show Flutter’s default broken-image UI inside the branded component. Update the Image.file usage in the Clean Air Forum filter card to include an errorBuilder that falls back to _SelfiePlaceholder, keeping the widget intentional even when the selected selfie fails to load.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/mobile/lib/src/app/dashboard/utils/air_quality_card_utils.dart`:
- Around line 11-22: The mojibake cleanup in sanitizeCardText is too broad and
relies on latin1.encode, which misses common Windows-1252 sequences like ’ and
“ and can also alter legitimate text. Update sanitizeCardText to use a
Windows-1252-aware decoding path and tighten the detection logic so only known
broken encodings are rewritten. Add regression tests covering one malformed
sample that should be fixed and one normal string that must remain unchanged.
In `@src/mobile/lib/src/app/dashboard/widgets/air_quality_share_sheet.dart`:
- Around line 238-260: The _openLiveCamera method can call setState after
awaiting Navigator.of(context).push without confirming the widget is still
mounted. Add the same mounted guard used in _pickSelfie before updating
_selfieFile in AirQualityShareSheet, so the state change only happens if the
widget is still in the tree; keep the existing finally block’s mounted check for
resetting _isPickingSelfie.
- Around line 157-176: The _pickSelfie method updates _selfieFile after an await
without verifying the widget is still mounted, which can throw if the sheet is
dismissed during image picking. In _pickSelfie, add a mounted check immediately
before the setState call that assigns File(picked.path), matching the existing
mounted guard used in the finally block. Keep the early return for null picks,
but ensure no state is set after disposal.
- Around line 309-333: The success message in _copySticker is shown immediately
after Pasteboard.writeImage, but that method does not guarantee the image was
actually accepted on every platform. Update _copySticker to use
platform-specific clipboard handling or a fallback validation path before
setting _stickerCopied and showing “Copied!”, and keep the failure message path
for cases where the write is ignored.
In `@src/mobile/lib/src/app/dashboard/widgets/clean_air_forum_camera_screen.dart`:
- Around line 51-58: The camera permission request in _setUp is unguarded, so a
platform channel failure can leave _initializeFuture unresolved and the UI stuck
on the loading state. Wrap Permission.camera.request() in the same kind of
try/catch used for availableCameras(), and on error set _errorMessage and stop
the setup flow so build() can show the failure instead of a permanent
CircularProgressIndicator.
- Around line 79-96: The _openCamera method in CleanAirForumCameraScreen leaves
a failed CameraController instance undisposed when _controller!.initialize()
throws, so dispose the newly created controller in the failure path before
returning and keep the previous controller cleanup as-is. Also update the
setState inside the catch block to follow the same mounted guard pattern used
elsewhere in this widget, referencing _openCamera and the controller
initialization flow.
In
`@src/mobile/lib/src/app/shared/services/clean_air_forum_submission_service.dart`:
- Around line 56-68: The shared secret in CleanAirForumSubmissionService is only
a weak, client-embedded deterrent because CLEAN_AIR_FORUM_API_SECRET is loaded
from the app bundle and can be recovered. Keep the temporary mock behavior if
intended, but update the class/service docs and implementation around the
request header in clean_air_forum_submission_service.dart to clearly treat this
as interim only and ensure the planned real backend swap uses a stronger
request-specific mechanism, such as server-issued short-lived tokens, instead of
relying on the bundled secret.
---
Nitpick comments:
In `@src/mobile/lib/src/app/dashboard/utils/clean_air_forum_branding.dart`:
- Around line 48-66: The AirQoIconMark widget’s size parameter is misleading
because it only controls width while height is derived from aspectRatio in
build(). Update the API in AirQoIconMark to use a clearer name like width, or
add inline documentation in the constructor/build method explaining that height
is automatically calculated from the intrinsic aspect ratio so callers don’t
assume a square render.
In `@src/mobile/lib/src/app/dashboard/widgets/air_quality_share_card.dart`:
- Around line 120-146: The AQI pill in the share card is only partially migrated
to the new design: the text already uses the saturated category color, but the
background still uses the old translucent alpha-based style. Update the pill in
air_quality_share_card.dart so the Container decoration uses
aqiPillBackground(categoryColor) instead of categoryColor.withValues(alpha:
0.18), keeping the styling consistent with the other cards that already use the
shared helper.
In `@src/mobile/lib/src/app/dashboard/widgets/clean_air_forum_filter_card.dart`:
- Around line 62-64: The selfie image in the card currently only handles the
null case, so a corrupt or unsupported gallery file can show Flutter’s default
broken-image UI inside the branded component. Update the Image.file usage in the
Clean Air Forum filter card to include an errorBuilder that falls back to
_SelfiePlaceholder, keeping the widget intentional even when the selected selfie
fails to load.
In `@src/mobile/lib/src/app/exposure/widgets/exposure_place_name_text_field.dart`:
- Around line 39-57: The focus listener setup in
exposure_place_name_text_field.dart only happens in initState, so if the
widget’s focusNode changes later the old FocusNode keeps the listener and the
new one never gets it. Update the ExposurePlaceNameTextField state to handle
FocusNode replacement in didUpdateWidget by comparing the previous and current
widget.focusNode, removing _handleFocusChange from the old node, attaching it to
the new node, and keeping the existing dispose cleanup aligned with the
_focusNode getter and _handleFocusChange listener.
In
`@src/mobile/lib/src/app/shared/services/clean_air_forum_submission_service.dart`:
- Around line 91-96: Validate the Cloudinary configuration in
_uploadToCloudinary before constructing cloudinaryUrl. If
NEXT_PUBLIC_CLOUDINARY_NAME or NEXT_PUBLIC_CLOUDINARY_PRESET are missing or
empty, fail fast with a clear configuration error instead of continuing to build
the request; keep the check near the dotenv lookups in
clean_air_forum_submission_service.dart so the malformed URL is never created.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 1d58da43-bc13-480c-8030-3dd6e4e61829
⛔ Files ignored due to path filters (6)
src/mobile/assets/icons/camera.svgis excluded by!**/*.svgsrc/mobile/assets/icons/check-circle.svgis excluded by!**/*.svgsrc/mobile/assets/icons/copy.svgis excluded by!**/*.svgsrc/mobile/assets/icons/gallery.svgis excluded by!**/*.svgsrc/mobile/assets/images/shared/airqo_icon_mark.svgis excluded by!**/*.svgsrc/mobile/pubspec.lockis excluded by!**/*.lock
📒 Files selected for processing (14)
src/mobile/android/app/build.gradlesrc/mobile/android/app/src/main/AndroidManifest.xmlsrc/mobile/android/app/src/main/res/xml/provider_paths.xmlsrc/mobile/lib/src/app/dashboard/utils/air_quality_card_utils.dartsrc/mobile/lib/src/app/dashboard/utils/clean_air_forum_branding.dartsrc/mobile/lib/src/app/dashboard/widgets/air_quality_share_card.dartsrc/mobile/lib/src/app/dashboard/widgets/air_quality_share_sheet.dartsrc/mobile/lib/src/app/dashboard/widgets/clean_air_forum_camera_screen.dartsrc/mobile/lib/src/app/dashboard/widgets/clean_air_forum_filter_card.dartsrc/mobile/lib/src/app/dashboard/widgets/clean_air_forum_sticker_frame.dartsrc/mobile/lib/src/app/exposure/widgets/exposure_place_name_text_field.dartsrc/mobile/lib/src/app/shared/services/air_quality_share_service.dartsrc/mobile/lib/src/app/shared/services/clean_air_forum_submission_service.dartsrc/mobile/pubspec.yaml
…, mojibake, cleanup) - sanitizeCardText: latin1.encode() can't represent Windows-1252-specific code points (smart quotes, em-dashes), so it threw and silently left that mojibake (e.g. "it’s") unfixed. Rebuild the byte sequence with a proper Windows-1252 reverse map instead, and tighten detection to drop the bare 'â' trigger (false-positives on legitimate text like "château") in favor of the more specific 'â€' compound signature. Added regression tests. - _pickSelfie / _openLiveCamera: guard the post-await setState calls with `mounted`, matching the pattern already used elsewhere in the file — picking/capturing a selfie after the sheet is dismissed could otherwise throw. - _copySticker: read the clipboard back after Pasteboard.writeImage to confirm something was actually copied before showing "Copied!" — on iOS, a failed image decode leaves UIPasteboard.general.image nil but still resolves the write as successful. - CleanAirForumCameraScreen._setUp: guard Permission.camera.request() with try/catch like the availableCameras() call beside it — build()'s FutureBuilder never checks snapshot.hasError, so an unguarded permission failure left the UI stuck on a permanent loading spinner. - CleanAirForumCameraScreen._openCamera: dispose and clear the controller when initialize() fails instead of leaving _controller pointing at a non-initialized, undisposed instance. - CleanAirForumSubmissionService: document that the bundled shared secret is a weak, interim deterrent only (extractable from the app binary), and that the real backend swap should use a proper per-request mechanism instead of carrying it forward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (2)
src/mobile/lib/src/app/dashboard/utils/air_quality_card_utils.dart (2)
88-104: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider consolidating the three category switches into one lookup table.
getMeasurementAqiColor,aqiCategoryLabel, andgetMeasurementAqiIconAsseteach re-implement the same category-to-value switch. A single map keyed by category (color + label + icon path) would avoid the case lists drifting apart as categories evolve.Also applies to: 108-116, 131-147
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/mobile/lib/src/app/dashboard/utils/air_quality_card_utils.dart` around lines 88 - 104, The AQI category logic is duplicated across getMeasurementAqiColor, aqiCategoryLabel, and getMeasurementAqiIconAsset, which risks the category mappings drifting apart. Refactor the category handling in air_quality_card_utils.dart to use one shared lookup structure keyed by normalized aqiCategory values, and have those three functions read color, label, and icon from that single source. Make sure the existing category symbols in the switch cases (such as good, moderate, unhealthy for sensitive groups/u4sg, unhealthy, very unhealthy, and hazardous) are preserved in the shared mapping.
80-86: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueOptional: validate hex length before parsing
aqiColor.If the API ever returns something other than a plain 6-digit hex (e.g., 8-digit with embedded alpha, or malformed),
int.parsesucceeds but the resultingColorvalue can silently overflow ARGB range instead of falling back to the category switch.🛡️ Optional defensive check
if (measurement.aqiColor != null) { try { final colorString = measurement.aqiColor!.replaceAll('#', ''); - return Color(int.parse('0xFF$colorString')); + if (RegExp(r'^[0-9A-Fa-f]{6}$').hasMatch(colorString)) { + return Color(int.parse('0xFF$colorString')); + } } catch (_) {} }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/mobile/lib/src/app/dashboard/utils/air_quality_card_utils.dart` around lines 80 - 86, getMeasurementAqiColor currently parses measurement.aqiColor without validating that it is a plain 6-digit hex, so malformed or 8-digit values can produce an invalid Color instead of falling back. Update the aqiColor parsing block in getMeasurementAqiColor to verify the cleaned string length and format before calling int.parse, and only return the parsed Color when it matches the expected 6-digit RGB hex; otherwise continue to the existing category switch fallback.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/mobile/lib/src/app/dashboard/utils/air_quality_card_utils.dart`:
- Around line 88-104: The AQI category logic is duplicated across
getMeasurementAqiColor, aqiCategoryLabel, and getMeasurementAqiIconAsset, which
risks the category mappings drifting apart. Refactor the category handling in
air_quality_card_utils.dart to use one shared lookup structure keyed by
normalized aqiCategory values, and have those three functions read color, label,
and icon from that single source. Make sure the existing category symbols in the
switch cases (such as good, moderate, unhealthy for sensitive groups/u4sg,
unhealthy, very unhealthy, and hazardous) are preserved in the shared mapping.
- Around line 80-86: getMeasurementAqiColor currently parses
measurement.aqiColor without validating that it is a plain 6-digit hex, so
malformed or 8-digit values can produce an invalid Color instead of falling
back. Update the aqiColor parsing block in getMeasurementAqiColor to verify the
cleaned string length and format before calling int.parse, and only return the
parsed Color when it matches the expected 6-digit RGB hex; otherwise continue to
the existing category switch fallback.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 1435a7b7-1826-43e3-922a-7522245d2f56
📒 Files selected for processing (5)
src/mobile/lib/src/app/dashboard/utils/air_quality_card_utils.dartsrc/mobile/lib/src/app/dashboard/widgets/air_quality_share_sheet.dartsrc/mobile/lib/src/app/dashboard/widgets/clean_air_forum_camera_screen.dartsrc/mobile/lib/src/app/shared/services/clean_air_forum_submission_service.dartsrc/mobile/test/app/dashboard/utils/air_quality_card_utils_test.dart
🚧 Files skipped from review as they are similar to previous changes (3)
- src/mobile/lib/src/app/shared/services/clean_air_forum_submission_service.dart
- src/mobile/lib/src/app/dashboard/widgets/clean_air_forum_camera_screen.dart
- src/mobile/lib/src/app/dashboard/widgets/air_quality_share_sheet.dart
Summary
Mobile-only half of #3753, split out per team feedback to keep mobile and website changes in separate PRs. See #3753 for the corresponding website PR (wall display + submissions API).
Test plan
flutter analyzeclean on all touched filesCleanAirForumBrand) before the forum — seedocs/clean-air-forum-followups.mdin the website PR🤖 Generated with Claude Code
Summary by CodeRabbit