Bladeren bron

Sessieduur gedocumenteerd (365 dagen) + taak 115 autofill

- CLAUDE.md: login-sessie staat sinds 2026-09-20 op een jaar
  (local.settings.php). Vastgelegd dat alleen gc_maxlifetime telt voor de app,
  dat de teller loopt vanaf de laatste call MET cookie, en welke calls dat zijn.
  Plus: session_api/session_cache hebben niets met inloggen te maken.
- CLAUDE.md: ssh-host gecorrigeerd naar de alias aegir-o1; aegir1:959 is
  IPv6-only en werkt niet vanaf Bob's desktop.
- CLAUDE.md: 403-detectie in drupalRequest + sessiemelding in VerbindingsBanner
  beschreven (geimplementeerd en geverifieerd, 0 analysefouten).
- TASKS.md: taak 115 voor autofill, met terugdraai-instructies per stap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bob 19 uur geleden
bovenliggende
commit
77021e36c3
2 gewijzigde bestanden met toevoegingen van 211 en 98 verwijderingen
  1. 48 1
      CLAUDE.md
  2. 163 97
      TASKS.md

+ 48 - 1
CLAUDE.md

@@ -5134,12 +5134,20 @@ alleen waren het de verkeerde. **Bouw daarom in elk beheerscript een
 "HUIDIGE STAND"-kop in die per doelwit `<-- FASE N` toont**, dan zie je in de
 droogdraai meteen of je het juiste script voor je hebt. Overzetten:
 ```
-scp -P 959 <script> o1@aegir1.digitalforce.nl:~/tmp/
+scp <script> aegir-o1:~/tmp/
 drush @<alias> php-script ~/tmp/<script>        # droogdraai als default
 drush @<alias> php-script ~/tmp/<script> doen   # uitvoeren
 ```
 Aliassen: `@uitgaanskrant.com` (productie) en `@devbob.uitgaanskrant.com`.
 
+**⚠️ De host heet `aegir-o1` (ssh-alias), niet `aegir1.digitalforce.nl:959`.** Die
+laatste — zoals hier eerder stond — resolvet alleen naar IPv6 en Bob's desktop heeft
+geen IPv6-route, dus je krijgt `Network is unreachable`; forceer je IPv4 dan geeft
+poort 959 `Connection refused`. De werkende aliassen staan in
+`~/.ssh/config.d/aegir.conf`: **`aegir-o1`** (drush-gebruiker, dít is de juiste) en
+`aegir` (root). Claude kan daar dus gewoon zelf lezen — `ssh aegir-o1 "drush @uitgaanskrant.com php-eval ..."`
+werkt, en dat scheelt een ronde heen-en-weer met Bob voor elke meting.
+
 **Meet bij het uitzetten van API-oppervlak ook wat er moet BLIJVEN werken — en
 meet dat op devbob vóór productie.** Alleen "geeft het nu 404?" is een halve
 check; de vraag is of de app nog draait. Vaste regressieset voor dit project:
@@ -5209,6 +5217,45 @@ de inlognaam van de auteur, dus user enumeration op al je redacteuren.
   checken), anders lijkt de UI kapot terwijl het eigenlijk gewoon de
   ontbrekende Engelse vertaling is.
 
