ソースを参照

Favorieten Tab 1: POST->GET drupalRequest (On Page Load), JSON Path array-wildcard->single-item fix (9 velden), inhoud-null-crash opgelost via ConditionalBuilder

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
bob 3 週間 前
コミット
c237ada5d3

+ 1 - 1
android/app/build.gradle

@@ -55,7 +55,7 @@ android {
     defaultConfig {
         // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
         applicationId "com.uitgaanskrant.app"
-        minSdkVersion flutter.minSdkVersion
+        minSdkVersion 23
         targetSdkVersion 36
         versionCode flutterVersionCode.toInteger()
         versionName flutterVersionName

+ 2 - 2
android/build.gradle

@@ -43,6 +43,6 @@ subprojects {
     project.evaluationDependsOn(':app')
 }
 
-tasks.register("clean", Delete) {
-    delete rootProject.layout.buildDirectory
+task clean(type: Delete) {
+    delete rootProject.buildDir
 }

+ 2 - 2
ios/Runner.xcodeproj/project.pbxproj

@@ -45,7 +45,7 @@
 		97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
 		97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
 		6436409127A31CD600820AF7 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = "<group>"; };
-		6436409B27A31CDA00820AF7 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
+		6436409B27A31CD700820AF7 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
 		97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
@@ -226,7 +226,7 @@
 			isa = PBXVariantGroup;
 			children = (
 				6436409127A31CD600820AF7 /* nl */,
-				6436409B27A31CDA00820AF7 /* en */,
+				6436409B27A31CD700820AF7 /* en */,
 			);
 			name = InfoPlist.strings;
 			sourceTree = "<group>";

+ 196 - 24
lib/auth/custom_auth/custom_auth_manager.dart

@@ -14,6 +14,10 @@ const _kRefreshTokenKey = '_auth_refresh_token_';
 const _kTokenExpirationKey = '_auth_token_expiration_';
 const _kUidKey = '_auth_uid_';
 const _kUserDataKey = '_auth_user_data_';
+// Set while a sign out is in effect. Its presence makes any session values
+// that survived the sign out unusable, so a failed cleanup cannot resurrect
+// them on the next launch.
+const _kSignedOutKey = '_auth_signed_out_';
 
 class CustomAuthManager {
   // Auth session attributes
@@ -25,6 +29,19 @@ class CustomAuthManager {
   UserStruct? userData;
 
   Future signOut() async {
+    // Record the sign out before changing anything else. Once this marker is
+    // stored, initialize() refuses to restore the session on the next launch,
+    // so the sign out cannot be undone even if clearing the stored values
+    // below fails. If the marker cannot be stored we have not touched the
+    // in-memory session or the user stream yet, so nothing is left
+    // half-signed-out: the caller is simply told the sign out did not happen.
+    final markError = await _recordSignedOut();
+    if (markError != null) {
+      throw StateError(
+        'Sign out could not be recorded, so you are still signed in: $markError',
+      );
+    }
+
     authenticationToken = null;
     refreshToken = null;
     tokenExpiration = null;
@@ -34,7 +51,10 @@ class CustomAuthManager {
     uitgaanskrantAuthUserSubject.add(
       UitgaanskrantAuthUser(loggedIn: false),
     );
-    persistAuthData();
+    // Clearing the stored values is best effort from here: the marker has
+    // already made the session unusable, so a failure only leaves inert data
+    // behind for the next launch to clean up.
+    await _clearPersistedSession();
   }
 
   Future<UitgaanskrantAuthUser?> signIn({
@@ -44,7 +64,7 @@ class CustomAuthManager {
     String? authUid,
     UserStruct? userData,
   }) async =>
-      _updateCurrentUser(
+      await _updateCurrentUser(
         authenticationToken: authenticationToken,
         refreshToken: refreshToken,
         tokenExpiration: tokenExpiration,
@@ -52,19 +72,19 @@ class CustomAuthManager {
         userData: userData,
       );
 
-  void updateAuthUserData({
+  Future<void> updateAuthUserData({
     String? authenticationToken,
     String? refreshToken,
     DateTime? tokenExpiration,
     String? authUid,
     UserStruct? userData,
-  }) {
+  }) async {
     assert(
       currentUser?.loggedIn ?? false,
       'User must be logged in to update auth user data.',
     );
 
-    _updateCurrentUser(
+    await _updateCurrentUser(
       authenticationToken: authenticationToken,
       refreshToken: refreshToken,
       tokenExpiration: tokenExpiration,
@@ -73,13 +93,13 @@ class CustomAuthManager {
     );
   }
 
-  UitgaanskrantAuthUser? _updateCurrentUser({
+  Future<UitgaanskrantAuthUser?> _updateCurrentUser({
     String? authenticationToken,
     String? refreshToken,
     DateTime? tokenExpiration,
     String? authUid,
     UserStruct? userData,
-  }) {
+  }) async {
     this.authenticationToken = authenticationToken;
     this.refreshToken = refreshToken;
     this.tokenExpiration = tokenExpiration;
@@ -92,7 +112,7 @@ class CustomAuthManager {
       userData: userData,
     );
     uitgaanskrantAuthUserSubject.add(updatedUser);
-    persistAuthData();
+    await persistAuthData();
     return updatedUser;
   }
 
@@ -101,6 +121,17 @@ class CustomAuthManager {
     _prefs = await SharedPreferences.getInstance();
 
     try {
+      if (_prefs.getBool(_kSignedOutKey) ?? false) {
+        // A previous sign out was recorded but may not have finished clearing
+        // the stored session. Restore nothing, and retry the cleanup, which
+        // removes the marker once it succeeds.
+        await _clearPersistedSession();
+        uitgaanskrantAuthUserSubject.add(
+          UitgaanskrantAuthUser(loggedIn: false),
+        );
+        return;
+      }
+
       authenticationToken = _prefs.getString(_kAuthTokenKey);
       refreshToken = _prefs.getString(_kRefreshTokenKey);
       tokenExpiration = _prefs.getInt(_kTokenExpirationKey) != null
@@ -132,22 +163,163 @@ class CustomAuthManager {
     uitgaanskrantAuthUserSubject.add(updatedUser);
   }
 
-  void persistAuthData() {
-    authenticationToken != null
-        ? _prefs.setString(_kAuthTokenKey, authenticationToken!)
-        : _prefs.remove(_kAuthTokenKey);
-    refreshToken != null
-        ? _prefs.setString(_kRefreshTokenKey, refreshToken!)
-        : _prefs.remove(_kRefreshTokenKey);
-    tokenExpiration != null
-        ? _prefs.setInt(
-            _kTokenExpirationKey, tokenExpiration!.millisecondsSinceEpoch)
-        : _prefs.remove(_kTokenExpirationKey);
-    uid != null ? _prefs.setString(_kUidKey, uid!) : _prefs.remove(_kUidKey);
-    userData != null
-        ? _prefs.setString(
-            _kUserDataKey, jsonEncode(userData!.toSerializableMap()))
-        : _prefs.remove(_kUserDataKey);
+  /// Serializes persistence so that overlapping auth transitions -- a sign-out
+  /// landing while a sign-in is still writing, for example -- cannot interleave
+  /// their storage operations. Each transition's writes complete, in call
+  /// order, before the next transition's begin.
+  Future<void> _persistQueue = Future.value();
+
+  /// Runs [task] after any persistence already in flight, returning the error
+  /// it failed with, or null if it succeeded.
+  ///
+  /// This never completes with an error itself, so one failed transition
+  /// cannot poison the queue for later ones; callers decide how to react.
+  /// Signing in treats a failure as non-fatal -- the session is live in memory
+  /// either way, and letting the error escape would abort the action flow that
+  /// triggered it. Signing out treats a failure as terminal, because a session
+  /// left behind on disk would be read back as valid on the next launch.
+  Future<Object?> _enqueuePersistTask(Future<void> Function() task) {
+    final pending = _persistQueue.then<Object?>((_) async {
+      try {
+        await task();
+        return null;
+      } catch (e) {
+        if (kDebugMode) {
+          print('Error persisting auth data: $e');
+        }
+        return e;
+      }
+    });
+    _persistQueue = pending;
+    return pending;
+  }
+
+  /// Removes [key], treating anything other than a confirmed removal as an
+  /// error, and retrying a bounded number of times.
+  ///
+  /// Used for the credentials: one that outlives its own removal would be read
+  /// back as a valid session on the next launch, so a removal that fails has
+  /// to become a visible error rather than a silent one.
+  ///
+  /// The result of remove() is the only trustworthy signal here. Reading the
+  /// key back would always report success: remove() drops the value from the
+  /// in-memory preference cache synchronously, before -- and regardless of --
+  /// the platform write, and containsKey()/getString() read that same cache.
+  Future<void> _removeVerified(String key) async {
+    Object? lastError;
+    for (var attempt = 0; attempt < 3; attempt++) {
+      if (attempt > 0) {
+        // Back off briefly; a retry that fires immediately tends to hit the
+        // same transient condition. Only ever reached on the failure path.
+        await Future.delayed(Duration(milliseconds: 50 * attempt));
+      }
+      try {
+        if (await _prefs.remove(key)) {
+          return;
+        }
+        lastError = StateError('Removing $key reported failure.');
+      } catch (e) {
+        lastError = e;
+      }
+    }
+    throw StateError('Could not remove $key: $lastError');
+  }
+
+  /// Stores the signed-out marker, retrying and reporting failure.
+  ///
+  /// Both setting and clearing the marker go through a write rather than a
+  /// removal, because only a write answers "did this stick?" unambiguously.
+  /// remove() forwards the platform result verbatim, so it cannot distinguish
+  /// a key that was never there from one that would not go away, and
+  /// containsKey() cannot stand in for that check: it reads the in-memory
+  /// cache, which remove() clears whether or not the platform write landed.
+  /// initialize() reads getBool(...) ?? false, so a stored false means exactly
+  /// the same thing as an absent key.
+  Future<void> _setSignedOutMarker(bool value) async {
+    Object? lastError;
+    for (var attempt = 0; attempt < 3; attempt++) {
+      if (attempt > 0) {
+        await Future.delayed(Duration(milliseconds: 50 * attempt));
+      }
+      try {
+        if (await _prefs.setBool(_kSignedOutKey, value)) {
+          return;
+        }
+        lastError =
+            StateError('Storing the signed out marker reported failure.');
+      } catch (e) {
+        lastError = e;
+      }
+    }
+    throw StateError('Could not store the signed out marker: $lastError');
+  }
+
+  /// Records that the user has signed out. While this marker is set,
+  /// initialize() will not restore a session, whatever else is still stored.
+  Future<Object?> _recordSignedOut() =>
+      _enqueuePersistTask(() => _setSignedOutMarker(true));
+
+  /// Removes every stored session value, then the signed-out marker. The
+  /// marker is removed last, so if any removal fails the marker survives and
+  /// the next launch retries instead of restoring a half-cleared session.
+  Future<Object?> _clearPersistedSession() => _enqueuePersistTask(() async {
+        await _removeVerified(_kAuthTokenKey);
+        await _removeVerified(_kRefreshTokenKey);
+        await _prefs.remove(_kTokenExpirationKey);
+        await _prefs.remove(_kUidKey);
+        await _prefs.remove(_kUserDataKey);
+        await _prefs.remove(_kSignedOutKey);
+      });
+
+  Future<Object?> persistAuthData() {
+    // Snapshot the session synchronously, before the first await, so that a
+    // transition landing while this write waits its turn in the queue cannot
+    // tear the values it persists. These locals shadow the fields of the same
+    // name, so the write below cannot read the mutable state by accident.
+    final authenticationToken = this.authenticationToken;
+    final refreshToken = this.refreshToken;
+    final tokenExpiration = this.tokenExpiration;
+    final uid = this.uid;
+    final userData = this.userData;
+
+    return _enqueuePersistTask(() async {
+      // The auth token goes first: it is what initialize() reads to decide
+      // whether a session exists, so clearing it first means an interrupted
+      // sign out still reads as signed out.
+      authenticationToken != null
+          ? await _prefs.setString(_kAuthTokenKey, authenticationToken)
+          : await _removeVerified(_kAuthTokenKey);
+      refreshToken != null
+          ? await _prefs.setString(_kRefreshTokenKey, refreshToken)
+          : await _removeVerified(_kRefreshTokenKey);
+      tokenExpiration != null
+          ? await _prefs.setInt(
+              _kTokenExpirationKey, tokenExpiration.millisecondsSinceEpoch)
+          : await _prefs.remove(_kTokenExpirationKey);
+      uid != null
+          ? await _prefs.setString(_kUidKey, uid)
+          : await _prefs.remove(_kUidKey);
+      userData != null
+          ? await _prefs.setString(
+              _kUserDataKey, jsonEncode(userData.toSerializableMap()))
+          : await _prefs.remove(_kUserDataKey);
+
+      if (authenticationToken != null) {
+        // Signing back in supersedes any recorded sign out. Written as false
+        // rather than removed, and unconditionally: a sign out earlier in this
+        // same run may have failed to remove the marker from storage while
+        // still clearing it from the cache, so neither containsKey() nor a
+        // removal can tell us whether the marker is really gone. Writing false
+        // is unambiguous, and reads treat it exactly like an absent key.
+        //
+        // Done last, so the session only becomes restorable once all of it has
+        // been stored. The cleanup in _clearPersistedSession() does not need
+        // this: a marker that survives there keeps the veto in place and the
+        // next launch retries, which is the safe direction, whereas here a
+        // surviving marker would discard a session the user legitimately has.
+        await _setSignedOutMarker(false);
+      }
+    });
   }
 }
 

+ 10 - 10
lib/favorieten/favorieten_widget.dart

@@ -41,7 +41,7 @@ class _FavorietenWidgetState extends State<FavorietenWidget>
     // On page load action.
     SchedulerBinding.instance.addPostFrameCallback((_) async {
       _model.responseActionDrupalRequestPageLoad = await actions.drupalRequest(
-        'POST',
+        'GET',
         'https://uitgaanskrant.com/nl/flutterdrup/favorieten_agenda.json',
         FFAppState().userSessionname,
         FFAppState().userSessionid,
@@ -322,41 +322,41 @@ class _FavorietenWidgetState extends State<FavorietenWidget>
                                                   'Keyiv6_${favorietenAgendaItemsIndex}_of_${favorietenAgendaItems.length}'),
                                               logo: getJsonField(
                                                 favorietenAgendaItemsItem,
-                                                r'''$[:].logo''',
+                                                r'''$.logo''',
                                               ),
                                               categorie: getJsonField(
                                                 favorietenAgendaItemsItem,
-                                                r'''$[:].categorie''',
+                                                r'''$.categorie''',
                                               ),
                                               titel: getJsonField(
                                                 favorietenAgendaItemsItem,
-                                                r'''$[:].titel''',
+                                                r'''$.titel''',
                                               ),
                                               horecagelegenheid: getJsonField(
                                                 favorietenAgendaItemsItem,
-                                                r'''$[:].horecagelegenheid''',
+                                                r'''$.horecagelegenheid''',
                                               ),
                                               adres: getJsonField(
                                                 favorietenAgendaItemsItem,
-                                                r'''$[:].adres''',
+                                                r'''$.adres''',
                                               ),
                                               plaats: getJsonField(
                                                 favorietenAgendaItemsItem,
-                                                r'''$[:].plaats''',
+                                                r'''$.plaats''',
                                               ),
                                               datum: getJsonField(
                                                 favorietenAgendaItemsItem,
-                                                r'''$[:].datum''',
+                                                r'''$.datum''',
                                               ),
                                               inhoud: null,
                                               nid: getJsonField(
                                                 favorietenAgendaItemsItem,
-                                                r'''$[:].nid''',
+                                                r'''$.nid''',
                                               ),
                                               horecagelegenheidNid:
                                                   getJsonField(
                                                 favorietenAgendaItemsItem,
-                                                r'''$[:].horecagelegenheidNid''',
+                                                r'''$.horecagelegenheidNid''',
                                               ),
                                             ),
                                           );

+ 1 - 0
lib/flutter_flow/flutter_flow_widgets.dart

@@ -406,6 +406,7 @@ class _FFFocusIndicatorState extends State<FFFocusIndicator> {
         splashColor: Colors.transparent,
         hoverColor: Colors.transparent,
         highlightColor: Colors.transparent,
+        focusColor: Colors.transparent,
         focusNode: _focusNode,
         onTap: widget.onTap,
         onLongPress: widget.onLongPress,

+ 34 - 22
lib/uitgaanspaginas/uitgaantabel_kaart/uitgaantabel_kaart_widget.dart

@@ -500,29 +500,41 @@ class _UitgaantabelKaartWidgetState extends State<UitgaantabelKaartWidget>
                               ],
                             ),
                             Expanded(
-                              child: Text(
-                                widget!.inhoud!.toString(),
-                                textAlign: TextAlign.start,
-                                style: FlutterFlowTheme.of(context)
-                                    .bodySmall
-                                    .override(
-                                      font: GoogleFonts.inter(
-                                        fontWeight: FlutterFlowTheme.of(context)
-                                            .bodySmall
-                                            .fontWeight,
-                                        fontStyle: FlutterFlowTheme.of(context)
-                                            .bodySmall
-                                            .fontStyle,
-                                      ),
-                                      letterSpacing: 0.0,
-                                      fontWeight: FlutterFlowTheme.of(context)
-                                          .bodySmall
-                                          .fontWeight,
-                                      fontStyle: FlutterFlowTheme.of(context)
+                              child: Builder(
+                                builder: (context) {
+                                  if (widget!.inhoud != null) {
+                                    return Text(
+                                      widget!.inhoud!.toString(),
+                                      textAlign: TextAlign.start,
+                                      style: FlutterFlowTheme.of(context)
                                           .bodySmall
-                                          .fontStyle,
-                                    ),
-                                overflow: TextOverflow.ellipsis,
+                                          .override(
+                                            font: GoogleFonts.inter(
+                                              fontWeight:
+                                                  FlutterFlowTheme.of(context)
+                                                      .bodySmall
+                                                      .fontWeight,
+                                              fontStyle:
+                                                  FlutterFlowTheme.of(context)
+                                                      .bodySmall
+                                                      .fontStyle,
+                                            ),
+                                            letterSpacing: 0.0,
+                                            fontWeight:
+                                                FlutterFlowTheme.of(context)
+                                                    .bodySmall
+                                                    .fontWeight,
+                                            fontStyle:
+                                                FlutterFlowTheme.of(context)
+                                                    .bodySmall
+                                                    .fontStyle,
+                                          ),
+                                      overflow: TextOverflow.ellipsis,
+                                    );
+                                  } else {
+                                    return Container();
+                                  }
+                                },
                               ),
                             ),
                           ],