Skip to content

Sync v1.3.0 - #4

Merged
Project516 merged 1 commit into
mainfrom
sync/v1.3.0
Aug 8, 2026
Merged

Sync v1.3.0#4
Project516 merged 1 commit into
mainfrom
sync/v1.3.0

Conversation

@SpectrumFRC3847

@SpectrumFRC3847 SpectrumFRC3847 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

The Maps tab now has a third layout for a bus, van, or box truck, next to the lab and pit diagrams, so a load-out photo lives alongside the rest of the team's maps.

Borrowed tools can carry a contact (phone or email), and the Borrowed tab shows a badge for overdue loans right on the nav bar, so an expensive tool on loan does not get forgotten.

The packing tab now lists every inventory item up front, showing which ones have not been started yet instead of only the ones someone remembered to add by hand.

Packing photos can now cover a whole drawer or container at once, not just one item at a time, for confirming everything went in together.

Summary by CodeRabbit

  • New Features
    • Added Vehicle maps alongside Lab and Pit maps.
    • Packing now groups items by location, includes untracked inventory, and supports container photo capture, replacement, removal, and synchronization.
    • Borrow records support optional phone or email contact details.
    • Added overdue-loan badges and a new “Not started” packing status.
  • Bug Fixes
    • Improved reliability for photo caching and concurrent data updates.
    • Authentication cleanup and recovery now handle failures more gracefully.
  • Documentation
    • Added SHA-256 download-integrity verification instructions.
  • Release
    • Updated the application version to 1.3.0.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The release adds container-photo synchronization, inventory-aware packing, vehicle maps, borrow contacts, overdue indicators, and new packing states. It also serializes selected remote and disk operations, improves desktop authentication cleanup, updates Firestore validation, and expands related tests.

Changes

Application features and reliability

