浏览代码

Taak 127 uitgewerkt: Kopie-knop op MijnAanmeldingen (klonen)

Onderzocht wat er nodig is om een aanmelding te klonen. De backend bestaat
al (aanmelding_kloonbron.json, staat aan op productie, met eigenaarscheck);
aan de app-kant is nog niets.

Drie gaten in de kloonbron hard gemeten en gepatcht (read-only getest op
productie, bestand niet gewijzigd):
- entree_tid ontbrak bij een activiteit; de veldkaart heeft 'entree' => NULL
  terwijl _custom_stadsactiviteit_create() wel naar field_act_entree schrijft
  (node 222818 heeft tid 36197) -> dataverlies bij klonen
- geen logo_url/fotos_urls, terwijl de aanmaakpagina daarop de preview toont
- geen plaatsketen, terwijl de pagina drie cascade-dropdowns heeft

Het keten-formaat is bewust gelijk aan dat van taak 123, zodat de custom
functions uit taak 124 hergebruikt kunnen worden. Alle 23 gevulde
field_act_state_municipality_tow-waarden staan op niveau 3, dus het
gemeente/provincie-randgeval hoeft niet afgevangen te worden.

TASKS.md: taak 127 in zes stappen (A Drupal, B App State, C knop,
D evenementen, E activiteiten na 124, F kloonBron wissen), met per stap de
exacte velden en bindingen.

Ook hersteld: de blokken 123 t/m 126 waren bij de vorige commit uit TASKS.md
verdwenen terwijl de tabel er nog naar verwijst.

CLAUDE.md: een widget achter een Visibility-conditie heeft geen
eerste-build-probleem (scheelt bij een cascade tweederde van het bindwerk),
een voorgeselecteerde dropdown vuurt geen onChanged, en file_create_url()
geeft onder drush http:// maar via een echte request https://.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bob 22 小时之前
父节点
当前提交
46d434229b
共有 5 个文件被更改,包括 627 次插入0 次删除
  1. 26 0
      CLAUDE.md
  2. 341 0
      TASKS.md
  3. 17 0
      snippets/LEESMIJ.md
  4. 60 0
      snippets/kloon_functies.dart.txt
  5. 183 0
      snippets/kloonbron-drupal-patch.inc.txt

+ 26 - 0
CLAUDE.md

@@ -1286,6 +1286,24 @@ state die je in On Page Load vult** — On Page Load draait in een
 `addPostFrameCallback`, dus ná de eerste build; de `FormFieldController` is dan
 al met de oude (lege) waarde geïnitialiseerd.
 
+**Datzelfde geldt voor de Initial Value van een `TextField` — maar een widget
+ACHTER een Visibility-conditie heeft dit probleem juist NIET.** Uitgezocht
+2026-09-20 (taak 127). De regel is niet "On Page Load is te laat", maar "een
+widget leest zijn beginwaarde één keer, bij zijn eerste build". Een widget dat
+pas ná de On-Page-Load-`safeSetState` voor het eerst bestaat, leest zijn
+Initial Value dus gewoon uit een page state die in die load gezet is. Concreet
+op `stadsactiviteitAanmaken`: de gemeente-dropdown zit achter
+`if (createProvincieID != null && != '')` en de plaats-dropdown achter
+`if (createGemeenteID ...)`, dus alleen de **provincie**-dropdown moet aan een
+page parameter of App State hangen; de andere twee mogen aan page state. Scheelt
+bij een cascade tweederde van het fragiele bindwerk — check dus eerst in de
+export welke velden überhaupt in de eerste build voorkomen.
+
+**Een voorgeselecteerde dropdown vuurt geen `onChanged`.** Zet de bijbehorende
+page state dus óók expliciet in On Page Load. Vergeet je dat, dan toont de
+dropdown de juiste waarde maar verstuurt de create-actie een lege — stil, en
+niet te zien in de builder.
+
 **"Insert Before"/"Insert After" in het widget-tree-contextmenu is GEEN
 widgetkiezer — het dupliceert de buur-widget.** Bevestigd 2026-09-03: "Insert
 Before" op een DropDown voegde een kopie van de dropdown erbóven toe (een derde
@@ -4951,6 +4969,14 @@ script `services-dicht.php` (scratchpad 2026-09-19) doet dat mét droogdraai en
 schrijft achteraf een `watchdog()`-regel — reproduceerbaar, te testen op devbob,
 en de uitvoer is meteen je backup.
 
+**`file_create_url()` geeft onder drush `http://`, via een echte request
+`https://` — een drush-artefact, geen bug.** Bevestigd 2026-09-20: `$base_url`
+staat op `http://uitgaanskrant.com` en drush heeft geen request-context om dat
+uit af te leiden. Meet je een beeld-URL met `drush php-eval`/`php-script`, ga
+die dan niet "repareren" met een hardcoded `https://`; controleer 'm met een
+gewone `curl` op het endpoint (zoals `flutterflowmobiel_establishment_info`, die
+geeft netjes `https://`).
+
 **⚠️ Stuur bij élke `curl`/Python-meting tegen productie een normale User-Agent
 mee — `Python-urllib` wordt door Cloudflare geblokkeerd met een 403.** Gevonden
 2026-09-19, en het kost je een half uur verwarring want het lijkt op een

+ 341 - 0
TASKS.md

