Rooms — Ownership + Chore Multi-Room + Multi-Select Picker Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make rooms first-class owned spaces — a room has persistent multi-owner ownership, a chore is tagged to a set of rooms, and who does a chore in a room resolves manual-override → room-owner(s) → chore-assignees, all driven by one reusable multi-select room picker.
Architecture: SDK-up. Add Place.ownerIds (additive), then replace Chore.placeId with Chore.roomIds as ONE compile unit through the model + StoragePort + all 5 adapters + ChoreService, and rewrite ChoreService.membersForRoom to the new 3-tier rule (override → place.ownerIds → chore.assignedMemberIds). Build one DS multi-select room picker and rewire the chore editor + Rooms/Household + Today/printables onto it. Close with a file-only migration and gadfly-canonical flow tests.
Tech Stack: Flutter 3.44 / Dart 3.9 (FVM: fvm flutter ...), flutter_bloc, Drift + Supabase adapters, json_serializable, Equatable, flutter_test + the flow_test harness.
Global Constraints
- One data path: Bloc/Cubit → Repository → Client facade → Service → Adapter. Presentation (
app/lib/inside/**) imports ONLY theclient_sdkfacade + app repositories — neverdrift/supabase/any I/O. - Typed errors only:
on <SpecificException>; never barecatch, never catchError. Domain rules throwDomainRuleException/ValidationExceptionin the service. Chore.placeId → roomIdsis ONE compile unit: the barrel re-exports the service + all 5 adapters, so the rename must land through model +StoragePort+ 5 adapters +ChoreServicein a single task or the package will not compile.ownerIds/roomIdsmust round-trip across all 5 adapters: in-memory, FakePort, cloud/Supabase, local/Drift, cached write-through.- Migration is file-only under
infra/supabase/migrations/; prod apply is DEPLOY-GATED (owner-gated).places.owner_ids uuid[]default'{}';chores.room_ids uuid[]backfilled fromplace_id. - Naming (owner standard): NEW app classes/files use the gadfly underscore
{Name}_Suffixconvention (docs/coding_guidelines/naming_conventions.md) — this is a fresh build, not in-flight legacy. EXCEPTIONS: models/entities are plain PascalCase (Place,Chore); DS components areDs-prefixed PascalCase (DsMultiRoomPicker); files are alwayssnake_case. When MODIFYING an existing PascalCase legacy widget/bloc in place, leave its name; only NEW extracted units take underscore. - Resolution (single source of truth =
ChoreService.membersForRoom):(a)non-emptychore.roomAssignees[roomId]→(b)non-emptyplace.ownerIds→(c)chore.assignedMemberIds. This REPLACES the current Tier-2 (member.homePlaceId) with room ownership. - v1 completion stays per-chore (one chore = one completion). Per-room completion is explicitly out of scope.
- TDD; frequent commits; flow tests gadfly-canonical (ONE flowTest, multiple stories); baselines must not drop.
File Structure
SDK (packages/client_sdk/)
lib/src/models/place.dart— addownerIds: List<String>(+ copyWith/props/json).lib/src/models/chore.dart— replaceplaceIdwithroomIds: List<String>; updateassignedMemberIdsUnionconsumers, copyWith, props, json, and theplaceId→roomIdsfromJson migration.lib/src/adapters/adapter.dart(StoragePort) + the 5 adapters (memory/,client_sdk_testingFakePort,cloud/supabase_catalog.dartplaces,cloud/supabase_chores.dartchores,local/local_database.dart+.g.dart+local_storage_adapter.dart,cached/cached_storage_adapter.dart) — carryowner_ids/room_ids.lib/src/services/chore_service.dart— newmembersForRoom(chore, roomId, {place})+assignedMemberIdsUnion/print-list callers iterateroomIds.lib/src/client/client.dart+client_impl.dart— facade signature formembersForRoom.
DS (packages/design_system/)
lib/src/molecules/ds_multi_room_picker.dart— the reusable multi-select, floor-grouped room picker +showDsMultiRoomPicker.
App (app/lib/inside/)
routes/authenticated/chore_editor/widgets/chore_editor_body.dart— multi-room tagging + per-room override + resolved-doers readout.routes/authenticated/rooms/page.dart+ a newRooms_Card_OwnerEditorwidget — room-owner editor.routes/authenticated/home/**Today by-room grouping +RoomPrintDialog— use the resolver + iterateroomIds.
Infra — infra/supabase/migrations/20260726000100_rooms_ownership_multiroom.sql.
Tests — SDK unit (test/services/chore_service_members_for_room_test.dart, adapter round-trip), DS golden (test/molecules/ds_multi_room_picker_test.dart), app flow (app/test/flows/rooms_ownership_test.dart).
Task 1: SDK — Place.ownerIds (additive)
Files:
- Modify:
packages/client_sdk/lib/src/models/place.dart - Test:
packages/client_sdk/test/models/place_test.dart
Interfaces — Produces: Place.ownerIds: List<String> (default const []), Place.copyWith({List<String>? ownerIds}), JSON key owner_ids.
- Step 1: Write the failing test
// packages/client_sdk/test/models/place_test.dart
import 'package:client_sdk/client_sdk.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('Place carries ownerIds through json + copyWith + props', () {
const p = Place(
id: 'r1', householdId: 'h1', houseId: 'ho1', name: 'Kitchen',
ownerIds: ['m1', 'm2'],
);
expect(p.ownerIds, ['m1', 'm2']);
expect(p.toJson()['owner_ids'], ['m1', 'm2']);
expect(Place.fromJson(p.toJson()).ownerIds, ['m1', 'm2']);
expect(p.copyWith(ownerIds: ['m3']).ownerIds, ['m3']);
const bare = Place(id: 'r1', householdId: 'h1', houseId: 'ho1', name: 'Kitchen');
expect(bare.ownerIds, isEmpty);
expect(p == bare, isFalse);
});
test('Place.fromJson defaults ownerIds to empty when key absent', () {
final p = Place.fromJson({
'id': 'r1', 'household_id': 'h1', 'house_id': 'ho1', 'name': 'Kitchen',
});
expect(p.ownerIds, isEmpty);
});
}
- Step 2: Run — expect FAIL (
ownerIdsundefined)
Run: cd packages/client_sdk && fvm flutter test test/models/place_test.dart
Expected: FAIL — The named parameter 'ownerIds' isn't defined.
- Step 3: Implement
Edit place.dart: add the field with a JSON default, thread it through the constructor, copyWith, props, and regenerate place.g.dart.
// in the constructor param list, after houseId/name/floor:
this.ownerIds = const <String>[],
// field, with a null-safe json default for rows written before this column:
/// Member ids that OWN this room (spec: persistent, household-scoped, multi-
/// owner). Drives chore-doer resolution (ChoreService.membersForRoom tier 2).
/// Wire twin `places.owner_ids uuid[]` default `'{}'`.
@JsonKey(defaultValue: <String>[])
final List<String> ownerIds;
// in copyWith params + body:
List<String>? ownerIds,
// ...
ownerIds: ownerIds ?? this.ownerIds,
// in props: add ownerIds
List<Object?> get props => [id, householdId, houseId, name, floor, ownerIds, createdAt];
Regenerate the serializer (scoped, then restore any clobbered sibling .g.dart per the Drift-regen rule):
cd packages/client_sdk && fvm flutter pub run build_runner build --delete-conflicting-outputs \
--build-filter "lib/src/models/place.g.dart"
git checkout HEAD -- $(git diff --name-only -- '*.g.dart' | grep -v 'lib/src/models/place.g.dart') 2>/dev/null || true
- Step 4: Run — expect PASS
Run: cd packages/client_sdk && fvm flutter test test/models/place_test.dart
Expected: PASS.
- Step 5: Commit
git add packages/client_sdk/lib/src/models/place.dart packages/client_sdk/lib/src/models/place.g.dart packages/client_sdk/test/models/place_test.dart
git commit -m "feat(sdk): add Place.ownerIds (room ownership)"
Task 2: SDK — Place.ownerIds across the 5 adapters (round-trip)
Files:
- Modify:
packages/client_sdk/lib/src/adapters/cloud/supabase_catalog.dart(places read/write),.../local/local_database.dart+.g.dart+local_storage_adapter.dart(Driftplacestable + mapping),.../memory/in_memory_storage_adapter.dart(stores objects — verify),.../cached/cached_storage_adapter.dart(delegates — verify), andpackages/client_sdk_testing/lib/src/**FakePort places. - Test:
packages/client_sdk/test/adapters/place_owner_ids_roundtrip_test.dart
Interfaces — Consumes: Place.ownerIds (Task 1). Produces: every adapter persists + returns ownerIds.
- Step 1: Write the failing test (in-memory + Drift local round-trip; use the REAL StoragePort place-upsert verb — read
adapter.dartfor its name)
// packages/client_sdk/test/adapters/place_owner_ids_roundtrip_test.dart
import 'package:client_sdk/client_sdk.dart';
import 'package:client_sdk/src/adapters/memory/in_memory_storage_adapter.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('InMemory adapter round-trips Place.ownerIds', () async {
final a = InMemoryStorageAdapter();
const place = Place(id: 'r1', householdId: 'h1', houseId: 'ho1',
name: 'Kitchen', ownerIds: ['m1', 'm2']);
await a.upsertPlace(place); // <-- use the real StoragePort verb name
final got = (await a.getPlaces('h1')).single;
expect(got.ownerIds, ['m1', 'm2']);
await a.upsertPlace(place.copyWith(ownerIds: ['m3']));
expect((await a.getPlaces('h1')).single.ownerIds, ['m3']);
});
}
-
Step 2: Run — expect FAIL or ownerIds dropped. Run:
cd packages/client_sdk && fvm flutter test test/adapters/place_owner_ids_roundtrip_test.dart. (In-memory stores the object directly, so it may already pass — the Drift path is the real work.) -
Step 3: Implement — add the
owner_idscolumn to the Driftplacestable (local_database.dart, reusing the SAME list-JSON converter the codebase already uses forChore.assignedMemberIds), regeneratelocal_database.g.dartscoped, map it inlocal_storage_adapter.dartplace read/write, and mapowner_idsinsupabase_catalog.dartplace select/insert/update. Verifyin_memory+cachedpass through unchanged.
cd packages/client_sdk && fvm flutter pub run build_runner build --delete-conflicting-outputs \
--build-filter "lib/src/adapters/local/local_database.g.dart"
git checkout HEAD -- $(git diff --name-only -- '*.g.dart' | grep -v 'local_database.g.dart') 2>/dev/null || true
-
Step 4: Run — expect PASS (add a Drift-backed round-trip assertion mirroring the existing local-adapter test setup in the repo).
-
Step 5: Commit
git add packages/client_sdk packages/client_sdk_testing
git commit -m "feat(sdk): persist Place.ownerIds across all 5 adapters"
Task 3: SDK — Chore.placeId → roomIds (ONE compile unit) + new resolution
Files:
- Modify:
packages/client_sdk/lib/src/models/chore.dart,.../services/chore_service.dart,.../client/client.dart+client_impl.dart,adapters/adapter.dart(ifplaceIdis in a StoragePort signature), all 5 adapters whereverplace_id/placeIdis read/written for chores (cloud/supabase_chores.dart,local/local_database.dart+.g.dart+local_storage_adapter.dart,memory/,cached/). - Test:
packages/client_sdk/test/services/chore_service_members_for_room_test.dart,.../test/models/chore_room_ids_test.dart
Interfaces — Produces:
-
Chore.roomIds: List<String>(defaultconst []) REPLACESChore.placeId. JSON keyroom_ids.Chore.fromJsonmigrates a legacyplace_id→roomIds: [place_id]whenroom_idsis absent. -
Chore.copyWith({List<String>? roomIds})(dropsetPlaceId). -
ChoreService.membersForRoom(Chore chore, String roomId, {required Place place})→ new 3-tier rule. -
Facade
membersForRoomsignature updated to pass thePlace. -
Step 1: Write the failing tests
// chore_room_ids_test.dart — model migration + shape (copy the required chore json
// fields from an existing chore test fixture in the repo).
import 'package:client_sdk/client_sdk.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('Chore.fromJson migrates legacy place_id to roomIds', () {
final c = Chore.fromJson({
'id': 'c1', 'household_id': 'h1', 'title': 'Tidy', 'place_id': 'r1',
});
expect(c.roomIds, ['r1']);
});
test('Chore.fromJson prefers room_ids when present', () {
final c = Chore.fromJson({
'id': 'c1', 'household_id': 'h1', 'title': 'Tidy',
'room_ids': ['r1', 'r2'], 'place_id': 'rX',
});
expect(c.roomIds, ['r1', 'r2']);
});
}
// chore_service_members_for_room_test.dart — the 3-tier rule.
// Build ChoreService + the _chore(...) fixture the same way the existing
// chore_service tests do (read one first for the constructor + fixtures).
import 'package:client_sdk/client_sdk.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
const room = Place(id: 'r1', householdId: 'h1', houseId: 'ho1',
name: 'Kitchen', ownerIds: ['owner1']);
group('membersForRoom 3-tier', () {
test('tier 1: explicit roomAssignees override wins', () {
final chore = _chore(roomIds: ['r1'], roomAssignees: {'r1': ['ov1']},
assignedMemberIds: ['a1']);
expect(service.membersForRoom(chore, 'r1', place: room), ['ov1']);
});
test('tier 2: falls back to room owners when no override', () {
final chore = _chore(roomIds: ['r1'], assignedMemberIds: ['a1']);
expect(service.membersForRoom(chore, 'r1', place: room), ['owner1']);
});
test('tier 3: falls back to chore assignees when no override and no owner', () {
final chore = _chore(roomIds: ['r1'], assignedMemberIds: ['a1']);
const ownerless = Place(id: 'r1', householdId: 'h1', houseId: 'ho1', name: 'Kitchen');
expect(service.membersForRoom(chore, 'r1', place: ownerless), ['a1']);
});
});
}
-
Step 2: Run — expect FAIL (compile errors:
roomIds/new signature undefined). Run:cd packages/client_sdk && fvm flutter test test/services/chore_service_members_for_room_test.dart test/models/chore_room_ids_test.dart. -
Step 3: Implement (the whole compile unit)
chore.dart: replace final String? placeId; with @JsonKey(defaultValue: <String>[]) final List<String> roomIds;; wrap the generated factory for the migration:
factory Chore.fromJson(Map<String, dynamic> json) {
if (json['room_ids'] == null && json['place_id'] != null) {
json = {...json, 'room_ids': [json['place_id']]};
}
return _$ChoreFromJson(json);
}
Update copyWith (drop setPlaceId, add roomIds), props (replace placeId with roomIds), and the print-tag doc. Keep roomAssignees + assignedMemberIds unchanged.
chore_service.dart: rewrite membersForRoom to the spec rule and update every caller that iterated placeId (printable lists) to iterate roomIds:
/// Resolves the doers for [roomId] on [chore] (spec 3-tier):
/// (a) chore.roomAssignees[roomId] non-empty → the manual override;
/// (b) place.ownerIds non-empty → the room's owner(s);
/// (c) chore.assignedMemberIds — the chore-level fallback.
List<String> membersForRoom(Chore chore, String roomId, {required Place place}) {
final override = chore.roomAssignees[roomId];
if (override != null && override.isNotEmpty) return override;
if (place.ownerIds.isNotEmpty) return place.ownerIds;
return chore.assignedMemberIds;
}
Thread place_id → room_ids through StoragePort (if present) + all 5 adapters (Drift chores.room_ids column w/ the list converter + regen scoped; Supabase room_ids array; in-memory/cached pass-through) and the facade client.dart/client_impl.dart membersForRoom signature (now takes Place).
cd packages/client_sdk && fvm flutter pub run build_runner build --delete-conflicting-outputs \
--build-filter "lib/src/adapters/local/local_database.g.dart" \
--build-filter "lib/src/models/chore.g.dart"
git checkout HEAD -- $(git diff --name-only -- '*.g.dart' | grep -vE 'local_database.g.dart|models/chore.g.dart') 2>/dev/null || true
- Step 4: Run — full SDK suite green (the whole package must recompile).
Run: cd packages/client_sdk && fvm flutter test
Expected: all pass (the new tests green; no baseline drop).
- Step 5: Commit
git add packages/client_sdk
git commit -m "feat(sdk): Chore.roomIds replaces placeId; membersForRoom = override->owner->assignees"
Task 4: DS — reusable multi-select room picker
Files:
- Create:
packages/design_system/lib/src/molecules/ds_multi_room_picker.dart(+ export from the DS barrel) - Test:
packages/design_system/test/molecules/ds_multi_room_picker_test.dart(widget + golden)
Interfaces — Produces: Future<Set<String>?> showDsMultiRoomPicker({required BuildContext context, required List<DsRoomOption> rooms, required Set<String> selected, String? title}) and a DsMultiRoomPicker widget. DsRoomOption({required String id, required String name, String? floor}). Returns the chosen id set (or null if dismissed). Mirrors the modal shape of the existing emoji picker (showDsEmojiPicker/DsEmojiPicker — read it first), floor-grouped via FilterChip, with a "clear" affordance. Test keys: Key('DsMultiRoomPicker.chip.<id>'), Key('DsMultiRoomPicker.done'), Key('DsMultiRoomPicker.clear').
- Step 1: Write the failing widget test
// ds_multi_room_picker_test.dart
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
Set<String>? _lastResult;
void main() {
const rooms = [
DsRoomOption(id: 'r1', name: 'Kitchen', floor: 'Main'),
DsRoomOption(id: 'r2', name: 'Bedroom', floor: 'Upstairs'),
DsRoomOption(id: 'r3', name: 'Garage'),
];
testWidgets('toggling chips builds the selected set; Done returns it',
(tester) async {
await tester.pumpWidget(MaterialApp(
theme: DsTheme.light.toThemeData(),
home: Scaffold(body: Builder(builder: (context) => TextButton(
onPressed: () async {
_lastResult = await showDsMultiRoomPicker(
context: context, rooms: rooms, selected: {'r1'});
},
child: const Text('open')))),
));
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('DsMultiRoomPicker.chip.r2')));
await tester.pump();
await tester.tap(find.byKey(const Key('DsMultiRoomPicker.done')));
await tester.pumpAndSettle();
expect(_lastResult, {'r1', 'r2'});
});
testWidgets('clear empties the selection', (tester) async {
// open with {'r1','r2'}, tap DsMultiRoomPicker.clear, Done → {}
});
}
-
Step 2: Run — expect FAIL (
showDsMultiRoomPickerundefined). Run:cd packages/design_system && fvm flutter test test/molecules/ds_multi_room_picker_test.dart. -
Step 3: Implement the widget mirroring
DsEmojiPicker'sshowDsSheet-hosted grid: grouproomsbyfloor(floorless rooms in a trailing "No floor" section — reuse the DS floor-grouping helper if one exists, else group inline), render each as aFilterChiptoggling membership in a localSet<String>, a "Clear" text button, and a "Done" primary button thatNavigator.pop(context, selected). Copy is caller-supplied (title). No brand strings. -
Step 4: Run — PASS + author the golden:
cd packages/design_system && fvm flutter test test/molecules/ds_multi_room_picker_test.dart --update-goldens
cd packages/design_system && fvm flutter test test/molecules/ds_multi_room_picker_test.dart
Expected: PASS; commit the golden PNG.
- Step 5: Commit
git add packages/design_system
git commit -m "feat(ds): DsMultiRoomPicker — reusable floor-grouped multi-select room picker"
Task 5: App — chore editor multi-room + per-room override + resolved doers
Files:
- Modify:
app/lib/inside/routes/authenticated/chore_editor/widgets/chore_editor_body.dartand the chore-editor bloc/state (read them first — they currently hold a singleplaceId+roomAssignees). - Test: extend the existing chore-editor widget/flow tests.
Interfaces — Consumes: Chore.roomIds, ChoreService.membersForRoom (Task 3), showDsMultiRoomPicker (Task 4). Produces: the editor writes roomIds (multi) + optional per-room roomAssignees and shows resolved doers per selected room.
-
Step 1: Write the failing widget test — asserting: opening the room picker and selecting two rooms sets the editor's
roomIdsto both; a per-room override chip-grid appears only for selected rooms; the read-only resolved-doers line reflectsmembersForRoom. (Mirror the existing chore-editor test setup; usefind.byKey(Key('DsMultiRoomPicker.chip.<id>')).) -
Step 2: Run — expect FAIL.
-
Step 3: Implement — replace the single
RoomPickerusage with a "Rooms this applies to" field openingshowDsMultiRoomPicker(seeded from the editor's currentroomIds); for each selected room, render the existing per-member override chip grid writingroomAssignees[roomId]; show a read-only "Doers: …" line frommembersForRoom(chore, roomId, place: <that place>). Keep the chore-level "Assigned to" as the fallback. NEW extracted widgets use underscore naming (e.g.ChoreEditor_Field_Rooms,ChoreEditor_Text_ResolvedDoers); the existingChoreEditorBodystays as-is (legacy). -
Step 4: Run — PASS (chore-editor widget + flow tests green).
-
Step 5: Commit
feat(app): chore editor multi-room tagging + per-room override + resolved doers.
Task 6: App — Rooms/Household room-owner editor
Files:
- Modify:
app/lib/inside/routes/authenticated/rooms/page.dart; Create:app/lib/inside/routes/authenticated/rooms/widgets/rooms_card_owner_editor.dart(classRooms_Card_OwnerEditor). - Test: extend the Rooms page widget/flow tests.
Interfaces — Consumes: Place.ownerIds, the places repository's update path, and the existing member-chip multi-select grid used by the chore per-room override. Produces: each room row gains an owner editor writing place.ownerIds.
-
Step 1: Failing widget test — tapping a room's "Owners" affordance opens the member multi-select; choosing members and confirming calls the places-update path with the new
ownerIds. (Mirror the existing Rooms page test.) -
Step 2: Run — expect FAIL.
-
Step 3: Implement
Rooms_Card_OwnerEditor— an owner row per place using the existing member-chip multi-select, persisting via the Rooms bloc/repository →updatePlace(place.copyWith(ownerIds: ...)). Household-scope only; typed errors. -
Step 4: Run — PASS.
-
Step 5: Commit
feat(app): room ownership editor on the Rooms page.
Task 7: App — Today / printables use the resolver + iterate roomIds
Files:
- Modify: the Today by-room grouping (
app/lib/inside/routes/authenticated/home/**— the "By room" grouping) andRoomPrintDialog(read them first). - Test: extend the Today + print tests.
Interfaces — Consumes: Chore.roomIds, ChoreService.membersForRoom(…, place:). Produces: a multi-room chore lists under EACH tagged room; per-room doers come from the resolver; room-print lists iterate roomIds.
-
Step 1: Failing test — a chore tagged to two rooms appears under both in the "By room" grouping, each showing the resolved doers (owner-inherited when no override). Room-print for a room lists all chores whose
roomIdscontains it. -
Step 2: Run — expect FAIL (still references
placeId). -
Step 3: Implement — swap
placeIdreads forroomIdsmembership in the grouping + print filters; compute per-room doers viamembersForRoom(chore, roomId, place: place). -
Step 4: Run — PASS.
-
Step 5: Commit
feat(app): Today by-room + room-print use roomIds + membersForRoom resolver.
Task 8: Migration (file-only) + gadfly flow tests + whole-suite green
Files:
- Create:
infra/supabase/migrations/20260726000100_rooms_ownership_multiroom.sql - Create:
app/test/flows/rooms_ownership_test.dart - Test: full SDK + app suites.
Migration (FILE-ONLY; prod apply DEPLOY-GATED — do NOT apply):
-- 20260726000100_rooms_ownership_multiroom.sql — room ownership + chore multi-room.
-- Additive + backfill; RLS unchanged (household-scoped, same as places/chores).
-- 1. Room ownership: multi-owner array on places (mirrors chore.assigned_member_ids).
alter table public.places
add column if not exists owner_ids uuid[] not null default '{}';
-- 2. Chore multi-room: room_ids replaces the single place_id as the room SET.
alter table public.chores
add column if not exists room_ids uuid[] not null default '{}';
-- 3. Backfill: every chore with a place_id becomes room_ids := array[place_id].
update public.chores
set room_ids = array[place_id]
where place_id is not null and (room_ids is null or room_ids = '{}');
-- place_id is retained (nullable) for one release as a safety net; a later
-- migration drops it once room_ids is proven in prod. Room-print lists + the
-- resolver read room_ids exclusively from here on.
-
Step 1: Write the migration file (above). Do NOT apply (owner-gated).
-
Step 2: Write the ONE gadfly-canonical flow test —
rooms_ownership_test.dart: one sharedbaseDescriptions(EPIC "Rooms ownership & multi-room" + STORY "A parent owns rooms and tags chores to them"), aflowTest('success', …)walking sequentialtester.screenshot(...)steps — set-room-ownership → tag-a-chore-to-multiple-rooms → room-owner-inherits (a chore with no override shows the owner as doer) → per-room-override-wins (adding an override replaces the owner) — each step assertingexpectations+expectedEvents. Use boundedpump(Duration(...))where any looping animation mounts. Followapp/test/flows/sign_in_test.dart(canonical) + the harnessMocksContainer. -
Step 3: Run FULL suites — expect green, baselines not dropped
Run: cd packages/client_sdk && fvm flutter test → all pass.
Run: cd app && fvm flutter test → all pass (chore-editor / Rooms / Today / print tests updated in Tasks 5-7; the new flow test green).
Run: cd app && fvm flutter analyze lib → clean.
-
Step 4: If any legacy test still references
Chore.placeIdor the old single-selectRoomPicker, repoint it toroomIds/DsMultiRoomPicker(do not delete coverage — migrate it). -
Step 5: Commit
feat: rooms ownership + multi-room migration + flow tests.
Self-Review
Spec coverage: Place.ownerIds (T1,T2) · Chore.roomIds replacing placeId + placeId→roomIds migration (T3,T8) · 3-tier membersForRoom override→owner→assignees (T3) · reusable multi-select picker (T4) · chore editor multi-room + per-room override + resolved doers (T5) · Rooms/Household owner editor (T6) · Today/printables via resolver iterating roomIds (T7) · adapters one-compile-unit + round-trip (T2,T3) · file-only migration + backfill (T8) · gadfly flow test with the four stories (T8). All spec sections mapped.
Placeholder scan: each task carries concrete file paths, code, and commands. Where a step says "read it first / mirror the existing pattern" it names the exact reference file — the SDD dispatch hands the implementer those files; the load-bearing SDK model + resolution + DS picker + migration carry full code.
Type consistency: Place.ownerIds: List<String>, Chore.roomIds: List<String>, membersForRoom(Chore, String roomId, {required Place place}), showDsMultiRoomPicker(...) → Set<String>?, DsRoomOption(id,name,floor?) are used identically across tasks. DB columns owner_ids/room_ids (uuid[]); model JSON keys owner_ids/room_ids; Drift list-converter reused from assigned_member_ids.
Known deviation flagged for the reviewer: the new membersForRoom tier 2 is place.ownerIds, REPLACING the current member.homePlaceId-based tier 2 (the spec's explicit intent). Confirm no surviving caller depends on the old home-room routing, and that HouseholdMember.homePlaceId (still the member's home-room attribute) is untouched.