Layer / File(s) Summary
Contracts and synchronization services
firestore.rules, lib/src/models/*, lib/src/services/container_photo_sync_service.dart, lib/src/services/desktop_container_photo_sync_service.dart
Adds vehicle map types, container-photo validation and access, borrow contacts, the notStarted packing status, and Firebase-backed synchronization services.
Application wiring and UI flows
lib/main.dart, lib/src/app.dart, lib/src/ui/app_shell.dart, lib/src/ui/packing_tab.dart, lib/src/ui/borrow_tab.dart, lib/src/ui/maps_tab.dart
Wires the synchronization service through the application. Adds inventory merging, location grouping, container-photo controls, vehicle map selection, borrow contacts, and overdue badges.
Reliability and cleanup
lib/src/services/desktop_auth_service.dart, lib/src/services/photo_service.dart, lib/src/services/user_scoped_firestore_cache.dart, lib/src/state/pit_controller_mixin.dart
Serializes authentication teardown, photo disk operations, and per-ID remote operations. Adds root-scoped cache cleanup.
Validation and support tests
test/packing_tab_test.dart, test/borrow_*_test.dart, test/maps_tab_test.dart, test/photo_service_test.dart, test/inventory_controller_test.dart, test/support/*
Adds coverage for the new feature flows, race conditions, synchronization fakes, and updated application dependencies. Existing explanatory comments and unchanged fixture formatting are also removed or adjusted.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested reviewers: project516

Poem

I’m a rabbit with photos packed neat,
Vehicle maps make the routes complete.
Contacts hop in, overdue counts glow,
Queued writes keep the data in flow.
SHA checks guard each downloaded byte—
Fresh little changes, verified right!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title identifies the v1.3.0 change set and references synchronization work, but it does not summarize the main user-facing features.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sync/v1.3.0

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (5)
test/borrow_tab_test.dart (1)

141-149: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Exercise contact submission in the checkout test.

The test leaves the new fifth field empty. The contact display tests use prebuilt records. They do not verify editor-to-BorrowRecord mapping.

Enter a contact value and assert controller.items.single.contact after checkout.

Proposed test update
     await tester.enterText(fields.at(2), '254');
     await tester.enterText(fields.at(3), 'Texas States');
+    await tester.enterText(fields.at(4), 'pit@team.org');

     final checkOutButton = find.widgetWithText(FilledButton, 'Check out');
     await tester.ensureVisible(checkOutButton);
     await tester.tap(checkOutButton);
     await tester.pumpAndSettle();

     expect(controller.items.length, 1);
     expect(controller.items.single.toolName, 'Wrench');
     expect(controller.items.single.teamNumber, 254);
+    expect(controller.items.single.contact, 'pit@team.org');
🤖 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 `@test/borrow_tab_test.dart` around lines 141 - 149, Update the checkout test
around the existing TextField entries and checkOutButton to fill the new fifth
contact field, then assert that controller.items.single.contact matches the
entered value after checkout. Preserve the existing input values and checkout
flow while validating the editor-to-BorrowRecord mapping.
lib/src/ui/packing_tab.dart (2)

292-316: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Replace the nested merge loop with a map lookup.

_mergedRows scans every record for every inventory item, which is O(items × records). build calls it on every controller notification, and AppShell now rebuilds every minute. Index the records by itemId first.

♻️ Proposed refactor
     final rows = <_PackingRowData>[];
+    final byItemId = <String, PackingRecord>{};
+    for (final record in records) {
+      byItemId.putIfAbsent(record.itemId, () => record);
+    }
     for (final item in inventoryItems) {
-      PackingRecord? match;
-      for (final record in records) {
-        if (record.itemId == item.id) {
-          match = record;
-          break;
-        }
-      }
+      final match = byItemId[item.id];
       rows.add(
🤖 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 `@lib/src/ui/packing_tab.dart` around lines 292 - 316, Update _mergedRows to
index records by itemId before iterating inventoryItems, then retrieve matches
through map lookup instead of the nested records scan. Preserve the existing
virtual-record creation and unmatched-record append behavior while reducing the
merge to linear-time processing.

62-76: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Move the container-photo load out of itemBuilder.

itemBuilder mutates _containerPhotoChecks and starts _loadContainerPhoto, which calls setState. The call is safe today only because _loadContainerPhoto awaits before it calls setState. If that method later reads a cached value synchronously, Flutter throws "setState() or markNeedsBuild() called during build".

Trigger the loads from a post-frame callback, or prefetch the distinct locations once after _mergedRows is computed.

♻️ Proposed change
                       if (location != null) {
                         if (!_containerPhotoChecks.contains(location)) {
                           _containerPhotoChecks.add(location);
-                          _loadContainerPhoto(location);
+                          WidgetsBinding.instance.addPostFrameCallback(
+                            (_) => unawaited(_loadContainerPhoto(location)),
+                          );
                         }
🤖 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 `@lib/src/ui/packing_tab.dart` around lines 62 - 76, Move container-photo
prefetching out of the itemBuilder callback: after _mergedRows or entries are
computed, collect distinct non-null locations and schedule _loadContainerPhoto
for them via a post-frame callback, while preserving _containerPhotoChecks
deduplication. Keep itemBuilder limited to rendering _LocationHeader and avoid
any setState-triggering mutation during build.
lib/src/ui/borrow_tab.dart (1)

615-615: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Trim once and reuse the value.

_contact.text.trim() runs twice on the same line. Compute it before the record is built.

♻️ Proposed refactor
     final teamNumber = int.tryParse(_teamNumber.text.trim()) ?? 0;
+    final contact = _contact.text.trim();
     final existing = widget.record;
@@
-        contact: _contact.text.trim().isEmpty ? null : _contact.text.trim(),
+        contact: contact.isEmpty ? null : contact,
🤖 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 `@lib/src/ui/borrow_tab.dart` at line 615, In the record-building flow
containing the contact field, compute the trimmed _contact.text value once
before constructing the record, then reuse that local value for both the empty
check and the assigned contact value.
lib/src/ui/app_shell.dart (1)

111-122: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider narrowing the periodic rebuild.

Timer.periodic calls setState on _AppShellState every minute. This rebuilds the app bar, the IndexedStack, and every tab body, only to refresh the overdue badge. Move the tick into a small widget that wraps the NavigationBar destinations, or rebuild only when overdueCount changes.

♻️ Sketch: rebuild only when the count changes
     _overdueTick = Timer.periodic(const Duration(minutes: 1), (_) {
-      if (mounted) setState(() {});
+      if (!mounted) return;
+      final count = widget.borrowController.overdueCount;
+      if (count == _overdueCount) return;
+      setState(() => _overdueCount = count);
     });
🤖 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 `@lib/src/ui/app_shell.dart` around lines 111 - 122, Refactor the _overdueTick
handling in _AppShellState so the one-minute timer no longer calls setState on
the entire app shell; isolate the periodic refresh in a small widget around the
NavigationBar destinations, or otherwise rebuild only when overdueCount changes.
Keep the existing overdue badge behavior while leaving the app bar,
IndexedStack, and tab bodies outside the timer-driven rebuild.
🤖 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 `@firestore.rules`:
- Around line 78-86: Update the containerPhotos rules to require docId to equal
the normalized request.resource.data.location using the existing
containerPhotoDocId normalization logic. Apply this binding to both create and
update alongside isValidContainerPhoto and isMonotonicUpdate, rejecting writes
whose document ID and location do not match.

In `@lib/src/services/container_photo_sync_service.dart`:
- Around line 5-11: The containerPhotoDocId function currently produces
colliding, lossy document IDs; replace the slug-based mapping with an immutable
container identifier or reversible encoding of the canonical location, while
preserving the unlabeled behavior as needed. Update both Firestore
implementations to use the collision-resistant ID consistently, and add tests
covering distinct locations such as “Drawer A/B” versus “Drawer A B” and empty
versus “unlabeled” locations.

In `@lib/src/services/photo_service.dart`:
- Around line 195-209: Update the disk-operation coordination around
_queueDiskOp and clearCache so clearCache acts as a barrier: await all in-flight
or previously queued disk actions, prevent those pre-clear actions from writing
after cache removal, then clear the disk cache. Add a test that gates a disk
write, invokes clearCache while it is pending, and verifies the write cannot
recreate data after cleanup.

In `@lib/src/state/pit_controller_mixin.dart`:
- Line 85: Update the queued upsert flow around _pitQueueRemote and
pitUpsertRemote so a failed queued chain restores only the last remotely
confirmed item, not the failed operation’s stale previousItem; track confirmed
state per ID or reload the remote record after failure. Preserve stale-failure
handling while ensuring consecutive failures for one ID leave the cache at the
confirmed remote value, and add coverage for two failing queued upserts.

In `@lib/src/ui/app_shell.dart`:
- Around line 197-205: Update the withBadge helper in the navigation destination
construction to wrap the badged icon in Semantics with an explicit label
describing the overdue count, while leaving icons without badges unchanged.

In `@lib/src/ui/borrow_tab.dart`:
- Around line 771-778: Add maxLength: 256 to the TextField controlled by
_contact, keeping the existing optional contact label and hint unchanged so its
input limit matches isValidBorrowRecord.

In `@lib/src/ui/packing_tab.dart`:
- Around line 352-357: Ensure virtual packing rows use one stable persistence
ID: update _virtualRecord to be the sole place that assigns it, then in _advance
reuse row.record.id instead of creating a new UUID. Add an itemId-keyed busy
guard around the in-flight upsert so repeated taps cannot create duplicate
records. Apply these changes at lib/src/ui/packing_tab.dart#L352-L357 and
lib/src/ui/packing_tab.dart#L105-L124.
- Around line 208-215: Update _loadContainerPhoto to track failures from readKey
instead of silently treating them as a missing key; when the read fails,
preserve a non-actionable header state or report the failure through
_showFailure, and prevent _containerPhotoKeys[location] from being set as a
normal null result that allows _captureContainerPhoto/writeKey to overwrite an
existing remote photo.
- Around line 225-254: Update _captureContainerPhoto so _busyContainerPhotos
retains location through the entire capture and writeKey flow, removing it only
after persistence or failure completes. Before overwriting the location’s key,
retain the previous _containerPhotoKeys value and delete that blob after
writeKey succeeds, matching the cleanup behavior used by _capture.

In `@README.md`:
- Around line 17-18: Update the checksum guidance in the README so it
distinguishes corruption detection from authenticity: a colocated .sha256 file
only verifies the downloaded artifact against that checksum and cannot detect an
attacker replacing both files. State that tamper detection requires an expected
checksum obtained through a separate trusted channel or a signed
artifact/checksum.

---

Nitpick comments:
In `@lib/src/ui/app_shell.dart`:
- Around line 111-122: Refactor the _overdueTick handling in _AppShellState so
the one-minute timer no longer calls setState on the entire app shell; isolate
the periodic refresh in a small widget around the NavigationBar destinations, or
otherwise rebuild only when overdueCount changes. Keep the existing overdue
badge behavior while leaving the app bar, IndexedStack, and tab bodies outside
the timer-driven rebuild.

In `@lib/src/ui/borrow_tab.dart`:
- Line 615: In the record-building flow containing the contact field, compute
the trimmed _contact.text value once before constructing the record, then reuse
that local value for both the empty check and the assigned contact value.

In `@lib/src/ui/packing_tab.dart`:
- Around line 292-316: Update _mergedRows to index records by itemId before
iterating inventoryItems, then retrieve matches through map lookup instead of
the nested records scan. Preserve the existing virtual-record creation and
unmatched-record append behavior while reducing the merge to linear-time
processing.
- Around line 62-76: Move container-photo prefetching out of the itemBuilder
callback: after _mergedRows or entries are computed, collect distinct non-null
locations and schedule _loadContainerPhoto for them via a post-frame callback,
while preserving _containerPhotoChecks deduplication. Keep itemBuilder limited
to rendering _LocationHeader and avoid any setState-triggering mutation during
build.

In `@test/borrow_tab_test.dart`:
- Around line 141-149: Update the checkout test around the existing TextField
entries and checkOutButton to fill the new fifth contact field, then assert that
controller.items.single.contact matches the entered value after checkout.
Preserve the existing input values and checkout flow while validating the
editor-to-BorrowRecord mapping.
🪄 Autofix

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 Plus

Run ID: eb60c0e8-f207-4f6f-aa41-5a0572cbf8ef

📥 Commits

Reviewing files that changed from the base of the PR and between 4bf1992 and 88fc0d9.

⛔ Files ignored due to path filters (1)
  • pubspec.lock is excluded by !**/*.lock