@@ -53,6 +53,7 @@ Google Play en één doorloop die alleen Bob kan doen.
 | **122** | Teksten aanmaakpagina's | ✅ **A/B/C af** (Claude 2026-09-20, 19 teksten, export-geverifieerd): verkeerde woorden, typefouten, technische veldnamen en het sterretje bij de categoriekeuze. Open blijven D (hints die het label herhalen), E-restje en F (`Select...` op een NL-pagina) — eigen blok hieronder |
 | **123** | `mijn_stadsrechten` uitbreiden met de keten | **Code staat klaar en is op productie getest.** Vervang regels 316-372 van `custom.evenementen_aanmaken.inc` + `cc all`. Blokkeert 124 |
 | **124-126** | Stadsrechten-cascade op `stadsactiviteitAanmaken` | Voorvullen van provincie/gemeente/plaats (124), een **crash** bij een lege plaatsenlijst (125) en een stille verkeerde plaats bij het wisselen van gemeente (126). Eigen blok hieronder |
+| **127** | **Kopie-knop op `MijnAanmeldingen`** (clonen) | Wens Bob 2026-09-20, **vóór de livegang**. Backend bestaat al; Drupal-patch voor 3 gaten staat klaar en is getest (127-A). App-deel in stappen B t/m F; het activiteitendeel (127-E) deelt de cascade met 124. Eigen blok hieronder |
 
 ---
 #
@@ -395,6 +396,346 @@ sterretje bij de categoriekeuze) zijn gedaan op béide pagina's:
   hulpregel zegt "de **zaak** waar het evenement plaatsvindt". Eén woord kiezen
   is een besluit voor Bob, geen opruimklus.
 
