Compare commits

...

8 Commits

Author SHA1 Message Date
Vixalie
8b0ddcecec completely refactor patterns atom workflow. 2025-03-11 16:41:22 +08:00
Vixalie
1c48bb36d3 refactor Pattern atoms structure. 2025-03-11 16:24:58 +08:00
Vixalie
2bb0cc35f9 add empty promption to Pattern Detail. 2025-03-11 14:48:06 +08:00
Vixalie
57ba0e3d49 comprehensively completed common control operations in Pattern List 2025-03-11 14:31:01 +08:00
Vixalie
579e7265fc update pattern card action. 2025-03-11 11:20:47 +08:00
Vixalie
0dac64f1e2 finish pattern creation. 2025-03-11 11:20:31 +08:00
Vixalie
adc1bba9e0 fix typo in Pattern defination. 2025-03-11 09:33:02 +08:00
Vixalie
c0b3648fd2 expose patttern storage commands. 2025-03-11 08:50:29 +08:00
12 changed files with 328 additions and 26 deletions

View File

@ -6,6 +6,7 @@ use btleplug::{
};
pub use state::{CentralState, ChannelState, PeripheralItem};
use tauri::{async_runtime::RwLock, AppHandle, Emitter, State};
use uuid::Uuid;
use crate::{
bluetooth, errors,
@ -157,3 +158,31 @@ pub async fn save_pattern(
.map_err(|e| errors::AppError::StorageFailure(e.to_string()))?;
Ok(())
}
#[tauri::command]
pub async fn get_pattern(
app_state: State<'_, Arc<RwLock<AppState>>>,
pattern_id: Uuid,
) -> Result<Option<Pattern>, errors::AppError> {
let state = app_state.read().await;
let pattern = state
.db
.get_pattern(&pattern_id)
.await
.map_err(|e| errors::AppError::StorageFailure(e.to_string()))?;
Ok(pattern)
}
#[tauri::command]
pub async fn remove_pattern(
app_state: State<'_, Arc<RwLock<AppState>>>,
pattern_id: Uuid,
) -> Result<(), errors::AppError> {
let state = app_state.read().await;
state
.db
.remove_pattern(&pattern_id)
.await
.map_err(|e| errors::AppError::StorageFailure(e.to_string()))?;
Ok(())
}

View File

@ -66,7 +66,9 @@ pub fn run() {
cmd::start_scan_devices,
cmd::stop_scan_devices,
cmd::list_patterns,
cmd::save_pattern
cmd::save_pattern,
cmd::get_pattern,
cmd::remove_pattern
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@ -1,9 +1,10 @@
import { invoke } from '@tauri-apps/api/core';
import dayjs from 'dayjs';
import { atom } from 'jotai';
import { atomFamily, atomWithRefresh } from 'jotai/utils';
import { atom, useSetAtom } from 'jotai';
import { atomWithRefresh } from 'jotai/utils';
import { get, reduce } from 'lodash-es';
import { v4 } from 'uuid';
import { NotificationType, ToastDuration, useNotification } from '../components/Notifications';
export enum FrequencyShifting {
/**
@ -91,7 +92,7 @@ export class Pattern {
this.createdAt = dayjs().valueOf();
this.lastModifiedAt = null;
this.smoothRepeat = true;
this.Pulses = [];
this.pulses = [];
}
}
@ -120,18 +121,31 @@ export function totalDuration(pattern: Pattern): number {
);
}
export const PatternsAtom = atomFamily((keyword: string) =>
atomWithRefresh(async () => {
try {
const patterns = await invoke<Pattern[]>('list_patterns', { keyword });
return patterns;
} catch (e) {
console.error('[retrieving pattern list]', e);
export const SearchKeywordAtom = atom<string | null>(null);
export const PatternsAtom = atomWithRefresh(async (get) => {
try {
const keyword = get(SearchKeywordAtom);
const patterns = await invoke<Pattern[]>('list_patterns', { keyword });
return patterns;
} catch (e) {
console.error('[retrieving pattern list]', e);
}
return [];
});
export const SelectedPatternIdAtom = atom<string | null>(null);
export const CurrentPatternAtom = atom<Pattern | null>(async (get) => {
try {
const patternId = get(SelectedPatternIdAtom);
if (patternId === null) {
return null;
}
return [];
}),
);
export const CurrentPatternAtom = atom<Pattern | null>(null);
const pattern = await invoke('get_pattern', { patternId });
return pattern;
} catch (e) {
console.error('[retrieving pattern]', e);
}
return null;
});
export const PulsesInCurrentPatternAtom = atom(
(get) => get(CurrentPatternAtom)?.pulses ?? [],
(get, set, pulse: Pulse) => {
@ -155,3 +169,27 @@ export const CurrentPatternDuration = atom((get) => {
if (!currentPattern) return 0;
return totalDuration(currentPattern);
});
export function useSavePattern() {
const refreshPatterns = useSetAtom(PatternsAtom);
const { showToast } = useNotification();
const savePattern = async (pattern: Pattern) => {
try {
await invoke('save_pattern', { pattern });
refreshPatterns();
return true;
} catch (error) {
console.error('[save pattern]', error);
showToast(
NotificationType.ERROR,
'Failed to save pattern. Please try again.',
'material-symbols-light:error-outline',
ToastDuration.MEDIUM,
);
}
return false;
};
return savePattern;
}

View File

@ -28,7 +28,7 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<Route path="/pattern-editor">
<Route index element={<PatternNavigator />} />
<Route path="new" element={<CreatePattern />} />
<Route path=":pattern" element={<PatternEditor />} />
<Route path="edit" element={<PatternEditor />} />
</Route>
<Route path="/settings" element={<Settings />} />
</Route>

View File

@ -1,14 +1,30 @@
import { Icon } from '@iconify/react/dist/iconify.js';
import { FC } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { FC, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import EditableContent from '../../components/EditableContent';
import { CurrentPatternAtom, SelectedPatternIdAtom } from '../../context/Patterns';
import styles from './PatternHeader.module.css';
const PatternHeader: FC = () => {
const navigate = useNavigate();
const currentPattern = useAtomValue(CurrentPatternAtom);
const setActivePattern = useSetAtom(SelectedPatternIdAtom);
const handleClosePattern = useCallback(() => {
setActivePattern(null);
navigate('/library');
}, []);
return (
<div className={styles.pattern_header}>
<EditableContent as="h3" placeholder="Pattern Name" additionalClassName={styles.content} />
<EditableContent
as="h3"
placeholder="Pattern Name"
value={currentPattern?.name ?? null}
additionalClassName={styles.content}
/>
<div className="spacer" />
<button className="tonal">
<button className="tonal" onClick={handleClosePattern}>
<Icon icon="material-symbols-light:close" />
Close Edit
</button>

View File

@ -3,5 +3,27 @@
flex: 2;
border-radius: calc(var(--border-radius) * 2);
background-color: var(--color-surface-container);
display: flex;
flex-direction: column;
align-items: stretch;
gap: calc(var(--spacing) * 2);
}
.empty_promption {
flex: 2;
border-radius: calc(var(--border-radius) * 2);
background-color: var(--color-surface-container);
display: flex;
flex-direction: column;
align-items: stretch;
.promption {
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
}
.spacer {
flex: 3;
}
}
}

View File

@ -1,8 +1,27 @@
import { useAtomValue } from 'jotai';
import { FC } from 'react';
import { CurrentPatternAtom, Pattern } from '../../context/Patterns';
import styles from './PatternDetail.module.css';
const PatternDetail: FC = () => {
const EmptyPromption: FC = () => {
return (
<div className={styles.empty_promption}>
<div className={styles.promption}>
<span className="empty_prompt">Select a pattern from left first.</span>
</div>
<div className={styles.spacer} />
</div>
);
};
const Detail: FC<{ pattern: Pattern }> = ({ pattern }) => {
return <div className={styles.pattern_detail}></div>;
};
const PatternDetail: FC = () => {
const currentPattern = useAtomValue(CurrentPatternAtom);
return !currentPattern ? <EmptyPromption /> : <Detail pattern={currentPattern} />;
};
export default PatternDetail;

View File

@ -24,4 +24,28 @@
align-items: stretch;
gap: calc(var(--spacing) * 2);
}
.pattern_card {
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 2);
border-radius: calc(var(--border-radius) * 2);
color: var(--color-on-surface);
background-color: var(--color-surface);
display: flex;
flex-direction: row;
align-items: center;
gap: calc(var(--spacing) * 2);
box-shadow: var(--elevation-0);
.name {
flex: 1;
}
&.selected {
background-color: color-mix(in oklch, var(--color-on-surface) 18%, transparent);
}
&:hover {
background-color: color-mix(in oklch, var(--color-on-surface) 8%, transparent);
box-shadow: var(--elevation-2-ambient), var(--elevation-2-umbra);
}
&:active {
background-color: color-mix(in oklch, var(--color-on-surface) 18%, transparent);
}
}
}

View File

@ -1,16 +1,59 @@
import { Icon } from '@iconify/react/dist/iconify.js';
import cx from 'clsx';
import { useAtomValue } from 'jotai';
import { FC, useState } from 'react';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { FC, useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useDebounce } from 'react-use';
import { ScrollArea } from '../../components/ScrollArea';
import { PatternsAtom } from '../../context/Patterns';
import {
Pattern,
PatternsAtom,
SearchKeywordAtom,
SelectedPatternIdAtom,
totalDuration,
} from '../../context/Patterns';
import styles from './Patterns.module.css';
const PatternCard: FC<{ pattern: Pattern }> = ({ pattern }) => {
const [selectedId, setSelectedId] = useAtom(SelectedPatternIdAtom);
const navigate = useNavigate();
const duration = useMemo(() => {
return totalDuration(pattern) / 1000;
}, [pattern]);
const selected = useMemo(() => selectedId === pattern.id, [selectedId, pattern]);
const handleSingleClick = useCallback(() => {
if (selectedId === pattern.id) {
setSelectedId(null);
} else {
setSelectedId(pattern.id);
}
}, [pattern, selectedId]);
const handleDblClick = useCallback(() => {
setSelectedId(pattern.id);
navigate('/pattern-editor/edit');
}, [pattern]);
return (
<div
className={cx(styles.pattern_card, selected && styles.selected)}
onClick={handleSingleClick}
onDoubleClick={handleDblClick}>
<h5 className={styles.name}>{pattern.name}</h5>
<span>{duration.toFixed(2)} s</span>
</div>
);
};
const Patterns: FC = () => {
const [rawKeyword, setRawKeyword] = useState<string>('');
const [keyword, setKeyword] = useState<string | null>(null);
const patterns = useAtomValue(PatternsAtom(keyword));
const [keyword, setKeyword] = useAtom(SearchKeywordAtom);
const patterns = useAtomValue(PatternsAtom);
const setPatternSelection = useSetAtom(SelectedPatternIdAtom);
const navigate = useNavigate();
const createNewAction = useCallback(() => {
setPatternSelection(null);
navigate('/pattern-editor/new');
}, []);
useDebounce(
() => {
@ -36,7 +79,7 @@ const Patterns: FC = () => {
onChange={(evt) => setRawKeyword(evt.currentTarget.value)}
/>
</div>
<button className="tonal secondary">
<button className="tonal secondary" onClick={createNewAction}>
<Icon icon="material-symbols-light:add" />
New Pattern
</button>
@ -44,6 +87,9 @@ const Patterns: FC = () => {
<ScrollArea enableY>
<div className={styles.pattern_list}>
{patterns.length === 0 && <div className="empty_prompt">No pattern found.</div>}
{patterns.map((p) => (
<PatternCard key={p.id} pattern={p} />
))}
</div>
</ScrollArea>
</div>

View File

@ -0,0 +1,15 @@
@layer pages {
.create_form {
border-radius: calc(var(--border-radius) * 2);
background-color: var(--color-surface-container);
}
.form_row {
display: flex;
flex-direction: row;
align-items: center;
gap: calc(var(--spacing) * 3);
.pattern_name_input {
min-width: 30em;
}
}
}

View File

@ -0,0 +1,79 @@
import cx from 'clsx';
import { useSetAtom } from 'jotai';
import { FC, useActionState } from 'react';
import { useNavigate } from 'react-router-dom';
import { NotificationType, ToastDuration, useNotification } from '../components/Notifications';
import { Pattern, SelectedPatternIdAtom, useSavePattern } from '../context/Patterns';
import styles from './CreatePattern.module.css';
const CreatePattern: FC = () => {
const { showToast } = useNotification();
const navigate = useNavigate();
const loadPattern = useSetAtom(SelectedPatternIdAtom);
const savePattern = useSavePattern();
const [errState, handleFormSubmit] = useActionState(async (state, formData) => {
const patternName = formData.get('pattern_name') as string | null;
if (patternName === null || patternName.length === 0) {
showToast(
NotificationType.ERROR,
'Please enter a pattern name.',
'material-symbols-light:error-outline',
ToastDuration.MEDIUM,
);
return true;
}
const newPattern = new Pattern();
newPattern.name = patternName;
try {
const updated = await savePattern(newPattern);
if (!updated) {
showToast(
NotificationType.ERROR,
'Failed to reload the created pattern. Please try again.',
'material-symbols-light:error-outline',
ToastDuration.MEDIUM,
);
loadPattern(null);
navigate('/library');
return true;
}
loadPattern(newPattern.id);
navigate('/pattern-editor/edit');
} catch (e) {
console.error('[save pattern]', e);
loadPattern(null);
showToast(
NotificationType.ERROR,
'Failed to create pattern. Please try again.',
'material-symbols-light:error-outline',
ToastDuration.MEDIUM,
);
}
return false;
}, false);
return (
<div className={cx('workspace', 'vertical', styles.create_form)}>
<div className="center">
<form action={handleFormSubmit} className={styles.form_row}>
<input
type="text"
name="pattern_name"
placeholder="pattern name"
className={cx(styles.pattern_name_input, errState && 'error')}
/>
<button type="submit" className="filled">
Create New Pattern
</button>
</form>
</div>
</div>
);
};
export default CreatePattern;

View File

@ -0,0 +1,12 @@
import { useAtomValue } from 'jotai';
import { FC } from 'react';
import { Navigate } from 'react-router-dom';
import { SelectedPatternIdAtom } from '../context/Patterns';
const PatternNavigator: FC = () => {
const selected = useAtomValue(SelectedPatternIdAtom);
return selected === null ? <Navigate to="new" /> : <Navigate to="edit" />;
};
export default PatternNavigator;