📒 Files selected for processing (74)
  • .pr_agent.toml
  • README.md
  • firestore.rules
  • lib/main.dart
  • lib/src/app.dart
  • lib/src/models/borrow_record.dart
  • lib/src/models/map_location.dart
  • lib/src/models/packing_record.dart
  • lib/src/services/container_photo_sync_service.dart
  • lib/src/services/desktop_auth_service.dart
  • lib/src/services/desktop_container_photo_sync_service.dart
  • lib/src/services/desktop_firestore_cache_io.dart
  • lib/src/services/photo_service.dart
  • lib/src/services/user_scoped_firestore_cache.dart
  • lib/src/state/borrow_controller.dart
  • lib/src/state/pit_controller_mixin.dart
  • lib/src/ui/app_shell.dart
  • lib/src/ui/borrow_tab.dart
  • lib/src/ui/maps_tab.dart
  • lib/src/ui/packing_tab.dart
  • lib/src/widgets/keyboard_shortcuts.dart
  • pubspec.yaml
  • test/app_bootstrap_error_test.dart
  • test/borrow_controller_test.dart
  • test/borrow_record_test.dart
  • test/borrow_tab_test.dart
  • test/container_photo_sync_service_test.dart
  • test/dark_theme_tokens_test.dart
  • test/debug_info_test.dart
  • test/desktop_auth_service_test.dart
  • test/desktop_launcher_service_test.dart
  • test/desktop_pit_shift_sync_service_test.dart
  • test/desktop_self_update_service_test.dart
  • test/desktop_sync_services_test.dart
  • test/desktop_update_service_test.dart
  • test/desktop_user_role_service_test.dart
  • test/docs_assets_exist_test.dart
  • test/docs_viewer_test.dart
  • test/http_timeout_client_test.dart
  • test/inventory_controller_test.dart
  • test/inventory_tab_test.dart
  • test/issue_report_service_test.dart
  • test/keyboard_shortcuts_test.dart
  • test/large_text_layout_test.dart
  • test/local_only_services_test.dart
  • test/map_location_controller_test.dart
  • test/map_location_test.dart
  • test/maps_tab_test.dart
  • test/packing_controller_test.dart
  • test/packing_record_test.dart
  • test/packing_tab_test.dart
  • test/photo_disk_cache_test.dart
  • test/photo_service_test.dart
  • test/pit_shift_controller_test.dart
  • test/pit_shift_test.dart
  • test/schedule_tab_test.dart
  • test/support/fake_borrow_sync_service.dart
  • test/support/fake_container_photo_sync_service.dart
  • test/support/fake_inventory_sync_service.dart
  • test/support/fake_map_diagram_sync_service.dart
  • test/support/fake_map_image_store.dart
  • test/support/fake_map_location_sync_service.dart
  • test/support/fake_packing_sync_service.dart
  • test/support/fake_pit_shift_sync_service.dart
  • test/support/fake_spectrum_auth_service.dart
  • test/support/fake_user_role_service.dart
  • test/support/photo_test_support.dart
  • test/synced_map_image_store_test.dart
  • test/telemetry_service_test.dart
  • test/theme_controller_test.dart
  • test/user_management_screen_test.dart
  • test/user_role_controller_test.dart
  • test/user_scoped_firestore_cache_test.dart
  • test/widget_test.dart
