Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,4 @@ Assets/StreamingAssets/EphysLink-*
Assets/Samples/*
Assets/settings_conversion.txt
.github/copilot-instructions.md
.nuget/
18 changes: 18 additions & 0 deletions Assets/Scripts/Models/SavedState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System;
using Models.Scene;
using Models.Settings;

namespace Models
{
/// <summary>
/// Represents the application state for save/load operations.
/// Contains scene, atlas, and rig state slices.
/// </summary>
[Serializable]
public class SavedState
{
public SceneState SceneState;
public RigState RigState;
public AtlasSettingsState AtlasSettingsState;
}
}
66 changes: 66 additions & 0 deletions Assets/Scripts/Services/FileStorageService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using System;
using System.IO;
using UnityEngine;

namespace Services
{
/// <summary>
/// Provides methods to save and load data to/from local JSON files.
/// </summary>
public class FileStorageService
{
/// <summary>
/// Saves a value of type <typeparamref name="T"/> to a JSON file.
/// </summary>
/// <typeparam name="T">The type of the value to save.</typeparam>
/// <param name="filePath">The full file path where the JSON file will be saved.</param>
/// <param name="value">The value to save.</param>
/// <returns>True if the save was successful, false otherwise.</returns>
public bool SaveToFile<T>(string filePath, T value)
{
try
{
var json = JsonUtility.ToJson(value, true);
File.WriteAllText(filePath, json);
Debug.Log($"Saved state to: {filePath}");
return true;
}
catch (Exception e)
{
Debug.LogError($"Failed to save to file {filePath}: {e.Message}");
return false;
}
}

/// <summary>
/// Loads a value of type <typeparamref name="T"/> from a JSON file.
/// </summary>
/// <typeparam name="T">The type of the value to load.</typeparam>
/// <param name="filePath">The full file path to the JSON file to load.</param>
/// <param name="result">The loaded value, or default if loading failed.</param>
/// <returns>True if the load was successful, false otherwise.</returns>
public bool LoadFromFile<T>(string filePath, out T result) where T : new()
{
result = default;

try
{
if (!File.Exists(filePath))
{
Debug.LogWarning($"File not found: {filePath}");
return false;
}

var json = File.ReadAllText(filePath);
result = JsonUtility.FromJson<T>(json);
Debug.Log($"Loaded state from: {filePath}");
return true;
}
catch (Exception e)
{
Debug.LogError($"Failed to load from file {filePath}: {e.Message}");
return false;
}
}
}
}
43 changes: 42 additions & 1 deletion Assets/Scripts/Services/StoreService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ namespace Services
public class StoreService
{
private readonly LocalStorageService _localStorageService;
private readonly FileStorageService _fileStorageService;

/// <summary>
/// Gets the Redux store instance for partitioned application state.
Expand All @@ -23,9 +24,11 @@ public class StoreService
/// Loads initial state from local storage and configures the Redux store.
/// </summary>
/// <param name="localStorageService">The local storage service for state persistence.</param>
public StoreService(LocalStorageService localStorageService)
/// <param name="fileStorageService">The file storage service for save/load operations.</param>
public StoreService(LocalStorageService localStorageService, FileStorageService fileStorageService)
{
_localStorageService = localStorageService;
_fileStorageService = fileStorageService;

// Initialize state in memory.
var initialMainState = _localStorageService.GetValue(
Expand Down Expand Up @@ -359,5 +362,43 @@ public void Save()
Store.GetState<AtlasSettingsState>(SliceNames.ATLAS_SETTINGS_SLICE)
);
}

/// <summary>
/// Saves the current state to a JSON file.
/// </summary>
/// <param name="filePath">The full file path where to save the state.</param>
/// <returns>True if the save was successful, false otherwise.</returns>
public bool SaveToFile(string filePath)
{
var savedState = new SavedState
{
SceneState = Store.GetState<SceneState>(SliceNames.SCENE_SLICE),
RigState = Store.GetState<RigState>(SliceNames.RIG_SLICE),
AtlasSettingsState = Store.GetState<AtlasSettingsState>(SliceNames.ATLAS_SETTINGS_SLICE)
};

return _fileStorageService.SaveToFile(filePath, savedState);
}

/// <summary>
/// Loads state from a JSON file and updates both the store and local storage.
/// </summary>
/// <param name="filePath">The full file path from which to load the state.</param>
/// <returns>True if the load was successful, false otherwise.</returns>
public bool LoadFromFile(string filePath)
{
if (!_fileStorageService.LoadFromFile<SavedState>(filePath, out var savedState))
return false;

// Update local storage with the loaded state.
_localStorageService.SetValue(SliceNames.SCENE_SLICE, savedState.SceneState);
_localStorageService.SetValue(SliceNames.RIG_SLICE, savedState.RigState);
_localStorageService.SetValue(SliceNames.ATLAS_SETTINGS_SLICE, savedState.AtlasSettingsState);

// Note: The scene will be reloaded after this method returns to apply the loaded state.
// The store will be re-initialized from the updated local storage on scene reload.

return true;
}
}
}
1 change: 1 addition & 0 deletions Assets/Scripts/UI/PinpointAppBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ protected override void OnConfiguringApp(AppBuilder builder)

// Services.
builder.services.AddSingleton<LocalStorageService>();
builder.services.AddSingleton<FileStorageService>();
builder.services.AddSingleton<StoreService>();
builder.services.AddSingleton<ProbeService>();
builder.services.AddSingleton<EphysLinkService>();
Expand Down
64 changes: 64 additions & 0 deletions Assets/Scripts/UI/ViewModels/MainViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
using Models;
using Models.Scene;
using Services;
using SimpleFileBrowser;
using Unity.AppUI.MVVM;
using Unity.AppUI.Redux;
using Unity.AppUI.UI;
using UnityEngine;
using UnityEngine.SceneManagement;
using Utils.Types;

namespace UI.ViewModels
Expand Down Expand Up @@ -125,6 +127,68 @@ private void SetLeftSidePanelTabIndex(int index)
_storeService.Store.Dispatch(MainActions.SET_LEFT_SIDE_PANEL_TAB_INDEX, index);
}

[ICommand]
private void SaveStateToFile()
{
FileBrowser.ShowSaveDialog(
onSuccess: (paths) =>
{
if (paths.Length > 0)
{
var filePath = paths[0];
if (_storeService.SaveToFile(filePath))
{
Debug.Log($"Scene state saved successfully to: {filePath}");
}
else
{
Debug.LogError("Failed to save scene state");
}
}
},
onCancel: () => { },
pickMode: FileBrowser.PickMode.Files,
allowMultiSelection: false,
initialPath: null,
initialFilename: "scene_state.json",
title: "Save Scene State",
saveButtonText: "Save"
);
}

[ICommand]
private void LoadStateFromFile()
{
FileBrowser.ShowLoadDialog(
onSuccess: (paths) =>
{
if (paths.Length > 0)
{
var filePath = paths[0];
if (_storeService.LoadFromFile(filePath))
{
Debug.Log($"Scene state loaded successfully from: {filePath}");
Debug.Log("Reloading scene to apply loaded state...");

// Reload the scene to apply the loaded state from local storage
SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
}
else
{
Debug.LogError("Failed to load scene state");
}
}
},
onCancel: () => { },
pickMode: FileBrowser.PickMode.Files,
allowMultiSelection: false,
initialPath: null,
initialFilename: null,
title: "Load Scene State",
loadButtonText: "Load"
);
}

#endregion
}
}
6 changes: 6 additions & 0 deletions Assets/Scripts/UI/Views/MainView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ public MainView(MainViewModel mainViewModel)
var leftSidePanelCollapseButton = root.Q<Button>("left-side-panel__collapse-button");
var rightSidePanelCollapseButton = root.Q<Button>("right-side-panel__collapse-button");
var leftSidePanelTabs = root.Q<Tabs>("left-side-panel__tabs");
var saveMenuItem = root.Q<Unity.AppUI.UI.MenuItem>("file-menu__save");
var loadMenuItem = root.Q<Unity.AppUI.UI.MenuItem>("file-menu__load");

// Initialize subviews.
_ = PinpointApp.Services.GetRequiredService<SceneView>();
Expand Down Expand Up @@ -80,6 +82,10 @@ public MainView(MainViewModel mainViewModel)
leftSidePanelTabs.RegisterValueChangedCallback(evt =>
mainViewModel.SetLeftSidePanelTabIndexCommand.Execute(evt.newValue)
);
saveMenuItem.clickable.clicked += () =>
mainViewModel.SaveStateToFileCommand.Execute(null);
loadMenuItem.clickable.clicked += () =>
mainViewModel.LoadStateFromFileCommand.Execute(null);

// Initialize view from view model state.
mainSplitView.RestoreState(mainViewModel.MainSplitViewState);
Expand Down
4 changes: 2 additions & 2 deletions Assets/UI/Main.uxml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
<Unity.AppUI.UI.MenuTrigger>
<Unity.AppUI.UI.ActionButton label="File" icon="file" />
<Unity.AppUI.UI.Menu>
<Unity.AppUI.UI.MenuItem label="Save" />
<Unity.AppUI.UI.MenuItem label="Load" />
<Unity.AppUI.UI.MenuItem label="Save" name="file-menu__save" />
<Unity.AppUI.UI.MenuItem label="Load" name="file-menu__load" />
</Unity.AppUI.UI.Menu>
</Unity.AppUI.UI.MenuTrigger>
<Unity.AppUI.UI.Spacer />
Expand Down