From 271f61c3774a1bf625285667fb83dd68f46ae071 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 4 Apr 2026 03:53:33 +0000 Subject: [PATCH 1/5] Stabilize Convex auth session setup Co-authored-by: Dara Adedeji --- lib/providers/auth_provider.dart | 137 ++++++++++++++++++++++--- test/providers/auth_provider_test.dart | 72 +++++++++++++ 2 files changed, 196 insertions(+), 13 deletions(-) diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index 2ef02739..e31ace7a 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -318,8 +318,10 @@ class AuthProvider extends Notifier { AuthProviderAuthHandle? _convexAuthHandle; Future? _inFlightConvexSetup; bool _queuedConvexSetup = false; + String? _queuedConvexTrigger; bool _showingIncidentPrompt = false; int _incidentCounter = 0; + int _authGeneration = 0; @visibleForTesting static AuthProviderSupabaseApi? debugSupabaseApi; @@ -330,6 +332,9 @@ class AuthProvider extends Notifier { @visibleForTesting static Duration? debugConvexAuthReadyTimeout; + late final AuthProviderSupabaseApi _supabaseApi; + late final AuthProviderConvexApi _convexApi; + @visibleForTesting static void resetTestOverrides() { debugSupabaseApi = null; @@ -337,15 +342,33 @@ class AuthProvider extends Notifier { debugConvexAuthReadyTimeout = null; } - AuthProviderSupabaseApi get _supabaseApi => - debugSupabaseApi ?? const _DefaultAuthProviderSupabaseApi(); + int _advanceAuthGeneration() { + _authGeneration += 1; + return _authGeneration; + } + + String _sessionFingerprint(Session? session) { + if (session == null) { + return 'signed_out'; + } + + return '${session.user.id}:${session.accessToken}'; + } - AuthProviderConvexApi get _convexApi => - debugConvexApi ?? const _DefaultAuthProviderConvexApi(); + bool _isAuthContextCurrent({ + required int generation, + required String sessionFingerprint, + }) { + return generation == _authGeneration && + _sessionFingerprint(_supabaseApi.currentSession) == sessionFingerprint; + } @override AppAuthState build() { + _supabaseApi = debugSupabaseApi ?? const _DefaultAuthProviderSupabaseApi(); + _convexApi = debugConvexApi ?? const _DefaultAuthProviderConvexApi(); final session = _supabaseApi.currentSession; + final initialGeneration = _advanceAuthGeneration(); _supabaseAuthSub ??= _supabaseApi.onAuthStateChange.listen( _handleSupabaseAuthStateChange, @@ -357,7 +380,13 @@ class AuthProvider extends Notifier { _convexAuthHandle?.dispose(); }); - unawaited(_configureConvexAuth(trigger: 'build')); + Future.microtask(() async { + await _configureConvexAuth( + trigger: 'build', + generation: initialGeneration, + sessionFingerprint: _sessionFingerprint(session), + ); + }); return AppAuthState.fromSession( session, @@ -370,6 +399,7 @@ class AuthProvider extends Notifier { void _handleSupabaseAuthStateChange(AuthState event) { final currentSession = event.session; + final generation = _advanceAuthGeneration(); state = AppAuthState.fromSession( currentSession, isLoading: false, @@ -383,7 +413,13 @@ class AuthProvider extends Notifier { _clearAuthIncident(); } - unawaited(_configureConvexAuth(trigger: 'supabase:${event.event}')); + unawaited( + _configureConvexAuth( + trigger: 'supabase:${event.event}', + generation: generation, + sessionFingerprint: _sessionFingerprint(currentSession), + ), + ); } void _handleSupabaseAuthStreamError(Object error, StackTrace stackTrace) { @@ -483,7 +519,6 @@ class AuthProvider extends Notifier { return message; } - await _configureConvexAuth(trigger: 'email_password_sign_in'); state = state.copyWith(isLoading: false); return null; } catch (error, stackTrace) { @@ -533,7 +568,6 @@ class AuthProvider extends Notifier { return message; } - await _configureConvexAuth(trigger: 'email_password_sign_up'); state = state.copyWith(isLoading: false); return null; } catch (error, stackTrace) { @@ -726,11 +760,20 @@ class AuthProvider extends Notifier { unawaited(_showAuthIncidentPrompt(incidentId)); } - Future _configureConvexAuth({required String trigger}) async { + Future _configureConvexAuth({ + required String trigger, + int? generation, + String? sessionFingerprint, + }) async { await Future.value(); + final targetGeneration = generation ?? _authGeneration; + final targetFingerprint = + sessionFingerprint ?? _sessionFingerprint(_supabaseApi.currentSession); + if (_inFlightConvexSetup != null) { _queuedConvexSetup = true; + _queuedConvexTrigger = trigger; await _inFlightConvexSetup; return; } @@ -739,14 +782,20 @@ class AuthProvider extends Notifier { _inFlightConvexSetup = completer.future; try { - await _runConvexAuthSetup(trigger: trigger); + await _runConvexAuthSetup( + trigger: trigger, + generation: targetGeneration, + sessionFingerprint: targetFingerprint, + ); } finally { completer.complete(); _inFlightConvexSetup = null; if (_queuedConvexSetup) { _queuedConvexSetup = false; - unawaited(_configureConvexAuth(trigger: 'queued')); + final queuedTrigger = _queuedConvexTrigger ?? 'queued'; + _queuedConvexTrigger = null; + unawaited(_configureConvexAuth(trigger: queuedTrigger)); } } } @@ -788,8 +837,19 @@ class AuthProvider extends Notifier { return 'stream'; } - Future _runConvexAuthSetup({required String trigger}) async { + Future _runConvexAuthSetup({ + required String trigger, + required int generation, + required String sessionFingerprint, + }) async { final session = _supabaseApi.currentSession; + if (!_isAuthContextCurrent( + generation: generation, + sessionFingerprint: sessionFingerprint, + )) { + return; + } + log( 'Starting Convex auth setup [$trigger] (hasSession: ${session != null})', name: 'auth', @@ -798,6 +858,12 @@ class AuthProvider extends Notifier { _convexAuthHandle?.dispose(); _convexAuthHandle = null; await _convexApi.clearAuth(); + if (!_isAuthContextCurrent( + generation: generation, + sessionFingerprint: sessionFingerprint, + )) { + return; + } _clearAuthIncident(); state = AppAuthState.fromSession( null, @@ -820,9 +886,15 @@ class AuthProvider extends Notifier { _convexAuthHandle?.dispose(); final wasAuthenticatedBeforeSetup = _convexApi.isAuthenticated; bool? reconnectResult; - _convexAuthHandle = await _convexApi.setAuthWithRefresh( + final authHandle = await _convexApi.setAuthWithRefresh( fetchToken: _fetchSupabaseAccessToken, onAuthChange: (isAuthenticated) { + if (!_isAuthContextCurrent( + generation: generation, + sessionFingerprint: sessionFingerprint, + )) { + return; + } if (isAuthenticated) { return; } @@ -841,6 +913,18 @@ class AuthProvider extends Notifier { ); }, ); + _convexAuthHandle = authHandle; + + if (!_isAuthContextCurrent( + generation: generation, + sessionFingerprint: sessionFingerprint, + )) { + authHandle.dispose(); + if (identical(_convexAuthHandle, authHandle)) { + _convexAuthHandle = null; + } + return; + } log( 'Convex auth handle configured [$trigger] (wasAuthenticatedBeforeSetup: ' @@ -868,11 +952,31 @@ class AuthProvider extends Notifier { trigger: trigger, reconnectResult: reconnectResult, ); + if (!_isAuthContextCurrent( + generation: generation, + sessionFingerprint: sessionFingerprint, + )) { + authHandle.dispose(); + if (identical(_convexAuthHandle, authHandle)) { + _convexAuthHandle = null; + } + return; + } log( 'Convex auth ready [$trigger] via $readinessSource', name: 'auth', ); await _convexApi.mutation(name: 'users:ensureCurrentUser', args: {}); + if (!_isAuthContextCurrent( + generation: generation, + sessionFingerprint: sessionFingerprint, + )) { + authHandle.dispose(); + if (identical(_convexAuthHandle, authHandle)) { + _convexAuthHandle = null; + } + return; + } log( 'Convex current user ensured [$trigger]', name: 'auth', @@ -885,6 +989,13 @@ class AuthProvider extends Notifier { clearError: true, ); } catch (error, stackTrace) { + if (!_isAuthContextCurrent( + generation: generation, + sessionFingerprint: sessionFingerprint, + )) { + return; + } + log( 'Failed configuring Convex auth [$trigger]: $error', name: 'auth', diff --git a/test/providers/auth_provider_test.dart b/test/providers/auth_provider_test.dart index 9223a5cf..408fce0e 100644 --- a/test/providers/auth_provider_test.dart +++ b/test/providers/auth_provider_test.dart @@ -172,6 +172,69 @@ void main() { container.read(authProvider).convexAuthStatus, ConvexAuthStatus.ready); }); + test( + 'email password sign-in relies on auth-state listener and does not duplicate setup', + () async { + supabaseApi.currentSession = fakeSession(); + supabaseApi.emitSignedInEventOnPasswordSignIn = true; + convexApi.autoAuthenticateOnSetAuth = false; + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await pumpMicrotasks(); + expect(convexApi.setAuthCalls, 1); + + final error = await container.read(authProvider.notifier).signInWithEmailPassword( + email: 'test@example.com', + password: 'password', + ); + await pumpMicrotasks(); + + expect(error, isNull); + expect(convexApi.setAuthCalls, 1); + expect(convexApi.reconnectCalls, 1); + expect(convexApi.mutationCalls, 0); + + convexApi.emitAuthState(true); + await pumpMicrotasks(); + + expect(convexApi.mutationCalls, 1); + expect( + container.read(authProvider).convexAuthStatus, + ConvexAuthStatus.ready, + ); + }); + + test('stale startup setup does not mark user ready after sign-out', () async { + supabaseApi.currentSession = fakeSession(); + convexApi.autoAuthenticateOnSetAuth = false; + convexApi.setAuthCompleter = Completer(); + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await pumpMicrotasks(); + + expect(convexApi.setAuthCalls, 1); + expect(convexApi.clearAuthCalls, 0); + + await container.read(authProvider.notifier).signOut(); + expect(container.read(authProvider).convexAuthStatus, ConvexAuthStatus.signedOut); + expect(convexApi.clearAuthCalls, 1); + + convexApi.setAuthCompleter!.complete(FakeAuthHandle()); + await pumpMicrotasks(); + convexApi.emitAuthState(true); + await pumpMicrotasks(); + + expect(convexApi.mutationCalls, 0); + final state = container.read(authProvider); + expect(state.isAuthenticated, isFalse); + expect(state.isConvexUserReady, isFalse); + expect(state.convexAuthStatus, ConvexAuthStatus.signedOut); + }); + test('null session clears Convex auth cleanly', () async { supabaseApi.currentSession = null; final container = ProviderContainer(); @@ -386,6 +449,7 @@ class FakeSupabaseApi implements AuthProviderSupabaseApi { @override Session? currentSession; bool emitInitialSessionOnListen = false; + bool emitSignedInEventOnPasswordSignIn = false; Session? sessionFromUrlSession; int getSessionFromUrlCalls = 0; final List> _controllers = @@ -438,12 +502,20 @@ class FakeSupabaseApi implements AuthProviderSupabaseApi { required String email, required String password, }) async { + if (emitSignedInEventOnPasswordSignIn) { + for (final controller in _controllers) { + controller.add(AuthState(AuthChangeEvent.signedIn, currentSession)); + } + } return AuthResponse(session: currentSession); } @override Future signOut() async { currentSession = null; + for (final controller in _controllers) { + controller.add(AuthState(AuthChangeEvent.signedOut, currentSession)); + } } @override From 7c2183da95ca1878079621d3330f0b8d516b2136 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 4 Apr 2026 01:01:19 -0400 Subject: [PATCH 2/5] feat(collab): stream Convex folder and strategy lists - Add watchAllFolders() for folders:listAll subscriptions in ConvexStrategyRepository. - Switch cloud folder, strategy, and all-folders providers to StreamProvider using repository watch streams. - Add docs/auth_flow_reference.md. Made-with: Cursor --- docs/auth_flow_reference.md | 717 ++++++++++++++++++ lib/collab/convex_strategy_repository.dart | 55 +- .../collab/remote_library_provider.dart | 42 +- 3 files changed, 795 insertions(+), 19 deletions(-) create mode 100644 docs/auth_flow_reference.md diff --git a/docs/auth_flow_reference.md b/docs/auth_flow_reference.md new file mode 100644 index 00000000..5e0b1134 --- /dev/null +++ b/docs/auth_flow_reference.md @@ -0,0 +1,717 @@ +# Auth Flow Reference + +This document describes the current authentication flow in Icarus as it exists today. + +It is meant to answer four questions: + +1. What provider are we actually using? +2. How does Flutter authenticate with Supabase and then with Convex? +3. Why do both `Supabase` auth state and `users:ensureCurrentUser` exist? +4. Where should we look first if cloud auth starts failing? + +## High-Level Summary + +The app uses **Supabase Auth as the identity provider** and **Convex as the application backend**. + +The important detail is that Convex is **not** the primary login provider here. Convex trusts a **Supabase-issued JWT** through `convex/auth.config.ts`, and the Flutter app forwards that token to Convex using `ConvexClient.setAuthWithRefresh(...)`. + +That means the flow is: + +`User signs in -> Supabase gets a session -> Flutter extracts/refreshes the access token -> Convex validates the JWT -> Convex functions can read identity -> app ensures a local Convex users row exists -> cloud features are enabled` + +## Core Files + +These are the files that define the current behavior: + +- `lib/main.dart` +- `lib/providers/auth_provider.dart` +- `lib/widgets/dialogs/auth/auth_dialog.dart` +- `convex/auth.config.ts` +- `convex/users.ts` +- `convex/lib/auth.ts` +- `convex/schema.ts` +- `lib/providers/library_workspace_provider.dart` +- `lib/providers/collab/cloud_collab_provider.dart` + +## 1. Startup: both clients are initialized up front + +At startup the app initializes the Convex client and then Supabase: + +```dart +await ConvexClient.initialize( + const ConvexConfig( + deploymentUrl: 'https://majestic-eel-413.convex.cloud', + clientId: 'dev:majestic-eel-413', + operationTimeout: Duration(seconds: 30), + healthCheckQuery: 'health:ping', + ), +); + +await Supabase.initialize( + url: 'https://gjdirtrtgnawqoruavqn.supabase.co', + anonKey: '...', + authOptions: const FlutterAuthClientOptions(detectSessionInUri: false), +); +``` + +Why this matters: + +- `ConvexClient.initialize(...)` creates the global Convex client used by the app. +- `Supabase.initialize(...)` sets up the auth provider that will issue JWTs. +- `detectSessionInUri: false` is intentional because the desktop app handles OAuth callback URIs itself instead of relying on automatic URI parsing. + +## 2. Convex trusts Supabase JWTs + +Convex is configured to accept a custom JWT provider: + +```ts +export default { + providers: [ + { + type: "customJwt", + applicationID: "authenticated", + issuer: "https://gjdirtrtgnawqoruavqn.supabase.co/auth/v1", + jwks: "https://gjdirtrtgnawqoruavqn.supabase.co/auth/v1/.well-known/jwks.json", + algorithm: "ES256", + }, + ], +} satisfies AuthConfig; +``` + +This is the bridge between Supabase and Convex. + +Why it works: + +- Supabase signs access tokens. +- Convex validates those tokens against the configured `issuer`, `jwks`, and `algorithm`. +- If validation succeeds, `ctx.auth.getUserIdentity()` returns a non-null identity inside Convex functions. +- If this file is wrong, missing, or out of sync with Supabase, Convex will treat every request as unauthenticated. + +## 3. Flutter auth state is owned by `authProvider` + +The single source of truth on the client is `authProvider` in `lib/providers/auth_provider.dart`. + +It tracks: + +- Whether Supabase has a session +- Whether Convex auth has been configured successfully +- Whether a Convex-side incident is active +- Whether the app is safe to use cloud features + +The important state enum is: + +```dart +enum ConvexAuthStatus { + signedOut, + configuring, + ready, + incident, +} +``` + +This is intentionally more specific than just "logged in / logged out". + +Why this exists: + +- A Supabase session by itself is not enough. +- The app only enables cloud mode when Convex has also accepted the token and the app-level user row exists. + +## 4. Login entry points + +### Email/password + +The dialog calls: + +- `signInWithEmailPassword(...)` +- `signUpWithEmailPassword(...)` + +Those methods authenticate directly with Supabase. + +### Discord OAuth + +Discord login uses Supabase OAuth with a custom desktop deep link: + +```dart +final launched = await _supabaseApi.signInWithOAuth( + OAuthProvider.discord, + redirectTo: 'icarus://auth/callback', + authScreenLaunchMode: LaunchMode.externalApplication, + scopes: 'identify email', +); +``` + +Why this works: + +- Supabase handles the OAuth exchange with Discord. +- On success, Supabase redirects back to `icarus://auth/callback`. +- The app listens for that deep link and hands it to Supabase to finalize the session. + +## 5. OAuth callback handling + +The app bootstraps `authProvider` immediately and routes deep links into it: + +```dart +ref.read(authProvider); + +unawaited( + ref.read(authProvider.notifier).handleAuthCallbackUri(uri, source: source), +); +``` + +The provider decides whether the incoming URI is an auth callback: + +```dart +bool isAuthCallbackUri(Uri uri) { + final isIcarusScheme = uri.scheme.toLowerCase() == 'icarus'; + final isAuthCallback = + uri.host.toLowerCase() == 'auth' && + uri.path.toLowerCase() == '/callback'; + + final hasAuthPayload = + uri.fragment.contains('access_token') || + uri.queryParameters.containsKey('code') || + uri.fragment.contains('error_description') || + uri.queryParameters.containsKey('error_description'); + + return isIcarusScheme && isAuthCallback && hasAuthPayload; +} +``` + +Then it completes the Supabase session: + +```dart +await _supabaseApi.getSessionFromUrl(uri); +``` + +Why this matters: + +- Desktop OAuth relies on the custom URI handler working correctly. +- If the deep link never reaches `handleAuthCallbackUri(...)`, the browser may show a successful login while the app stays signed out. + +## 6. Supabase auth changes trigger Convex auth setup + +When `authProvider` builds, it subscribes to the Supabase auth stream: + +```dart +_supabaseAuthSub ??= _supabaseApi.onAuthStateChange.listen( + _handleSupabaseAuthStateChange, + onError: _handleSupabaseAuthStreamError, +); +``` + +Every auth state change immediately resets Convex readiness and reruns the bridge setup: + +```dart +state = AppAuthState.fromSession( + currentSession, + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: currentSession == null + ? ConvexAuthStatus.signedOut + : ConvexAuthStatus.configuring, +); + +unawaited( + _configureConvexAuth( + trigger: 'supabase:${event.event}', + generation: generation, + sessionFingerprint: _sessionFingerprint(currentSession), + ), +); +``` + +Why this is important: + +- Supabase is the source of identity. +- Convex auth must be re-bound whenever the session changes, refreshes, or disappears. + +## 7. The client forwards the Supabase token to Convex + +This is the most important bridge in the whole system. + +The provider configures the Convex client like this: + +```dart +final authHandle = await _convexApi.setAuthWithRefresh( + fetchToken: _fetchSupabaseAccessToken, + onAuthChange: (isAuthenticated) { + if (!isAuthenticated && _supabaseApi.currentSession != null) { + unawaited( + reportConvexUnauthenticated( + source: 'convex:onAuthChange', + error: Exception('Convex auth state changed to unauthenticated'), + ), + ); + } + }, +); +``` + +The token supplier is: + +```dart +Future _fetchSupabaseAccessToken() async { + final session = _supabaseApi.currentSession; + if (session == null) return null; + + final expiresAt = session.expiresAt; + if (expiresAt != null) { + final expiresAtUtc = DateTime.fromMillisecondsSinceEpoch( + expiresAt * 1000, + isUtc: true, + ); + final shouldRefresh = expiresAtUtc + .isBefore(DateTime.now().toUtc().add(const Duration(minutes: 1))); + + if (shouldRefresh) { + final refreshed = await _supabaseApi.refreshSession(); + final refreshedToken = refreshed.session?.accessToken; + if (refreshedToken != null && refreshedToken.isNotEmpty) { + return refreshedToken; + } + } + } + + return session.accessToken; +} +``` + +Why this works: + +- Convex does not know how to log the user in to Supabase. +- Instead, Convex asks the Flutter client for a valid token whenever it needs one. +- The provider refreshes the Supabase session if the token is about to expire. +- That keeps long-lived desktop sessions working without forcing the user to log in again constantly. + +## 8. Convex auth is not considered ready until the connection authenticates + +After setting the auth handler, the provider explicitly reconnects the Convex client and waits for it to become authenticated: + +```dart +reconnectResult = await _convexApi.reconnect(); +final readinessSource = await _waitForConvexAuthenticated( + trigger: trigger, + reconnectResult: reconnectResult, +); +``` + +And `_waitForConvexAuthenticated(...)` blocks until either: + +- the client is already authenticated, or +- `authState` emits `true`, or +- a timeout occurs + +Why this exists: + +- A Supabase session can exist before the Convex websocket/query layer has fully reconnected with the new token. +- Without this wait, the app could immediately fire protected Convex queries and get intermittent unauthenticated errors. + +## 9. The app provisions a Convex `users` row after auth is ready + +Once Convex says the token is accepted, the app immediately runs: + +```dart +await _convexApi.mutation(name: 'users:ensureCurrentUser', args: {}); +``` + +That mutation does this: + +```ts +export const ensureCurrentUser = mutation({ + args: {}, + handler: async (ctx) => { + const identity = await ctx.auth.getUserIdentity(); + if (identity === null) { + throw unauthenticatedError(); + } + + const externalId = getCanonicalExternalId(identity); + const displayName = identity.name ?? identity.nickname ?? "Discord user"; + const avatarUrl = identity.pictureUrl ?? undefined; + + const existingUser = await findUserByIdentity(ctx, identity); + + if (existingUser !== null) { + await ctx.db.patch(existingUser._id, { + externalId, + displayName, + avatarUrl, + updatedAt: Date.now(), + }); + return existingUser._id; + } + + return await ctx.db.insert("users", { + externalId, + displayName, + avatarUrl, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + }, +}); +``` + +Why this exists: + +- Convex auth gives us an authenticated identity, but that identity is not the same thing as an app-level `users` table row. +- The rest of the data model uses `users._id` as a foreign key for folders, strategies, collaborators, invites, and images. +- So the app must materialize "the current authenticated identity" into a stable row in `users`. + +## 10. Identity mapping uses `tokenIdentifier` as the canonical key + +The backend auth helper makes this explicit: + +```ts +export function getCanonicalExternalId(identity: { + tokenIdentifier: string; +}): string { + return identity.tokenIdentifier; +} +``` + +It also supports a fallback for older records: + +```ts +export function getLegacyExternalId(identity: { + subject?: string | null; + tokenIdentifier: string; +}): string | null { + const subject = identity.subject; + if (subject == null || subject == identity.tokenIdentifier) { + return null; + } + return subject; +} +``` + +Then user lookup tries the canonical value first and the legacy value second. + +Why this matters: + +- `tokenIdentifier` is the current stable identity key used by Convex auth. +- The legacy fallback strongly suggests the app used `subject` at some point and later migrated. +- This is a compatibility layer so older `users.externalId` values do not strand existing accounts. + +## 11. Protected Convex functions always derive the user server-side + +Protected functions never trust a client-supplied `userId`. + +They call: + +```ts +export async function requireCurrentUser(ctx: AnyCtx): Promise> { + const identity = await ctx.auth.getUserIdentity(); + if (identity === null) { + throw unauthenticatedError(); + } + + const user = await findUserByIdentity(ctx, identity); + if (user === null) { + throw new Error( + "Missing user record. Call users:ensureCurrentUser before querying collaborative data.", + ); + } + + return user; +} +``` + +And for strategy-level access: + +```ts +export async function assertStrategyRole( + ctx: AnyCtx, + strategy: Doc<"strategies">, + required: StrategyRole, +): Promise<{ user: Doc<"users">; role: StrategyRole }> { + const user = await requireCurrentUser(ctx); + const role = await getStrategyRoleForUser(ctx, strategy, user._id); + + if (!hasRole(role, required)) { + throw new Error("Forbidden"); + } + + return { user, role: role as StrategyRole }; +} +``` + +Why this works: + +- The client cannot impersonate another user by sending a fake ID. +- Ownership and collaboration checks are done against the authenticated identity that Convex extracted from the JWT. + +## 12. App data is linked to the Convex user row + +The schema makes that explicit: + +```ts +users: defineTable({ + externalId: v.string(), + displayName: v.string(), + avatarUrl: v.optional(v.string()), + createdAt: v.number(), + updatedAt: v.number(), +}).index("by_externalId", ["externalId"]), +``` + +And other tables point to `users._id`: + +- `folders.ownerId` +- `strategies.ownerId` +- `strategyCollaborators.userId` +- `inviteTokens.createdByUserId` +- `imageAssets.createdByUserId` + +So auth is not just "can this request run?". +It is also "which app-level user owns this data?". + +## 13. Cloud mode is gated on both auth layers + +The app does **not** consider cloud mode available when only Supabase is signed in. + +It requires both: + +- `isAuthenticated == true` +- `isConvexUserReady == true` + +That gate is here: + +```dart +final isCloudWorkspaceAvailableProvider = Provider((ref) { + final auth = ref.watch(authProvider); + return auth.isAuthenticated && auth.isConvexUserReady; +}); +``` + +And again here: + +```dart +return featureFlagEnabled && + isAuthenticated && + isConvexUserReady && + !forceLocalFallback; +``` + +Why this matters: + +- It prevents the UI from exposing cloud functionality during the gap between "Supabase has a session" and "Convex has accepted the token and provisioned a user". +- That is a major reason the current flow is more stable than a simple boolean login flag. + +## 14. Real cloud queries and mutations depend on this contract + +The repository calls Convex functions directly: + +```dart +final response = await _client.query('folders:listForParent', { + if (parentFolderPublicId != null) + 'parentFolderPublicId': parentFolderPublicId, +}); +``` + +Backend functions enforce auth immediately: + +```ts +export const listForParent = query({ + args: { + parentFolderPublicId: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const user = await requireCurrentUser(ctx); + // ... + }, +}); +``` + +And collaborative editing requires role checks: + +```ts +export const applyBatch = mutation({ + args: { + strategyPublicId: v.string(), + clientId: v.string(), + ops: v.array(strategyOpValidator), + }, + handler: async (ctx, args) => { + let strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "editor"); + // ... + }, +}); +``` + +So if auth fails anywhere upstream, these functions are where the break becomes visible. + +## 15. How unauthenticated incidents are detected + +The system has a special "Supabase says signed in, but Convex says unauthenticated" path. + +Convex returns a structured error: + +```ts +export function unauthenticatedError(): ConvexError<{ + code: 'UNAUTHENTICATED'; + message: string; +}> { + return new ConvexError({ + code: 'UNAUTHENTICATED', + message: 'Unauthenticated', + }); +} +``` + +The Flutter client looks for that code in either structured payloads or error strings: + +```dart +bool isConvexUnauthenticatedError(Object error) { + if (error is Map) { + final code = error['code']?.toString().toUpperCase(); + if (code == 'UNAUTHENTICATED') { + return true; + } + } + + return isConvexUnauthenticatedMessage(error.toString()); +} +``` + +When that happens, the provider enters `incident` state and can prompt the user to: + +- retry Convex auth +- sign out +- dismiss and keep cloud paused + +Why this exists: + +- Desktop sessions can drift out of sync. +- Supabase may still think the user is signed in while Convex no longer accepts the token or has lost the authenticated connection. +- This gives the app a controlled recovery path instead of repeated silent failures. + +## 16. Why the race-protection code exists + +`authProvider` contains several safeguards that are easy to overlook but are very important: + +- `_authGeneration` +- `_sessionFingerprint(...)` +- `_isAuthContextCurrent(...)` +- `_inFlightConvexSetup` +- `_queuedConvexSetup` + +These exist because auth setup is asynchronous and can be triggered many times: + +- initial app startup +- Supabase sign-in +- OAuth callback completion +- session refresh +- manual retry +- sign-out + +Without these guards, a stale setup task could finish late and overwrite state for a newer session. + +In other words, this code is preventing classic auth race conditions. + +## 17. Why this flow works overall + +The flow is stable because each layer has a single responsibility: + +- **Supabase** proves who the user is and issues JWTs. +- **Flutter authProvider** owns session lifecycle, token refresh, deep link handling, and bridge state. +- **Convex auth config** teaches Convex how to verify Supabase JWTs. +- `**users:ensureCurrentUser`** converts external identity into an app-level `users` row. +- `**requireCurrentUser` / `assertStrategyRole**` protect actual business data and collaboration rules. +- **Cloud feature gates** stop the UI from using Convex too early. +- **Incident handling** gives recovery behavior when Supabase and Convex drift apart. + +That separation is the main reason the system is understandable and debuggable. + +## 18. Failure points and what they usually mean + +### Symptom: Supabase login succeeds but Convex queries return unauthenticated + +Likely causes: + +- `convex/auth.config.ts` no longer matches the Supabase issuer or JWKS +- the access token is not being forwarded to Convex +- token refresh failed and an expired token is being reused +- Convex connection did not fully re-authenticate after session change + +First places to inspect: + +- `convex/auth.config.ts` +- `_fetchSupabaseAccessToken()` +- `_runConvexAuthSetup(...)` +- `reportConvexUnauthenticated(...)` + +### Symptom: user is authenticated but gets "Missing user record" + +Likely causes: + +- `users:ensureCurrentUser` never ran +- it ran before Convex auth was actually ready +- identity mapping changed and `findUserByIdentity(...)` can no longer find the row +- the `users` row was deleted or corrupted + +First places to inspect: + +- `users:ensureCurrentUser` +- `findUserByIdentity(...)` +- `users.externalId` +- `getCanonicalExternalId(...)` +- `getLegacyExternalId(...)` + +### Symptom: Discord browser flow completes but app never signs in + +Likely causes: + +- the `icarus://auth/callback` deep link is not reaching the app +- the callback URI shape changed +- `getSessionFromUrl(...)` is failing +- duplicate-link filtering or platform URI handling is dropping the callback + +First places to inspect: + +- `signInWithDiscord()` +- `main.dart` deep link handling +- `handleAuthCallbackUri(...)` +- `isAuthCallbackUri(...)` + +### Symptom: signed-in user cannot access a strategy + +Likely causes: + +- the user exists, but has no owner/collaborator mapping for that strategy +- collaborator rows are missing or wrong +- backend is correctly returning `Forbidden` + +First places to inspect: + +- `assertStrategyRole(...)` +- `getStrategyRoleForUser(...)` +- `strategyCollaborators` +- `strategies.ownerId` + +## 19. Practical debug checklist + +If auth breaks, verify these in order: + +1. Does Supabase have a non-null current session? +2. Is `_fetchSupabaseAccessToken()` returning a real token? +3. Does `ConvexClient` transition to authenticated after `setAuthWithRefresh(...)` and `reconnect()`? +4. Does `ctx.auth.getUserIdentity()` return a value inside Convex? +5. Does `users:ensureCurrentUser` succeed? +6. Does `users.me` return the expected app user? +7. Does `requireCurrentUser(...)` resolve that user inside protected functions? +8. If the request is strategy-scoped, does `assertStrategyRole(...)` return the expected role? + +If one of these steps fails, the break is usually in that layer or the layer immediately before it. + +## 20. Mental model to keep in mind + +The most useful mental model is: + +- **Supabase session** means "the user has logged in". +- **Convex authenticated connection** means "Convex trusts the Supabase JWT". +- **Convex user row exists** means "the application can attach ownership and permissions to this identity". +- **Cloud enabled** means "all three conditions are true enough for the UI to rely on cloud state". + +That distinction is the key to understanding the current system. \ No newline at end of file diff --git a/lib/collab/convex_strategy_repository.dart b/lib/collab/convex_strategy_repository.dart index 489844f0..c2ab6e28 100644 --- a/lib/collab/convex_strategy_repository.dart +++ b/lib/collab/convex_strategy_repository.dart @@ -33,13 +33,15 @@ class ConvexStrategyRepository { if (decoded is Map) { return Map.from(decoded); } - throw FormatException('Expected object payload, received ${decoded.runtimeType}'); + throw FormatException( + 'Expected object payload, received ${decoded.runtimeType}'); } List> _decodeObjectList(dynamic value) { final decoded = _decodeJsonPayload(value); if (decoded is! List) { - throw FormatException('Expected list payload, received ${decoded.runtimeType}'); + throw FormatException( + 'Expected list payload, received ${decoded.runtimeType}'); } return decoded @@ -68,6 +70,46 @@ class ConvexStrategyRepository { .toList(growable: false); } + Stream> watchAllFolders() { + final controller = StreamController>.broadcast(); + dynamic subscription; + + Future start() async { + try { + controller.add(await listAllFolders()); + } catch (error, stackTrace) { + controller.addError(error, stackTrace); + } + + subscription = await _client.subscribe( + name: 'folders:listAll', + args: const {}, + onUpdate: (value) { + try { + final mapped = _decodeObjectList(value) + .map(CloudFolderSummary.fromJson) + .toList(growable: false); + controller.add(mapped); + } catch (error, stackTrace) { + controller.addError(error, stackTrace); + } + }, + onError: (message, value) { + controller.addError(Exception('folders:listAll error: $message')); + }, + ); + } + + start(); + controller.onCancel = () { + try { + subscription?.cancel(); + } catch (_) {} + }; + + return controller.stream; + } + Future> listStrategiesForFolder( String? folderPublicId, ) async { @@ -109,7 +151,8 @@ class ConvexStrategyRepository { } }, onError: (message, value) { - controller.addError(Exception('folders:listForParent error: $message')); + controller + .addError(Exception('folders:listForParent error: $message')); }, ); } @@ -186,7 +229,8 @@ class ConvexStrategyRepository { } }, onError: (message, value) { - controller.addError(Exception('strategies:getHeader error: $message')); + controller + .addError(Exception('strategies:getHeader error: $message')); }, ); } @@ -267,7 +311,8 @@ class ConvexStrategyRepository { }, ); - final resultList = (_decodeObject(response)['results'] as List?) ?? const []; + final resultList = + (_decodeObject(response)['results'] as List?) ?? const []; return resultList .whereType() .map((item) => OpAck.fromJson(Map.from(item))) diff --git a/lib/providers/collab/remote_library_provider.dart b/lib/providers/collab/remote_library_provider.dart index afc81ddf..8417b261 100644 --- a/lib/providers/collab/remote_library_provider.dart +++ b/lib/providers/collab/remote_library_provider.dart @@ -9,23 +9,27 @@ import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/library_workspace_provider.dart'; final cloudFoldersProvider = - FutureProvider.autoDispose>((ref) async { + StreamProvider.autoDispose>((ref) async* { final isCloud = ref.watch(isCloudCollabEnabledProvider); final auth = ref.watch(authProvider); if (!isCloud || auth.hasActiveAuthIncident) { - return const []; + yield const []; + return; } final parentFolderId = ref.watch(folderProvider); final repo = ref.watch(convexStrategyRepositoryProvider); try { - return await repo.listFoldersForParent(parentFolderId); + await for (final folders in repo.watchFoldersForParent(parentFolderId)) { + yield folders; + } } catch (error, stackTrace) { if (_isInvalidFolderError(error)) { ref .read(folderProvider.notifier) .updateWorkspaceFolderId(LibraryWorkspace.cloud, null); - return const []; + yield const []; + return; } if (isConvexUnauthenticatedError(error)) { unawaited( @@ -35,30 +39,35 @@ final cloudFoldersProvider = stackTrace: stackTrace, ), ); - return const []; + yield const []; + return; } rethrow; } }); final cloudStrategiesProvider = - FutureProvider.autoDispose>((ref) async { + StreamProvider.autoDispose>((ref) async* { final isCloud = ref.watch(isCloudCollabEnabledProvider); final auth = ref.watch(authProvider); if (!isCloud || auth.hasActiveAuthIncident) { - return const []; + yield const []; + return; } final folderId = ref.watch(folderProvider); final repo = ref.watch(convexStrategyRepositoryProvider); try { - return await repo.listStrategiesForFolder(folderId); + await for (final strategies in repo.watchStrategiesForFolder(folderId)) { + yield strategies; + } } catch (error, stackTrace) { if (_isInvalidFolderError(error)) { ref .read(folderProvider.notifier) .updateWorkspaceFolderId(LibraryWorkspace.cloud, null); - return const []; + yield const []; + return; } if (isConvexUnauthenticatedError(error)) { unawaited( @@ -68,23 +77,27 @@ final cloudStrategiesProvider = stackTrace: stackTrace, ), ); - return const []; + yield const []; + return; } rethrow; } }); final cloudAllFoldersProvider = - FutureProvider.autoDispose>((ref) async { + StreamProvider.autoDispose>((ref) async* { final isCloud = ref.watch(isCloudCollabEnabledProvider); final auth = ref.watch(authProvider); if (!isCloud || auth.hasActiveAuthIncident) { - return const []; + yield const []; + return; } final repo = ref.watch(convexStrategyRepositoryProvider); try { - return await repo.listAllFolders(); + await for (final folders in repo.watchAllFolders()) { + yield folders; + } } catch (error, stackTrace) { if (isConvexUnauthenticatedError(error)) { unawaited( @@ -94,7 +107,8 @@ final cloudAllFoldersProvider = stackTrace: stackTrace, ), ); - return const []; + yield const []; + return; } rethrow; } From a57f4d9c24cfb19bf22aa84c822890322141ceef Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 4 Apr 2026 01:38:23 -0400 Subject: [PATCH 3/5] fix(strategy): use animated cloud page transitions consistently Remove cloud-only shortcuts that skipped the shared transition path. Route refresh and delete flows through setActivePageAnimated. Add tests for cloud animated and relative page switches. Made-with: Cursor --- .../strategy_page_session_provider.dart | 12 -- lib/providers/strategy_provider.dart | 25 +-- test/strategy_page_session_provider_test.dart | 168 +++++++++++++++++- 3 files changed, 179 insertions(+), 26 deletions(-) diff --git a/lib/providers/strategy_page_session_provider.dart b/lib/providers/strategy_page_session_provider.dart index 50932de6..ee3db310 100644 --- a/lib/providers/strategy_page_session_provider.dart +++ b/lib/providers/strategy_page_session_provider.dart @@ -197,12 +197,6 @@ class StrategyPageSessionNotifier extends Notifier { return; } - final strategyState = ref.read(strategyProvider); - if (strategyState.source == StrategySource.cloud) { - await _switchToPage(pageId, animated: false); - return; - } - final transitionState = ref.read(transitionProvider); final transitionNotifier = ref.read(transitionProvider.notifier); if (transitionState.active || @@ -268,12 +262,6 @@ class StrategyPageSessionNotifier extends Notifier { : (currentIndex - 1 + state.availablePageIds.length) % state.availablePageIds.length; final nextPageId = state.availablePageIds[nextIndex]; - final strategyState = ref.read(strategyProvider); - if (strategyState.source == StrategySource.cloud) { - await setActivePage(nextPageId); - return; - } - await setActivePageAnimated( nextPageId, direction: direction == PageSwitchDirection.next diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index cbb4eeff..50e8653b 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -165,13 +165,6 @@ class StrategyProvider extends Notifier { } Future switchPage(String pageID) async { - if (_currentStrategyIsCloud()) { - await ref - .read(strategyPageSessionProvider.notifier) - .setActivePage(pageID); - return; - } - await ref.read(strategyPageSessionProvider.notifier).setActivePageAnimated( pageID, direction: PageTransitionDirection.forward, @@ -428,7 +421,10 @@ class StrategyProvider extends Notifier { await ref.read(remoteStrategySnapshotProvider.notifier).refresh(); await ref .read(strategyPageSessionProvider.notifier) - .setActivePage(pageID); + .setActivePageAnimated( + pageID, + direction: PageTransitionDirection.forward, + ); return; } @@ -523,8 +519,9 @@ class StrategyProvider extends Notifier { ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); final activePageId = ref.read(strategyPageSessionProvider).activePageId ?? pages.first.publicId; - final remaining = - pages.where((page) => page.publicId != pageId).toList(growable: false); + final remaining = pages + .where((page) => page.publicId != pageId) + .toList(growable: false); final nextActivePageId = activePageId == pageId && remaining.isNotEmpty ? remaining.first.publicId : activePageId; @@ -554,7 +551,10 @@ class StrategyProvider extends Notifier { if (nextActivePageId != activePageId) { await ref .read(strategyPageSessionProvider.notifier) - .setActivePage(nextActivePageId); + .setActivePageAnimated( + nextActivePageId, + direction: PageTransitionDirection.forward, + ); } return; } @@ -565,7 +565,8 @@ class StrategyProvider extends Notifier { final strat = box.get(strategyId); if (strat == null || strat.pages.length <= 1) return; - final remaining = [...strat.pages]..removeWhere((page) => page.id == pageId); + final remaining = [...strat.pages] + ..removeWhere((page) => page.id == pageId); final reindexed = [ for (var i = 0; i < remaining.length; i++) remaining[i].copyWith(sortIndex: i), diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index 9fff64e6..52c984bb 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -10,6 +10,7 @@ import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/transition_data.dart'; import 'package:icarus/hive/hive_registration.dart'; import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; @@ -19,6 +20,8 @@ import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_save_state_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/providers/transition_provider.dart' + as overlay_transition; import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; @@ -314,7 +317,6 @@ void main() { remoteNotifier: remoteNotifier, queueNotifier: queueNotifier, ); - await container .read(strategyPageSessionProvider.notifier) .initializeForStrategy( @@ -382,7 +384,6 @@ void main() { remoteNotifier: remoteNotifier, queueNotifier: queueNotifier, ); - await container .read(strategyPageSessionProvider.notifier) .initializeForStrategy( @@ -468,6 +469,169 @@ void main() { expect(container.read(textProvider).single.text, 'page-two'); }); + test('cloud animated page switch uses shared transition state', () async { + const strategyId = 'cloud-strategy'; + final snapshot = _cloudSnapshot( + strategyId: strategyId, + sequence: 1, + pages: [ + _remotePage(strategyId: strategyId, pageId: 'page-1', sortIndex: 0), + _remotePage(strategyId: strategyId, pageId: 'page-2', sortIndex: 1), + ], + elementsByPage: { + 'page-1': [ + _remoteText( + strategyId: strategyId, + pageId: 'page-1', + elementId: 'text-1', + text: 'before', + ), + ], + 'page-2': [ + _remoteText( + strategyId: strategyId, + pageId: 'page-2', + elementId: 'text-2', + text: 'after', + ), + ], + }, + ); + + final remoteNotifier = _FakeRemoteStrategySnapshotNotifier(snapshot); + final queueNotifier = _FakeStrategyOpQueueNotifier(strategyId); + final container = await _cloudContainer( + strategyState: const StrategyState( + strategyId: strategyId, + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + storageDirectory: null, + isOpen: true, + ), + remoteNotifier: remoteNotifier, + queueNotifier: queueNotifier, + ); + + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: strategyId, + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + container.read(textProvider.notifier).fromHive([ + PlacedText(id: 'local-text', position: const Offset(50, 60)) + ..text = 'needs-sync', + ]); + + await container + .read(strategyPageSessionProvider.notifier) + .setActivePageAnimated( + 'page-2', + direction: PageTransitionDirection.forward, + ); + + expect( + container.read(strategyPageSessionProvider).transitionState, + PageTransitionState.animatingForward, + ); + final transitionState = + container.read(overlay_transition.transitionProvider); + expect(transitionState.hideView, isTrue); + expect( + transitionState.phase, + overlay_transition.PageTransitionPhase.preparing, + ); + expect(transitionState.direction, PageTransitionDirection.forward); + expect(queueNotifier.flushNowCount, 1); + expect(container.read(textProvider).single.text, 'after'); + }); + + test('cloud relative page switch preserves backward transition direction', + () async { + const strategyId = 'cloud-strategy'; + final snapshot = _cloudSnapshot( + strategyId: strategyId, + sequence: 1, + pages: [ + _remotePage(strategyId: strategyId, pageId: 'page-1', sortIndex: 0), + _remotePage(strategyId: strategyId, pageId: 'page-2', sortIndex: 1), + ], + elementsByPage: { + 'page-1': [ + _remoteText( + strategyId: strategyId, + pageId: 'page-1', + elementId: 'text-1', + text: 'before', + ), + ], + 'page-2': [ + _remoteText( + strategyId: strategyId, + pageId: 'page-2', + elementId: 'text-2', + text: 'after', + ), + ], + }, + ); + + final remoteNotifier = _FakeRemoteStrategySnapshotNotifier(snapshot); + final queueNotifier = _FakeStrategyOpQueueNotifier(strategyId); + final container = await _cloudContainer( + strategyState: const StrategyState( + strategyId: strategyId, + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + storageDirectory: null, + isOpen: true, + ), + remoteNotifier: remoteNotifier, + queueNotifier: queueNotifier, + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: strategyId, + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + await container.read(strategyPageSessionProvider.notifier).setActivePage( + 'page-2', + ); + + queueNotifier + ..enqueueAllCount = 0 + ..flushNowCount = 0 + ..enqueuedOps.clear(); + container.read(textProvider.notifier).fromHive([ + PlacedText(id: 'page-two-draft', position: const Offset(40, 70)) + ..text = 'draft', + ]); + + await container + .read(strategyPageSessionProvider.notifier) + .switchRelativePage(PageSwitchDirection.previous); + + expect( + container.read(strategyPageSessionProvider).transitionState, + PageTransitionState.animatingBackward, + ); + final transitionState = + container.read(overlay_transition.transitionProvider); + expect(transitionState.hideView, isTrue); + expect( + transitionState.phase, + overlay_transition.PageTransitionPhase.preparing, + ); + expect(transitionState.direction, PageTransitionDirection.backward); + expect(queueNotifier.flushNowCount, 1); + expect(container.read(strategyPageSessionProvider).activePageId, 'page-1'); + expect(container.read(textProvider).single.text, 'before'); + }); + test('pending remote reapply resumes through non-flushing path', () async { const strategyId = 'cloud-strategy'; final pageOne = From 413327104c0fab521982500be56d11b2bd320afb Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 4 Apr 2026 11:31:52 -0400 Subject: [PATCH 4/5] Add action history hydration and shared transform models. Extract ActionHistoryTransformContext and ActionObjectState for round-tripping placed objects through history. Wire capture/hydration into ability, agent, drawing, image, text, utility, and line providers; extend action_provider and strategy apply flow. Add hydration tests. Made-with: Cursor --- lib/const/line_provider.dart | 118 ++++-- lib/providers/ability_provider.dart | 173 +++++--- lib/providers/action_history_models.dart | 366 +++++++++++++++++ lib/providers/action_provider.dart | 377 +++++++++++++++++- lib/providers/agent_provider.dart | 192 ++++++--- lib/providers/drawing_provider.dart | 155 +++++-- lib/providers/image_provider.dart | 157 ++++++-- lib/providers/map_provider.dart | 4 + .../strategy_page_session_provider.dart | 4 + lib/providers/text_provider.dart | 176 ++++++-- lib/providers/utility_provider.dart | 157 +++++--- lib/strategy/strategy_page_apply.dart | 19 +- test/action_history_hydration_test.dart | 142 +++++++ 13 files changed, 1768 insertions(+), 272 deletions(-) create mode 100644 lib/providers/action_history_models.dart create mode 100644 test/action_history_hydration_test.dart diff --git a/lib/const/line_provider.dart b/lib/const/line_provider.dart index 5431c400..b2182bc3 100644 --- a/lib/const/line_provider.dart +++ b/lib/const/line_provider.dart @@ -5,6 +5,7 @@ import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/action_history_models.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'dart:ui'; @@ -150,7 +151,13 @@ class LineUpProvider extends Notifier { void addLineUp(LineUp lineUp) { final action = UserAction( - type: ActionType.addition, id: lineUp.id, group: ActionGroup.lineUp); + type: ActionType.addition, + id: lineUp.id, + group: ActionGroup.lineUp, + objectDelta: ObjectHistoryDelta( + after: ActionObjectState.lineUp(lineUp), + ), + ); ref.read(actionProvider.notifier).addAction(action); state = state.copyWith( @@ -329,10 +336,20 @@ class LineUpProvider extends Notifier { //the right click menu, and not through the delete key. final action = UserAction( - type: ActionType.deletion, id: id, group: ActionGroup.lineUp); + type: ActionType.deletion, + id: id, + group: ActionGroup.lineUp, + objectDelta: ObjectHistoryDelta( + before: ActionObjectState.lineUp( + state.lineUps.firstWhere((lineUp) => lineUp.id == id), + ), + ), + ); ref.read(actionProvider.notifier).addAction(action); - - _poppedLineUps.add(state.lineUps.firstWhere((lineUp) => lineUp.id == id)); + _poppedLineUps.removeWhere((lineUp) => lineUp.id == id); + _poppedLineUps.add( + cloneLineUp(state.lineUps.firstWhere((lineUp) => lineUp.id == id)), + ); state = state.copyWith( lineUps: state.lineUps.where((lineUp) => lineUp.id != id).toList(), @@ -340,15 +357,31 @@ class LineUpProvider extends Notifier { } void undoAction(UserAction action) { + final delta = action.objectDelta; + if (delta == null) { + switch (action.type) { + case ActionType.addition: + deleteLineUpById(action.id); + return; + case ActionType.deletion: + if (_poppedLineUps.isEmpty) return; + _upsertLineUp(cloneLineUp(_poppedLineUps.removeLast())); + return; + case ActionType.edit: + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } + } switch (action.type) { case ActionType.addition: deleteLineUpById(action.id); + return; case ActionType.deletion: - if (_poppedLineUps.isEmpty) return; - final newState = state.copyWith( - lineUps: [...state.lineUps, _poppedLineUps.removeLast()], - ); - state = newState; + final before = delta.before?.lineUp; + if (before == null) return; + _upsertLineUp(cloneLineUp(before)); + return; case ActionType.edit: //Do nothing case ActionType.bulkDeletion: @@ -358,21 +391,45 @@ class LineUpProvider extends Notifier { } void redoAction(UserAction action) { + final delta = action.objectDelta; + if (delta == null) { + switch (action.type) { + case ActionType.addition: + if (_poppedLineUps.isEmpty) return; + _upsertLineUp(cloneLineUp(_poppedLineUps.removeLast())); + return; + case ActionType.deletion: + final existing = state.lineUps.where((lineUp) => lineUp.id == action.id); + if (existing.isEmpty) return; + _poppedLineUps.removeWhere((lineUp) => lineUp.id == action.id); + _poppedLineUps.add(cloneLineUp(existing.first)); + state = state.copyWith( + lineUps: + state.lineUps.where((lineUp) => lineUp.id != action.id).toList(), + ); + return; + case ActionType.edit: + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } + } switch (action.type) { case ActionType.addition: - final index = - _poppedLineUps.indexWhere((lineUp) => lineUp.id == action.id); - state = state.copyWith( - lineUps: [...state.lineUps, _poppedLineUps.removeAt(index)], - ); + final after = delta.after?.lineUp; + if (after == null) return; + _upsertLineUp(cloneLineUp(after)); + return; case ActionType.deletion: - final newState = state.copyWith( - lineUps: [...state.lineUps], + final existing = state.lineUps.where((lineUp) => lineUp.id == action.id); + if (existing.isEmpty) return; + _poppedLineUps.removeWhere((lineUp) => lineUp.id == action.id); + _poppedLineUps.add(cloneLineUp(existing.first)); + state = state.copyWith( + lineUps: + state.lineUps.where((lineUp) => lineUp.id != action.id).toList(), ); - final index = - newState.lineUps.indexWhere((lineUp) => lineUp.id == action.id); - _poppedLineUps.add(newState.lineUps.removeAt(index)); - state = newState; + return; case ActionType.edit: //Do nothing case ActionType.bulkDeletion: @@ -388,16 +445,29 @@ class LineUpProvider extends Notifier { LineUpProviderSnapshot takeSnapshot() { return LineUpProviderSnapshot( - lineUps: [...state.lineUps], - poppedLineUps: [..._poppedLineUps], + lineUps: state.lineUps.map((lineUp) => cloneLineUp(lineUp)).toList(), + poppedLineUps: _poppedLineUps.map((lineUp) => cloneLineUp(lineUp)).toList(), ); } void restoreSnapshot(LineUpProviderSnapshot snapshot) { _poppedLineUps ..clear() - ..addAll(snapshot.poppedLineUps); - state = state.copyWith(lineUps: [...snapshot.lineUps]); + ..addAll(snapshot.poppedLineUps.map((lineUp) => cloneLineUp(lineUp))); + state = state.copyWith( + lineUps: snapshot.lineUps.map((lineUp) => cloneLineUp(lineUp)).toList(), + ); + } + + void _upsertLineUp(LineUp lineUp) { + final newState = [...state.lineUps]; + final index = newState.indexWhere((existing) => existing.id == lineUp.id); + if (index < 0) { + newState.add(lineUp); + } else { + newState[index] = lineUp; + } + state = state.copyWith(lineUps: newState); } } diff --git a/lib/providers/ability_provider.dart b/lib/providers/ability_provider.dart index 824875ad..d8646f8c 100644 --- a/lib/providers/ability_provider.dart +++ b/lib/providers/ability_provider.dart @@ -7,6 +7,7 @@ import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/action_history_models.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; @@ -32,6 +33,7 @@ class AbilitySnapshot { class AbilityProvider extends Notifier> { List poppedAbility = []; + final Map _pendingEditBefore = {}; List snapshots = []; @override List build() { @@ -42,20 +44,27 @@ class AbilityProvider extends Notifier> { final action = UserAction( type: ActionType.addition, id: placedAbility.id, - group: ActionGroup.ability); + group: ActionGroup.ability, + objectDelta: ObjectHistoryDelta( + after: ActionObjectState.ability(placedAbility), + )); ref.read(actionProvider.notifier).addAction(action); state = [...state, placedAbility]; } void removeAbilityAsAction(String id) { - if (!state.any((ability) => ability.id == id)) return; + final index = PlacedWidget.getIndexByID(id, state); + if (index < 0) return; ref.read(actionProvider.notifier).addAction( UserAction( type: ActionType.deletion, id: id, group: ActionGroup.ability, + objectDelta: ObjectHistoryDelta( + before: ActionObjectState.ability(state[index]), + ), ), ); removeAbility(id); @@ -69,6 +78,7 @@ class AbilityProvider extends Notifier> { if (index < 0) return; final ability = newState[index]; + final before = ActionObjectState.ability(ability); final coordinateSystem = CoordinateSystem.instance; final mapState = ref.read(mapProvider); @@ -91,8 +101,15 @@ class AbilityProvider extends Notifier> { final temp = newState.removeAt(index); - final action = - UserAction(type: ActionType.edit, id: id, group: ActionGroup.ability); + final action = UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.ability, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.ability(temp), + ), + ); ref.read(actionProvider.notifier).addAction(action); state = [...newState, temp]; @@ -128,16 +145,22 @@ class AbilityProvider extends Notifier> { List? armLengthsMeters, }) { final newState = [...state]; - updateGeometryHistory(index); + final before = _pendingEditBefore.remove(newState[index].id) ?? + ActionObjectState.ability(newState[index]); newState[index].updateGeometry( newRotation: rotation, newLength: length, newArmLengthsMeters: armLengthsMeters, ); final action = UserAction( - type: ActionType.edit, - id: newState[index].id, - group: ActionGroup.ability); + type: ActionType.edit, + id: newState[index].id, + group: ActionGroup.ability, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.ability(newState[index]), + ), + ); ref.read(actionProvider.notifier).addAction(action); state = newState; } @@ -152,13 +175,17 @@ class AbilityProvider extends Notifier> { return; } - updateGeometryHistory(index); + final before = ActionObjectState.ability(newState[index]); newState[index].updateVisualState(visualState); ref.read(actionProvider.notifier).addAction( UserAction( type: ActionType.edit, id: newState[index].id, group: ActionGroup.ability, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.ability(newState[index]), + ), ), ); state = newState; @@ -182,11 +209,8 @@ class AbilityProvider extends Notifier> { } void updateGeometryHistory(int index) { - final newState = [...state]; - - newState[index].updateGeometryHistory(); - - state = newState; + if (index < 0 || index >= state.length) return; + _pendingEditBefore[state[index].id] = ActionObjectState.ability(state[index]); } // void updateLengthHistory(int index) { @@ -198,25 +222,44 @@ class AbilityProvider extends Notifier> { // } void undoAction(UserAction action) { + final delta = action.objectDelta; + if (delta == null) { + switch (action.type) { + case ActionType.addition: + removeAbility(action.id); + return; + case ActionType.deletion: + if (poppedAbility.isEmpty) return; + final newState = [...state]; + newState.add(clonePlacedAbility(poppedAbility.removeLast())); + state = newState; + return; + case ActionType.edit: + final index = PlacedWidget.getIndexByID(action.id, state); + if (index < 0) return; + final newState = [...state]; + newState[index].undoAction(); + state = newState; + return; + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } + } switch (action.type) { case ActionType.addition: removeAbility(action.id); + return; case ActionType.deletion: - if (poppedAbility.isEmpty) { - return; - } - - final newState = [...state]; - - newState.add(poppedAbility.removeLast()); - state = newState; + final before = delta.before?.ability; + if (before == null) return; + _upsertAbility(clonePlacedAbility(before)); + return; case ActionType.edit: - final newState = [...state]; - - final index = PlacedWidget.getIndexByID(action.id, newState); - - newState[index].undoAction(); - state = newState; + final before = delta.before?.ability; + if (before == null) return; + _upsertAbility(clonePlacedAbility(before)); + return; case ActionType.bulkDeletion: case ActionType.transaction: return; @@ -224,27 +267,50 @@ class AbilityProvider extends Notifier> { } void redoAction(UserAction action) { - final newState = [...state]; - - try { + final delta = action.objectDelta; + if (delta == null) { + final newState = [...state]; switch (action.type) { case ActionType.addition: - final index = PlacedWidget.getIndexByID(action.id, poppedAbility); - newState.add(poppedAbility.removeAt(index)); - + if (poppedAbility.isEmpty) return; + newState.add(clonePlacedAbility(poppedAbility.removeLast())); + state = newState; + return; case ActionType.deletion: - final index = PlacedWidget.getIndexByID(action.id, poppedAbility); - - poppedAbility.add(newState.removeAt(index)); + final index = PlacedWidget.getIndexByID(action.id, newState); + if (index < 0) return; + poppedAbility.add(clonePlacedAbility(newState.removeAt(index))); + state = newState; + return; case ActionType.edit: final index = PlacedWidget.getIndexByID(action.id, newState); + if (index < 0) return; newState[index].redoAction(); + state = newState; + return; case ActionType.bulkDeletion: case ActionType.transaction: return; } - } catch (_) {} - state = newState; + } + switch (action.type) { + case ActionType.addition: + final after = delta.after?.ability; + if (after == null) return; + _upsertAbility(clonePlacedAbility(after)); + return; + case ActionType.deletion: + removeAbility(action.id); + return; + case ActionType.edit: + final after = delta.after?.ability; + if (after == null) return; + _upsertAbility(clonePlacedAbility(after)); + return; + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } } void removeAbility(String id) { @@ -253,32 +319,49 @@ class AbilityProvider extends Notifier> { final index = PlacedWidget.getIndexByID(id, newState); if (index < 0) return; - final ability = newState.removeAt(index); - poppedAbility.add(ability); + final removedAbility = newState.removeAt(index); + poppedAbility.removeWhere((item) => item.id == id); + poppedAbility.add(clonePlacedAbility(removedAbility)); state = newState; } void fromHive(List hiveAbilities) { poppedAbility = []; + _pendingEditBefore.clear(); state = hiveAbilities; } void clearAll() { poppedAbility = []; + _pendingEditBefore.clear(); state = []; } AbilityProviderSnapshot takeSnapshot() { return AbilityProviderSnapshot( - abilities: [...state], - poppedAbilities: [...poppedAbility], + abilities: state.map((ability) => clonePlacedAbility(ability)).toList(), + poppedAbilities: + poppedAbility.map((ability) => clonePlacedAbility(ability)).toList(), ); } void restoreSnapshot(AbilityProviderSnapshot snapshot) { - poppedAbility = [...snapshot.poppedAbilities]; - state = [...snapshot.abilities]; + poppedAbility = + snapshot.poppedAbilities.map((ability) => clonePlacedAbility(ability)).toList(); + _pendingEditBefore.clear(); + state = snapshot.abilities.map((ability) => clonePlacedAbility(ability)).toList(); + } + + void _upsertAbility(PlacedAbility ability) { + final newState = [...state]; + final index = PlacedWidget.getIndexByID(ability.id, newState); + if (index < 0) { + newState.add(ability); + } else { + newState[index] = ability; + } + state = newState; } String toJson() { diff --git a/lib/providers/action_history_models.dart b/lib/providers/action_history_models.dart new file mode 100644 index 00000000..1c43d691 --- /dev/null +++ b/lib/providers/action_history_models.dart @@ -0,0 +1,366 @@ +import 'dart:ui'; + +import 'package:icarus/const/bounding_box.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/drawing_element.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/placed_classes.dart'; + +class ActionHistoryTransformContext { + final double agentSize; + final double abilitySize; + final double mapScale; + final Map imageSizes; + final Map textHeights; + + const ActionHistoryTransformContext({ + required this.agentSize, + required this.abilitySize, + required this.mapScale, + required this.imageSizes, + required this.textHeights, + }); +} + +class ActionObjectState { + final String id; + final ActionObjectKind kind; + final PlacedAgentNode? agent; + final PlacedAbility? ability; + final DrawingElement? drawing; + final PlacedText? text; + final PlacedImage? image; + final PlacedUtility? utility; + final LineUp? lineUp; + + const ActionObjectState._({ + required this.id, + required this.kind, + this.agent, + this.ability, + this.drawing, + this.text, + this.image, + this.utility, + this.lineUp, + }); + + factory ActionObjectState.agent(PlacedAgentNode value) => ActionObjectState._( + id: value.id, + kind: ActionObjectKind.agent, + agent: clonePlacedAgentNode(value), + ); + + factory ActionObjectState.ability(PlacedAbility value) => + ActionObjectState._( + id: value.id, + kind: ActionObjectKind.ability, + ability: clonePlacedAbility(value), + ); + + factory ActionObjectState.drawing(DrawingElement value) => + ActionObjectState._( + id: value.id, + kind: ActionObjectKind.drawing, + drawing: cloneDrawingElement(value), + ); + + factory ActionObjectState.text(PlacedText value) => ActionObjectState._( + id: value.id, + kind: ActionObjectKind.text, + text: clonePlacedText(value), + ); + + factory ActionObjectState.image(PlacedImage value) => ActionObjectState._( + id: value.id, + kind: ActionObjectKind.image, + image: clonePlacedImage(value), + ); + + factory ActionObjectState.utility(PlacedUtility value) => ActionObjectState._( + id: value.id, + kind: ActionObjectKind.utility, + utility: clonePlacedUtility(value), + ); + + factory ActionObjectState.lineUp(LineUp value) => ActionObjectState._( + id: value.id, + kind: ActionObjectKind.lineUp, + lineUp: cloneLineUp(value), + ); + + ActionObjectState clone() { + return switch (kind) { + ActionObjectKind.agent => ActionObjectState.agent(agent!), + ActionObjectKind.ability => ActionObjectState.ability(ability!), + ActionObjectKind.drawing => ActionObjectState.drawing(drawing!), + ActionObjectKind.text => ActionObjectState.text(text!), + ActionObjectKind.image => ActionObjectState.image(image!), + ActionObjectKind.utility => ActionObjectState.utility(utility!), + ActionObjectKind.lineUp => ActionObjectState.lineUp(lineUp!), + }; + } + + ActionObjectState switchSides(ActionHistoryTransformContext context) { + return switch (kind) { + ActionObjectKind.agent => ActionObjectState.agent( + clonePlacedAgentNode(agent!)..switchSides(context.agentSize), + ), + ActionObjectKind.ability => ActionObjectState.ability( + clonePlacedAbility(ability!) + ..switchSides( + mapScale: context.mapScale, + abilitySize: context.abilitySize, + ), + ), + ActionObjectKind.drawing => ActionObjectState.drawing( + switchDrawingElementSides(cloneDrawingElement(drawing!)), + ), + ActionObjectKind.text => ActionObjectState.text( + clonePlacedText(text!) + ..switchSides(context.textHeights[text!.id] ?? Offset.zero), + ), + ActionObjectKind.image => ActionObjectState.image( + clonePlacedImage(image!) + ..switchSides(context.imageSizes[image!.id] ?? Offset.zero), + ), + ActionObjectKind.utility => ActionObjectState.utility( + clonePlacedUtility(utility!) + ..switchSides( + mapScale: context.mapScale, + agentSize: context.agentSize, + abilitySize: context.abilitySize, + ), + ), + ActionObjectKind.lineUp => ActionObjectState.lineUp( + cloneLineUp(lineUp!) + ..switchSides( + agentSize: context.agentSize, + abilitySize: context.abilitySize, + mapScale: context.mapScale, + ), + ), + }; + } +} + +enum ActionObjectKind { + agent, + ability, + drawing, + text, + image, + utility, + lineUp, +} + +class ObjectHistoryDelta { + final ActionObjectState? before; + final ActionObjectState? after; + final Map beforeImageSizes; + final Map afterImageSizes; + final Map beforeTextHeights; + final Map afterTextHeights; + + const ObjectHistoryDelta({ + this.before, + this.after, + this.beforeImageSizes = const {}, + this.afterImageSizes = const {}, + this.beforeTextHeights = const {}, + this.afterTextHeights = const {}, + }); + + String get id => after?.id ?? before!.id; + + ObjectHistoryDelta clone() { + return ObjectHistoryDelta( + before: before?.clone(), + after: after?.clone(), + beforeImageSizes: Map.from(beforeImageSizes), + afterImageSizes: Map.from(afterImageSizes), + beforeTextHeights: Map.from(beforeTextHeights), + afterTextHeights: Map.from(afterTextHeights), + ); + } + + ObjectHistoryDelta switchSides(ActionHistoryTransformContext context) { + return ObjectHistoryDelta( + before: before?.switchSides(context), + after: after?.switchSides(context), + beforeImageSizes: Map.from(beforeImageSizes), + afterImageSizes: Map.from(afterImageSizes), + beforeTextHeights: Map.from(beforeTextHeights), + afterTextHeights: Map.from(afterTextHeights), + ); + } +} + +PlacedAgentNode clonePlacedAgentNode(PlacedAgentNode value) { + return switch (value) { + PlacedAgent() => value.copyWith(), + PlacedViewConeAgent() => value.copyWith(), + PlacedCircleAgent() => value.copyWith(), + }; +} + +PlacedAbility clonePlacedAbility(PlacedAbility value) => + value.copyWith()..isDeleted = value.isDeleted; + +PlacedText clonePlacedText(PlacedText value) => value.copyWith( + text: value.text, + isDeleted: value.isDeleted, + )..isDeleted = value.isDeleted; + +PlacedImage clonePlacedImage(PlacedImage value) => + value.copyWith(isDeleted: value.isDeleted, link: value.link) + ..isDeleted = value.isDeleted; + +PlacedUtility clonePlacedUtility(PlacedUtility value) => + value.copyWith()..isDeleted = value.isDeleted; + +LineUp cloneLineUp(LineUp value) { + return LineUp( + id: value.id, + agent: clonePlacedAgentNode(value.agent) as PlacedAgent, + ability: clonePlacedAbility(value.ability), + youtubeLink: value.youtubeLink, + images: value.images.map((image) => image.copyWith()).toList(), + notes: value.notes, + ); +} + +DrawingElement cloneDrawingElement(DrawingElement value) { + if (value is FreeDrawing) { + return value.copyWith( + listOfPoints: [...value.listOfPoints], + boundingBox: cloneBoundingBox(value.boundingBox), + cachedPolylineLengthUnits: value.cachedPolylineLengthUnits, + ); + } + if (value is Line) { + return value.copyWith( + boundingBox: cloneBoundingBox(value.boundingBox), + ); + } + if (value is RectangleDrawing) { + return RectangleDrawing( + start: value.start, + end: value.end, + color: value.color, + thickness: value.thickness, + boundingBox: cloneBoundingBox(value.boundingBox), + isDotted: value.isDotted, + hasArrow: value.hasArrow, + id: value.id, + ); + } + if (value is EllipseDrawing) { + return EllipseDrawing( + start: value.start, + end: value.end, + color: value.color, + thickness: value.thickness, + boundingBox: cloneBoundingBox(value.boundingBox), + isDotted: value.isDotted, + hasArrow: value.hasArrow, + id: value.id, + ); + } + throw UnsupportedError('Unsupported drawing element: ${value.runtimeType}'); +} + +BoundingBox? cloneBoundingBox(BoundingBox? value) { + if (value == null) { + return null; + } + return BoundingBox(min: value.min, max: value.max); +} + +DrawingElement switchDrawingElementSides(DrawingElement value) { + final flipped = cloneDrawingElement(value); + if (flipped is FreeDrawing) { + flipped.replacePoints( + flipped.listOfPoints.map(_flipCoordinatePoint).toList(), + ); + flipped.boundingBox = _boundingBoxForPoints(flipped.listOfPoints); + flipped.rebuildPath(CoordinateSystem.instance); + return flipped; + } + if (flipped is Line) { + final start = _flipCoordinatePoint(flipped.lineStart); + final end = _flipCoordinatePoint(flipped.lineEnd); + return flipped.copyWith( + lineStart: start, + lineEnd: end, + boundingBox: _lineBoundingBox(start, end), + ); + } + if (flipped is RectangleDrawing) { + final start = _flipCoordinatePoint(flipped.start); + final end = _flipCoordinatePoint(flipped.end); + return RectangleDrawing( + start: start, + end: end, + color: flipped.color, + thickness: flipped.thickness, + boundingBox: _lineBoundingBox(start, end), + isDotted: flipped.isDotted, + hasArrow: flipped.hasArrow, + id: flipped.id, + ); + } + if (flipped is EllipseDrawing) { + final start = _flipCoordinatePoint(flipped.start); + final end = _flipCoordinatePoint(flipped.end); + return EllipseDrawing( + start: start, + end: end, + color: flipped.color, + thickness: flipped.thickness, + boundingBox: _lineBoundingBox(start, end), + isDotted: flipped.isDotted, + hasArrow: flipped.hasArrow, + id: flipped.id, + ); + } + return flipped; +} + +Offset _flipCoordinatePoint(Offset point) { + final coordinateSystem = CoordinateSystem.instance; + return Offset( + coordinateSystem.worldNormalizedWidth - point.dx, + coordinateSystem.normalizedHeight - point.dy, + ); +} + +BoundingBox _lineBoundingBox(Offset start, Offset end) { + return BoundingBox( + min: Offset( + start.dx < end.dx ? start.dx : end.dx, + start.dy < end.dy ? start.dy : end.dy, + ), + max: Offset( + start.dx > end.dx ? start.dx : end.dx, + start.dy > end.dy ? start.dy : end.dy, + ), + ); +} + +BoundingBox? _boundingBoxForPoints(List points) { + if (points.isEmpty) { + return null; + } + double minX = points.first.dx; + double minY = points.first.dy; + double maxX = points.first.dx; + double maxY = points.first.dy; + for (final point in points.skip(1)) { + if (point.dx < minX) minX = point.dx; + if (point.dy < minY) minY = point.dy; + if (point.dx > maxX) maxX = point.dx; + if (point.dy > maxY) maxY = point.dy; + } + return BoundingBox(min: Offset(minX, minY), max: Offset(maxX, maxY)); +} diff --git a/lib/providers/action_provider.dart b/lib/providers/action_provider.dart index f1d2fea1..b7fc222a 100644 --- a/lib/providers/action_provider.dart +++ b/lib/providers/action_provider.dart @@ -1,17 +1,23 @@ import 'dart:ui'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/drawing_element.dart'; import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/providers/ability_bar_provider.dart'; +import 'package:icarus/providers/action_history_models.dart'; import 'package:icarus/providers/ability_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/providers/image_widget_size_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/providers/text_widget_height_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; +import 'package:icarus/const/maps.dart'; import 'package:uuid/uuid.dart'; enum ActionGroup { @@ -43,6 +49,22 @@ class TransactionSnapshot { required this.before, required this.after, }); + + TransactionSnapshot copy() { + return TransactionSnapshot( + targetGroups: [...targetGroups], + before: before.copy(), + after: after.copy(), + ); + } + + TransactionSnapshot switchSides(ActionHistoryTransformContext context) { + return TransactionSnapshot( + targetGroups: [...targetGroups], + before: before.switchSides(context), + after: after.switchSides(context), + ); + } } class BulkActionSnapshot { @@ -73,12 +95,250 @@ class BulkActionSnapshot { this.imageSizeSnapshot = const {}, this.textHeightSnapshot = const {}, }); + + BulkActionSnapshot copy() { + return BulkActionSnapshot( + targetGroups: [...targetGroups], + actionStateBefore: actionStateBefore.map((action) => action.copy()).toList(), + redoStateBefore: redoStateBefore.map((action) => action.copy()).toList(), + agentSnapshot: agentSnapshot == null + ? null + : AgentProviderSnapshot( + agents: agentSnapshot!.agents + .map((agent) => clonePlacedAgentNode(agent)) + .toList(), + poppedAgents: agentSnapshot!.poppedAgents + .map((agent) => clonePlacedAgentNode(agent)) + .toList(), + ), + abilitySnapshot: abilitySnapshot == null + ? null + : AbilityProviderSnapshot( + abilities: abilitySnapshot!.abilities + .map((ability) => clonePlacedAbility(ability)) + .toList(), + poppedAbilities: abilitySnapshot!.poppedAbilities + .map((ability) => clonePlacedAbility(ability)) + .toList(), + ), + drawingSnapshot: drawingSnapshot == null + ? null + : DrawingProviderSnapshot( + state: DrawingState( + elements: drawingSnapshot!.state.elements + .map((element) => cloneDrawingElement(element)) + .toList(), + updateCounter: drawingSnapshot!.state.updateCounter, + currentElement: drawingSnapshot!.state.currentElement == null + ? null + : cloneDrawingElement(drawingSnapshot!.state.currentElement!), + ), + poppedElements: drawingSnapshot!.poppedElements + .map((element) => cloneDrawingElement(element)) + .toList(), + ), + textSnapshot: textSnapshot == null + ? null + : TextProviderSnapshot( + texts: + textSnapshot!.texts.map((text) => clonePlacedText(text)).toList(), + poppedText: textSnapshot!.poppedText + .map((text) => clonePlacedText(text)) + .toList(), + ), + imageSnapshot: imageSnapshot == null + ? null + : PlacedImageProviderSnapshot( + images: imageSnapshot!.images + .map((image) => clonePlacedImage(image)) + .toList(), + poppedImages: imageSnapshot!.poppedImages + .map((image) => clonePlacedImage(image)) + .toList(), + ), + utilitySnapshot: utilitySnapshot == null + ? null + : UtilityProviderSnapshot( + utilities: utilitySnapshot!.utilities + .map((utility) => clonePlacedUtility(utility)) + .toList(), + poppedUtilities: utilitySnapshot!.poppedUtilities + .map((utility) => clonePlacedUtility(utility)) + .toList(), + ), + lineUpSnapshot: lineUpSnapshot == null + ? null + : LineUpProviderSnapshot( + lineUps: lineUpSnapshot!.lineUps + .map((lineUp) => cloneLineUp(lineUp)) + .toList(), + poppedLineUps: lineUpSnapshot!.poppedLineUps + .map((lineUp) => cloneLineUp(lineUp)) + .toList(), + ), + imageSizeSnapshot: Map.from(imageSizeSnapshot), + textHeightSnapshot: Map.from(textHeightSnapshot), + ); + } + + BulkActionSnapshot switchSides(ActionHistoryTransformContext context) { + return BulkActionSnapshot( + targetGroups: [...targetGroups], + actionStateBefore: + actionStateBefore.map((action) => action.switchSides(context)).toList(), + redoStateBefore: + redoStateBefore.map((action) => action.switchSides(context)).toList(), + agentSnapshot: agentSnapshot == null + ? null + : AgentProviderSnapshot( + agents: agentSnapshot!.agents + .map( + (agent) => + clonePlacedAgentNode(agent)..switchSides(context.agentSize), + ) + .toList(), + poppedAgents: agentSnapshot!.poppedAgents + .map( + (agent) => + clonePlacedAgentNode(agent)..switchSides(context.agentSize), + ) + .toList(), + ), + abilitySnapshot: abilitySnapshot == null + ? null + : AbilityProviderSnapshot( + abilities: abilitySnapshot!.abilities + .map( + (ability) => clonePlacedAbility(ability) + ..switchSides( + mapScale: context.mapScale, + abilitySize: context.abilitySize, + ), + ) + .toList(), + poppedAbilities: abilitySnapshot!.poppedAbilities + .map( + (ability) => clonePlacedAbility(ability) + ..switchSides( + mapScale: context.mapScale, + abilitySize: context.abilitySize, + ), + ) + .toList(), + ), + drawingSnapshot: drawingSnapshot == null + ? null + : DrawingProviderSnapshot( + state: DrawingState( + elements: drawingSnapshot!.state.elements + .map((element) => switchDrawingElementSides(element)) + .toList(), + updateCounter: drawingSnapshot!.state.updateCounter, + currentElement: drawingSnapshot!.state.currentElement == null + ? null + : switchDrawingElementSides( + drawingSnapshot!.state.currentElement!, + ), + ), + poppedElements: drawingSnapshot!.poppedElements + .map((element) => switchDrawingElementSides(element)) + .toList(), + ), + textSnapshot: textSnapshot == null + ? null + : TextProviderSnapshot( + texts: textSnapshot!.texts + .map( + (text) => clonePlacedText(text) + ..switchSides( + context.textHeights[text.id] ?? Offset.zero, + ), + ) + .toList(), + poppedText: textSnapshot!.poppedText + .map( + (text) => clonePlacedText(text) + ..switchSides( + context.textHeights[text.id] ?? Offset.zero, + ), + ) + .toList(), + ), + imageSnapshot: imageSnapshot == null + ? null + : PlacedImageProviderSnapshot( + images: imageSnapshot!.images + .map( + (image) => clonePlacedImage(image) + ..switchSides(context.imageSizes[image.id] ?? Offset.zero), + ) + .toList(), + poppedImages: imageSnapshot!.poppedImages + .map( + (image) => clonePlacedImage(image) + ..switchSides(context.imageSizes[image.id] ?? Offset.zero), + ) + .toList(), + ), + utilitySnapshot: utilitySnapshot == null + ? null + : UtilityProviderSnapshot( + utilities: utilitySnapshot!.utilities + .map( + (utility) => clonePlacedUtility(utility) + ..switchSides( + mapScale: context.mapScale, + agentSize: context.agentSize, + abilitySize: context.abilitySize, + ), + ) + .toList(), + poppedUtilities: utilitySnapshot!.poppedUtilities + .map( + (utility) => clonePlacedUtility(utility) + ..switchSides( + mapScale: context.mapScale, + agentSize: context.agentSize, + abilitySize: context.abilitySize, + ), + ) + .toList(), + ), + lineUpSnapshot: lineUpSnapshot == null + ? null + : LineUpProviderSnapshot( + lineUps: lineUpSnapshot!.lineUps + .map( + (lineUp) => cloneLineUp(lineUp) + ..switchSides( + agentSize: context.agentSize, + abilitySize: context.abilitySize, + mapScale: context.mapScale, + ), + ) + .toList(), + poppedLineUps: lineUpSnapshot!.poppedLineUps + .map( + (lineUp) => cloneLineUp(lineUp) + ..switchSides( + agentSize: context.agentSize, + abilitySize: context.abilitySize, + mapScale: context.mapScale, + ), + ) + .toList(), + ), + imageSizeSnapshot: Map.from(imageSizeSnapshot), + textHeightSnapshot: Map.from(textHeightSnapshot), + ); + } } class UserAction { final ActionGroup group; final String id; final ActionType type; + final ObjectHistoryDelta? objectDelta; final BulkActionSnapshot? bulkSnapshot; final TransactionSnapshot? transactionSnapshot; @@ -86,10 +346,33 @@ class UserAction { required this.type, required this.id, required this.group, + this.objectDelta, this.bulkSnapshot, this.transactionSnapshot, }); + UserAction copy() { + return UserAction( + type: type, + id: id, + group: group, + objectDelta: objectDelta?.clone(), + bulkSnapshot: bulkSnapshot?.copy(), + transactionSnapshot: transactionSnapshot?.copy(), + ); + } + + UserAction switchSides(ActionHistoryTransformContext context) { + return UserAction( + type: type, + id: id, + group: group, + objectDelta: objectDelta?.switchSides(context), + bulkSnapshot: bulkSnapshot?.switchSides(context), + transactionSnapshot: transactionSnapshot?.switchSides(context), + ); + } + @override String toString() { return """ @@ -133,7 +416,7 @@ class ActionProvider extends Notifier> { .updateData(null); // Make the agent tab disappear after an action } poppedItems = []; - state = [...state, action]; + state = [...state, action.copy()]; // log("\n Current state \n ${state.toString()}"); } @@ -243,6 +526,32 @@ class ActionProvider extends Notifier> { state = []; } + void clearActionHistory({bool markUnsaved = false}) { + poppedItems = []; + if (markUnsaved) { + ref.read(strategyProvider.notifier).setUnsaved(); + } + state = []; + } + + void reconcileHistory() { + state = _reconcileActions(state); + poppedItems = _reconcileActions(poppedItems); + } + + void switchSides() { + final mapState = ref.read(mapProvider); + final context = ActionHistoryTransformContext( + agentSize: ref.read(strategySettingsProvider).agentSize, + abilitySize: ref.read(strategySettingsProvider).abilitySize, + mapScale: Maps.mapScale[mapState.currentMap] ?? 1.0, + imageSizes: Map.from(ref.read(imageWidgetSizeProvider)), + textHeights: Map.from(ref.read(textWidgetHeightProvider)), + ); + state = state.map((action) => action.switchSides(context)).toList(); + poppedItems = poppedItems.map((action) => action.switchSides(context)).toList(); + } + void clearAllAsAction() { _performBulkClear(_undoableGroups); } @@ -357,8 +666,8 @@ class ActionProvider extends Notifier> { return BulkActionSnapshot( targetGroups: [...groups], - actionStateBefore: [...state], - redoStateBefore: [...poppedItems], + actionStateBefore: state.map((action) => action.copy()).toList(), + redoStateBefore: poppedItems.map((action) => action.copy()).toList(), agentSnapshot: groups.contains(ActionGroup.agent) ? ref.read(agentProvider.notifier).takeSnapshot() : null, @@ -505,7 +814,7 @@ class ActionProvider extends Notifier> { _restoreBulkSnapshot(snapshot); poppedItems.add(action); ref.read(strategyProvider.notifier).setUnsaved(); - state = [...snapshot.actionStateBefore]; + state = snapshot.actionStateBefore.map((item) => item.copy()).toList(); } void _redoBulkAction(UserAction action) { @@ -545,4 +854,64 @@ class ActionProvider extends Notifier> { ref.read(abilityBarProvider.notifier).updateData(null); state = newState; } + + List _reconcileActions(List actions) { + final reconciled = []; + for (final action in actions) { + if (action.type == ActionType.edit && action.objectDelta != null) { + if (!_canKeepEditAction(action.objectDelta!)) { + continue; + } + } + reconciled.add(action.copy()); + } + return reconciled; + } + + bool _canKeepEditAction(ObjectHistoryDelta delta) { + final current = _currentObjectState(delta.id, delta.before?.kind ?? delta.after?.kind); + if (current == null) { + return false; + } + final expectedKind = delta.after?.kind ?? delta.before?.kind; + return current.kind == expectedKind; + } + + ActionObjectState? _currentObjectState(String id, ActionObjectKind? kind) { + if (kind == null) { + return null; + } + switch (kind) { + case ActionObjectKind.agent: + final index = PlacedWidget.getIndexByID(id, ref.read(agentProvider)); + if (index < 0) return null; + return ActionObjectState.agent(ref.read(agentProvider)[index]); + case ActionObjectKind.ability: + final index = PlacedWidget.getIndexByID(id, ref.read(abilityProvider)); + if (index < 0) return null; + return ActionObjectState.ability(ref.read(abilityProvider)[index]); + case ActionObjectKind.drawing: + final index = DrawingElement.getIndexByID(id, ref.read(drawingProvider).elements); + if (index < 0) return null; + return ActionObjectState.drawing(ref.read(drawingProvider).elements[index]); + case ActionObjectKind.text: + final index = PlacedWidget.getIndexByID(id, ref.read(textProvider)); + if (index < 0) return null; + return ActionObjectState.text(ref.read(textProvider)[index]); + case ActionObjectKind.image: + final images = ref.read(placedImageProvider).images; + final index = PlacedWidget.getIndexByID(id, images); + if (index < 0) return null; + return ActionObjectState.image(images[index]); + case ActionObjectKind.utility: + final index = PlacedWidget.getIndexByID(id, ref.read(utilityProvider)); + if (index < 0) return null; + return ActionObjectState.utility(ref.read(utilityProvider)[index]); + case ActionObjectKind.lineUp: + final lineUps = ref.read(lineUpProvider).lineUps; + final index = lineUps.indexWhere((lineUp) => lineUp.id == id); + if (index < 0) return null; + return ActionObjectState.lineUp(lineUps[index]); + } + } } diff --git a/lib/providers/agent_provider.dart b/lib/providers/agent_provider.dart index a4dcb3f9..253a63f5 100644 --- a/lib/providers/agent_provider.dart +++ b/lib/providers/agent_provider.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/action_history_models.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/const/utilities.dart'; import 'package:uuid/uuid.dart'; @@ -26,6 +27,7 @@ class AgentProviderSnapshot { class AgentProvider extends Notifier> { List poppedAgents = []; + final Map _pendingEditBefore = {}; static const _uuid = Uuid(); @override @@ -38,6 +40,9 @@ class AgentProvider extends Notifier> { type: ActionType.addition, id: placedAgent.id, group: ActionGroup.agent, + objectDelta: ObjectHistoryDelta( + after: ActionObjectState.agent(placedAgent), + ), ); ref.read(actionProvider.notifier).addAction(action); @@ -50,19 +55,25 @@ class AgentProvider extends Notifier> { final index = PlacedWidget.getIndexByID(id, newState); if (index < 0) return; - poppedAgents.add(newState.removeAt(index)); + final removedAgent = newState.removeAt(index); + poppedAgents.removeWhere((agent) => agent.id == id); + poppedAgents.add(clonePlacedAgentNode(removedAgent)); state = newState; } void removeAgentAsAction(String id) { - if (!state.any((agent) => agent.id == id)) return; + final index = PlacedWidget.getIndexByID(id, state); + if (index < 0) return; ref.read(actionProvider.notifier).addAction( UserAction( type: ActionType.deletion, id: id, group: ActionGroup.agent, + objectDelta: ObjectHistoryDelta( + before: ActionObjectState.agent(state[index]), + ), ), ); removeAgent(id); @@ -72,12 +83,21 @@ class AgentProvider extends Notifier> { final newState = [...state]; final index = PlacedWidget.getIndexByID(id, newState); if (index < 0) return; + final before = ActionObjectState.agent(newState[index]); newState[index].state = newState[index].state == AgentState.dead ? AgentState.none : AgentState.dead; final action = - UserAction(type: ActionType.edit, id: id, group: ActionGroup.agent); + UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.agent, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.agent(newState[index]), + ), + ); ref.read(actionProvider.notifier).addAction(action); state = newState; @@ -99,12 +119,20 @@ class AgentProvider extends Notifier> { return; } if (index < 0) return; + final before = ActionObjectState.agent(newState[index]); newState[index].updatePosition(position); final temp = newState.removeAt(index); - final action = - UserAction(type: ActionType.edit, id: id, group: ActionGroup.agent); + final action = UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.agent, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.agent(temp), + ), + ); ref.read(actionProvider.notifier).addAction(action); state = [...newState, temp]; @@ -131,13 +159,11 @@ class AgentProvider extends Notifier> { } void updateViewConeHistory(String id) { - final newState = [...state]; - final index = PlacedWidget.getIndexByID(id, newState); + final index = PlacedWidget.getIndexByID(id, state); if (index < 0) return; - final node = newState[index]; + final node = state[index]; if (node is! PlacedViewConeAgent) return; - node.updateGeometryHistory(); - state = newState; + _pendingEditBefore[id] = ActionObjectState.agent(node); } void updateViewConeGeometry({ @@ -150,9 +176,18 @@ class AgentProvider extends Notifier> { if (index < 0) return; final node = newState[index]; if (node is! PlacedViewConeAgent) return; + final before = _pendingEditBefore.remove(id) ?? ActionObjectState.agent(node); node.updateGeometry(newRotation: rotation, newLength: length); ref.read(actionProvider.notifier).addAction( - UserAction(type: ActionType.edit, id: id, group: ActionGroup.agent), + UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.agent, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.agent(node), + ), + ), ); state = newState; } @@ -168,18 +203,26 @@ class AgentProvider extends Notifier> { if (index < 0) return; final node = newState[index]; if (node is! PlacedCircleAgent) return; + final before = ActionObjectState.agent(node); final hasChange = node.diameterMeters != diameterMeters || node.colorValue != colorValue || node.opacityPercent != opacityPercent; if (!hasChange) return; - node.updateGeometryHistory(); node.updateGeometry( newDiameterMeters: diameterMeters, newColorValue: colorValue, newOpacityPercent: opacityPercent, ); ref.read(actionProvider.notifier).addAction( - UserAction(type: ActionType.edit, id: id, group: ActionGroup.agent), + UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.agent, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.agent(node), + ), + ), ); state = newState; } @@ -195,6 +238,7 @@ class AgentProvider extends Notifier> { if (index < 0) return false; final node = newState[index]; if (node is! PlacedAgent) return false; + final before = ActionObjectState.agent(node); newState[index] = PlacedViewConeAgent( id: node.id, @@ -208,7 +252,15 @@ class AgentProvider extends Notifier> { )..isDeleted = node.isDeleted; ref.read(actionProvider.notifier).addAction( - UserAction(type: ActionType.edit, id: id, group: ActionGroup.agent), + UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.agent, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.agent(newState[index]), + ), + ), ); state = newState; return true; @@ -225,6 +277,7 @@ class AgentProvider extends Notifier> { if (index < 0) return false; final node = newState[index]; if (node is! PlacedAgent) return false; + final before = ActionObjectState.agent(node); newState[index] = PlacedCircleAgent( id: node.id, @@ -238,30 +291,58 @@ class AgentProvider extends Notifier> { )..isDeleted = node.isDeleted; ref.read(actionProvider.notifier).addAction( - UserAction(type: ActionType.edit, id: id, group: ActionGroup.agent), + UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.agent, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.agent(newState[index]), + ), + ), ); state = newState; return true; } void undoAction(UserAction action) { + final delta = action.objectDelta; + if (delta == null) { + switch (action.type) { + case ActionType.addition: + removeAgent(action.id); + return; + case ActionType.deletion: + if (poppedAgents.isEmpty) return; + _upsertAgent(clonePlacedAgentNode(poppedAgents.removeLast())); + return; + case ActionType.edit: + final index = PlacedWidget.getIndexByID(action.id, state); + if (index < 0) return; + final newState = [...state]; + newState[index].undoAction(); + state = newState; + return; + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } + } switch (action.type) { case ActionType.addition: removeAgent(action.id); return; case ActionType.deletion: - final index = PlacedWidget.getIndexByID(action.id, poppedAgents); - if (index < 0) { + final before = delta.before?.agent; + if (before == null) { return; } - final newState = [...state]; - - final restoredAgent = poppedAgents.removeAt(index); - newState.add(restoredAgent); - state = newState; + _upsertAgent(clonePlacedAgentNode(before)); return; case ActionType.edit: - undoPosition(action.id); + final before = delta.before?.agent; + if (before == null) return; + _upsertAgent(clonePlacedAgentNode(before)); return; case ActionType.bulkDeletion: case ActionType.transaction: @@ -269,36 +350,17 @@ class AgentProvider extends Notifier> { } } - void undoPosition(String id) { - final newState = [...state]; - - final index = PlacedWidget.getIndexByID(id, newState); - if (index < 0) return; - - newState[index].undoAction(); - - state = newState; - } - void redoAction(UserAction action) { - final newState = [...state]; - - try { + final delta = action.objectDelta; + if (delta == null) { + final newState = [...state]; switch (action.type) { case ActionType.addition: - final index = PlacedWidget.getIndexByID(action.id, poppedAgents); - if (index < 0) return; - final restoredAgent = poppedAgents.removeAt(index); - newState.add(restoredAgent); - state = newState; + if (poppedAgents.isEmpty) return; + _upsertAgent(clonePlacedAgentNode(poppedAgents.removeLast())); return; - case ActionType.deletion: - final index = PlacedWidget.getIndexByID(action.id, newState); - if (index < 0) return; - final removedAgent = newState.removeAt(index); - poppedAgents.add(removedAgent); - state = newState; + removeAgent(action.id); return; case ActionType.edit: final index = PlacedWidget.getIndexByID(action.id, newState); @@ -310,7 +372,25 @@ class AgentProvider extends Notifier> { case ActionType.transaction: return; } - } catch (_) {} + } + switch (action.type) { + case ActionType.addition: + final after = delta.after?.agent; + if (after == null) return; + _upsertAgent(clonePlacedAgentNode(after)); + return; + case ActionType.deletion: + removeAgent(action.id); + return; + case ActionType.edit: + final after = delta.after?.agent; + if (after == null) return; + _upsertAgent(clonePlacedAgentNode(after)); + return; + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } } String toJson() { @@ -327,6 +407,7 @@ class AgentProvider extends Notifier> { void fromHive(List hiveAgents) { poppedAgents = []; + _pendingEditBefore.clear(); state = hiveAgents; } @@ -370,6 +451,7 @@ class AgentProvider extends Notifier> { void clearAll() { poppedAgents = []; + _pendingEditBefore.clear(); state = []; } @@ -387,11 +469,23 @@ class AgentProvider extends Notifier> { poppedAgents = snapshot.poppedAgents .map((agent) => agent.snapshotCopy()) .toList(); + _pendingEditBefore.clear(); state = snapshot.agents .map((agent) => agent.snapshotCopy()) .toList(); } + void _upsertAgent(PlacedAgentNode agent) { + final newState = [...state]; + final index = PlacedWidget.getIndexByID(agent.id, newState); + if (index < 0) { + newState.add(agent); + } else { + newState[index] = agent; + } + state = newState; + } + PlacedAgentNode _duplicateNode( PlacedAgentNode source, { required String id, diff --git a/lib/providers/drawing_provider.dart b/lib/providers/drawing_provider.dart index 93e0b90b..4e06afc0 100644 --- a/lib/providers/drawing_provider.dart +++ b/lib/providers/drawing_provider.dart @@ -11,6 +11,7 @@ import 'package:icarus/const/json_converters.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/traversal_speed.dart'; import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/action_history_models.dart'; import 'package:uuid/uuid.dart'; class DrawingState { @@ -82,48 +83,72 @@ class DrawingProvider extends Notifier { } void undoAction(UserAction action) { - final newElements = [...state.elements]; - try { + final delta = action.objectDelta; + if (delta == null) { + final newElements = [...state.elements]; switch (action.type) { case ActionType.addition: - final index = DrawingElement.getIndexByID(action.id, newElements); - poppedElements.add(newElements.removeAt(index)); - + _removeDrawingById(action.id); + return; case ActionType.deletion: - final index = DrawingElement.getIndexByID(action.id, poppedElements); - newElements.add(poppedElements.removeAt(index)); + if (poppedElements.isEmpty) return; + newElements.add(cloneDrawingElement(poppedElements.removeLast())); + state = state.copyWith(elements: newElements); + _triggerRepaint(); + return; case ActionType.edit: case ActionType.bulkDeletion: case ActionType.transaction: - break; + return; } - } catch (_) { - dev.log("Can't find index in undo action"); } - state = state.copyWith(elements: newElements); - _triggerRepaint(); + switch (action.type) { + case ActionType.addition: + _removeDrawingById(action.id); + return; + case ActionType.deletion: + final before = delta.before?.drawing; + if (before == null) return; + _upsertDrawing(cloneDrawingElement(before)); + return; + case ActionType.edit: + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } } void redoAction(UserAction action) { - final newElements = [...state.elements]; - try { + final delta = action.objectDelta; + if (delta == null) { switch (action.type) { case ActionType.addition: - final index = DrawingElement.getIndexByID(action.id, poppedElements); - newElements.add(poppedElements.removeAt(index)); + if (poppedElements.isEmpty) return; + _upsertDrawing(cloneDrawingElement(poppedElements.removeLast())); + return; case ActionType.deletion: - final index = DrawingElement.getIndexByID(action.id, newElements); - poppedElements.add(newElements.removeAt(index)); + _removeDrawingById(action.id); + return; case ActionType.edit: case ActionType.bulkDeletion: case ActionType.transaction: - break; + return; } - } catch (_) { - dev.log("Can't find index in redo action"); } - state = state.copyWith(elements: newElements); - _triggerRepaint(); + switch (action.type) { + case ActionType.addition: + final after = delta.after?.drawing; + if (after == null) return; + _upsertDrawing(cloneDrawingElement(after)); + return; + case ActionType.deletion: + _removeDrawingById(action.id); + return; + case ActionType.edit: + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } } String toJson() { @@ -369,12 +394,16 @@ class DrawingProvider extends Notifier { final newElements = [...state.elements]; final poppedElement = newElements.removeAt(index); - poppedElements.add(poppedElement); + poppedElements.removeWhere((element) => element.id == poppedElement.id); + poppedElements.add(cloneDrawingElement(poppedElement)); final action = UserAction( type: ActionType.deletion, id: poppedElement.id, group: ActionGroup.drawing, + objectDelta: ObjectHistoryDelta( + before: ActionObjectState.drawing(poppedElement), + ), ); ref.read(actionProvider.notifier).addAction(action); @@ -637,9 +666,13 @@ class DrawingProvider extends Notifier { ); final action = UserAction( - type: ActionType.addition, - id: finalDrawing.id, - group: ActionGroup.drawing); + type: ActionType.addition, + id: finalDrawing.id, + group: ActionGroup.drawing, + objectDelta: ObjectHistoryDelta( + after: ActionObjectState.drawing(finalDrawing), + ), + ); ref.read(actionProvider.notifier).addAction(action); _triggerRepaint(); @@ -717,6 +750,9 @@ class DrawingProvider extends Notifier { type: ActionType.addition, id: rectangle.id, group: ActionGroup.drawing, + objectDelta: ObjectHistoryDelta( + after: ActionObjectState.drawing(rectangle), + ), ); ref.read(actionProvider.notifier).addAction(action); @@ -797,6 +833,9 @@ class DrawingProvider extends Notifier { type: ActionType.addition, id: ellipse.id, group: ActionGroup.drawing, + objectDelta: ObjectHistoryDelta( + after: ActionObjectState.drawing(ellipse), + ), ); ref.read(actionProvider.notifier).addAction(action); @@ -874,6 +913,9 @@ class DrawingProvider extends Notifier { type: ActionType.addition, id: line.id, group: ActionGroup.drawing, + objectDelta: ObjectHistoryDelta( + after: ActionObjectState.drawing(line), + ), ); ref.read(actionProvider.notifier).addAction(action); @@ -902,23 +944,70 @@ class DrawingProvider extends Notifier { DrawingProviderSnapshot takeSnapshot() { return DrawingProviderSnapshot( state: DrawingState( - elements: [...state.elements], + elements: state.elements.map((element) => cloneDrawingElement(element)).toList(), updateCounter: state.updateCounter, - currentElement: state.currentElement, + currentElement: state.currentElement == null + ? null + : cloneDrawingElement(state.currentElement!), ), - poppedElements: [...poppedElements], + poppedElements: + poppedElements.map((element) => cloneDrawingElement(element)).toList(), ); } void restoreSnapshot(DrawingProviderSnapshot snapshot) { - poppedElements = [...snapshot.poppedElements]; + poppedElements = + snapshot.poppedElements.map((element) => cloneDrawingElement(element)).toList(); state = DrawingState( - elements: [...snapshot.state.elements], + elements: snapshot.state.elements + .map((element) => cloneDrawingElement(element)) + .toList(), updateCounter: snapshot.state.updateCounter, - currentElement: snapshot.state.currentElement, + currentElement: snapshot.state.currentElement == null + ? null + : cloneDrawingElement(snapshot.state.currentElement!), ); _triggerRepaint(); } + + void switchSides() { + final switched = state.elements.map(switchDrawingElementSides).toList(); + final current = state.currentElement == null + ? null + : switchDrawingElementSides(state.currentElement!); + poppedElements = poppedElements.map(switchDrawingElementSides).toList(); + state = DrawingState( + elements: switched, + updateCounter: state.updateCounter, + currentElement: current, + ); + _triggerRepaint(); + } + + void _upsertDrawing(DrawingElement element) { + final newElements = [...state.elements]; + final index = DrawingElement.getIndexByID(element.id, newElements); + if (index < 0) { + newElements.add(element); + } else { + newElements[index] = element; + } + state = state.copyWith(elements: newElements); + _triggerRepaint(); + } + + void _removeDrawingById(String id) { + final newElements = [...state.elements]; + final index = DrawingElement.getIndexByID(id, newElements); + if (index < 0) { + return; + } + final removedElement = newElements.removeAt(index); + poppedElements.removeWhere((element) => element.id == id); + poppedElements.add(cloneDrawingElement(removedElement)); + state = state.copyWith(elements: newElements); + _triggerRepaint(); + } } BoundingBox _boundingBoxForPoints(Offset a, Offset b) { diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart index c4889c10..0e30d4b3 100644 --- a/lib/providers/image_provider.dart +++ b/lib/providers/image_provider.dart @@ -11,6 +11,7 @@ import 'dart:ui' as ui; import 'dart:async' show Completer; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/action_history_models.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:path/path.dart' as path; @@ -156,6 +157,11 @@ class PlacedImageProvider extends Notifier { type: ActionType.addition, id: placedImage.id, group: ActionGroup.image, + objectDelta: ObjectHistoryDelta( + after: ActionObjectState.image(placedImage), + afterImageSizes: + ref.read(imageWidgetSizeProvider.notifier).takeSnapshotForIds([imageID]), + ), ); ref.read(actionProvider.notifier).addAction(action); @@ -164,13 +170,20 @@ class PlacedImageProvider extends Notifier { } void removeImageAsAction(String id) { - if (!state.images.any((image) => image.id == id)) return; + final index = PlacedWidget.getIndexByID(id, state.images); + if (index < 0) return; ref.read(actionProvider.notifier).addAction( UserAction( type: ActionType.deletion, id: id, group: ActionGroup.image, + objectDelta: ObjectHistoryDelta( + before: ActionObjectState.image(state.images[index]), + beforeImageSizes: ref + .read(imageWidgetSizeProvider.notifier) + .takeSnapshotForIds([id]), + ), ), ); removeImage(id); @@ -181,8 +194,9 @@ class PlacedImageProvider extends Notifier { final index = PlacedWidget.getIndexByID(id, newImages); if (index < 0) return; - final image = newImages.removeAt(index); - poppedImages.add(image); + final removedImage = newImages.removeAt(index); + poppedImages.removeWhere((item) => item.id == id); + poppedImages.add(clonePlacedImage(removedImage)); state = state.copyWith(images: newImages); } @@ -192,6 +206,7 @@ class PlacedImageProvider extends Notifier { final index = PlacedWidget.getIndexByID(id, newImages); if (index < 0) return; + final before = ActionObjectState.image(newImages[index]); newImages[index].updatePosition(position); final temp = newImages.removeAt(index); @@ -200,6 +215,14 @@ class PlacedImageProvider extends Notifier { type: ActionType.edit, id: id, group: ActionGroup.image, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.image(temp), + beforeImageSizes: + ref.read(imageWidgetSizeProvider.notifier).takeSnapshotForIds([id]), + afterImageSizes: + ref.read(imageWidgetSizeProvider.notifier).takeSnapshotForIds([id]), + ), ); ref.read(actionProvider.notifier).addAction(action); @@ -229,56 +252,97 @@ class PlacedImageProvider extends Notifier { } void undoAction(UserAction action) { + final delta = action.objectDelta; + if (delta == null) { + switch (action.type) { + case ActionType.addition: + removeImage(action.id); + return; + case ActionType.deletion: + if (poppedImages.isEmpty) return; + _upsertImage(clonePlacedImage(poppedImages.removeLast())); + return; + case ActionType.edit: + final index = PlacedWidget.getIndexByID(action.id, state.images); + if (index < 0) return; + final newImages = [...state.images]; + newImages[index].undoAction(); + state = state.copyWith(images: newImages); + return; + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } + } switch (action.type) { case ActionType.addition: + _clearImageSizes(delta.afterImageSizes.keys); removeImage(action.id); + return; case ActionType.deletion: - if (poppedImages.isEmpty) { + final before = delta.before?.image; + if (before == null) { return; } - final newImages = [...state.images]; - newImages.add(poppedImages.removeLast()); - state = state.copyWith(images: newImages); + _upsertImage(clonePlacedImage(before)); + _restoreImageSizes(delta.beforeImageSizes); + return; case ActionType.edit: - undoPosition(action.id); + final before = delta.before?.image; + if (before == null) return; + _upsertImage(clonePlacedImage(before)); + _restoreImageSizes(delta.beforeImageSizes); + return; case ActionType.bulkDeletion: case ActionType.transaction: return; } } - void undoPosition(String id) { - final newImages = [...state.images]; - final index = PlacedWidget.getIndexByID(id, newImages); - - if (index < 0) return; - newImages[index].undoAction(); - - state = state.copyWith(images: newImages); - } - void redoAction(UserAction action) { - final newImages = [...state.images]; - - try { + final delta = action.objectDelta; + if (delta == null) { switch (action.type) { case ActionType.addition: - final index = PlacedWidget.getIndexByID(action.id, poppedImages); - newImages.add(poppedImages.removeAt(index)); - + if (poppedImages.isEmpty) return; + _upsertImage(clonePlacedImage(poppedImages.removeLast())); + return; case ActionType.deletion: - final index = PlacedWidget.getIndexByID(action.id, poppedImages); - poppedImages.add(newImages.removeAt(index)); - + removeImage(action.id); + return; case ActionType.edit: - final index = PlacedWidget.getIndexByID(action.id, newImages); + final index = PlacedWidget.getIndexByID(action.id, state.images); + if (index < 0) return; + final newImages = [...state.images]; newImages[index].redoAction(); + state = state.copyWith(images: newImages); + return; case ActionType.bulkDeletion: case ActionType.transaction: return; } - } catch (_) {} - state = state.copyWith(images: newImages); + } + switch (action.type) { + case ActionType.addition: + final after = delta.after?.image; + if (after == null) return; + _upsertImage(clonePlacedImage(after)); + _restoreImageSizes(delta.afterImageSizes); + return; + case ActionType.deletion: + _clearImageSizes(delta.beforeImageSizes.keys); + removeImage(action.id); + return; + case ActionType.edit: + final after = delta.after?.image; + if (after == null) return; + _upsertImage(clonePlacedImage(after)); + _restoreImageSizes(delta.afterImageSizes); + return; + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } } static Future getImageFolder(String strategyID) async { @@ -416,14 +480,39 @@ class PlacedImageProvider extends Notifier { PlacedImageProviderSnapshot takeSnapshot() { return PlacedImageProviderSnapshot( - images: [...state.images], - poppedImages: [...poppedImages], + images: state.images.map((image) => clonePlacedImage(image)).toList(), + poppedImages: + poppedImages.map((image) => clonePlacedImage(image)).toList(), ); } void restoreSnapshot(PlacedImageProviderSnapshot snapshot) { - poppedImages = [...snapshot.poppedImages]; - state = state.copyWith(images: [...snapshot.images]); + poppedImages = + snapshot.poppedImages.map((image) => clonePlacedImage(image)).toList(); + state = state.copyWith( + images: snapshot.images.map((image) => clonePlacedImage(image)).toList(), + ); + } + + void _upsertImage(PlacedImage image) { + final newImages = [...state.images]; + final index = PlacedWidget.getIndexByID(image.id, newImages); + if (index < 0) { + newImages.add(image); + } else { + newImages[index] = image; + } + state = state.copyWith(images: newImages); + } + + void _restoreImageSizes(Map snapshot) { + if (snapshot.isEmpty) return; + ref.read(imageWidgetSizeProvider.notifier).restoreSnapshot(snapshot); + } + + void _clearImageSizes(Iterable ids) { + if (ids.isEmpty) return; + ref.read(imageWidgetSizeProvider.notifier).clearEntries(ids); } } diff --git a/lib/providers/map_provider.dart b/lib/providers/map_provider.dart index 1f7533fc..b29584fe 100644 --- a/lib/providers/map_provider.dart +++ b/lib/providers/map_provider.dart @@ -4,7 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/providers/ability_provider.dart'; +import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; @@ -75,10 +77,12 @@ class MapProvider extends Notifier { // Flip all placed agents to mirror positions before toggling the side ref.read(agentProvider.notifier).switchSides(); ref.read(abilityProvider.notifier).switchSides(); + ref.read(drawingProvider.notifier).switchSides(); ref.read(utilityProvider.notifier).switchSides(); ref.read(lineUpProvider.notifier).switchSides(); ref.read(textProvider.notifier).switchSides(); ref.read(placedImageProvider.notifier).switchSides(); + ref.read(actionProvider.notifier).switchSides(); state = state.copyWith(isAttack: !state.isAttack); ref.read(strategyProvider.notifier).setUnsaved(); } diff --git a/lib/providers/strategy_page_session_provider.dart b/lib/providers/strategy_page_session_provider.dart index ee3db310..901ff654 100644 --- a/lib/providers/strategy_page_session_provider.dart +++ b/lib/providers/strategy_page_session_provider.dart @@ -357,6 +357,9 @@ class StrategyPageSessionNotifier extends Notifier { required String strategyId, required StrategySource source, }) async { + final preserveHistory = source == StrategySource.cloud && + _lastHydratedRemoteStrategyId == strategyId && + _lastHydratedRemotePageId == pageData.pageId; final themeProfileId = _resolveThemeProfileId(source, strategyId); final themeOverridePalette = _resolveThemeOverridePalette(source, strategyId); @@ -374,6 +377,7 @@ class StrategyPageSessionNotifier extends Notifier { pageData, themeProfileId: themeProfileId, themeOverridePalette: themeOverridePalette, + preserveHistory: preserveHistory, ); _updateHydrationBookkeeping(pageData.pageId); } finally { diff --git a/lib/providers/text_provider.dart b/lib/providers/text_provider.dart index cdc5cebf..055d0c93 100644 --- a/lib/providers/text_provider.dart +++ b/lib/providers/text_provider.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/action_history_models.dart'; import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/providers/text_widget_height_provider.dart'; @@ -48,6 +49,11 @@ class TextProvider extends Notifier> { type: ActionType.addition, id: text.id, group: ActionGroup.text, + objectDelta: ObjectHistoryDelta( + after: ActionObjectState.text(text), + afterTextHeights: + ref.read(textWidgetHeightProvider.notifier).takeSnapshotForIds([text.id]), + ), ); ref.read(actionProvider.notifier).addAction(action); @@ -56,13 +62,20 @@ class TextProvider extends Notifier> { } void removeTextAsAction(String id) { - if (!state.any((text) => text.id == id)) return; + final index = PlacedWidget.getIndexByID(id, state); + if (index < 0) return; ref.read(actionProvider.notifier).addAction( UserAction( type: ActionType.deletion, id: id, group: ActionGroup.text, + objectDelta: ObjectHistoryDelta( + before: ActionObjectState.text(state[index]), + beforeTextHeights: ref + .read(textWidgetHeightProvider.notifier) + .takeSnapshotForIds([id]), + ), ), ); removeText(id); @@ -74,14 +87,26 @@ class TextProvider extends Notifier> { final index = PlacedWidget.getIndexByID(id, newState); if (index < 0) return; + final before = ActionObjectState.text(newState[index]); newState[index].updatePosition(position); //Moving foward final temp = newState.removeAt(index); - final action = - UserAction(type: ActionType.edit, id: id, group: ActionGroup.text); + final action = UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.text, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.text(temp), + beforeTextHeights: + ref.read(textWidgetHeightProvider.notifier).takeSnapshotForIds([id]), + afterTextHeights: + ref.read(textWidgetHeightProvider.notifier).takeSnapshotForIds([id]), + ), + ); ref.read(actionProvider.notifier).addAction(action); state = [...newState, temp]; @@ -109,9 +134,24 @@ class TextProvider extends Notifier> { if (newState[index].text == nextText) return; + final before = ActionObjectState.text(newState[index]); newState[index].commitText(nextText); ref.read(actionProvider.notifier).addAction( - UserAction(type: ActionType.edit, id: id, group: ActionGroup.text), + UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.text, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.text(newState[index]), + beforeTextHeights: ref + .read(textWidgetHeightProvider.notifier) + .takeSnapshotForIds([id]), + afterTextHeights: ref + .read(textWidgetHeightProvider.notifier) + .takeSnapshotForIds([id]), + ), + ), ); state = newState; } @@ -126,25 +166,46 @@ class TextProvider extends Notifier> { } void undoAction(UserAction action) { + final delta = action.objectDelta; + if (delta == null) { + switch (action.type) { + case ActionType.addition: + removeText(action.id); + return; + case ActionType.deletion: + if (poppedText.isEmpty) return; + _upsertText(clonePlacedText(poppedText.removeLast())); + return; + case ActionType.edit: + final index = PlacedWidget.getIndexByID(action.id, state); + if (index < 0) return; + final newState = [...state]; + newState[index].undoAction(); + state = newState; + return; + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } + } switch (action.type) { case ActionType.addition: + _clearTextHeights(delta.afterTextHeights.keys); removeText(action.id); + return; case ActionType.deletion: - if (poppedText.isEmpty) return; - - final newState = [...state]; - - newState.add(poppedText.removeLast()); - state = newState; + final before = delta.before?.text; + if (before == null) return; + _upsertText(clonePlacedText(before)); + _restoreTextHeights(delta.beforeTextHeights); + return; case ActionType.edit: - final newState = [...state]; - - final index = PlacedWidget.getIndexByID(action.id, newState); - - newState[index].undoAction(); - - state = newState; + final before = delta.before?.text; + if (before == null) return; + _upsertText(clonePlacedText(before)); + _restoreTextHeights(delta.beforeTextHeights); + return; case ActionType.bulkDeletion: case ActionType.transaction: return; @@ -152,29 +213,49 @@ class TextProvider extends Notifier> { } void redoAction(UserAction action) { - final newState = [...state]; - - try { + final delta = action.objectDelta; + if (delta == null) { switch (action.type) { case ActionType.addition: - final index = PlacedWidget.getIndexByID(action.id, poppedText); - newState.add(poppedText.removeAt(index)); - + if (poppedText.isEmpty) return; + _upsertText(clonePlacedText(poppedText.removeLast())); + return; case ActionType.deletion: - final index = PlacedWidget.getIndexByID(action.id, poppedText); - - poppedText.add(newState.removeAt(index)); - + removeText(action.id); + return; case ActionType.edit: - final index = PlacedWidget.getIndexByID(action.id, newState); - + final index = PlacedWidget.getIndexByID(action.id, state); + if (index < 0) return; + final newState = [...state]; newState[index].redoAction(); + state = newState; + return; case ActionType.bulkDeletion: case ActionType.transaction: return; } - } catch (_) {} - state = newState; + } + switch (action.type) { + case ActionType.addition: + final after = delta.after?.text; + if (after == null) return; + _upsertText(clonePlacedText(after)); + _restoreTextHeights(delta.afterTextHeights); + return; + case ActionType.deletion: + _clearTextHeights(delta.beforeTextHeights.keys); + removeText(action.id); + return; + case ActionType.edit: + final after = delta.after?.text; + if (after == null) return; + _upsertText(clonePlacedText(after)); + _restoreTextHeights(delta.afterTextHeights); + return; + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } } void updateSize(int index, double size) { @@ -197,8 +278,8 @@ class TextProvider extends Notifier> { text.text = draft; ref.read(textDraftProvider.notifier).clearDraft(id); } - poppedText.add(text); - + poppedText.removeWhere((item) => item.id == id); + poppedText.add(clonePlacedText(text)); state = newState; } @@ -253,14 +334,35 @@ class TextProvider extends Notifier> { TextProviderSnapshot takeSnapshot() { return TextProviderSnapshot( - texts: [...state], - poppedText: [...poppedText], + texts: state.map((text) => clonePlacedText(text)).toList(), + poppedText: poppedText.map((text) => clonePlacedText(text)).toList(), ); } void restoreSnapshot(TextProviderSnapshot snapshot) { ref.read(textDraftProvider.notifier).clearAllDrafts(); - poppedText = [...snapshot.poppedText]; - state = [...snapshot.texts]; + poppedText = snapshot.poppedText.map((text) => clonePlacedText(text)).toList(); + state = snapshot.texts.map((text) => clonePlacedText(text)).toList(); + } + + void _upsertText(PlacedText text) { + final newState = [...state]; + final index = PlacedWidget.getIndexByID(text.id, newState); + if (index < 0) { + newState.add(text); + } else { + newState[index] = text; + } + state = newState; + } + + void _restoreTextHeights(Map snapshot) { + if (snapshot.isEmpty) return; + ref.read(textWidgetHeightProvider.notifier).restoreSnapshot(snapshot); + } + + void _clearTextHeights(Iterable ids) { + if (ids.isEmpty) return; + ref.read(textWidgetHeightProvider.notifier).clearEntries(ids); } } diff --git a/lib/providers/utility_provider.dart b/lib/providers/utility_provider.dart index 828c0246..2e2c2374 100644 --- a/lib/providers/utility_provider.dart +++ b/lib/providers/utility_provider.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/action_history_models.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; @@ -23,6 +24,7 @@ class UtilityProviderSnapshot { class UtilityProvider extends Notifier> { List poppedUtilities = []; + final Map _pendingEditBefore = {}; @override List build() { @@ -34,6 +36,9 @@ class UtilityProvider extends Notifier> { type: ActionType.addition, id: utility.id, group: ActionGroup.utility, + objectDelta: ObjectHistoryDelta( + after: ActionObjectState.utility(utility), + ), ); ref.read(actionProvider.notifier).addAction(action); @@ -41,13 +46,17 @@ class UtilityProvider extends Notifier> { } void removeUtilityAsAction(String id) { - if (!state.any((utility) => utility.id == id)) return; + final index = PlacedWidget.getIndexByID(id, state); + if (index < 0) return; ref.read(actionProvider.notifier).addAction( UserAction( type: ActionType.deletion, id: id, group: ActionGroup.utility, + objectDelta: ObjectHistoryDelta( + before: ActionObjectState.utility(state[index]), + ), ), ); removeUtility(id); @@ -57,11 +66,19 @@ class UtilityProvider extends Notifier> { final newState = [...state]; final index = PlacedWidget.getIndexByID(id, newState); if (index < 0) return; + final before = ActionObjectState.utility(newState[index]); newState[index].updatePosition(position); final temp = newState.removeAt(index); - final action = - UserAction(type: ActionType.edit, id: id, group: ActionGroup.utility); + final action = UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.utility, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.utility(temp), + ), + ); ref.read(actionProvider.notifier).addAction(action); state = [...newState, temp]; @@ -69,12 +86,18 @@ class UtilityProvider extends Notifier> { void updateRotation(int index, double rotation, double length) { final newState = [...state]; - updateRotationHistory(index); + final before = _pendingEditBefore.remove(newState[index].id) ?? + ActionObjectState.utility(newState[index]); newState[index].updateRotation(rotation, length); final action = UserAction( - type: ActionType.edit, - id: newState[index].id, - group: ActionGroup.utility); + type: ActionType.edit, + id: newState[index].id, + group: ActionGroup.utility, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.utility(newState[index]), + ), + ); ref.read(actionProvider.notifier).addAction(action); state = newState; } @@ -121,6 +144,7 @@ class UtilityProvider extends Notifier> { utility.customLength != nextLength; if (!hasGeometryChange) return; + final before = ActionObjectState.utility(utility); utility.updateCustomShapeGeometry( newPosition: position, newDiameter: diameterMeters, @@ -128,18 +152,22 @@ class UtilityProvider extends Notifier> { newLength: lengthMeters, ); - final action = - UserAction(type: ActionType.edit, id: id, group: ActionGroup.utility); + final action = UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.utility, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.utility(utility), + ), + ); ref.read(actionProvider.notifier).addAction(action); state = newState; } void updateRotationHistory(int index) { - final newState = [...state]; - - newState[index].updateRotationHistory(); - - state = newState; + if (index < 0 || index >= state.length) return; + _pendingEditBefore[state[index].id] = ActionObjectState.utility(state[index]); } void switchSides() { @@ -167,30 +195,43 @@ class UtilityProvider extends Notifier> { } void undoAction(UserAction action) { + final delta = action.objectDelta; + if (delta == null) { + switch (action.type) { + case ActionType.addition: + removeUtility(action.id); + return; + case ActionType.deletion: + if (poppedUtilities.isEmpty) return; + _upsertUtility(clonePlacedUtility(poppedUtilities.removeLast())); + return; + case ActionType.edit: + final index = PlacedWidget.getIndexByID(action.id, state); + if (index < 0) return; + final newState = [...state]; + newState[index].undoAction(); + state = newState; + return; + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } + } switch (action.type) { case ActionType.addition: removeUtility(action.id); return; case ActionType.deletion: - final index = PlacedWidget.getIndexByID(action.id, poppedUtilities); - if (index < 0) { + final before = delta.before?.utility; + if (before == null) { return; } - - final newState = [...state]; - - final restoredUtility = poppedUtilities.removeAt(index); - newState.add(restoredUtility); - state = newState; + _upsertUtility(clonePlacedUtility(before)); return; case ActionType.edit: - final newState = [...state]; - - final index = PlacedWidget.getIndexByID(action.id, newState); - if (index < 0) return; - - newState[index].undoAction(); - state = newState; + final before = delta.before?.utility; + if (before == null) return; + _upsertUtility(clonePlacedUtility(before)); return; case ActionType.bulkDeletion: case ActionType.transaction: @@ -199,23 +240,16 @@ class UtilityProvider extends Notifier> { } void redoAction(UserAction action) { - final newState = [...state]; - - try { + final delta = action.objectDelta; + if (delta == null) { + final newState = [...state]; switch (action.type) { case ActionType.addition: - final index = PlacedWidget.getIndexByID(action.id, poppedUtilities); - if (index < 0) return; - final restoredUtility = poppedUtilities.removeAt(index); - newState.add(restoredUtility); - state = newState; + if (poppedUtilities.isEmpty) return; + _upsertUtility(clonePlacedUtility(poppedUtilities.removeLast())); return; - case ActionType.deletion: - final index = PlacedWidget.getIndexByID(action.id, newState); - if (index < 0) return; - poppedUtilities.add(newState.removeAt(index)); - state = newState; + removeUtility(action.id); return; case ActionType.edit: final index = PlacedWidget.getIndexByID(action.id, newState); @@ -227,7 +261,25 @@ class UtilityProvider extends Notifier> { case ActionType.transaction: return; } - } catch (_) {} + } + switch (action.type) { + case ActionType.addition: + final after = delta.after?.utility; + if (after == null) return; + _upsertUtility(clonePlacedUtility(after)); + return; + case ActionType.deletion: + removeUtility(action.id); + return; + case ActionType.edit: + final after = delta.after?.utility; + if (after == null) return; + _upsertUtility(clonePlacedUtility(after)); + return; + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } } void removeUtility(String id) { @@ -236,19 +288,22 @@ class UtilityProvider extends Notifier> { final index = PlacedWidget.getIndexByID(id, newState); if (index < 0) return; - final ability = newState.removeAt(index); - poppedUtilities.add(ability); + final removedUtility = newState.removeAt(index); + poppedUtilities.removeWhere((utility) => utility.id == id); + poppedUtilities.add(clonePlacedUtility(removedUtility)); state = newState; } void fromHive(List hiveUtilities) { poppedUtilities = []; + _pendingEditBefore.clear(); state = hiveUtilities; } void clearAll() { poppedUtilities = []; + _pendingEditBefore.clear(); state = []; } @@ -292,8 +347,20 @@ class UtilityProvider extends Notifier> { poppedUtilities = snapshot.poppedUtilities .map((utility) => utility.snapshotCopy()) .toList(); + _pendingEditBefore.clear(); state = snapshot.utilities .map((utility) => utility.snapshotCopy()) .toList(); } + + void _upsertUtility(PlacedUtility utility) { + final newState = [...state]; + final index = PlacedWidget.getIndexByID(utility.id, newState); + if (index < 0) { + newState.add(utility); + } else { + newState[index] = utility; + } + state = newState; + } } diff --git a/lib/strategy/strategy_page_apply.dart b/lib/strategy/strategy_page_apply.dart index f150921c..05e0ee56 100644 --- a/lib/strategy/strategy_page_apply.dart +++ b/lib/strategy/strategy_page_apply.dart @@ -6,10 +6,12 @@ import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/image_widget_size_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/map_theme_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/providers/text_widget_height_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; @@ -19,8 +21,20 @@ Future applyStrategyEditorPageData( StrategyEditorPageData data, { required String themeProfileId, required MapThemePalette? themeOverridePalette, + bool preserveHistory = false, }) async { - ref.read(actionProvider.notifier).resetActionState(); + ref.read(agentProvider.notifier).clearAll(); + ref.read(abilityProvider.notifier).clearAll(); + ref.read(drawingProvider.notifier).clearAll(); + ref.read(textProvider.notifier).clearAll(); + ref.read(placedImageProvider.notifier).clearAll(); + ref.read(utilityProvider.notifier).clearAll(); + ref.read(lineUpProvider.notifier).clearAll(); + ref.read(imageWidgetSizeProvider.notifier).clearAll(); + ref.read(textWidgetHeightProvider.notifier).clearAll(); + if (!preserveHistory) { + ref.read(actionProvider.notifier).clearActionHistory(); + } ref.read(agentProvider.notifier).fromHive(data.agents); ref.read(abilityProvider.notifier).fromHive(data.abilities); ref.read(drawingProvider.notifier).fromHive(data.drawings); @@ -34,6 +48,9 @@ Future applyStrategyEditorPageData( profileId: themeProfileId, overridePalette: themeOverridePalette, ); + if (preserveHistory) { + ref.read(actionProvider.notifier).reconcileHistory(); + } WidgetsBinding.instance.addPostFrameCallback((_) { ref diff --git a/test/action_history_hydration_test.dart b/test/action_history_hydration_test.dart new file mode 100644 index 00000000..a7c0107d --- /dev/null +++ b/test/action_history_hydration_test.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/drawing_element.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +class _NoopStrategyProvider extends StrategyProvider { + @override + StrategyState build() { + return StrategyState( + strategyId: 'test-strategy', + strategyName: 'Test Strategy', + source: StrategySource.local, + storageDirectory: null, + isOpen: true, + ); + } + + @override + void setUnsaved() { + state = state.copyWith(isOpen: true); + } +} + +ProviderContainer _createContainer() { + final container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(_NoopStrategyProvider.new), + ], + ); + addTearDown(container.dispose); + return container; +} + +Offset _flipPoint(Offset point) { + final coordinateSystem = CoordinateSystem.instance; + return Offset( + coordinateSystem.worldNormalizedWidth - point.dx, + coordinateSystem.normalizedHeight - point.dy, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + CoordinateSystem(playAreaSize: const Size(1920, 1080)); + }); + + test('preserveHistory keeps text undo/redo working across hydration', () async { + final container = _createContainer(); + final notifier = container.read(textProvider.notifier); + + notifier.fromHive([ + PlacedText( + id: 'text-1', + position: const Offset(10, 20), + )..text = 'before', + ]); + notifier.commitText('text-1', 'after'); + + notifier.clearAll(); + notifier.fromHive([ + PlacedText( + id: 'text-1', + position: const Offset(10, 20), + )..text = 'after', + ]); + container.read(actionProvider.notifier).reconcileHistory(); + + expect(container.read(actionProvider), hasLength(1)); + + container.read(actionProvider.notifier).undoAction(); + expect(container.read(textProvider).single.text, 'before'); + + container.read(actionProvider.notifier).redoAction(); + expect(container.read(textProvider).single.text, 'after'); + }); + + test('non-preserved hydration clears action history', () async { + final container = _createContainer(); + final notifier = container.read(textProvider.notifier); + + notifier.fromHive([ + PlacedText( + id: 'text-1', + position: const Offset(10, 20), + )..text = 'before', + ]); + notifier.commitText('text-1', 'after'); + + notifier.clearAll(); + notifier.fromHive(const []); + container.read(actionProvider.notifier).clearActionHistory(); + + expect(container.read(actionProvider), isEmpty); + }); + + test('switchSide mirrors live and deleted drawings', () { + final container = _createContainer(); + final notifier = container.read(drawingProvider.notifier); + + final deletedLine = Line( + id: 'deleted-line', + lineStart: const Offset(10, 20), + lineEnd: const Offset(40, 50), + color: Colors.red, + isDotted: false, + hasArrow: false, + ); + final liveLine = Line( + id: 'live-line', + lineStart: const Offset(100, 110), + lineEnd: const Offset(130, 160), + color: Colors.blue, + isDotted: false, + hasArrow: false, + ); + + notifier.fromHive([deletedLine, liveLine]); + notifier.deleteDrawing(0); + + container.read(mapProvider.notifier).switchSide(); + + final flippedLive = container.read(drawingProvider).elements.single as Line; + final flippedDeleted = notifier.poppedElements.single as Line; + + expect(flippedLive.lineStart, _flipPoint(const Offset(100, 110))); + expect(flippedLive.lineEnd, _flipPoint(const Offset(130, 160))); + expect(flippedDeleted.lineStart, _flipPoint(const Offset(10, 20))); + expect(flippedDeleted.lineEnd, _flipPoint(const Offset(40, 50))); + }); +} From a312552bc18574a867d03fca1117e7945791b047 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 4 Apr 2026 12:17:58 -0400 Subject: [PATCH 5/5] Add grill-me agent skill and lock entry Made-with: Cursor --- .agents/skills/grill-me/SKILL.md | 10 ++++++++++ skills-lock.json | 5 +++++ 2 files changed, 15 insertions(+) create mode 100644 .agents/skills/grill-me/SKILL.md diff --git a/.agents/skills/grill-me/SKILL.md b/.agents/skills/grill-me/SKILL.md new file mode 100644 index 00000000..bd04394c --- /dev/null +++ b/.agents/skills/grill-me/SKILL.md @@ -0,0 +1,10 @@ +--- +name: grill-me +description: Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions "grill me". +--- + +Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. + +Ask the questions one at a time. + +If a question can be answered by exploring the codebase, explore the codebase instead. diff --git a/skills-lock.json b/skills-lock.json index 1c45028c..275d9822 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -25,6 +25,11 @@ "source": "get-convex/agent-skills", "sourceType": "github", "computedHash": "7cc29991c446d2ea574dc9abb5fb85c0c9a7f3ef3af4c94454eef45017bda794" + }, + "grill-me": { + "source": "mattpocock/skills", + "sourceType": "github", + "computedHash": "daf64ca15f4fa081a6747766db538e2dbd1131725ed4fcdd3d538dc62c7035ba" } } }