💤 Files with no reviewable changes (17)
  • test/support/fake_map_location_sync_service.dart
  • test/keyboard_shortcuts_test.dart
  • test/pit_shift_controller_test.dart
  • test/support/fake_map_image_store.dart
  • test/map_location_controller_test.dart
  • test/user_role_controller_test.dart
  • test/support/fake_borrow_sync_service.dart
  • test/support/fake_pit_shift_sync_service.dart
  • .pr_agent.toml
  • test/theme_controller_test.dart
  • test/desktop_user_role_service_test.dart
  • test/support/fake_packing_sync_service.dart
  • lib/src/widgets/keyboard_shortcuts.dart
  • test/http_timeout_client_test.dart
  • test/support/fake_spectrum_auth_service.dart
  • test/photo_disk_cache_test.dart
  • test/packing_controller_test.dart

Comment thread firestore.rules
Comment on lines +78 to +86
match /containerPhotos/{docId} {
allow read: if isAuthed() && isMember();
allow create: if isAuthed() && isMember()
&& isValidContainerPhoto(request.resource.data);
allow update: if isAuthed() && isMember()
&& isValidContainerPhoto(request.resource.data)
&& isMonotonicUpdate(resource.data, request.resource.data);
allow delete: if isAuthed() && isMember();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect how container photo document ids are derived from location.
fd -t f 'container_photo' | xargs -r rg -n -C5 'doc\(|location|docId|collection\('

Repository: Spectrum3847/spectrum-pit

Length of output: 12682


🌐 Web query:

Firebase Firestore query "contains multiple documents" no query results

💡 Result:

When a Cloud Firestore query returns no results, it is rarely due to a technical error. Instead, it typically indicates that the query constraints are not matching any documents in your database [1]. Use the following troubleshooting checklist to diagnose the issue: 1. Check for Missing Indexes Complex queries, especially those combining inequality filters or sorting by multiple fields, often require composite indexes [2][3]. If your query requires an index and it is missing, Firestore will return an error [2][4]. Check your console or application logs for a URL provided by Firestore that will automatically create the required index [2][3]. 2. Verify Security Rules Security rules are not filters; they act as an "all or nothing" gatekeeper [5]. If your security rules are configured in a way that the query could potentially access a document you do not have permission to read, the entire request will fail with a "Missing or insufficient permissions" error, rather than returning a partial result [5]. Ensure your security rules align with your query constraints [5]. 3. Understand Field Existence Firestore indexes only include documents that contain the field being queried or ordered [6][7]. If a document lacks a field entirely, it will be excluded from any query that uses an orderBy clause or a range filter (e.g., <, <=, >, >=) on that field [6][7]. 4. Validate Query Logic and Path - Inequality Limitations: You can only use a range/inequality filter on a single field per query [8][9]. Attempting to filter on multiple fields using range comparisons (e.g., age > 20 AND price < 100) is not supported [8][10]. - Data Pathing: If querying subcollections, ensure the path is correct [11]. If data is nested within a Map field, you must use dot notation (e.g., parentMap.childField) to access it [11]. - Empty Result Handling: A query returning zero results is a successful operation [1]. Your code should explicitly check for this state (e.g., checking if the snapshot is empty) rather than assuming an error occurred [1][2][4]. 5. Debugging - Console Comparison: Test your query directly in the Firebase Console's Firestore interface to verify the logic and ensure the documents you expect to retrieve actually exist and match your criteria [12]. - Check for Errors: Always wrap your query execution in a try/catch block (or use.catch) to handle permissions or index-related errors that might otherwise go unnoticed [2][13].

Citations:


🌐 Web query:

location field Firestore duplicate document id

💡 Result:

In Cloud Firestore, document IDs are unique within their specific collection [1][2][3]. It is impossible to have two documents with the same ID in the same collection; any attempt to create a document with an ID already in use will either overwrite the existing document (if using a set operation) or fail (if using specific constraints or transaction logic) [4][5][6]. Key points regarding document IDs and uniqueness: Scope of Uniqueness: The uniqueness of a document ID is scoped strictly to the collection that contains it [1][3]. You can have documents with the same ID if they reside in different collections or subcollections [1][3]. The true unique identifier for any document in Firestore is its full path, which includes the collection name and the document ID [1][3]. Auto-Generated IDs: When you allow Firestore to auto-generate a document ID, it uses a random string (20 characters long) that is statistically guaranteed to be unique within the target collection [7][8][9]. The probability of a collision is considered extremely low, making these IDs effectively unique [7][10]. Common Causes of Apparent Duplicates: If you perceive a "duplicate" document ID in your collection, it is typically due to one of the following: * Hidden Characters: An ID might appear identical but contain hidden whitespace or invisible characters [11][6]. * Different Collections: The documents exist in different collections or subcollections [1][3]. * Overwriting: If you use set to create a document with an ID that already exists, you will overwrite the data in that document unless you use options like merge [5][6]. If you are encountering errors regarding "document already exists," verify the exact path you are targeting, including any potential leading/trailing spaces or unintended path segments [11][6].

Citations:


🌐 Web query:

Firebase Firestore set document non existing document rules document data

💡 Result:

To manage security rules for Cloud Firestore operations concerning the existence of documents, you should leverage the built-in resource variable, the exists() function, and the distinction between create and update rules [1][2][3]. Core Concepts: 1. The resource variable refers to the document currently in the database [3]. If you are performing a create operation, resource will be null because the document does not yet exist [1][4]. If you are performing an update or delete operation, resource will contain the existing document's data [3]. 2. The request.resource variable refers to the state of the document after the requested operation is completed [3]. 3. The exists() function allows you to check for the presence of a document at a specific path, returning a boolean value [5][6]. Common Patterns: Restricting Creation to Non-Existing Documents If you want to ensure a document is created only if it does not already exist, you can use the create rule [2]. Since the create rule only applies to operations that result in a new document, you do not technically need to check for existence within that rule itself [1]. However, you can explicitly enforce it if needed: service cloud.firestore { match /databases/{database}/documents { match /users/{uid} { allow create: if request.auth!= null &&!exists(/databases/$(database)/documents/users/$(uid)); } } } Handling Set Operations The set() method in client libraries can behave as either a create or an update depending on whether the document already exists [7]. Security rules differentiate this automatically: - If the document does not exist, Firestore evaluates the create rule [2]. - If the document already exists, Firestore evaluates the update rule [7][2]. Because of this, it is recommended to split your write rules into specific create and update rules to ensure that logic for new documents (e.g., initial field validation) does not clash with logic for existing documents (e.g., preventing modification of immutable fields) [7][4]. Accessing Data When checking existing data, always ensure the document exists to avoid runtime errors [8][9]. For instance, accessing resource.data on a create operation will result in an error because resource is null [4]. Using logical operators can help safely chain these checks: - exists(path) && get(path).data.field == value ensures that get() is only called if the document exists, preventing potential errors [9]. For further guidance, consult the official documentation on writing conditions and understanding rule behavior [5][10].

Citations:


Bind containerPhotos IDs to the normalized location field.

Firestore clients use containerPhotoDocId(location) as the document id, but the rule allows any docId. A member can set a different raw location value through the rule, so the id and location can diverge. Add a validation check that maps docId back to request.resource.data.location using the same slug normalization, and reject mismatched writes.

🤖 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 `@firestore.rules` around lines 78 - 86, Update the containerPhotos rules to
require docId to equal the normalized request.resource.data.location using the
existing containerPhotoDocId normalization logic. Apply this binding to both
create and update alongside isValidContainerPhoto and isMonotonicUpdate,
rejecting writes whose document ID and location do not match.

Comment on lines +5 to +11
String containerPhotoDocId(String location) {
final slug = location
.trim()
.toLowerCase()
.replaceAll(RegExp('[^a-z0-9]+'), '-')
.replaceAll(RegExp('^-+|-+\$'), '');
return slug.isEmpty ? unlabeledDocId : slug;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use a collision-resistant container identifier.

containerPhotoDocId maps distinct locations to the same document ID. For example, "Drawer A/B" and "Drawer A B" both map to drawer-a-b. An empty location and "unlabeled" also map to unlabeled.

Both Firestore implementations use this ID. A write or clear for one container can overwrite or delete the other container photo. Use an immutable container ID, or a reversible encoding of the canonical location. Add collision tests.

🤖 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 `@lib/src/services/container_photo_sync_service.dart` around lines 5 - 11, The
containerPhotoDocId function currently produces colliding, lossy document IDs;
replace the slug-based mapping with an immutable container identifier or
reversible encoding of the canonical location, while preserving the unlabeled
behavior as needed. Update both Firestore implementations to use the
collision-resistant ID consistently, and add tests covering distinct locations
such as “Drawer A/B” versus “Drawer A B” and empty versus “unlabeled” locations.

Comment on lines +195 to +209
Future<void> _queueDiskOp(String key, Future<void> Function() action) {
final previous = _diskOps[key];
late final Future<void> next;
next = () async {
try {
await previous;
} catch (_) {}
try {
await action();
} finally {
if (identical(_diskOps[key], next)) _diskOps.remove(key);
}
}();
_diskOps[key] = next;
return next;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Serialize clearCache with queued disk operations.

A queued write can complete after clearCache removes the disk cache. This leaves photos from a signed-out session on disk. StrategyApp starts this cleanup on sign-out.

Make clearCache a barrier. It must wait for in-flight actions and prevent actions queued before the clear from writing afterward. Add a test that clears the cache while a disk write is gated.

🤖 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 `@lib/src/services/photo_service.dart` around lines 195 - 209, Update the
disk-operation coordination around _queueDiskOp and clearCache so clearCache
acts as a barrier: await all in-flight or previously queued disk actions,
prevent those pre-clear actions from writing after cache removal, then clear the
disk cache. Add a test that gates a disk write, invokes clearCache while it is
pending, and verifies the write cannot recreate data after cleanup.

notifyListeners();
try {
await pitUpsertRemote(item);
await _pitQueueRemote(item.id, () => pitUpsertRemote(item));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not restore an unconfirmed optimistic item after queued failures.

If updates A → B → C queue for one ID and both remote updates fail, the first failure does not roll back because it is stale. The second failure restores B from previousItem, although the remote record is still A. The cache then persists B.

Track the last confirmed item per ID, or reload the record after a failed chain. Add coverage for two failing queued upserts for the same ID.

🤖 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 `@lib/src/state/pit_controller_mixin.dart` at line 85, Update the queued upsert
flow around _pitQueueRemote and pitUpsertRemote so a failed queued chain
restores only the last remotely confirmed item, not the failed operation’s stale
previousItem; track confirmed state per ID or reload the remote record after
failure. Preserve stale-failure handling while ensuring consecutive failures for
one ID leave the cache at the confirmed remote value, and add coverage for two
failing queued upserts.

Comment thread lib/src/ui/app_shell.dart
Comment on lines +197 to +205
final overdueCount = widget.borrowController.overdueCount;
return _featureTabIndices.map((i) {
final m = _kTabMeta[i];
final showBadge = i == AppTabs.borrowed && overdueCount > 0;
Widget withBadge(Icon icon) =>
showBadge ? Badge(label: Text('$overdueCount'), child: icon) : icon;
return NavigationDestination(
icon: Icon(m.icon),
selectedIcon: Icon(m.selectedIcon),
icon: withBadge(Icon(m.icon)),
selectedIcon: withBadge(Icon(m.selectedIcon)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add an accessible label for the overdue badge.

The badge exposes only the number. A screen reader announces "Borrowed, 3", which does not state what the number counts. Wrap the badged icon in Semantics with an explicit label.

♿ Proposed change
-      Widget withBadge(Icon icon) =>
-          showBadge ? Badge(label: Text('$overdueCount'), child: icon) : icon;
+      Widget withBadge(Icon icon) => showBadge
+          ? Semantics(
+              label: '$overdueCount overdue ${overdueCount == 1 ? 'loan' : 'loans'}',
+              child: Badge(label: Text('$overdueCount'), child: icon),
+            )
+          : icon;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
final overdueCount = widget.borrowController.overdueCount;
return _featureTabIndices.map((i) {
final m = _kTabMeta[i];
final showBadge = i == AppTabs.borrowed && overdueCount > 0;
Widget withBadge(Icon icon) =>
showBadge ? Badge(label: Text('$overdueCount'), child: icon) : icon;
return NavigationDestination(
icon: Icon(m.icon),
selectedIcon: Icon(m.selectedIcon),
icon: withBadge(Icon(m.icon)),
selectedIcon: withBadge(Icon(m.selectedIcon)),
final overdueCount = widget.borrowController.overdueCount;
return _featureTabIndices.map((i) {
final m = _kTabMeta[i];
final showBadge = i == AppTabs.borrowed && overdueCount > 0;
Widget withBadge(Icon icon) => showBadge
? Semantics(
label: '$overdueCount overdue ${overdueCount == 1 ? 'loan' : 'loans'}',
child: Badge(label: Text('$overdueCount'), child: icon),
)
: icon;
return NavigationDestination(
icon: withBadge(Icon(m.icon)),
selectedIcon: withBadge(Icon(m.selectedIcon)),
🤖 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 `@lib/src/ui/app_shell.dart` around lines 197 - 205, Update the withBadge
helper in the navigation destination construction to wrap the badged icon in
Semantics with an explicit label describing the overdue count, while leaving
icons without badges unchanged.

Comment on lines +771 to +778
const SizedBox(height: 12),
TextField(
controller: _contact,
decoration: const InputDecoration(
labelText: 'Contact (optional)',
hintText: 'Phone or email',
),
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Cap the contact field to match the Firestore rule.

isValidBorrowRecord in firestore.rules rejects a contact value longer than 256 characters. This field accepts unlimited text. A long value therefore fails the remote write with a permission error after the user saves. Add maxLength: 256.

🛡️ Proposed fix
               TextField(
                 controller: _contact,
+                maxLength: 256,
+                keyboardType: TextInputType.text,
                 decoration: const InputDecoration(
                   labelText: 'Contact (optional)',
                   hintText: 'Phone or email',
                 ),
               ),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const SizedBox(height: 12),
TextField(
controller: _contact,
decoration: const InputDecoration(
labelText: 'Contact (optional)',
hintText: 'Phone or email',
),
),
const SizedBox(height: 12),
TextField(
controller: _contact,
maxLength: 256,
keyboardType: TextInputType.text,
decoration: const InputDecoration(
labelText: 'Contact (optional)',
hintText: 'Phone or email',
),
),
🤖 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 `@lib/src/ui/borrow_tab.dart` around lines 771 - 778, Add maxLength: 256 to the
TextField controlled by _contact, keeping the existing optional contact label
and hint unchanged so its input limit matches isValidBorrowRecord.

Comment on lines +208 to +215
Future<void> _loadContainerPhoto(String location) async {
String? key;
try {
key = await widget.containerPhotoSyncService.readKey(location);
} catch (_) {}
if (!mounted) return;
setState(() => _containerPhotoKeys[location] = key);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not swallow the container-photo read failure silently.

catch (_) {} discards every error from readKey. On failure, key stays null and _containerPhotoKeys[location] is set to null. The header then shows "Add a container photo" even when a photo exists remotely. If the user taps it, _captureContainerPhoto runs and writeKey overwrites the existing container photo reference. The previous photo becomes unreachable.

Track the failure and keep the header in a non-actionable state, or report the error with _showFailure.

🤖 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 `@lib/src/ui/packing_tab.dart` around lines 208 - 215, Update
_loadContainerPhoto to track failures from readKey instead of silently treating
them as a missing key; when the read fails, preserve a non-actionable header
state or report the failure through _showFailure, and prevent
_containerPhotoKeys[location] from being set as a normal null result that allows
_captureContainerPhoto/writeKey to overwrite an existing remote photo.

Comment on lines +225 to +254
Future<void> _captureContainerPhoto(String location) async {
final source = await choosePhotoSource(
context,
widget.photoService.sources,
);
if (source == null || !mounted) return;
setState(() => _busyContainerPhotos.add(location));
String? key;
try {
key = await widget.photoService.capture(source);
} catch (error) {
_showFailure('add a photo to "$location"', error);
} finally {
if (mounted) setState(() => _busyContainerPhotos.remove(location));
}
if (key == null) return;
if (!mounted) {
await _deleteKey(key, location);
return;
}
try {
await widget.containerPhotoSyncService.writeKey(location, key);
} catch (error) {
_showFailure('save the photo for "$location"', error);
await _deleteKey(key, location);
return;
}
if (!mounted) return;
setState(() => _containerPhotoKeys[location] = key);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Clear the busy flag after the write, and delete the replaced photo.

Two problems exist in _captureContainerPhoto.

  1. The finally block removes location from _busyContainerPhotos immediately after capture returns, before writeKey runs. During the writeKey await, _LocationHeader re-enables the button and _containerPhotoKeys[location] still holds the old value. A second tap therefore starts another capture for the same location.
  2. On replace, the previous key is never deleted. _openContainerPhoto calls _captureContainerPhoto for PackingPhotoAction.replace, and writeKey overwrites the stored key. The previous blob stays on disk forever. The per-item path in _capture deletes previous after a successful upsert; this path does not.
🐛 Proposed fix
     if (source == null || !mounted) return;
     setState(() => _busyContainerPhotos.add(location));
+    final previous = _containerPhotoKeys[location];
     String? key;
     try {
       key = await widget.photoService.capture(source);
-    } catch (error) {
-      _showFailure('add a photo to "$location"', error);
-    } finally {
-      if (mounted) setState(() => _busyContainerPhotos.remove(location));
-    }
-    if (key == null) return;
-    if (!mounted) {
-      await _deleteKey(key, location);
-      return;
-    }
-    try {
-      await widget.containerPhotoSyncService.writeKey(location, key);
-    } catch (error) {
-      _showFailure('save the photo for "$location"', error);
-      await _deleteKey(key, location);
-      return;
-    }
-    if (!mounted) return;
-    setState(() => _containerPhotoKeys[location] = key);
+      if (key == null) return;
+      if (!mounted) {
+        await _deleteKey(key, location);
+        return;
+      }
+      await widget.containerPhotoSyncService.writeKey(location, key);
+      if (!mounted) return;
+      setState(() => _containerPhotoKeys[location] = key);
+      if (previous != null && previous != key) {
+        await _deleteKey(previous, location);
+      }
+    } catch (error) {
+      _showFailure('add a photo to "$location"', error);
+      if (key != null) await _deleteKey(key, location);
+    } finally {
+      if (mounted) setState(() => _busyContainerPhotos.remove(location));
+    }
🤖 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 `@lib/src/ui/packing_tab.dart` around lines 225 - 254, Update
_captureContainerPhoto so _busyContainerPhotos retains location through the
entire capture and writeKey flow, removing it only after persistence or failure
completes. Before overwriting the location’s key, retain the previous
_containerPhotoKeys value and delete that blob after writeKey succeeds, matching
the cleanup behavior used by _capture.

Comment on lines +352 to +357
PackingRecord _virtualRecord(InventoryItem item) => PackingRecord(
id: item.id,
itemId: item.id,
packingStatus: PackingStatus.notStarted,
updatedAt: item.updatedAt,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Virtual packing rows have no stable identity, so each persistence path creates its own record. _virtualRecord assigns id: item.id, but _advance and _RecordEditorSheetState._save both discard that id and generate const Uuid().v4(), while the photo path (_handlePhoto_capture_current) persists the virtual record with id == item.id. Two paths on the same not-started row therefore write two PackingRecord documents for one itemId. _mergedRows matches only the first, and the second loop drops the rest, so the extra documents persist remotely and are unreachable from the UI.

  • lib/src/ui/packing_tab.dart#L352-L357: decide the single id used when a virtual row is first persisted, and make _virtualRecord the only place that produces it.
  • lib/src/ui/packing_tab.dart#L105-L124: reuse row.record.id instead of generating a new UUID, and add a busy guard keyed by itemId so a second tap during the in-flight upsert cannot create another record.
📍 Affects 1 file
  • lib/src/ui/packing_tab.dart#L352-L357 (this comment)
  • lib/src/ui/packing_tab.dart#L105-L124
🤖 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 `@lib/src/ui/packing_tab.dart` around lines 352 - 357, Ensure virtual packing
rows use one stable persistence ID: update _virtualRecord to be the sole place
that assigns it, then in _advance reuse row.record.id instead of creating a new
UUID. Add an itemId-keyed busy guard around the in-flight upsert so repeated
taps cannot create duplicate records. Apply these changes at
lib/src/ui/packing_tab.dart#L352-L357 and lib/src/ui/packing_tab.dart#L105-L124.

Comment thread README.md
Comment on lines +17 to +18
Every artifact ships with a `.sha256` file next to it. Because these builds are unsigned, the instructions below ask you to click past your platform's own integrity check, so this is the only thing left that tells a good download from a corrupted or tampered one. Download both files into the same folder and run:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not claim that a colocated checksum detects tampering.

A .sha256 file downloaded from the same release detects corruption. It does not prove authenticity. An attacker who replaces both files can provide a matching checksum.

State that tamper detection requires the expected checksum from a separate trusted channel, or a signed artifact or checksum.

Proposed documentation change
-Every artifact ships with a `.sha256` file next to it. Because these builds are unsigned, the instructions below ask you to click past your platform's own integrity check, so this is the only thing left that tells a good download from a corrupted or tampered one.
+Every artifact ships with a `.sha256` file next to it. These commands detect a corrupted download. They do not authenticate the artifact when both files come from the same release.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Every artifact ships with a `.sha256` file next to it. Because these builds are unsigned, the instructions below ask you to click past your platform's own integrity check, so this is the only thing left that tells a good download from a corrupted or tampered one. Download both files into the same folder and run:
Every artifact ships with a `.sha256` file next to it. These commands detect a corrupted download. They do not authenticate the artifact when both files come from the same release. Download both files into the same folder and run:
🤖 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 `@README.md` around lines 17 - 18, Update the checksum guidance in the README
so it distinguishes corruption detection from authenticity: a colocated .sha256
file only verifies the downloaded artifact against that checksum and cannot
detect an attacker replacing both files. State that tamper detection requires an
expected checksum obtained through a separate trusted channel or a signed
artifact/checksum.

@Project516
Project516 merged commit 81dd6f9 into main Aug 8, 2026
2 checks passed
@Project516
Project516 deleted the sync/v1.3.0 branch August 8, 2026 02:29
@coderabbitai coderabbitai Bot mentioned this pull request Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants