Skip to content

Commit b04aee5

Browse files
myieyeclaude
andauthored
Add preferences service for cross-platform user settings storage (#2158)
* Wire up MAUI Preferences for project storage Add PreferencesService to expose MAUI IPreferences to the TypeScript frontend via JSInterop. When running in MAUI, project storage now uses native preferences instead of localStorage. - Add IPreferencesService interface and PreferencesServiceJsInvokable - Register service in FwLiteProvider and FwLiteMauiKernel - Refactor project-storage.svelte.ts with StorageBackend abstraction - Fall back to localStorage when not running in MAUI https://claude.ai/code/session_01XdJtubGvpeSyetSuA3zfFA * Refactor StorageProp to use async set() method Change API from setter to explicit async method: - `current` getter is reactive and read-only - `set()` is async - callers can await or fire-and-forget - Add error logging for load failures Update TasksView to use onValueChange instead of bind:value. https://claude.ai/code/session_01XdJtubGvpeSyetSuA3zfFA * Simplify storage backend to use IPreferencesService directly - Remove redundant StorageBackend interface (identical to IPreferencesService) - Remove PreferencesBackend wrapper class (just a passthrough) - LocalStorageBackend now implements IPreferencesService directly - Make load() async without catch - errors hit global handler - Add IPreferencesService to Services barrel export https://claude.ai/code/session_01XdJtubGvpeSyetSuA3zfFA * Move PreferencesServiceJsInvokable to FwLiteMaui IPreferences is a MAUI type, so the implementation must live in FwLiteMaui, not FwLiteShared. The interface (IPreferencesService) remains in FwLiteShared. https://claude.ai/code/session_01XdJtubGvpeSyetSuA3zfFA * Add guard to prevent load() from overwriting set() values If set() is called before load() completes, the #hasBeenSet flag prevents load() from overwriting the user's value with stale data. https://claude.ai/code/session_01XdJtubGvpeSyetSuA3zfFA * Wait until selectedTaskId is loaded before determining list open state --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f267896 commit b04aee5

10 files changed

Lines changed: 148 additions & 33 deletions

File tree

backend/FwLite/FwLiteMaui/FwLiteMauiKernel.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ public static void AddFwLiteMauiServices(this IServiceCollection services,
127127
services.AddSingleton(_ => Launcher.Default);
128128
services.AddSingleton(_ => Browser.Default);
129129
services.AddSingleton(_ => Share.Default);
130+
services.AddSingleton<IPreferencesService, PreferencesServiceJsInvokable>();
130131
services.AddSingleton<ITroubleshootingService, MauiTroubleshootingService>();
131132
logging.AddConsole();
132133
#if DEBUG
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using FwLiteShared.Services;
2+
using Microsoft.JSInterop;
3+
4+
namespace FwLiteMaui.Services;
5+
6+
/// <summary>
7+
/// JSInvokable wrapper around IPreferences for exposing preferences to JavaScript.
8+
/// Only available when running in MAUI (where IPreferences is registered).
9+
/// </summary>
10+
public class PreferencesServiceJsInvokable(IPreferences preferences) : IPreferencesService
11+
{
12+
[JSInvokable]
13+
public string? Get(string key)
14+
{
15+
return preferences.Get<string?>(key, null);
16+
}
17+
18+
[JSInvokable]
19+
public void Set(string key, string value)
20+
{
21+
preferences.Set(key, value);
22+
}
23+
24+
[JSInvokable]
25+
public void Remove(string key)
26+
{
27+
preferences.Remove(key);
28+
}
29+
}

backend/FwLite/FwLiteShared/Services/FwLiteProvider.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ IServiceProvider services
3636
DotnetService.JsEventListener,
3737
DotnetService.JsInvokableLogger,
3838
DotnetService.UpdateService,
39+
DotnetService.PreferencesService,
3940
];
4041

4142
public static Type GetServiceType(DotnetService service) => service switch
@@ -55,6 +56,7 @@ IServiceProvider services
5556
DotnetService.JsEventListener => typeof(JsEventListener),
5657
DotnetService.JsInvokableLogger => typeof(JsInvokableLogger),
5758
DotnetService.UpdateService => typeof(UpdateService),
59+
DotnetService.PreferencesService => typeof(IPreferencesService),
5860
_ => throw new ArgumentOutOfRangeException(nameof(service), service, null)
5961
};
6062

@@ -114,4 +116,5 @@ public enum DotnetService
114116
JsEventListener,
115117
JsInvokableLogger,
116118
UpdateService,
119+
PreferencesService,
117120
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using Microsoft.JSInterop;
2+
using Reinforced.Typings.Attributes;
3+
4+
namespace FwLiteShared.Services;
5+
6+
/// <summary>
7+
/// Interface for a key-value preferences service.
8+
/// Used for storing user preferences, exposed to JavaScript via JSInterop.
9+
/// </summary>
10+
public interface IPreferencesService
11+
{
12+
[JSInvokable]
13+
[TsFunction(Type = "Promise<string | null>")]
14+
string? Get(string key);
15+
[JSInvokable]
16+
void Set(string key, string value);
17+
[JSInvokable]
18+
void Remove(string key);
19+
}

frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/DotnetService.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export enum DotnetService {
1818
MultiWindowService = "MultiWindowService",
1919
JsEventListener = "JsEventListener",
2020
JsInvokableLogger = "JsInvokableLogger",
21-
UpdateService = "UpdateService"
21+
UpdateService = "UpdateService",
22+
PreferencesService = "PreferencesService"
2223
}
2324
/* eslint-enable */
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/* eslint-disable */
2+
// This code was generated by a Reinforced.Typings tool.
3+
// Changes to this file may cause incorrect behavior and will be lost if
4+
// the code is regenerated.
5+
6+
export interface IPreferencesService
7+
{
8+
get(key: string) : Promise<string | null>;
9+
set(key: string, value: string) : Promise<void>;
10+
remove(key: string) : Promise<void>;
11+
}
12+
/* eslint-enable */

frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export * from './IJsInvokableLogger';
55
export * from './IMiniLcmFeatures';
66
export * from './IMiniLcmJsInvokable';
77
export * from './IMultiWindowService';
8+
export * from './IPreferencesService';
89
export * from './IProjectScope';
910
export * from './IProjectServicesProvider';
1011
export * from './IReadFileResponseJs';

frontend/viewer/src/lib/services/service-provider.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type {IHistoryServiceJsInvokable} from '$lib/dotnet-types/generated-types
1818
import type {ISyncServiceJsInvokable} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/ISyncServiceJsInvokable';
1919
import {useProjectContext} from '$project/project-context.svelte';
2020
import type {IJsInvokableLogger} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IJsInvokableLogger';
21+
import type {IPreferencesService} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IPreferencesService';
2122

2223
import type {IUpdateService} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IUpdateService';
2324

@@ -38,6 +39,7 @@ export type LexboxServiceRegistry = {
3839
[DotnetService.JsEventListener]: IJsEventListener,
3940
[DotnetService.JsInvokableLogger]: IJsInvokableLogger,
4041
[DotnetService.UpdateService]: IUpdateService,
42+
[DotnetService.PreferencesService]: IPreferencesService,
4143
};
4244

4345
export const SERVICE_KEYS = Object.values(DotnetService);
@@ -129,6 +131,10 @@ export function useUpdateService(): IUpdateService {
129131
return window.lexbox.ServiceProvider.getService(DotnetService.UpdateService);
130132
}
131133

134+
export function tryUsePreferencesService(): IPreferencesService | undefined {
135+
return window.lexbox.ServiceProvider.tryGetService(DotnetService.PreferencesService);
136+
}
137+
132138
export function useService<K extends ServiceKey>(key: K): LexboxServiceRegistry[K] {
133139
return window.lexbox.ServiceProvider.getService(key);
134140
}

frontend/viewer/src/lib/utils/project-storage.svelte.ts

Lines changed: 64 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,112 @@
1-
import { getContext, setContext } from 'svelte';
2-
import { useProjectContext } from '$project/project-context.svelte';
1+
import {getContext, setContext} from 'svelte';
2+
3+
import type {IPreferencesService} from '$lib/dotnet-types/generated-types/FwLiteShared/Services';
4+
import {tryUsePreferencesService} from '$lib/services/service-provider';
5+
import {useProjectContext} from '$project/project-context.svelte';
36

47
/**
58
* Project-specific storage service
69
*
710
* This service provides project-scoped storage for user preferences.
8-
* Currently uses localStorage with project-prefixed keys.
9-
*
10-
* TODO: Enhance to use MAUI Preferences when running in MAUI app
11-
* (detect platform via useFwLiteConfig().os and use a preferences service)
11+
* When running in MAUI, uses MAUI Preferences via the PreferencesService.
12+
* Otherwise, falls back to localStorage.
1213
*/
1314

1415
const projectStorageContextKey = 'project-storage';
1516