+### 123 · Stadsrechten-cascade op `stadsactiviteitAanmaken` — Drupal-deel · Eigenaar: Bob
+
+**Code staat klaar en is getest** (2026-09-20, Claude): vervang in
+`sites/all/modules/custom/custom.evenementen_aanmaken.inc` de **regels 316 t/m
+372** (docblock t/m sluitende accolade van `_custom_mijn_stadsrechten_service()`)
+door de aangeleverde versie. Daarna `drush @<alias> cc all`.
+
+**Waarom:** `field_town_access` mag een term op **elk** van de drie niveaus van
+de town-vocabulary bevatten — plaats (tot 2026-08-27), gemeente (huidige
+standaard) of provincie (de widget staat op Max depth 2 en laat dat toe; Bob
+geeft ze bewust op provincieniveau, dat scheelt veel werk). De app stopte die tid
+rechtstreeks in `plaatsen_bij_gemeente`, en dat geeft bij provincie én plaats een
+lege lijst → zie taak 125.
+
+De nieuwe versie geeft per recht de **volledig uitgeklapte keten** terug:
+`niveau` · `provincie_tid`/`_titel` · `gemeente_tid`/`_titel` ·
+`plaats_tid`/`_titel` · `keten` (`"provincie|gemeente|plaats"`, ontbrekende delen
+leeg) · `label` (bv. *"Noord-Brabant (hele provincie)"*). `tid`, `titel`,
+`parent_tid` en `parent_titel` blijven ongewijzigd staan, dus bestaande
+consumenten merken niets. Sortering is hiërarchisch i.p.v. alfabetisch op titel.
+
+Getest op productie onder een testnaam, alle drie de niveaus:
+
+| account | recht | `keten` |
+|---|---|---|
+| bobcity | 28694 Amsterdam (gemeente) | `28666\|28694\|` |
+| bobhoreca | 27409 Noord-Brabant (provincie) | `27409\|\|` |
+| Team Klein Frankrijk | 29912 Zuid-Holland (provincie) | `29912\|\|` |
+
+### 124 · Stadsrechten-cascade — app-deel · Eigenaar: nader te bepalen (na 123)
+
+De eerste dropdown in het *Waar*-blok van `stadsactiviteitAanmaken` moet de drie
+cascade-dropdowns voorvullen tot zover het recht reikt, in plaats van alleen
+`createGemeenteID` te zetten:
+
+| recht | voorvullen | gebruiker kiest nog |
+|---|---|---|
+| provincie | provincie | gemeente + plaats |
+| gemeente | provincie + gemeente | plaats |
+| plaats (oud) | alle drie | niets |
+
+**Stappen:** dropdown-waarde omzetten van `$[:].tid` naar **`$[:].keten`** (label
+op `$[:].label`), plus drie custom functions van **één** String-argument
+(`ketenProvincie` / `ketenGemeente` / `ketenPlaats` → `split('|')[n]`). Die ene
+parameter is bewust: een lookup op de hele respons vraagt een custom function met
+meerdere argumenten, en die binding loopt in de Action Flow Editor structureel
+vast (zie `CLAUDE.md`). On Selected → `Update Page State` voor
+`createProvincieID`/`createGemeenteID`/`createPlaatsID` + `Set Form Field` op de
+drie dropdowns.
+
+Een lege `Set Form Field`-waarde wordt geweigerd ("Value cannot be empty"), dus
+voor de lege delen van de keten is **Reset Form Fields** nodig — wat meteen de
+tweede helft van taak 126 afhandelt.
+
+**Voorvullen, niet verbergen** (besluit Bob 2026-09-20): met verbergen kun je
+achteraf niet meer wisselen zonder eerst de rechten-dropdown leeg te maken.
+
+### 125 · Crash: lege plaatsenlijst sloopt de Plaats-dropdown · Eigenaar: nader te bepalen
+
+`stadsactiviteit_aanmaken_widget.dart:1878` doet
+`(getJsonField(respons, r'$[:].plaatsid', true) as List?)!`. `getJsonField` geeft
+bij **0 matches `null`** — ook met `isForList: true`, want de `isEmpty`-check
+staat ervóór ([`flutter_flow_util.dart:356`]). Een lege respons `[]` geeft dus
+`Null check operator used on a null value`; in een release-build een grijs blok
+zonder melding.
+
+Treedt nu op bij elke provincie- of plaats-tid in `field_town_access`, maar staat
+**los van taak 123/124**: ook een gemeente zónder plaatsen in de taxonomie geeft
+dezelfde crash. Zelfde patroon staat op de **gemeente**- en **provincie**-dropdown
+van diezelfde pagina — alle drie langslopen.
+
+Fix: Generate-Dynamic-Children-achtige null-veiligheid via een **Predefined Path**
+op de API-call (`$[:]`, Is List) i.p.v. een JSON Path — dat genereert
+`(XCall.items(jsonBody)?.toList() ?? [])`. Recept staat in `CLAUDE.md`.
+
+### 126 · Stale `createPlaatsID` bij het wisselen van gemeente · Eigenaar: nader te bepalen
+
+Geen van de vier dropdowns in het *Waar*-blok reset de onderliggende. Scenario:
+kies gemeente A → plaats A₁ (`createPlaatsID` gezet) → wissel naar gemeente B.
+De Plaats-dropdown toont dan weer zijn hint (`FlutterFlowDropDown` filtert een
+waarde die niet in de opties zit weg, [`flutter_flow_drop_down.dart:101`]), maar
+`createPlaatsID` houdt **A₁** vast. De verzendknop blijft dus zichtbaar en je
+dient in voor de vorige plaats. Stil, geen foutmelding.
+
+Fix: `Reset Form Fields` + `Update Page State` (Reset Value) op de onderliggende
+velden in de On-Selected van provincie, gemeente én de rechten-dropdown. Overlapt
+met taak 124 — samen oppakken.
+
+### 127 · "Kopie"-knop op `MijnAanmeldingen` — evenement én activiteit klonen
+
+**Wens Bob (2026-09-20):** op elke kaart in *Mijn aanmeldingen* een knop **Kopie**.
+Eén tik opent het aanmaakformulier volledig ingevuld; de gebruiker vult de datum
+aan, past eventueel iets aan en dient in. Vóór de livegang. Beide types.
+
+**De datum komt bewust NIET mee** — je kloont juist om hetzelfde op een nieuwe
+datum te zetten. Dat zit al zo in de kloonbron.
+
+#### Wat er al is (gemeten 2026-09-20, niet opnieuw uitzoeken)
+
+`GET /nl/flutterdrup/aanmelding_kloonbron.json?nid=X` **bestaat en staat aan op
+productie** (`custom.mijn_aanmeldingen.inc:248`, resource `aanmelding_kloonbron`,
+operation `index`). Sessie-geauthenticeerd, met eigenaarscheck (403 op andermans
+node, 404 onbekend, 400 bij een ander contenttype). De sleutelnamen zijn met
+opzet gelijk aan wat `evenementen/create` en `stadsactiviteiten/create`
+verwachten — tids en fids, geen weergavewaarden.
+
+Aan de **app-kant is nog niets**: geen API Call, geen knop, 0 treffers op
+`kloonbron` in een verse export.
+
+#### 127-A · Drupal: drie gaten in de kloonbron dichten · Eigenaar: Bob
+
+**Code staat klaar en is op productie getest** (2026-09-20, Claude, read-only via
+`drush php-script` — het bestand is niet gewijzigd). Drie gaten, alle drie hard
+gemeten:
+
+| # | Gat | Bewijs |
+|---|---|---|
+| 1 | **`entree_tid` ontbreekt bij een activiteit** | De veldkaart heeft `'entree' => NULL` voor `activity`, maar `_custom_stadsactiviteit_create()` schrijft wél naar **`field_act_entree`** (Bob 2026-08-26, zelfde vocabulary als `field_goo_entree`). Testnode 222818 heeft `field_act_entree = 36197`; de kloonbron gaf dat niet terug → **dataverlies bij klonen** |
+| 2 | **Geen `logo_url`/`fotos_urls`** | De aanmaakpagina toont de preview uit page state `createLogoUrl`/`createFotosUrls`. Zonder URL's gaat het beeld wél mee (de fid) maar zie je een leeg previewvak — verwarrend |
+| 3 | **Geen plaatsketen bij een activiteit** | Alleen `plaats_tid`. De pagina heeft drie cascade-dropdowns (provincie → gemeente → plaats) en kan uit één tid niet afleiden welke provincie/gemeente erbij horen |
+
+**De patch** staat als `kloonbron-patch.inc` in het sessie-scratchpad van
+2026-09-20; opnieuw te genereren met de beschrijving hieronder. Twee ingrepen in
+`sites/all/modules/custom/custom.mijn_aanmeldingen.inc`:
+
+1. **Regel 125**: `'entree' => NULL,` → `'entree' => 'field_act_entree',`
+2. **Regels 240 t/m 313** (docblock t/m sluitende accolade van
+   `_custom_aanmelding_kloonbron()`) vervangen door de nieuwe versie. Die voegt
+   twee helpers toe (`_custom_aanm_bestand_urls()` en `_custom_aanm_keten()`) en
+   geeft extra terug: `logo_url`, `fotos_urls`, `entree_tid` (nu voor **beide**
+   types, uit het gedeelde blok), en voor een activiteit `provincie_tid`,
+   `gemeente_tid` en **`keten`**.
+
+Daarna `drush @uitgaanskrant.com cc all`.
+
+⚠️ **`keten` heeft bewust exact hetzelfde formaat als taak 123**
+(`"provincie|gemeente|plaats"`, ontbrekende delen leeg). De drie custom functions
+uit taak 124 (`ketenProvincie` / `ketenGemeente` / `ketenPlaats`) zijn daardoor
+1-op-1 herbruikbaar — bouw ze niet twee keer.
+
+**Getest op productie, beide types plus alle randgevallen:**
+
+| invoer | `keten` |
+|---|---|
+| plaats 28739 (Hoogkarspel) | `28666\|28737\|28739` |
+| gemeente 28694 (Amsterdam) | `28666\|28694\|` |
+| provincie 28666 (Noord-Holland) | `28666\|\|` |
+| `NULL` / `0` / onbestaande tid | `\|\|` (geen crash) |
+
+**Gemeten: alle 23 gevulde `field_act_state_municipality_tow`-waarden staan op
+niveau 3 (plaats).** Er is dus geen activiteit waarbij `plaats_tid` en het derde
+deel van de keten uiteenlopen — dat randgeval hoeft niet afgevangen te worden.
+
+ℹ️ **Niet "repareren": `file_create_url()` geeft onder drush `http://`.** Dat is
+een drush-artefact (geen request-context, `$base_url` staat op
+`http://uitgaanskrant.com`). Via een echte HTTP-request levert Drupal gewoon
+`https://` — geverifieerd op `flutterflowmobiel_establishment_info`. Controleer
+het bij de eerste test vanuit de app, maar ga er niet aan sleutelen.
+
+#### 127-B · App: transport via App State · Eigenaar: nader te bepalen
+
+**Waarom niet gewoon On Page Load?** Een `TextField` leest zijn Initial Value
+**tijdens de eerste build**; een Backend Call op *On Page Load* draait in een
+`addPostFrameCallback`, dus daarná. Het veld blijft dan leeg terwijl de data
+binnen is. Zelfde valkuil als bij Initial Option Value (staat in `CLAUDE.md`).
+Daarom: **de call gebeurt op de knop, vóór de navigatie.**
+
+**Besluit Bob 2026-09-20: App State, niet page parameters.** Eén JSON-veld scheelt
+de helft van het bindwerk (1 binding bij het vullen i.p.v. 16) en houdt de route
+schoon.
+
+**Nieuw aan te maken:**
+
+| wat | waar | details |
+|---|---|---|
+| App State `kloonBron` | App Values → State | Type **JSON**, *Is List* uit, **niet** persisted |
+| API Call `AanmeldingKloonbron` | API Calls | GET `https://uitgaanskrant.com/nl/flutterdrup/aanmelding_kloonbron.json`, header `Cookie: [sessionName]=[sessid]` (rechte haken!), query `nid`. Kopieer `MijnAanmeldingenCall` als voorbeeld |
+| Custom function `kloonTekst` | Custom Code | `String kloonTekst(dynamic bron, String sleutel)` — **non-nullable** return, `''` bij ontbreken |
+| Custom function `kloonLijst` | Custom Code | `List<String> kloonLijst(dynamic bron, String sleutel)` — non-nullable, `[]` bij ontbreken |
+
+```dart
+// kloonTekst — plat schrijven, geen closures (FlutterFlow's validator
+// struikelt over een genest (params) { ... }-patroon).
+String kloonTekst(dynamic bron, String sleutel) {
+  if (bron == null || bron is! Map) return '';
+  final waarde = bron[sleutel];
+  if (waarde == null) return '';
+  return waarde.toString();
+}
+
+List<String> kloonLijst(dynamic bron, String sleutel) {
+  final uit = <String>[];
+  if (bron == null || bron is! Map) return uit;
+  final waarde = bron[sleutel];
+  if (waarde is List) {
+    for (final item in waarde) {
+      if (item != null) uit.add(item.toString());
+    }
+  }
+  return uit;
+}
+```
+
+⚠️ **Return type non-nullable houden.** Bij een `String?` eist FlutterFlow een
+"Default Variable Value", en `valueOrDefault` substitueert die óók bij een lege
+string — dan kun je nooit een leeg veld klonen. Geen `getJsonField()` gebruiken:
+die bestaat niet binnen een Custom Function (wel in een Custom Action).
+
+#### 127-C · De knop op de kaart · Eigenaar: nader te bepalen
+
+Huidige opbouw van de kaart (`mijn_aanmeldingen_widget.dart`, itemBuilder):
+`Padding > Container > Padding > InkWell > Column [logo, titel, datum_start,
+status_label]`. De hele kaart is één `InkWell`.
+
+De knop moet **buiten die InkWell** staan, anders vuurt ook de navigatie naar
+`EventCurrent`. Recept (staat in `CLAUDE.md`): rechtsklik de `InkWell` →
+**Wrap Widget → Column** → "+" op die nieuwe Column → de knop. Een `TabBar Page`
+of `InkWell` accepteert maar één kind, een `Column` meerdere.
+
+**Actieketen op de knop:**
+
+1. **Backend Call** → `AanmeldingKloonbron`, `nid` = `$.nid` van het loop-item
+   (`aanmeldingItemItem`), sessie uit App State.
+2. **Update App State** → `kloonBron` = *Action Outputs → JSON Body →
+   No Further Changes*. Doe dit in de **Action Flow Editor** (knop *Edit*, dan het
+   `|<` om de trigger-kolom in te klappen) — in het compacte Actions-paneel
+   reageert het Value-veld niet.
+3. **Add Conditional** op de **page parameter `type`** (`Equal to` `go_out_event`):
+   - TRUE → `Navigate To` **`uitgaansevenementAanmaken`**, page parameter
+     `horecagelegenheidNid` = `$.horecagelegenheid_nid` uit de action output.
+   - FALSE → `Navigate To` **`stadsactiviteitAanmaken`**.
+
+   ⚠️ Conditioneer op de **page parameter**, niet op `$.type` van het item: een
+   JSON-Path-binding krijgt type *Json* en levert `if (getJsonField(...))` op —
+   een `dynamic` als `bool`, die compileert en pas bij runtime crasht. De page
+   parameter is een gewone String, dus een veilige Single Condition.
+
+**Gratis meegenomen:** `horecagelegenheidNid` bestaat al als page parameter en
+heeft in `horecaVoorselectie()` (`custom_functions.dart:245`) al **voorrang** op
+de automatische voorselectie. Voor evenementen hoeft de horecagelegenheid dus
+niet via App State.
+
+#### 127-D · Evenementenformulier voorvullen · Eigenaar: nader te bepalen
+
+14 bindingen. Initial Value = `kloonTekst(FFAppState().kloonBron, '<sleutel>')`.
+
+| kloonbron-sleutel | doel | soort |
+|---|---|---|
+| `titel` | `textFieldTitel` | Initial Value |
+| `omschrijving` | `textFieldOmschrijving` | Initial Value |
+| `entree_prijs` | `textFieldEntreeprijs` | Initial Value |
+| `toelichting_entree` | `textFieldToelichtingEntree` | Initial Value |
+| `website_url` | `textFieldWebsiteURL` | Initial Value |
+| `tickets_url` | `textFieldTicketsURL` | Initial Value |
+| `entree_tid` | `dropDownEntree` | Initial Option Value **+** page state `createEntreeTid` in On Page Load |
+| `categorie_tids` | `dropDownCategorieen` | Initial Option Value (`kloonLijst`) **+** page state `createCategorieTids` |
+| `logo_fid` / `logo_url` | — | page states `createLogoFid` / `createLogoUrl` |
+| `fotos_fids` / `fotos_urls` | — | page states `createFotosFids` / `createFotosUrls` |
+| `horecagelegenheid_nid` | page parameter | ✅ gratis, zie 127-C |
+| datums | — | bewust leeg |
+
+**Waarom een dropdown twee bindingen nodig heeft:** de page state wordt gezet in
+`onChanged`, en die vuurt niet bij een voorselectie. Zonder de tweede binding
+selecteert de dropdown wél de juiste waarde maar verstuurt de create-actie leeg.
+
+De page states worden pas bij het indienen gelezen, dus die mogen gewoon in
+On Page Load — daar speelt het timingprobleem niet.
+
+#### 127-E · Activiteitenformulier voorvullen · ⚠️ na 124 · Eigenaar: n.t.b.
+
+24 bindingen. Tien TextFields (`titel`, `omschrijving`, `organisator`, `contact`,
+`adres_straat` → *Adres*, `adres_plaats` → *Plaats*, `adres_postcode`,
+`entree_prijs`, `toelichting_entree`, `website_url`), plus `entree_tid` en
+`categorie_tids` (elk 2), de vier beeld-page-states, en **de cascade** (3 × 2).
+
+**De cascade is precies taak 124.** Beide moeten de drie dropdowns voorvullen uit
+een `keten`-string, met dezelfde drie custom functions. Bouw 124 eerst, of bouw ze
+samen — anders doe je het werk twee keer.
+
+**Hoe de timing hier uitpakt** (gemeten in de export):
+
+- De **gemeente**-dropdown zit achter `if (createProvincieID != null && != '')`
+  (`stadsactiviteit_aanmaken_widget.dart:1852`) en de **plaats**-dropdown achter
+  `if (createGemeenteID ...)` (regel 1991). Die twee **bestaan nog niet** tijdens
+  de eerste build, dus hun `FormFieldController` wordt pas ná On Page Load
+  aangemaakt — daar is het eerste-build-probleem er dus **niet**.
+- Alleen de **provincie**-dropdown staat er meteen. Die ene heeft zijn Initial
+  Option Value uit App State nodig: `ketenProvincie(kloonTekst(kloonBron,'keten'))`.
+- On Page Load moet **alle drie** de page states zetten
+  (`createProvincieID`/`createGemeenteID`/`createPlaatsID`), want een
+  voorgeselecteerde dropdown vuurt geen `onChanged`.
+
+ℹ️ `gemeenteTid` bestaat al als page parameter van deze pagina en heeft voorrang
+in `gemeenteVoorselectie()` — bruikbaar, maar hij zet alleen `createGemeenteID` en
+**niet** `createProvincieID`, dus de gemeente-dropdown blijft dan onzichtbaar. Voor
+een kloon is de App-State-route via `keten` dus de volledige oplossing.
+
+#### 127-F · `kloonBron` wissen · Eigenaar: nader te bepalen
+
+Zonder dit blijft een kloon hangen: wie daarna een **vers** evenement aanmaakt,
+krijgt het vorige formulier opnieuw ingevuld.
+
+Zet vóór elke bestaande "nieuw aanmaken"-navigatie een `Update App State` met
+`kloonBron` op **Clear Value** (zelfde patroon als de uitlogknop). Vier plekken in
+een verse export van 2026-09-20:
+
+| bestand | regel | doel |
+|---|---|---|
+| `mijn_profiel_widget.dart` | 823 | `UitgaansevenementAanmaken` |
+| `mijn_profiel_widget.dart` | 1317 | `StadsactiviteitAanmaken` |
+| `mijn_profiel_widget.dart` | 1506 | `StadsactiviteitAanmaken` |
+| `favorieten_widget.dart` | 1042 | `StadsactiviteitAanmaken` |
+
+Dit zijn **alle** ingangen: de aanmaakpagina's zijn verder nergens vanuit de app
+bereikbaar (wel via een directe route).
+
+#### Volgorde en omvang
+
+1. **127-A** (Drupal) — code staat klaar, ±15 min, blokkeert alles.
+2. **127-B** (App State + call + 2 functies) — ±30 min.
+3. **127-C + 127-D + 127-F** (evenementen, end-to-end werkend) — één sessie.
+4. **124/125/126** (cascade) en daarna **127-E** (activiteiten) — eigen sessie.
+
+Stap 1-3 leveren al een werkende kloonknop voor evenementen; de knop op een
+activiteitenkaart verbergen tot 127-E klaar is, is één Visibility-conditie op de
+page parameter `type`.
+
+#### Testen
+
+- Kloon een evenement **met** en **zonder** logo/foto's, en één met meerdere
+  categorieën (testnode 222818 heeft er drie).
+- Klonen van een **ongepubliceerde** aanmelding moet gewoon werken — de kloonbron
+  kijkt niet naar `status`. Juist dát is het scenario: een afgekeurde of nog
+  wachtende aanmelding opnieuw indienen.
+- Controleer dat de datumvelden **leeg** zijn en dat indienen zonder datum
+  geweigerd wordt.
+- Na een kloon: terug naar Mijn profiel → *Nieuw evenement* → formulier moet
+  **leeg** zijn (127-F).
+- Controleer in de eerste echte respons dat `logo_url` op `https://` staat.
+
 ### 121 · 15 verweesde mappen in `lib/` · Eigenaar: Bob (Claude verwijdert niets)
 
 Gevonden 2026-09-20 bij het committen van de achterstallige working tree. Deze