+- **Een login-sessie is sinds 2026-09-20 een JAAR geldig (was 28,5 dagen).**
+  Ingesteld in `local.settings.php` van de site (dat bestand wordt als láátste
+  ingeladen en overleeft een Aegir verify; `settings.php` niet):
+  `ini_set('session.gc_maxlifetime', 31536000)` + `session.cookie_lifetime` idem.
+  Meet 'm met
+  `ssh aegir-o1 "drush @uitgaanskrant.com php-eval \"echo ini_get('session.gc_maxlifetime')/86400;\""`.
+  - **Voor de app telt alleen `gc_maxlifetime`.** De app stuurt de cookie met de
+    hand mee uit secure storage (`Cookie: SESSxxx=sessid`), er is geen browser die
+    op een vervaldatum let — `cookie_lifetime` doet dus alleen iets voor de website.
+  - **De teller loopt vanaf de laatste geauthenticeerde request, niet vanaf het
+    inloggen.** `_drupal_session_write()` in `includes/session.inc` schrijft
+    `timestamp = REQUEST_TIME` bij elke request die >180 s (`session_write_interval`)
+    na de vorige komt. **Alleen de cookie is nodig, geen wachtwoord** — er hoeft dus
+    niets "ingebouwd" te worden om een sessie te verlengen.
+  - **Maar bladeren verlengt niks:** Home, de overzichten en evenement-detail gaan
+    anoniem. Alleen deze sturen de cookie mee, en verlengen dus:
+    `drupalRequest`, `evenementCreate`, `bestandUpload`, `stadsactiviteitCreate`,
+    en de API Calls `Categorieen` / `Entreeopties` / `MijnHorecagelegenheden` /
+    `MijnStadsrechten`. In de praktijk: Favorieten, Mijn Profiel, een hartje,
+    gemeente volgen.
+  - **`_drupal_session_read()` doet `WHERE s.sid = :sid` zónder timestamp-check**,
+    dus een formeel verlopen sessie werkt gewoon door tot garbage collection hem
+    opruimt — en die draait met 1 kans op 1000 per request.
+  - ⚠️ **`session_api` en `session_cache` hebben NIETS met inloggen te maken**, hoe
+    veelbelovend hun instellingenscherm ook oogt. Dat zijn eigen tracking-cookies
+    voor álle bezoekers (`Drupal.session_cache.sid`, 120 dagen; `session_api_session`,
+    30 dagen). Hun variabelen staan bovendien alle drie op `NULL` in de
+    variabelentabel — je ziet daar fabriekswaarden, geen gemaakte keuze. Ga dus niet
+    aan die schermen sleutelen om de logingeldigheid te veranderen.
+- **Een verlopen sessie logt de app sinds 2026-09-20 netjes uit.** `drupalRequest`
+  herkent 401, of 403 mét het woord `anonymous` ("Toegang geweigerd voor gebruiker
+  anonymous"), **maar alleen als er daadwerkelijk een cookie meeging** — een 403
+  zonder dat woord is een rechtenkwestie en mag niemand uitloggen. Hij wist dan
+  dezelfde zes App State-velden als de uitlogknop, waardoor alle bestaande
+  "ben je ingelogd?"-condities vanzelf omklappen naar hun uitgelogde variant, en zet
+  de niet-persisted boolean `sessieVerlopen`. `VerbindingsBanner` (staat al in
+  `HeaderButtonsComponent`, dus op 11 pagina's) toont daarop een eenmalige SnackBar
+  met een knop naar Login. De returnwaarde blijft bewust `[]` — aanroepers doen blind
+  `.toList()`.
 - **Drupal 7 Services sessie-auth**: `sessid` + `session_name` (uit
   `LoginCall`'s response) vormen samen de sessie-cookie:
   `Cookie: <session_name>=<sessid>`. `token` is een los CSRF-token,

+ 163 - 97
TASKS.md

@@ -44,10 +44,10 @@ Google Play en één doorloop die alleen Bob kan doen.
 | **P1-17** | Vertalingen | ✅ **Rond voor de app** (2026-09-20): 0 van de 218 gerenderde sleutels mist nog Engels. Alleen de vier lege-lijst-teksten op `mijnProfiel` blijven over — dat zijn parameterwaarden, geen i18n-sleutels |
 | **Drupal** | taak 56-restant · taak 28/109 | Cron-description op "xx days" ✅ gedaan · events zonder categorie gaat via de importer |
 | **Bij livegang** | Schakelaars en opruimklussen | Eigen blok hieronder |
-| **115** | AdBanner verplaatsen op `PUitgaanPage` | Onder de slider, bóven zoekveld/datum/chips. Eigen blok hieronder |
-| **116** | AdBanner springt bij het laden | Oorzaak gevonden in de gegenereerde widget; fix is een vaste hoogte. Eigen blok hieronder |
-| **117** | Kalenderpermissie eruit | De app heeft 'm niet nodig; scheelt een permissie in de Play-listing. Eigen blok hieronder |
-| **118** | AdMob EU-consentbericht | De app roept UMP al aan, maar het bericht moet in de AdMob-console gepubliceerd zijn. Eigen blok hieronder |
+| ~~115~~ | ~~AdBanner verplaatsen~~ | ✅ **AF** (Bob 2026-09-20, export-geverifieerd): volgorde is nu slider → banner → filterbalk → lijst |
+| **116** | AdBanner springt bij het laden | ✅ Container + responsive hoogte staat goed. ⚠️ Eén restpunt: de oude `Padding(top 15)` zit nú **binnen** die Container en eet 15 van de 50 dp |
+| ~~117~~ | ~~Kalenderpermissie eruit~~ | ✅ **AF** (Bob 2026-09-20, export-geverifieerd): `READ_CALENDAR`/`WRITE_CALENDAR` én `NSCalendarsUsageDescription` zijn weg; `CAMERA` staat er nog (terecht) |
+| **118** | AdMob EU-consentbericht | ⚠️ Half: de FlutterFlow-toggle staat goed, maar het bericht moet in de **AdMob-console** gepubliceerd zijn. Eigen blok hieronder |
 | **119** | Besluit: App Links voor `https://` | Gedeelde links openen nu altijd de browser, nooit de app. Post-launch keuze |
 
 ---
@@ -251,88 +251,45 @@ bij inschrijving.
 **Route wanneer het zover is:** FlutterFlow kan ook voor iOS bouwen, dus een Mac
 is niet strikt nodig. Punt 5 en 6 zijn het echte werk; de rest is administratie.
 
-### 115 · AdBanner verplaatsen op `PUitgaanPage` · Eigenaar: Bob
-
-**Wens (Bob 2026-09-20):** de banner onder de slider en **boven** het zoekveld,
-de datumkiezer en de choicechips.
-
-**Stand nu** (verse export 2026-09-20, `lib/uitgaanspaginas/p_uitgaan_page/p_uitgaan_page_widget.dart:164-230`),
-body-`Column` met vier kinderen:
-
-1. `PUitgaanSliderComponent` — Visibility `!FFAppState().zoekOpen`
-2. `Expanded > FilterBalkComponent` — zoekveld, datum, chips
-3. `Padding(top 15) > FlutterFlowAdBanner` ← moet naar plek 2
-4. `Expanded > Padding > Container > PUitgaantabelKaartComponent`
-
-**Route: twee sleepacties.** Een drop op een container-rij landt altijd op
-**positie 0**, dus in omgekeerde volgorde slepen:
-
-1. Klap alle vier de kinderen in (chevrons), selecteer de body-`Column`-rij één
-   keer en druk `Escape` — een sleep landt alleen als de doelrij vooraf
-   geselecteerd is.
-2. Sleep de **`Padding`-rij met de AdBanner** op de **`Column`-rij**
-   → `[banner, slider, filterbalk, lijst]`.
-3. Sleep de **slider-rij** op de **`Column`-rij**
-   → `[slider, banner, filterbalk, lijst]` ✅
-
-Eén sleep per keer, met een verse `zoom` op de boom ertussen (de rijen
-verschuiven). De Visibility-conditie op de slider overleeft een sleep.
-
-**Let op:** de twee horeca-overzichtspagina's hebben hun banner **onderaan** de
-Column staan. Die is niet meeverplaatst — apart besluit als je ze gelijk wilt
-trekken.
-
-### 116 · AdBanner "opent groot en wordt dan klein" · Eigenaar: Bob
-
-**Geen AdMob-kwestie — het zit in `lib/flutter_flow/flutter_flow_ad_banner.dart`
-(gegenereerd, dus niet lokaal te repareren).** Vóór het laden rendert die widget
-een zwarte `Container` **zonder eigen hoogte** met een laadtekst erin; die tekst
-is extra lang zolang `showsTestAd: true` (er komt een waarschuwingsalinea bij).
-Op 411 dp is dat ruwweg 150 dp hoog. Zodra de banner binnen is wordt de Container
-exact zo hoog als de banner (~50 dp). Vandaar de sprong — en de `Expanded`-blokken
-eromheen krimpen en groeien mee, dus de hele lijst verspringt.
-
-Twee dingen volgen daaruit: het wordt **vanzelf minder** zodra *Show Test Ads*
-uit gaat (kortere laadtekst), en het verdwijnt **niet** vanzelf — bij een
-uitblijvende fill blijft die placeholder gewoon staan.
-
-**Fix: geef de banner een gereserveerde hoogte.**
-- Rechtsklik de AdBanner → **Wrap Widget → Container**, geen Fill Color.
-- Op die Container **Height** als **Responsive Value**: `50 / 50 / 90 / 90`.
-  Dat volgt Google's anchored-adaptive-hoogtes (<400 dp → 32, 400-720 → 50,
-  >720 → 90); op een tablet wil de banner dus echt 90.
-- **Ad Banner Dimensions leeg laten** (= adaptive). Dat is bewust anders dan de
-  twee horeca-pagina's, die `width: schermbreedte, height: 50` hebben staan: dat
-  is een *custom* AdSize en die vult AdMob beduidend slechter dan een
-  standaard/adaptive formaat. Nameten zodra er echte advertenties draaien.
-
-**Terugvaloptie als de Responsive-Value-dialoog tegenwerkt:** vaste Height 50 en
-accepteren dat de tabletbanner 40 dp krapper staat dan hij wil.
-
-### 117 · Kalenderpermissie uit het manifest · Eigenaar: Bob
-
-**De app vraagt `READ_CALENDAR` en `WRITE_CALENDAR` aan zonder ze te gebruiken.**
-Nagemeten 2026-09-20:
-- `zet_in_agenda.dart:51` gebruikt `Add2Calendar.addEvent2Cal(...)`. Dat is de
-  **intent**-route (`ACTION_INSERT`) en die vraagt het toestel om de agenda-app te
-  openen — daar is geen enkele permissie voor nodig.
-- De plugin declareert ze zélf niet: `~/.pub-cache/.../add_2_calendar-3.0.1/android/
-  src/main/AndroidManifest.xml` is een **leeg** manifest.
-- Ze komen dus uit **App Settings → Permissions** (aangezet bij taak 98) en staan
-  in de export op `android/app/src/main/AndroidManifest.xml:8-9`.
-
-**Waarom het uitmaakt:** Play toont "Agenda" bij de app-permissies, en dat is een
-van de dingen waar mensen op afhaken bij een uitgaansapp. Bovendien scheelt het een
-rij in het Data safety-formulier (G2.7).
-
-**Doen:** App Settings → Permissions → **Calendar uit** → verse export → de twee
-regels moeten uit het manifest zijn → daarna de agendaknop op `EventCurrent` één
-keer testen op een echt toestel (de AVD's kunnen dit niet, hun AOSP-kalender is
-kapot — zie `CLAUDE.md`).
-
-⚠️ **Bij de iOS-ronde terugzetten.** Op iOS gebruikt `add_2_calendar` EventKit en
-daar is de permissie + `NSCalendarsUsageDescription` wél nodig. Nu Android-only is
-dat geen bezwaar.
+### ~~115~~ · ✅ AdBanner verplaatst — AF (2026-09-20)
+
+Verse export bevestigt de body-`Column` van `PUitgaanPage`:
+`[slider (if !zoekOpen), Container > AdBanner, Expanded > FilterBalkComponent,
+Expanded > lijst]`. Precies de gevraagde volgorde.
+
+### 116 · AdBanner-hoogte — ⚠️ één restpunt · Eigenaar: Bob
+
+**Gedaan en geverifieerd:** de `Container` staat er met `height` als Responsive
+Value `50 / 50 / 90 / 90`, en de AdBanner heeft **geen** eigen width/height meer
+(dus adaptive, zoals bedoeld). De layout springt hiermee niet meer.
+
+⚠️ **Restpunt dat ik over het hoofd zag toen ik het recept gaf:** de oude
+`Padding(top: 15)` die om de banner zat, zit nu **binnen** die nieuwe Container.
+De Container geeft 50 dp, de padding pakt daar 15 van af, dus de banner houdt er
+**35** over terwijl hij er 50 wil. Constraints winnen van een gewenste hoogte, dus
+de advertentie wordt aan de onderkant afgeknepen — zonder foutmelding, en met test
+ads makkelijk over het hoofd te zien.
+
+**Kies er één:**
+- **Padding op 0** (de banner staat nu toch al los onder de slider), of
+- **hoogte op `65 / 65 / 105 / 105`** als je die 15 dp witruimte erboven wilt houden.
+
+🔸 Los daarvan blijft de zwarte laadtekst van `FlutterFlowAdBanner` hoger dan de
+Container en tekent dus even buiten zijn kader. Dat is inherent aan de gegenereerde
+widget en niet vanuit de builder te clippen; met test ads laadt hij snel genoeg om
+het niet te merken. De layout zelf beweegt niet meer, en dat was het doel.
+
+### ~~117~~ · ✅ Kalenderpermissie eruit — AF (2026-09-20)
+
+Bob heeft Calendar uitgezet in App Settings → Permissions. Verse export bevestigt:
+`android/app/src/main/AndroidManifest.xml` heeft nog **vier** permissies
+(`INTERNET`, `CAMERA`, `READ_EXTERNAL_STORAGE`, `WRITE_EXTERNAL_STORAGE`) en
+`ios/Runner/Info.plist` nog **twee** usage descriptions (Camera, PhotoLibrary).
+`READ_CALENDAR`/`WRITE_CALENDAR` en `NSCalendarsUsageDescription` zijn weg.
+
+Rest: **de agendaknop op `EventCurrent` één keer testen op een echt toestel**
+(de AVD's kunnen dit niet). En bij een latere iOS-ronde de permissie terugzetten —
+daar gebruikt `add_2_calendar` wél EventKit.
 
 ### 118 · AdMob: is het EU-consentbericht gepubliceerd? · Eigenaar: Bob
 
@@ -349,20 +306,72 @@ advertenties (lagere opbrengst), en Google kan de serving beperken.
 voor de app en op **Gepubliceerd** zetten. Daarna bij de eerste release-test
 controleren dat het scherm ook echt verschijnt bij een verse installatie.
 
-### 119 · Besluit: App Links voor `https://uitgaanskrant.com` · Eigenaar: Bob
+⚠️ **Niet te verwarren met de FlutterFlow-toggle.** Bob liet 2026-09-20 zien dat
+*App Settings → AdMob → "Show GDPR Consent Dialog at App Launch"* aan staat — dat
+is de **app**-kant en die was al goed (die toggle genereert juist de
+`adMobRequestConsent()`-aanroep in `main.dart`). De ontbrekende helft zit op
+**admob.google.com**, niet in FlutterFlow. Zonder gepubliceerd bericht vraagt de
+app netjes om een formulier, krijgt er geen, en gaat stil verder.
+
+🔸 Terzijde uit diezelfde screenshot: *Ad content filtering* staat op **Unset**.
+Dat is AdMob's standaard en prima, maar het loont om er bij G2.5/G2.6 (content
+rating + doelgroep) bewust een waarde bij te kiezen zodat de advertenties passen
+bij wat je bij Play aangeeft.
+
+### 119 · App Links — ontwerp na Bob's vraag van 2026-09-20 · Eigenaar: Bob
 
-Het manifest heeft één intent-filter, op het **custom scheme**
-`uitgaanskrant://uitgaanskrant.com` (`AndroidManifest.xml:47-52`). Er is géén
-filter voor `https://`. Gevolg: een uitgaanskrant.com-link die iemand in WhatsApp
-of een mail aantikt opent **altijd de browser**, nooit de app — ook de link die de
-deelknop zelf verstuurt (`https://uitgk.com/<nid>`).
+**Bob's vraag:** "ik kan een lijstje domeinen opgeven — maar hoe weet mijn app wat
+de URL is? een evenement, een horecagelegenheid, een stadspagina?"
 
-Dat is nu geen fout, maar wel een gemiste kans zodra de app live staat. Het vergt
-een `assetlinks.json` op de domeinen plus een tweede intent-filter, en dus een
-bewust besluit. **Post-launch, geen blokker.**
+**Antwoord: Android geeft de VOLLEDIGE URL aan de app, en de app moet die zelf
+vertalen naar een route.** Dat vertalen is het hele werk; het domeinlijstje bepaalt
+alleen *of* de app aan de beurt komt.
 
-(Terzijde: `android:autoVerify="true"` staat nu op het custom-scheme-filter en doet
-daar niets — autoVerify geldt alleen voor http/https. Onschadelijk.)
+**Daarom NIET `uitgaanskrant.com` registreren.** Twee redenen:
+1. De site-URL's zijn SEO-aliassen zonder nid en zonder type
+   (`/nl/Noord-Holland/Amsterdam/Bimhuis-Amsterdam`). De app kan daar niets mee
+   zonder Drupal om een vertaling te vragen — een extra endpoint erbij.
+2. Je zou **élke** link naar je site de app in trekken: privacybeleid,
+   registratieformulier, zoekpagina's, de hele CMS-kant. Voor alles wat de app niet
+   kent moet je dan een nette terugval bouwen.
+
+**Wel `uitgk.com`.** Dat is jouw eigen korte domein, het wordt alleen gebruikt door
+de deelknop (`event_current_widget.dart:414`) en door `zetInAgenda`
+(`zet_in_agenda.dart:42`), en het heeft één voorspelbare vorm: `/<nid>`.
+
+**Het type is met één API-call te bepalen — gemeten 2026-09-20:**
+
+| | `flutterflow_events.json?nid=` | `flutterflowmobiel_establishment_info.json?nid=` |
+|---|---|---|
+| **222338** (event) | 1 rij, mét `horecagelegenheidnid=75459` | 0 rijen |
+| **75459** (zaak) | 0 rijen | 1 rij |
+
+Dus: één call naar `flutterflow_events.json?nid=X`; komt er een rij uit, dan is het
+een event **en heb je de `horecaid` meteen te pakken** (die heeft `EventCurrent`
+als tweede parameter nodig). Nul rijen → behandel als zaak →
+`HorecagelegenheidCurrent(nid: X)`. Allebei leeg → Home.
+
+**Stadspagina's erbuiten laten.** `PUitgaanPage` wil een `plaats`-tid, en die staat
+in geen enkele URL — dat zou een derde lookup via `plaatsen.json` vergen voor
+weinig winst.
+
+**Twee routes om uit te kiezen:**
+- **A — deel-URL kort houden (`uitgk.com/222338`) + een routerpagina in de app.**
+  Mooiste links. Vergt een pagina met een pad-parameter op rootniveau (`/:nid`),
+  de API-call hierboven en een conditionele navigatie. ⚠️ **Open vraag: kan
+  FlutterFlow een routePath `/:nid` op rootniveau aan zonder te botsen met `/home`,
+  `/favorieten` enz.?** go_router matcht exacte paden eerst, dus het zou moeten
+  kunnen — maar dat is niet gemeten. Eén testpagina + een export geeft uitsluitsel.
+- **B — deel-URL wordt `uitgk.com/eventCurrent?nid=..&horecaid=..`.** Dan matcht
+  go_router het pad meteen en hoeft er in de app **niets** gebouwd te worden (het
+  custom scheme werkt al precies zo). Prijs: lange, lelijke deellinks, plus een
+  redirect-regel op uitgk.com voor wie de app niet heeft.
+
+**Volgorde-afhankelijkheid, hierom is dit hoe dan ook post-launch:**
+`/.well-known/assetlinks.json` op uitgk.com moet de **SHA-256 van het
+Play-app-signing-certificaat** bevatten, en dat certificaat krijg je pas ná de
+eerste upload (Play Console → Setup → App integrity). Vóór die tijd is de
+verificatie niet af te maken.
 
 ### 65 · Release-build — wat er NOG getest moet worden · Eigenaar: Bob
 
@@ -648,6 +657,63 @@ werkelijkheid gewoon events van vandaag waren die al begonnen waren — precies
 het `>= -2 uur`-filter hoort weg te laten. Staat ook al in `CLAUDE.md`; ik trapte
 er alsnog in.
 
+### Taak 115 · Autofill: wachtwoordmanager op de loginpagina · Eigenaar: Claude (Bob plant een losse sessie)
+
+**Doel:** de gebruiker zijn inloggegevens door Android/iOS laten opslaan en
+invullen, zodat opnieuw inloggen twee tikken kost. De app slaat bewust géén
+wachtwoord op — dat blijft bij het besturingssysteem.
+
+**Context:** sessies zijn sinds 2026-09-20 een jaar geldig (zie `CLAUDE.md`),
+dus dit is comfort, geen noodzaak. Los oppakken, niet meeliften op ander werk.
+
+**Stap 1 — invullen (FlutterFlow-property, laag risico).**
+Pagina `login`, beide `TextFormField`s:
+
+| veld | herkenbaar aan | Auto Fill Hint |
+|---|---|---|
+| gebruikersnaam | `obscureText: false`, regel ~227 in de export | `username` |
+| wachtwoord | `obscureText: !_model.passwordFieldVisibility`, regel ~402 | `password` |
+
+Zit in **Properties Panel → Additional Properties → Auto Fill Hint** (toggle
+aan, dan de optie kiezen). Kies `username` en **niet** `email`: Drupal 7's
+`user/login` accepteert standaard alleen de gebruikersnaam.
+
+**Stap 2 — opslaan aanbieden (custom action, hier zit het risico).**
+Alleen hints is niet genoeg: het OS vraagt pas "wachtwoord opslaan?" na
+`TextInput.finishAutofillContext()`. FlutterFlow genereert die aanroep niet en
+documenteert hem niet. Nieuwe custom action `autofillAfronden`, zonder
+argumenten, aan te hangen **ná** een geslaagde `drupalLogin`:
+
+```dart
+import 'package:flutter/services.dart';
+
+Future autofillAfronden() async {
+  TextInput.finishAutofillContext();
+}
+```
+
+**Open vraag:** werkt dit zonder een `AutofillGroup` om beide velden heen?
+FlutterFlow genereert die niet zichtbaar, en Android is daar in Flutter
+historisch kieskeurig in. Lukt het niet, dan is de enige route een custom widget
+dat het hele loginformulier vervangt — dat is een flinke ingreep en waarschijnlijk
+niet de moeite waard voor puur comfort.
+
+**⚠️ Testen kan NIET op de emulators.** De tablet-AVD heet
+`Medium_Tablet_no-google`: geen Play Services, dus geen Google Password Manager.
+Dit moet op Bob's fysieke Xiaomi, met een profile-build.
+
+**Terugdraaien** (per stap, beide onafhankelijk):
+1. *Stap 1:* Auto Fill Hint-toggle op beide velden weer uit. In een verse export
+   verdwijnt dan `autofillHints:` uit `lib/login/login/login_widget.dart` —
+   controleer met `grep -c autofillHints lib/login/login/login_widget.dart`,
+   hoort **0** te zijn.
+2. *Stap 2:* de actie loskoppelen van de loginknop (Actions-tab → ⋮ op de
+   actie-node → *Delete Action*) en daarna de custom action zelf weggooien.
+   Controle: `grep -rc finishAutofillContext lib/` hoort **0** te geven.
+
+Geen van beide stappen raakt de sessie-afhandeling, `drupalLogin` of App State,
+dus terugdraaien kan niet stukmaken wat nu werkt.
+
 ## 📚 Referentie — geen taak, wél lezen vóór je iets opnieuw onderzoekt
 
 #