1617
/**
17-
* Reactive storage property that automatically syncs to localStorage
18+
* localStorage-based storage backend implementing IPreferencesService
19+
*/
20+
class LocalStorageBackend implements IPreferencesService {
21+
get(key: string): Promise<string | null> {
22+
return Promise.resolve(localStorage.getItem(key));
23+
}
24+
25+
set(key: string, value: string): Promise<void> {
26+
localStorage.setItem(key, value);
27+
return Promise.resolve();
28+
}
29+
30+
remove(key: string): Promise<void> {
31+
localStorage.removeItem(key);
32+
return Promise.resolve();
33+
}
34+
}
35+
36+
/**
37+
* Returns the preferences service if available (MAUI), otherwise localStorage fallback
38+
*/
39+
function getPreferencesService(): IPreferencesService {
40+
return tryUsePreferencesService() ?? new LocalStorageBackend();
41+
}
42+
43+
/**
44+
* Reactive storage property with async persistence.
45+
*
46+
* - `current` getter is reactive (Svelte 5 runes) and read-only
47+
* - `set()` is async - callers can await or fire-and-forget
48+
* - Initial value loads asynchronously; early subscribers get updates when ready
1849
*/
1950
class StorageProp {
2051
#projectCode: string;
2152
#key: string;
53+
#backend: IPreferencesService;
2254
#value = $state<string>('');
55+
#hasBeenSet = $state(false);
2356

24-
constructor(projectCode: string, key: string) {
57+
constructor(projectCode: string, key: string, backend: IPreferencesService) {
2558
this.#projectCode = projectCode;
2659
this.#key = key;
27-
// Load initial value
28-
this.#value = this.load();
60+
this.#backend = backend;
61+
void this.load();
2962
}
3063

3164
get current(): string {
3265
return this.#value;
3366
}
3467

35-
set current(value: string) {
68+
get loading(): boolean {
69+
return !this.#hasBeenSet;
70+
}
71+
72+
async set(value: string): Promise<void> {
73+
this.#hasBeenSet = true;
3674
this.#value = value;
37-
this.persist(value);
75+
const storageKey = this.getStorageKey();
76+
if (value) {
77+
await this.#backend.set(storageKey, value);
78+
} else {
79+
await this.#backend.remove(storageKey);
80+
}
3881
}
3982

4083
private getStorageKey(): string {
4184
return `project:${this.#projectCode}:${this.#key}`;
4285
}
4386

44-
private load(): string {
45-
return localStorage.getItem(this.getStorageKey()) ?? '';
46-
}
47-
48-
private persist(value: string): void {
49-
const storageKey = this.getStorageKey();
50-
if (value) {
51-
localStorage.setItem(storageKey, value);
52-
} else {
53-
localStorage.removeItem(storageKey);
87+
private async load(): Promise<void> {
88+
const value = await this.#backend.get(this.getStorageKey());
89+
if (!this.#hasBeenSet) {
90+
this.#value = value ?? '';
91+
this.#hasBeenSet = true;
5492
}
5593
}
5694
}
5795

5896
export class ProjectStorage {
5997
readonly selectedTaskId: StorageProp;
6098

61-
constructor(projectCode: string) {
62-
this.selectedTaskId = new StorageProp(projectCode, 'selectedTaskId');
99+
constructor(projectCode: string, backend: IPreferencesService) {
100+
this.selectedTaskId = new StorageProp(projectCode, 'selectedTaskId', backend);
63101
}
64102
}
65103

66104
export function useProjectStorage(): ProjectStorage {
67105
let storage = getContext<ProjectStorage>(projectStorageContextKey);
68106
if (!storage) {
69107
const projectContext = useProjectContext();
70-
storage = new ProjectStorage(projectContext.projectCode);
108+
const backend = getPreferencesService();
109+
storage = new ProjectStorage(projectContext.projectCode, backend);
71110
setContext(projectStorageContextKey, storage);
72111
}
73112
return storage;

frontend/viewer/src/project/tasks/TasksView.svelte

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,31 @@
44
import {t} from 'svelte-i18n-lingui';
55
import {useProjectStorage} from '$lib/utils/project-storage.svelte';
66
import TaskView from './TaskView.svelte';
7-
import {onMount} from 'svelte';
7+
import {untrack} from 'svelte';
88
import {SidebarTrigger} from '$lib/components/ui/sidebar';
99
1010
const selectedTaskId = useProjectStorage().selectedTaskId;
1111
const tasksService = useTasksService();
1212
const tasks = $derived(tasksService.listTasks());
1313
const selectedTask = $derived(tasks.find(task => task.id === selectedTaskId.current));
1414
15-
onMount(() => {
16-
if (!selectedTaskId.current) {
17-
open = true;
15+
let open = $state(false);
16+
$effect(() => {
17+
if (untrack(() => !selectedTaskId.loading)) {
18+
untrack(() => open = !selectedTaskId.current);
19+
return;
1820
}
21+
// stay subscribed until loading is complete
22+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
23+
selectedTaskId.loading;
1924
});
20-
let open = $state(false);
2125
</script>
2226

2327
<div class="flex flex-col h-full p-4 gap-4">
2428
<div class="flex flex-row items-center">
2529
<SidebarTrigger icon="i-mdi-menu" class="aspect-square p-0 mr-2" />
2630

27-
<Select.Root bind:open type="single" bind:value={selectedTaskId.current}>
31+
<Select.Root bind:open type="single" value={selectedTaskId.current} onValueChange={(v) => selectedTaskId.set(v ?? '')}>
2832
<Select.Trigger>{$t`Task ${selectedTask?.subject ?? ''}`}</Select.Trigger>
2933
<Select.Content>
3034
{#each tasks as task (task.id)}
@@ -34,7 +38,7 @@
3438
</Select.Root>
3539
</div>
3640
{#if selectedTaskId.current}
37-
<TaskView taskId={selectedTaskId.current} onClose={() => selectedTaskId.current = ''}/>
41+
<TaskView taskId={selectedTaskId.current} onClose={() => selectedTaskId.set('')}/>
3842
{:else}
3943
<h1 class="text-xl p-4 mx-auto">{$t`Select a new task to work on`}</h1>
4044
{/if}

0 commit comments

Comments
 (0)