+ 17 - 0
snippets/LEESMIJ.md

@@ -45,3 +45,20 @@ bindingen op `ButtonIndienen` blijven werken. Invoeren: Custom Code →
 action openen → Cancel/Discard is niet nodig, gewoon de hele body vervangen
 (alles onder `// DO NOT REMOVE`), **Ctrl+S**, en verifiëren met een verse
 export op de string `einddatum ligt vóór`.
+
+## Kopie-knop / klonen (taak 127)
+
+- `kloonbron-drupal-patch.inc.txt` — **geen FlutterFlow-code**, maar een patch
+  voor `custom.mijn_aanmeldingen.inc` op de server (taak 127-A). Dicht drie
+  gaten in `aanmelding_kloonbron.json`: het ontbrekende `entree_tid` bij een
+  activiteit, `logo_url`/`fotos_urls` voor de preview, en de uitgeklapte
+  plaatsketen (`provincie_tid`/`gemeente_tid`/`keten`) voor de cascade. Read-only
+  getest op productie 2026-09-20, inclusief randgevallen.
+- `kloon_functies.dart.txt` — twee Custom **Functions** (`kloonTekst`,
+  `kloonLijst`) die één veld uit App State `kloonBron` lezen. Beide met een
+  **non-nullable** return, anders vult `valueOrDefault` een lege waarde op met
+  de default.
+
+⚠️ Het `keten`-formaat (`"provincie|gemeente|plaats"`) is met opzet identiek aan
+dat van taak 123, zodat de custom functions `ketenProvincie`/`ketenGemeente`/
+`ketenPlaats` uit taak 124 hergebruikt kunnen worden. Bouw ze niet twee keer.

+ 60 - 0
snippets/kloon_functies.dart.txt

@@ -0,0 +1,60 @@
+Taak 127-B — twee Custom FUNCTIONS voor de kloonknop
+====================================================
+
+Custom Code-tab -> "+" -> Function. Twee losse functies.
+
+  kloonTekst : argumenten  bron (JSON)  +  sleutel (String, niet-nullable)
+               Return Type String, **Nullable UIT**
+  kloonLijst : argumenten  bron (JSON)  +  sleutel (String, niet-nullable)
+               Return Type String, **Is List AAN**, **Nullable UIT**
+
+⚠️ Return type non-nullable houden. Bij een String? eist FlutterFlow een
+"Default Variable Value", en valueOrDefault substitueert die ook bij een LEGE
+string -- dan kun je een leeg veld nooit klonen.
+
+⚠️ Geen getJsonField() gebruiken: die bestaat niet binnen een Custom Function
+(wel in een Custom Action). Gewone Map-toegang dus.
+
+⚠️ Plat geschreven, bewust geen .map() of andere closure: FlutterFlow's
+validator struikelt over een genest (params) { ... }-patroon in de body en
+weigert dan op te slaan met "The function is empty or cannot be parsed".
+
+Vergeet Ctrl+S niet -- zonder expliciete save haalt de wijziging de export niet,
+ook al staat er "Synced".
+
+⬇ PLAK VANAF HIER (kloonTekst) ⬇
+
+String kloonTekst(
+  dynamic bron,
+  String sleutel,
+) {
+  if (bron == null || bron is! Map) {
+    return '';
+  }
+  final waarde = bron[sleutel];
+  if (waarde == null) {
+    return '';
+  }
+  return waarde.toString();
+}
+
+⬇ PLAK VANAF HIER (kloonLijst) ⬇
+
+List<String> kloonLijst(
+  dynamic bron,
+  String sleutel,
+) {
+  final uit = <String>[];
+  if (bron == null || bron is! Map) {
+    return uit;
+  }
+  final waarde = bron[sleutel];
+  if (waarde is List) {
+    for (final item in waarde) {
+      if (item != null) {
+        uit.add(item.toString());
+      }
+    }
+  }
+  return uit;
+}

+ 183 - 0
snippets/kloonbron-drupal-patch.inc.txt

@@ -0,0 +1,183 @@
+Taak 127-A — Drupal-patch voor de kloonbron
+===========================================
+
+Bestand op de server:
+  sites/all/modules/custom/custom.mijn_aanmeldingen.inc
+  (volledig pad productie: /data/disk/o1/static/uitgaanskrant/sites/all/
+   modules/custom/custom.mijn_aanmeldingen.inc)
+
+TWEE INGREPEN, daarna `drush @uitgaanskrant.com cc all`:
+
+1) REGEL 125 in _custom_aanm_veldkaart(), blok 'activity':
+       'entree'      => NULL,
+   wordt
+       'entree'      => 'field_act_entree',
+
+   Waarom: _custom_stadsactiviteit_create() schrijft wel degelijk naar
+   field_act_entree (door Bob toegevoegd 2026-08-26, zelfde vocabulary als
+   field_goo_entree). Zonder deze regel gaat het entree-type verloren bij
+   het klonen van een activiteit. Bewijs: node 222818 heeft
+   field_act_entree = 36197 en dat kwam niet mee in de respons.
+
+2) REGELS 240 t/m 313 (de regel `/**` van het A2-docblock t/m de laatste `}`
+   van het bestand) vervangen door ALLES hieronder, vanaf de markering.
+
+Getest op productie 2026-09-20 (read-only, via drush php-script; het bestand
+zelf is niet gewijzigd). Uitkomsten staan in TASKS.md taak 127-A.
+
+Let op: `file_create_url()` geeft onder drush `http://` omdat er geen
+request-context is. Via een echte HTTP-request levert Drupal `https://` --
+niet "repareren", wel bij de eerste test vanuit de app controleren.
+
+⬇ PLAK VANAF HIER ⬇
+
+/**
+ * Alle bestands-URL's van een (multi-value) bestandsveld.
+ *
+ * Tegenhanger van _custom_aanm_bestand_url() voor meerdere waarden.
+ */
+function _custom_aanm_bestand_urls($node, $veld) {
+  $uit = array();
+  if (empty($veld)) {
+    return $uit;
+  }
+  $items = field_get_items('node', $node, $veld);
+  if (empty($items) || !is_array($items)) {
+    return $uit;
+  }
+  foreach ($items as $item) {
+    if (is_array($item) && !empty($item['uri'])) {
+      $uit[] = file_create_url($item['uri']);
+    }
+  }
+  return $uit;
+}
+
+/**
+ * Uitgeklapte keten provincie|gemeente|plaats voor een town-tid.
+ *
+ * Zelfde niveaubepaling als _custom_mijn_stadsrechten_service():
+ * count(taxonomy_get_parents_all()) geeft 1 = provincie, 2 = gemeente,
+ * 3 = plaats. De app splitst 'keten' met dezelfde custom functions als
+ * bij de stadsrechten-cascade, dus het scheidingsteken moet '|' blijven.
+ */
+function _custom_aanm_keten($tid) {
+  $leeg = array(
+    'provincie_tid' => NULL,
+    'gemeente_tid'  => NULL,
+    'plaats_tid'    => NULL,
+    'keten'         => '||',
+  );
+  if (empty($tid)) {
+    return $leeg;
+  }
+  $ouders = taxonomy_get_parents_all((int) $tid);
+  if (empty($ouders)) {
+    return $leeg;
+  }
+  // taxonomy_get_parents_all levert kind -> ... -> wortel; omdraaien.
+  $pad = array_reverse($ouders);
+  $uit = $leeg;
+  if (isset($pad[0])) {
+    $uit['provincie_tid'] = (string) $pad[0]->tid;
+  }
+  if (isset($pad[1])) {
+    $uit['gemeente_tid'] = (string) $pad[1]->tid;
+  }
+  if (isset($pad[2])) {
+    $uit['plaats_tid'] = (string) $pad[2]->tid;
+  }
+  $uit['keten'] = implode('|', array(
+    $uit['provincie_tid'] !== NULL ? $uit['provincie_tid'] : '',
+    $uit['gemeente_tid'] !== NULL ? $uit['gemeente_tid'] : '',
+    $uit['plaats_tid'] !== NULL ? $uit['plaats_tid'] : '',
+  ));
+  return $uit;
+}
+
+/**
+ * A2 — kloonbron: de RAUWE waarden van één eigen aanmelding.
+ *
+ * GET /nl/flutterdrup/aanmelding_kloonbron.json?nid=X
+ *
+ * Geeft tids en fids terug, niet de weergavewaarden — precies de sleutels
+ * die evenementen/create en stadsactiviteiten/create verwachten. Daarnaast
+ * logo_url/fotos_urls voor de preview op de aanmaakpagina, en bij een
+ * activiteit de uitgeklapte plaatsketen voor de cascade-dropdowns.
+ */
+function _custom_aanmelding_kloonbron() {
+  global $user;
+
+  if (empty($user->uid)) {
+    return services_error('Niet ingelogd.', 401);
+  }
+
+  $nid = isset($_GET['nid']) ? (int) $_GET['nid'] : 0;
+  if ($nid < 1) {
+    return services_error('Parameter nid ontbreekt.', 400);
+  }
+
+  $node = node_load($nid);
+  if (!$node) {
+    return services_error('Aanmelding niet gevonden.', 404);
+  }
+  if (!in_array($node->type, array('go_out_event', 'activity'), TRUE)) {
+    return services_error('Dit is geen aanmelding.', 400);
+  }
+  // Eigenaarscheck: zonder dit leest iemand andermans gegevens uit.
+  if ((int) $node->uid !== (int) $user->uid) {
+    return services_error('Geen toegang tot deze aanmelding.', 403);
+  }
+
+  $v = _custom_aanm_veldkaart($node->type);
+
+  $titel = !empty($v['titel']) ? _custom_aanm_item($node, $v['titel']) : NULL;
+  if (empty($titel)) {
+    $titel = $node->title;
+  }
+
+  // Gedeelde velden. De datumvelden komen bewust NIET mee: je kloont juist
+  // om hetzelfde op een nieuwe datum te zetten.
+  $uit = array(
+    'nid'                => (int) $node->nid,
+    'type'               => $node->type,
+    'titel'              => $titel,
+    'omschrijving'       => _custom_aanm_item($node, 'body', 'value'),
+    'entree_prijs'       => _custom_aanm_item($node, $v['prijs'], 'value'),
+    'toelichting_entree' => _custom_aanm_item($node, $v['toelichting'], 'value'),
+    'website_url'        => _custom_aanm_item($node, $v['website'], 'url'),
+    'categorie_tids'     => _custom_aanm_lijst($node, $v['categorie'], 'tid'),
+    'logo_fid'           => _custom_aanm_item($node, $v['logo'], 'fid'),
+    'fotos_fids'         => _custom_aanm_lijst($node, $v['fotos'], 'fid'),
+    // Voor de preview op de aanmaakpagina; de create-actie gebruikt de fids.
+    'logo_url'           => _custom_aanm_bestand_url($node, $v['logo']),
+    'fotos_urls'         => _custom_aanm_bestand_urls($node, $v['fotos']),
+    // Beide types hebben een entree-veld (field_goo_entree resp.
+    // field_act_entree, zelfde vocabulary) — zie _custom_aanm_veldkaart().
+    'entree_tid'         => !empty($v['entree'])
+      ? _custom_aanm_item($node, $v['entree'], 'tid')
+      : NULL,
+  );
+
+  if ($node->type === 'go_out_event') {
+    $uit['horecagelegenheid_nid'] = _custom_aanm_item($node, $v['horeca'], 'target_id');
+    $uit['tickets_url']           = _custom_aanm_item($node, $v['tickets'], 'url');
+  }
+  else {
+    $plaats_tid = _custom_aanm_item($node, $v['plaats'], 'tid');
+    $uit['plaats_tid'] = $plaats_tid;
+    // Uitgeklapt, zodat de app de drie cascade-dropdowns kan voorvullen.
+    $uit += _custom_aanm_keten($plaats_tid);
+    $uit['organisator'] = _custom_aanm_item($node, $v['organisator'], 'value');
+    $uit['contact']     = _custom_aanm_item($node, $v['contact'], 'value');
+
+    $adres = field_get_items('node', $node, $v['adres']);
+    if (!empty($adres[0]) && is_array($adres[0])) {
+      $uit['adres_straat']   = isset($adres[0]['thoroughfare']) ? $adres[0]['thoroughfare'] : NULL;
+      $uit['adres_postcode'] = isset($adres[0]['postal_code']) ? $adres[0]['postal_code'] : NULL;
+      $uit['adres_plaats']   = isset($adres[0]['locality']) ? $adres[0]['locality'] : NULL;
+    }
+  }
+
+  return $uit;
+}