mirror of
https://github.com/DocNR/POWR.git
synced 2025-04-19 19:01:18 +00:00
update to nostr exercise nip and minor UI
This commit is contained in:
parent
dca4ef5b33
commit
173e4e31e4
@ -1,17 +1,74 @@
|
||||
// app/(tabs)/library/programs.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, ScrollView } from 'react-native';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { View, ScrollView, TextInput, ActivityIndicator, Platform, TouchableOpacity } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { AlertCircle, CheckCircle2, Database, RefreshCcw, Trash2, Code, Search, ListFilter } from 'lucide-react-native';
|
||||
import {
|
||||
AlertCircle, CheckCircle2, Database, RefreshCcw, Trash2,
|
||||
Code, Search, ListFilter, Wifi, Zap, FileJson
|
||||
} from 'lucide-react-native';
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { ExerciseType, ExerciseCategory, Equipment } from '@/types/exercise';
|
||||
import { SQLTransaction, SQLResultSet, SQLError } from '@/lib/db/types';
|
||||
import { schema } from '@/lib/db/schema';
|
||||
import { Platform } from 'react-native';
|
||||
import { FilterSheet, type FilterOptions, type SourceType } from '@/components/library/FilterSheet';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { getPublicKey } from 'nostr-tools';
|
||||
|
||||
// Constants for Nostr
|
||||
const EVENT_KIND_EXERCISE = 33401;
|
||||
const EVENT_KIND_WORKOUT_TEMPLATE = 33402;
|
||||
const EVENT_KIND_WORKOUT_RECORD = 1301;
|
||||
|
||||
// Simplified mock implementations for testing
|
||||
const generatePrivateKey = (): string => {
|
||||
// Generate a random hex string (32 bytes)
|
||||
return Array.from({ length: 64 }, () =>
|
||||
Math.floor(Math.random() * 16).toString(16)
|
||||
).join('');
|
||||
};
|
||||
|
||||
const getEventHash = (event: any): string => {
|
||||
// For testing, just create a mock hash
|
||||
const eventData = JSON.stringify([
|
||||
0,
|
||||
event.pubkey,
|
||||
event.created_at,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content
|
||||
]);
|
||||
|
||||
// Simple hash function for demonstration
|
||||
return Array.from(eventData)
|
||||
.reduce((hash, char) => {
|
||||
return ((hash << 5) - hash) + char.charCodeAt(0);
|
||||
}, 0)
|
||||
.toString(16)
|
||||
.padStart(64, '0');
|
||||
};
|
||||
|
||||
const signEvent = (event: any, privateKey: string): string => {
|
||||
// In real implementation, this would sign the event hash with the private key
|
||||
// For testing, we'll just return a mock signature
|
||||
return Array.from({ length: 128 }, () =>
|
||||
Math.floor(Math.random() * 16).toString(16)
|
||||
).join('');
|
||||
};
|
||||
|
||||
interface NostrEvent {
|
||||
id?: string;
|
||||
pubkey: string;
|
||||
created_at: number;
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
content: string;
|
||||
sig?: string;
|
||||
}
|
||||
|
||||
interface TableInfo {
|
||||
name: string;
|
||||
@ -52,9 +109,10 @@ const initialFilters: FilterOptions = {
|
||||
tags: [],
|
||||
source: []
|
||||
};
|
||||
|
||||
export default function ProgramsScreen() {
|
||||
const db = useSQLiteContext();
|
||||
|
||||
// Database state
|
||||
const [dbStatus, setDbStatus] = useState<{
|
||||
initialized: boolean;
|
||||
tables: string[];
|
||||
@ -72,12 +130,34 @@ export default function ProgramsScreen() {
|
||||
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
|
||||
const [currentFilters, setCurrentFilters] = useState<FilterOptions>(initialFilters);
|
||||
const [activeFilters, setActiveFilters] = useState(0);
|
||||
|
||||
// Nostr state
|
||||
const [relayUrl, setRelayUrl] = useState('ws://localhost:7777');
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [events, setEvents] = useState<NostrEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [privateKey, setPrivateKey] = useState('');
|
||||
const [publicKey, setPublicKey] = useState('');
|
||||
const [useGeneratedKeys, setUseGeneratedKeys] = useState(true);
|
||||
const [eventKind, setEventKind] = useState(EVENT_KIND_EXERCISE);
|
||||
const [eventContent, setEventContent] = useState('');
|
||||
|
||||
// WebSocket reference
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
|
||||
// Tab state
|
||||
const [activeTab, setActiveTab] = useState('database');
|
||||
|
||||
useEffect(() => {
|
||||
checkDatabase();
|
||||
inspectDatabase();
|
||||
generateKeys();
|
||||
}, []);
|
||||
|
||||
// DATABASE FUNCTIONS
|
||||
|
||||
const inspectDatabase = async () => {
|
||||
try {
|
||||
const result = await db.getAllAsync<TableSchema>(
|
||||
@ -113,7 +193,6 @@ export default function ProgramsScreen() {
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const resetDatabase = async () => {
|
||||
try {
|
||||
await db.withTransactionAsync(async () => {
|
||||
@ -230,6 +309,185 @@ export default function ProgramsScreen() {
|
||||
// Implement filtering logic for programs when available
|
||||
};
|
||||
|
||||
// NOSTR FUNCTIONS
|
||||
|
||||
// Generate new keypair
|
||||
const generateKeys = () => {
|
||||
try {
|
||||
const privKey = generatePrivateKey();
|
||||
// For getPublicKey, we can use a mock function that returns a valid-looking pubkey
|
||||
const pubKey = privKey.slice(0, 64); // Just use part of the private key for demo
|
||||
|
||||
setPrivateKey(privKey);
|
||||
setPublicKey(pubKey);
|
||||
setStatusMessage('Keys generated successfully');
|
||||
} catch (error) {
|
||||
setStatusMessage(`Error generating keys: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Connect to relay
|
||||
const connectToRelay = () => {
|
||||
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.close();
|
||||
}
|
||||
|
||||
setConnecting(true);
|
||||
setStatusMessage('Connecting to relay...');
|
||||
|
||||
try {
|
||||
const socket = new WebSocket(relayUrl);
|
||||
|
||||
socket.onopen = () => {
|
||||
setConnected(true);
|
||||
setConnecting(false);
|
||||
setStatusMessage('Connected to relay!');
|
||||
socketRef.current = socket;
|
||||
|
||||
// Subscribe to exercise-related events
|
||||
const subscriptionId = 'test-sub-' + Math.random().toString(36).substring(2, 15);
|
||||
const subscription = JSON.stringify([
|
||||
'REQ',
|
||||
subscriptionId,
|
||||
{ kinds: [EVENT_KIND_EXERCISE, EVENT_KIND_WORKOUT_TEMPLATE, EVENT_KIND_WORKOUT_RECORD], limit: 10 }
|
||||
]);
|
||||
socket.send(subscription);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data[0] === 'EVENT' && data[1] && data[2]) {
|
||||
const nostrEvent = data[2];
|
||||
setEvents(prev => [nostrEvent, ...prev].slice(0, 50)); // Keep most recent 50 events
|
||||
} else if (data[0] === 'NOTICE') {
|
||||
setStatusMessage(`Relay message: ${data[1]}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing message:', error, event.data);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
setConnected(false);
|
||||
setConnecting(false);
|
||||
setStatusMessage('Disconnected from relay');
|
||||
};
|
||||
|
||||
socket.onerror = (error) => {
|
||||
setConnected(false);
|
||||
setConnecting(false);
|
||||
setStatusMessage(`Connection error: ${error}`);
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
} catch (error) {
|
||||
setConnecting(false);
|
||||
setStatusMessage(`Failed to connect: ${error}`);
|
||||
console.error('Connection setup error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Disconnect from relay
|
||||
const disconnectFromRelay = () => {
|
||||
if (socketRef.current) {
|
||||
socketRef.current.close();
|
||||
}
|
||||
};
|
||||
|
||||
// Create and publish a new event
|
||||
const publishEvent = () => {
|
||||
if (!socketRef.current || socketRef.current.readyState !== WebSocket.OPEN) {
|
||||
setStatusMessage('Not connected to a relay');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!privateKey || !publicKey) {
|
||||
setStatusMessage('Need private and public keys to publish');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Create event with required pubkey (no longer optional)
|
||||
const event: NostrEvent = {
|
||||
pubkey: publicKey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: eventKind,
|
||||
tags: [],
|
||||
content: eventContent,
|
||||
};
|
||||
|
||||
// A basic implementation for each event kind
|
||||
if (eventKind === EVENT_KIND_EXERCISE) {
|
||||
event.tags.push(['d', `exercise-${Date.now()}`]);
|
||||
event.tags.push(['title', 'Test Exercise']);
|
||||
event.tags.push(['format', 'weight', 'reps']);
|
||||
event.tags.push(['format_units', 'kg', 'count']);
|
||||
event.tags.push(['equipment', 'barbell']);
|
||||
} else if (eventKind === EVENT_KIND_WORKOUT_TEMPLATE) {
|
||||
event.tags.push(['d', `template-${Date.now()}`]);
|
||||
event.tags.push(['title', 'Test Workout Template']);
|
||||
event.tags.push(['type', 'strength']);
|
||||
} else if (eventKind === EVENT_KIND_WORKOUT_RECORD) {
|
||||
event.tags.push(['d', `workout-${Date.now()}`]);
|
||||
event.tags.push(['title', 'Test Workout Record']);
|
||||
event.tags.push(['start', `${Math.floor(Date.now() / 1000) - 3600}`]);
|
||||
event.tags.push(['end', `${Math.floor(Date.now() / 1000)}`]);
|
||||
}
|
||||
|
||||
// Hash and sign
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, privateKey);
|
||||
|
||||
// Publish to relay
|
||||
const message = JSON.stringify(['EVENT', event]);
|
||||
socketRef.current.send(message);
|
||||
|
||||
setStatusMessage('Event published successfully!');
|
||||
setEventContent('');
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
setStatusMessage(`Error publishing event: ${error}`);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Query events from relay
|
||||
const queryEvents = () => {
|
||||
if (!socketRef.current || socketRef.current.readyState !== WebSocket.OPEN) {
|
||||
setStatusMessage('Not connected to a relay');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setEvents([]);
|
||||
setLoading(true);
|
||||
|
||||
// Create a new subscription for the selected event kind
|
||||
const subscriptionId = 'query-' + Math.random().toString(36).substring(2, 15);
|
||||
const subscription = JSON.stringify([
|
||||
'REQ',
|
||||
subscriptionId,
|
||||
{ kinds: [eventKind], limit: 20 }
|
||||
]);
|
||||
|
||||
socketRef.current.send(subscription);
|
||||
setStatusMessage(`Querying events of kind ${eventKind}...`);
|
||||
|
||||
// Close this subscription after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(JSON.stringify(['CLOSE', subscriptionId]));
|
||||
setLoading(false);
|
||||
setStatusMessage(`Completed query for kind ${eventKind}`);
|
||||
}
|
||||
}, 5000);
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
setStatusMessage(`Error querying events: ${error}`);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<View className="flex-1 bg-background">
|
||||
{/* Search bar with filter button */}
|
||||
@ -272,137 +530,432 @@ export default function ProgramsScreen() {
|
||||
availableFilters={availableFilters}
|
||||
/>
|
||||
|
||||
<ScrollView className="flex-1 p-4">
|
||||
<View className="py-4 space-y-4">
|
||||
<Text className="text-lg font-semibold text-center mb-4">Programs Coming Soon</Text>
|
||||
<Text className="text-center text-muted-foreground mb-6">
|
||||
Training programs will allow you to organize your workouts into structured training plans.
|
||||
</Text>
|
||||
|
||||
<Text className="text-lg font-semibold text-center mb-4">Database Debug Panel</Text>
|
||||
|
||||
{/* Schema Inspector Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex-row items-center gap-2">
|
||||
<Code size={20} className="text-foreground" />
|
||||
<Text className="text-lg font-semibold">Database Schema ({Platform.OS})</Text>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<View className="space-y-4">
|
||||
{schemas.map((table) => (
|
||||
<View key={table.name} className="space-y-2">
|
||||
<Text className="font-semibold">{table.name}</Text>
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{table.sql}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onPress={inspectDatabase}
|
||||
>
|
||||
<Text className="text-primary-foreground">Refresh Schema</Text>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Status Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex-row items-center gap-2">
|
||||
<Database size={20} className="text-foreground" />
|
||||
<Text className="text-lg font-semibold">Database Status</Text>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<View className="space-y-2">
|
||||
<Text>Initialized: {dbStatus.initialized ? '✅' : '❌'}</Text>
|
||||
<Text>Tables Found: {dbStatus.tables.length}</Text>
|
||||
<View className="pl-4">
|
||||
{dbStatus.tables.map(table => (
|
||||
<Text key={table} className="text-muted-foreground">• {table}</Text>
|
||||
))}
|
||||
</View>
|
||||
{dbStatus.error && (
|
||||
<View className="mt-4 p-4 bg-destructive/10 rounded-lg border border-destructive">
|
||||
<View className="flex-row items-center gap-2">
|
||||
<AlertCircle className="text-destructive" size={20} />
|
||||
<Text className="font-semibold text-destructive">Error</Text>
|
||||
</View>
|
||||
<Text className="mt-2 text-destructive">{dbStatus.error}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Operations Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex-row items-center gap-2">
|
||||
<RefreshCcw size={20} className="text-foreground" />
|
||||
<Text className="text-lg font-semibold">Database Operations</Text>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<View className="space-y-4">
|
||||
<Button
|
||||
onPress={runTestInsert}
|
||||
className="w-full"
|
||||
>
|
||||
<Text className="text-primary-foreground">Run Test Insert</Text>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onPress={resetDatabase}
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
>
|
||||
<Trash2 size={18} className="mr-2" />
|
||||
<Text className="text-destructive-foreground">Reset Database</Text>
|
||||
</Button>
|
||||
|
||||
{testResults && (
|
||||
<View className={`mt-4 p-4 rounded-lg border ${
|
||||
testResults.success
|
||||
? 'bg-primary/10 border-primary'
|
||||
: 'bg-destructive/10 border-destructive'
|
||||
}`}>
|
||||
<View className="flex-row items-center gap-2">
|
||||
{testResults.success ? (
|
||||
<CheckCircle2
|
||||
className="text-primary"
|
||||
size={20}
|
||||
/>
|
||||
) : (
|
||||
<AlertCircle
|
||||
className="text-destructive"
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
<Text className={`font-semibold ${
|
||||
testResults.success ? 'text-primary' : 'text-destructive'
|
||||
}`}>
|
||||
{testResults.success ? "Success" : "Error"}
|
||||
{/* Custom Tab Bar */}
|
||||
<View className="flex-row mx-4 mt-4 bg-card rounded-lg overflow-hidden">
|
||||
<TouchableOpacity
|
||||
className={`flex-1 flex-row items-center justify-center py-3 ${activeTab === 'database' ? 'bg-primary' : ''}`}
|
||||
onPress={() => setActiveTab('database')}
|
||||
>
|
||||
<Database size={18} className={`mr-2 ${activeTab === 'database' ? 'text-white' : 'text-foreground'}`} />
|
||||
<Text className={activeTab === 'database' ? 'text-white' : 'text-foreground'}>Database</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
className={`flex-1 flex-row items-center justify-center py-3 ${activeTab === 'nostr' ? 'bg-primary' : ''}`}
|
||||
onPress={() => setActiveTab('nostr')}
|
||||
>
|
||||
<Zap size={18} className={`mr-2 ${activeTab === 'nostr' ? 'text-white' : 'text-foreground'}`} />
|
||||
<Text className={activeTab === 'nostr' ? 'text-white' : 'text-foreground'}>Nostr</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'database' && (
|
||||
<ScrollView className="flex-1 p-4">
|
||||
<View className="py-4 space-y-4">
|
||||
<Text className="text-lg font-semibold text-center mb-4">Programs Coming Soon</Text>
|
||||
<Text className="text-center text-muted-foreground mb-6">
|
||||
Training programs will allow you to organize your workouts into structured training plans.
|
||||
</Text>
|
||||
|
||||
<Text className="text-lg font-semibold text-center mb-4">Database Debug Panel</Text>
|
||||
|
||||
{/* Schema Inspector Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex-row items-center gap-2">
|
||||
<Code size={20} className="text-foreground" />
|
||||
<Text className="text-lg font-semibold">Database Schema ({Platform.OS})</Text>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<View className="space-y-4">
|
||||
{schemas.map((table) => (
|
||||
<View key={table.name} className="space-y-2">
|
||||
<Text className="font-semibold">{table.name}</Text>
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{table.sql}
|
||||
</Text>
|
||||
</View>
|
||||
<ScrollView className="mt-2">
|
||||
<Text className={`${
|
||||
testResults.success ? 'text-foreground' : 'text-destructive'
|
||||
}`}>
|
||||
{testResults.message}
|
||||
))}
|
||||
</View>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onPress={inspectDatabase}
|
||||
>
|
||||
<Text className="text-primary-foreground">Refresh Schema</Text>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Status Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex-row items-center gap-2">
|
||||
<Database size={20} className="text-foreground" />
|
||||
<Text className="text-lg font-semibold">Database Status</Text>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<View className="space-y-2">
|
||||
<Text>Initialized: {dbStatus.initialized ? '✅' : '❌'}</Text>
|
||||
<Text>Tables Found: {dbStatus.tables.length}</Text>
|
||||
<View className="pl-4">
|
||||
{dbStatus.tables.map(table => (
|
||||
<Text key={table} className="text-muted-foreground">• {table}</Text>
|
||||
))}
|
||||
</View>
|
||||
{dbStatus.error && (
|
||||
<View className="mt-4 p-4 bg-destructive/10 rounded-lg border border-destructive">
|
||||
<View className="flex-row items-center gap-2">
|
||||
<AlertCircle className="text-destructive" size={20} />
|
||||
<Text className="font-semibold text-destructive">Error</Text>
|
||||
</View>
|
||||
<Text className="mt-2 text-destructive">{dbStatus.error}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Operations Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex-row items-center gap-2">
|
||||
<RefreshCcw size={20} className="text-foreground" />
|
||||
<Text className="text-lg font-semibold">Database Operations</Text>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<View className="space-y-4">
|
||||
<Button
|
||||
onPress={runTestInsert}
|
||||
className="w-full"
|
||||
>
|
||||
<Text className="text-primary-foreground">Run Test Insert</Text>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onPress={resetDatabase}
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
>
|
||||
<Trash2 size={18} className="mr-2" />
|
||||
<Text className="text-destructive-foreground">Reset Database</Text>
|
||||
</Button>
|
||||
|
||||
{testResults && (
|
||||
<View className={`mt-4 p-4 rounded-lg border ${
|
||||
testResults.success
|
||||
? 'bg-primary/10 border-primary'
|
||||
: 'bg-destructive/10 border-destructive'
|
||||
}`}>
|
||||
<View className="flex-row items-center gap-2">
|
||||
{testResults.success ? (
|
||||
<CheckCircle2
|
||||
className="text-primary"
|
||||
size={20}
|
||||
/>
|
||||
) : (
|
||||
<AlertCircle
|
||||
className="text-destructive"
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
<Text className={`font-semibold ${
|
||||
testResults.success ? 'text-primary' : 'text-destructive'
|
||||
}`}>
|
||||
{testResults.success ? "Success" : "Error"}
|
||||
</Text>
|
||||
</View>
|
||||
<ScrollView className="mt-2">
|
||||
<Text className={`${
|
||||
testResults.success ? 'text-foreground' : 'text-destructive'
|
||||
}`}>
|
||||
{testResults.message}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
{activeTab === 'nostr' && (
|
||||
<ScrollView className="flex-1 p-4">
|
||||
<View className="py-4 space-y-4">
|
||||
<Text className="text-lg font-semibold text-center mb-4">Nostr Integration Test</Text>
|
||||
|
||||
{/* Connection controls */}
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex-row items-center gap-2">
|
||||
<Wifi size={20} className="text-foreground" />
|
||||
<Text className="text-lg font-semibold">Relay Connection</Text>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Input
|
||||
value={relayUrl}
|
||||
onChangeText={setRelayUrl}
|
||||
placeholder="wss://relay.example.com"
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<View className="flex-row gap-4">
|
||||
<Button
|
||||
onPress={connectToRelay}
|
||||
disabled={connecting || connected}
|
||||
className="flex-1"
|
||||
>
|
||||
{connecting ? (
|
||||
<><ActivityIndicator size="small" color="#fff" /><Text className="text-white ml-2">Connecting...</Text></>
|
||||
) : (
|
||||
<Text className="text-white">Connect</Text>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onPress={disconnectFromRelay}
|
||||
disabled={!connected}
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
>
|
||||
<Text className="text-white">Disconnect</Text>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<Text className={`mt-2 ${connected ? 'text-green-500' : 'text-red-500'}`}>
|
||||
Status: {connected ? 'Connected' : 'Disconnected'}
|
||||
</Text>
|
||||
|
||||
{statusMessage ? (
|
||||
<Text className="mt-2 text-gray-500">{statusMessage}</Text>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Keys */}
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex-row items-center gap-2">
|
||||
<Code size={20} className="text-foreground" />
|
||||
<Text className="text-lg font-semibold">Nostr Keys</Text>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<View className="flex-row items-center mb-4">
|
||||
<Switch
|
||||
checked={useGeneratedKeys}
|
||||
onCheckedChange={setUseGeneratedKeys}
|
||||
id="use-generated-keys"
|
||||
/>
|
||||
<Label htmlFor="use-generated-keys" className="ml-2">Use generated keys</Label>
|
||||
<Button
|
||||
onPress={generateKeys}
|
||||
className="ml-auto"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Text>Generate New Keys</Text>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<Text className="mb-1 font-medium">Public Key:</Text>
|
||||
<Input
|
||||
value={publicKey}
|
||||
onChangeText={setPublicKey}
|
||||
placeholder="Public key (hex)"
|
||||
editable={!useGeneratedKeys}
|
||||
className={`mb-4 ${useGeneratedKeys ? 'opacity-70' : ''}`}
|
||||
/>
|
||||
|
||||
<Text className="mb-1 font-medium">Private Key:</Text>
|
||||
<Input
|
||||
value={privateKey}
|
||||
onChangeText={setPrivateKey}
|
||||
placeholder="Private key (hex)"
|
||||
editable={!useGeneratedKeys}
|
||||
className={`mb-2 ${useGeneratedKeys ? 'opacity-70' : ''}`}
|
||||
/>
|
||||
<Text className="text-xs text-muted-foreground">Note: Never share your private key in a production app</Text>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create Event */}
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex-row items-center gap-2">
|
||||
<Zap size={20} className="text-foreground" />
|
||||
<Text className="text-lg font-semibold">Create Event</Text>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Text className="mb-1 font-medium">Event Kind:</Text>
|
||||
<View className="flex-row gap-2 mb-4">
|
||||
<Button
|
||||
variant={eventKind === EVENT_KIND_EXERCISE ? "default" : "outline"}
|
||||
onPress={() => setEventKind(EVENT_KIND_EXERCISE)}
|
||||
size="sm"
|
||||
>
|
||||
<Text className={eventKind === EVENT_KIND_EXERCISE ? "text-white" : ""}>Exercise</Text>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={eventKind === EVENT_KIND_WORKOUT_TEMPLATE ? "default" : "outline"}
|
||||
onPress={() => setEventKind(EVENT_KIND_WORKOUT_TEMPLATE)}
|
||||
size="sm"
|
||||
>
|
||||
<Text className={eventKind === EVENT_KIND_WORKOUT_TEMPLATE ? "text-white" : ""}>Template</Text>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={eventKind === EVENT_KIND_WORKOUT_RECORD ? "default" : "outline"}
|
||||
onPress={() => setEventKind(EVENT_KIND_WORKOUT_RECORD)}
|
||||
size="sm"
|
||||
>
|
||||
<Text className={eventKind === EVENT_KIND_WORKOUT_RECORD ? "text-white" : ""}>Workout</Text>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<Text className="mb-1 font-medium">Content:</Text>
|
||||
<TextInput
|
||||
value={eventContent}
|
||||
onChangeText={setEventContent}
|
||||
placeholder="Event content"
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
className="border border-gray-300 dark:border-gray-700 rounded-md p-2 mb-4 min-h-24"
|
||||
/>
|
||||
|
||||
<View className="flex-row gap-4">
|
||||
<Button
|
||||
onPress={publishEvent}
|
||||
disabled={!connected || loading}
|
||||
className="flex-1"
|
||||
>
|
||||
{loading ? (
|
||||
<><ActivityIndicator size="small" color="#fff" /><Text className="text-white ml-2">Publishing...</Text></>
|
||||
) : (
|
||||
<Text className="text-white">Publish Event</Text>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onPress={queryEvents}
|
||||
disabled={!connected || loading}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
<Text>Query Events</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Event List */}
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex-row items-center gap-2">
|
||||
<Database size={20} className="text-foreground" />
|
||||
<Text className="text-lg font-semibold">Recent Events</Text>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{loading && events.length === 0 ? (
|
||||
<View className="items-center justify-center p-4">
|
||||
<ActivityIndicator size="large" />
|
||||
<Text className="mt-2">Loading events...</Text>
|
||||
</View>
|
||||
) : events.length === 0 ? (
|
||||
<View className="items-center justify-center p-4">
|
||||
<Text className="text-muted-foreground">No events yet</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView className="p-4" style={{ maxHeight: 200 }}>
|
||||
{events.map((event, index) => (
|
||||
<View key={event.id || index} className="mb-4">
|
||||
<Text className="font-bold">
|
||||
Kind: {event.kind} | Created: {new Date(event.created_at * 1000).toLocaleString()}
|
||||
</Text>
|
||||
<Text className="mb-1">ID: {event.id}</Text>
|
||||
|
||||
{/* Display tags */}
|
||||
{event.tags && event.tags.length > 0 && (
|
||||
<View className="mb-1">
|
||||
<Text className="font-medium">Tags:</Text>
|
||||
{event.tags.map((tag, tagIndex) => (
|
||||
<Text key={tagIndex} className="ml-2">
|
||||
{tag.join(', ')}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text className="font-medium">Content:</Text>
|
||||
<Text className="ml-2 mb-2">{event.content}</Text>
|
||||
|
||||
{index < events.length - 1 && <Separator className="my-2" />}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Event JSON Viewer */}
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex-row items-center gap-2">
|
||||
<FileJson size={20} className="text-foreground" />
|
||||
<Text className="text-lg font-semibold">Event Details</Text>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{events.length > 0 ? (
|
||||
<View>
|
||||
<Text className="font-medium mb-2">Selected Event (Latest):</Text>
|
||||
<ScrollView
|
||||
className="border border-border p-2 rounded-md"
|
||||
style={{ maxHeight: 200 }}
|
||||
>
|
||||
<Text className="font-mono">
|
||||
{JSON.stringify(events[0], null, 2)}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
) : (
|
||||
<Text className="text-muted-foreground">No events to display</Text>
|
||||
)}
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* How To Use Guide */}
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex-row items-center gap-2">
|
||||
<AlertCircle size={20} className="text-foreground" />
|
||||
<Text className="text-lg font-semibold">Testing Guide</Text>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Text className="font-medium mb-2">How to test Nostr integration:</Text>
|
||||
<View className="space-y-2">
|
||||
<Text>1. Start local strfry relay using:</Text>
|
||||
<Text className="ml-4 font-mono bg-muted p-2 rounded">./strfry relay</Text>
|
||||
<Text>2. Connect to the relay (ws://localhost:7777)</Text>
|
||||
<Text>3. Generate or enter Nostr keys</Text>
|
||||
<Text>4. Create and publish test events</Text>
|
||||
<Text>5. Query for existing events</Text>
|
||||
<Text className="mt-2 text-muted-foreground">For details, see the Nostr Integration Testing Guide</Text>
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
@ -19,13 +19,13 @@ export default function SocialLayout() {
|
||||
<Header useLogo={true} />
|
||||
|
||||
<Tab.Navigator
|
||||
initialRouteName="following"
|
||||
initialRouteName="powr"
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: theme.colors.tabIndicator,
|
||||
tabBarInactiveTintColor: theme.colors.tabInactive,
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 14,
|
||||
textTransform: 'capitalize',
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
tabBarIndicatorStyle: {
|
||||
|
@ -267,7 +267,7 @@ export default function CreateWorkoutScreen() {
|
||||
className="mb-6 overflow-hidden border border-border bg-card"
|
||||
>
|
||||
{/* Exercise Header */}
|
||||
<View className="flex-row justify-between items-center px-4 py-3 border-b border-border">
|
||||
<View className="flex-row justify-between items-center px-4 py-1 border-b border-border">
|
||||
<Text className="text-lg font-semibold text-[#8B5CF6]">
|
||||
{exercise.title}
|
||||
</Text>
|
||||
@ -284,19 +284,19 @@ export default function CreateWorkoutScreen() {
|
||||
</View>
|
||||
|
||||
{/* Sets Info */}
|
||||
<View className="px-4 py-2">
|
||||
<View className="px-4 py-1">
|
||||
<Text className="text-sm text-muted-foreground">
|
||||
{exercise.sets.filter(s => s.isCompleted).length} sets completed
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Set Headers */}
|
||||
<View className="flex-row px-4 py-2 border-t border-border bg-muted/30">
|
||||
<Text className="w-16 text-sm font-medium text-muted-foreground">SET</Text>
|
||||
<Text className="w-20 text-sm font-medium text-muted-foreground">PREV</Text>
|
||||
<View className="flex-row px-4 py-1 border-t border-border bg-muted/30">
|
||||
<Text className="w-8 text-sm font-medium text-muted-foreground text-center">SET</Text>
|
||||
<Text className="w-20 text-sm font-medium text-muted-foreground text-center">PREV</Text>
|
||||
<Text className="flex-1 text-sm font-medium text-center text-muted-foreground">KG</Text>
|
||||
<Text className="flex-1 text-sm font-medium text-center text-muted-foreground">REPS</Text>
|
||||
<View style={{ width: 44 }} />
|
||||
<View style={{ width: 44 }} /> {/* Space for the checkmark/complete button */}
|
||||
</View>
|
||||
|
||||
{/* Exercise Sets */}
|
||||
@ -322,7 +322,7 @@ export default function CreateWorkoutScreen() {
|
||||
{/* Add Set Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex-row justify-center items-center py-3 border-t border-border"
|
||||
className="flex-row justify-center items-center py-2 border-t border-border"
|
||||
onPress={() => handleAddSet(exerciseIndex)}
|
||||
>
|
||||
<Plus size={18} className="text-foreground mr-2" />
|
||||
@ -386,7 +386,9 @@ export default function CreateWorkoutScreen() {
|
||||
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Cancel Workout</AlertDialogTitle>
|
||||
<AlertDialogTitle>
|
||||
<Text>Cancel Workout</Text>
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<Text>Are you sure you want to cancel this workout? All progress will be lost.</Text>
|
||||
</AlertDialogDescription>
|
||||
|
@ -18,14 +18,14 @@ export default function HomeWorkout({ onStartBlank, onSelectTemplate }: HomeWork
|
||||
</CardHeader>
|
||||
<CardContent className="flex-col gap-4">
|
||||
<Button
|
||||
variant="purple"
|
||||
size="lg"
|
||||
className="w-full flex-row items-center justify-center gap-2"
|
||||
onPress={onStartBlank}
|
||||
>
|
||||
<Play className="h-5 w-5" />
|
||||
<Text className="text-primary-foreground">Quick Start</Text>
|
||||
<Play className="h-5 w-5" color="white" />
|
||||
<Text className="text-white">Quick Start</Text>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
|
@ -2,7 +2,7 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { View, TextInput, TouchableOpacity } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Feather } from '@expo/vector-icons';
|
||||
import { Circle, CheckCircle } from 'lucide-react-native'; // Lucide React icons
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useWorkoutStore } from '@/stores/workoutStore';
|
||||
import { useColorScheme } from '@/lib/useColorScheme';
|
||||
@ -90,7 +90,6 @@ export default function SetInput({
|
||||
}, [previousSet]);
|
||||
|
||||
// Get the appropriate colors based on theme variables
|
||||
// Using the --purple and --muted-foreground from your theme
|
||||
const purpleColor = 'hsl(261, 90%, 66%)'; // --purple from your constants
|
||||
const mutedForegroundColor = isDarkColorScheme
|
||||
? 'hsl(240, 5%, 64.9%)' // --muted-foreground dark
|
||||
@ -98,11 +97,11 @@ export default function SetInput({
|
||||
|
||||
return (
|
||||
<View className={cn(
|
||||
"flex-row items-center px-4 py-2 border-b border-border",
|
||||
"flex-row items-center px-4 py-1 border-b border-border",
|
||||
isCompleted && "bg-primary/5"
|
||||
)}>
|
||||
{/* Set Number */}
|
||||
<Text className="w-8 text-sm font-medium text-muted-foreground">
|
||||
<Text className="w-8 text-sm font-medium text-muted-foreground text-center">
|
||||
{setNumber}
|
||||
</Text>
|
||||
|
||||
@ -119,7 +118,7 @@ export default function SetInput({
|
||||
>
|
||||
<TextInput
|
||||
className={cn(
|
||||
"h-10 px-3 rounded-md text-center text-foreground",
|
||||
"h-8 px-3 rounded-md text-center text-foreground",
|
||||
"bg-secondary border border-border",
|
||||
isCompleted && "bg-primary/10 border-primary/20"
|
||||
)}
|
||||
@ -141,7 +140,7 @@ export default function SetInput({
|
||||
>
|
||||
<TextInput
|
||||
className={cn(
|
||||
"h-10 px-3 rounded-md text-center text-foreground",
|
||||
"h-8 px-3 rounded-md text-center text-foreground",
|
||||
"bg-secondary border border-border",
|
||||
isCompleted && "bg-primary/10 border-primary/20"
|
||||
)}
|
||||
@ -155,16 +154,23 @@ export default function SetInput({
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Complete Button using Feather icons with appropriate theme colors */}
|
||||
{/* Complete Button using Lucide React icons - without fill */}
|
||||
<TouchableOpacity
|
||||
className="w-10 h-10 items-center justify-center"
|
||||
onPress={handleCompleteSet}
|
||||
>
|
||||
<Feather
|
||||
name={isCompleted ? "check-circle" : "circle"}
|
||||
size={24}
|
||||
color={isCompleted ? purpleColor : mutedForegroundColor}
|
||||
/>
|
||||
{isCompleted ? (
|
||||
<CheckCircle
|
||||
size={24}
|
||||
color={purpleColor}
|
||||
// Removed fill and fillOpacity properties
|
||||
/>
|
||||
) : (
|
||||
<Circle
|
||||
size={24}
|
||||
color={mutedForegroundColor}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
|
960
docs/design/cache-management.md
Normal file
960
docs/design/cache-management.md
Normal file
@ -0,0 +1,960 @@
|
||||
# POWR App: Cache Management Implementation Guide
|
||||
|
||||
This document outlines the implementation of cache management features in the POWR fitness app, including data synchronization options and cache clearing functions.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The cache management system will allow users to:
|
||||
1. Sync their library data from Nostr on demand
|
||||
2. Clear different levels of cached data
|
||||
3. View storage usage information
|
||||
4. Configure automatic sync behavior
|
||||
|
||||
## 2. Data Services Implementation
|
||||
|
||||
### 2.1 CacheService Class
|
||||
|
||||
Create a new service to handle cache management operations:
|
||||
|
||||
```typescript
|
||||
// lib/services/CacheService.ts
|
||||
import { SQLiteDatabase } from 'expo-sqlite';
|
||||
import { schema } from '@/lib/db/schema';
|
||||
|
||||
export enum CacheClearLevel {
|
||||
RELAY_CACHE = 'relay_cache', // Just temporary relay data
|
||||
NETWORK_CONTENT = 'network', // Other users' content
|
||||
EVERYTHING = 'everything' // Reset the entire database (except user credentials)
|
||||
}
|
||||
|
||||
export class CacheService {
|
||||
private db: SQLiteDatabase;
|
||||
|
||||
constructor(db: SQLiteDatabase) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage usage statistics by category
|
||||
*/
|
||||
async getStorageStats(): Promise<{
|
||||
userContent: number; // bytes used by user's content
|
||||
networkContent: number; // bytes used by other users' content
|
||||
temporaryCache: number; // bytes used by temporary cache
|
||||
total: number; // total bytes used
|
||||
}> {
|
||||
// Implementation to calculate database size by category
|
||||
// This is a placeholder - actual implementation would depend on platform-specific APIs
|
||||
|
||||
// For SQLite, you'd typically query the page_count and page_size
|
||||
// from sqlite_master to estimate database size
|
||||
try {
|
||||
const dbSize = await this.db.getFirstAsync<{ size: number }>(
|
||||
"SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()"
|
||||
);
|
||||
|
||||
// For a more detailed breakdown, you'd need to query each table size
|
||||
// This is simplified
|
||||
const userContentSize = dbSize?.size ? Math.floor(dbSize.size * 0.4) : 0;
|
||||
const networkContentSize = dbSize?.size ? Math.floor(dbSize.size * 0.4) : 0;
|
||||
const tempCacheSize = dbSize?.size ? Math.floor(dbSize.size * 0.2) : 0;
|
||||
|
||||
return {
|
||||
userContent: userContentSize,
|
||||
networkContent: networkContentSize,
|
||||
temporaryCache: tempCacheSize,
|
||||
total: dbSize?.size || 0
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting storage stats:', error);
|
||||
return {
|
||||
userContent: 0,
|
||||
networkContent: 0,
|
||||
temporaryCache: 0,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears cache based on the specified level
|
||||
*/
|
||||
async clearCache(level: CacheClearLevel, currentUserPubkey?: string): Promise<void> {
|
||||
switch(level) {
|
||||
case CacheClearLevel.RELAY_CACHE:
|
||||
// Clear temporary relay cache but keep all local content
|
||||
await this.clearRelayCache();
|
||||
break;
|
||||
|
||||
case CacheClearLevel.NETWORK_CONTENT:
|
||||
// Clear other users' content but keep user's own content
|
||||
if (!currentUserPubkey) throw new Error('User pubkey required for this operation');
|
||||
await this.clearNetworkContent(currentUserPubkey);
|
||||
break;
|
||||
|
||||
case CacheClearLevel.EVERYTHING:
|
||||
// Reset everything except user credentials
|
||||
await this.resetDatabase();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears only temporary cache entries
|
||||
*/
|
||||
private async clearRelayCache(): Promise<void> {
|
||||
await this.db.withTransactionAsync(async () => {
|
||||
// Clear cache_metadata table
|
||||
await this.db.runAsync('DELETE FROM cache_metadata');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears network content from other users
|
||||
*/
|
||||
private async clearNetworkContent(userPubkey: string): Promise<void> {
|
||||
await this.db.withTransactionAsync(async () => {
|
||||
// Delete events from other users
|
||||
await this.db.runAsync(
|
||||
'DELETE FROM nostr_events WHERE pubkey != ?',
|
||||
[userPubkey]
|
||||
);
|
||||
|
||||
// Delete references to those events
|
||||
await this.db.runAsync(
|
||||
`DELETE FROM event_tags
|
||||
WHERE event_id NOT IN (
|
||||
SELECT id FROM nostr_events
|
||||
)`
|
||||
);
|
||||
|
||||
// Delete exercises that reference deleted events
|
||||
await this.db.runAsync(
|
||||
`DELETE FROM exercises
|
||||
WHERE source = 'nostr'
|
||||
AND nostr_event_id NOT IN (
|
||||
SELECT id FROM nostr_events
|
||||
)`
|
||||
);
|
||||
|
||||
// Delete tags for those exercises
|
||||
await this.db.runAsync(
|
||||
`DELETE FROM exercise_tags
|
||||
WHERE exercise_id NOT IN (
|
||||
SELECT id FROM exercises
|
||||
)`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the entire database but preserves user credentials
|
||||
*/
|
||||
private async resetDatabase(): Promise<void> {
|
||||
// Save user credentials before reset
|
||||
const userProfiles = await this.db.getAllAsync(
|
||||
'SELECT * FROM user_profiles'
|
||||
);
|
||||
|
||||
const userRelays = await this.db.getAllAsync(
|
||||
'SELECT * FROM user_relays'
|
||||
);
|
||||
|
||||
// Reset schema (keeping user credentials)
|
||||
await this.db.withTransactionAsync(async () => {
|
||||
// Drop all content tables
|
||||
await this.db.execAsync('DROP TABLE IF EXISTS exercises');
|
||||
await this.db.execAsync('DROP TABLE IF EXISTS exercise_tags');
|
||||
await this.db.execAsync('DROP TABLE IF EXISTS nostr_events');
|
||||
await this.db.execAsync('DROP TABLE IF EXISTS event_tags');
|
||||
await this.db.execAsync('DROP TABLE IF EXISTS cache_metadata');
|
||||
|
||||
// Recreate schema
|
||||
await schema.createTables(this.db);
|
||||
});
|
||||
|
||||
// Restore user profiles and relays
|
||||
if (userProfiles.length > 0) {
|
||||
await this.db.withTransactionAsync(async () => {
|
||||
for (const profile of userProfiles) {
|
||||
await this.db.runAsync(
|
||||
`INSERT INTO user_profiles (
|
||||
pubkey, name, display_name, about, website,
|
||||
picture, nip05, lud16, last_updated
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
profile.pubkey,
|
||||
profile.name,
|
||||
profile.display_name,
|
||||
profile.about,
|
||||
profile.website,
|
||||
profile.picture,
|
||||
profile.nip05,
|
||||
profile.lud16,
|
||||
profile.last_updated
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
for (const relay of userRelays) {
|
||||
await this.db.runAsync(
|
||||
`INSERT INTO user_relays (
|
||||
pubkey, relay_url, read, write, created_at
|
||||
) VALUES (?, ?, ?, ?, ?)`,
|
||||
[
|
||||
relay.pubkey,
|
||||
relay.relay_url,
|
||||
relay.read,
|
||||
relay.write,
|
||||
relay.created_at
|
||||
]
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 NostrSyncService Class
|
||||
|
||||
Create a service for syncing content from Nostr:
|
||||
|
||||
```typescript
|
||||
// lib/services/NostrSyncService.ts
|
||||
import { SQLiteDatabase } from 'expo-sqlite';
|
||||
import { EventCache } from '@/lib/db/services/EventCache';
|
||||
import { ExerciseService } from '@/lib/db/services/ExerciseService';
|
||||
import { NostrEvent } from '@/types/nostr';
|
||||
import { convertNostrToExercise } from '@/utils/converters';
|
||||
|
||||
export interface SyncProgress {
|
||||
total: number;
|
||||
processed: number;
|
||||
status: 'idle' | 'syncing' | 'complete' | 'error';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export class NostrSyncService {
|
||||
private db: SQLiteDatabase;
|
||||
private eventCache: EventCache;
|
||||
private exerciseService: ExerciseService;
|
||||
private syncStatus: SyncProgress = {
|
||||
total: 0,
|
||||
processed: 0,
|
||||
status: 'idle'
|
||||
};
|
||||
|
||||
constructor(db: SQLiteDatabase) {
|
||||
this.db = db;
|
||||
this.eventCache = new EventCache(db);
|
||||
this.exerciseService = new ExerciseService(db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current sync status
|
||||
*/
|
||||
getSyncStatus(): SyncProgress {
|
||||
return { ...this.syncStatus };
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize user's library from Nostr
|
||||
*/
|
||||
async syncUserLibrary(
|
||||
pubkey: string,
|
||||
ndk: any, // Replace with NDK type
|
||||
progressCallback?: (progress: SyncProgress) => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.syncStatus = {
|
||||
total: 0,
|
||||
processed: 0,
|
||||
status: 'syncing',
|
||||
message: 'Starting sync...'
|
||||
};
|
||||
|
||||
if (progressCallback) progressCallback(this.syncStatus);
|
||||
|
||||
// 1. Fetch exercise events (kind 33401)
|
||||
this.syncStatus.message = 'Fetching exercises...';
|
||||
if (progressCallback) progressCallback(this.syncStatus);
|
||||
|
||||
const exercises = await this.fetchUserExercises(pubkey, ndk);
|
||||
|
||||
this.syncStatus.total = exercises.length;
|
||||
this.syncStatus.message = `Processing ${exercises.length} exercises...`;
|
||||
if (progressCallback) progressCallback(this.syncStatus);
|
||||
|
||||
// 2. Process each exercise
|
||||
for (const exercise of exercises) {
|
||||
await this.processExercise(exercise);
|
||||
this.syncStatus.processed++;
|
||||
|
||||
if (progressCallback) progressCallback(this.syncStatus);
|
||||
}
|
||||
|
||||
// 3. Update final status
|
||||
this.syncStatus.status = 'complete';
|
||||
this.syncStatus.message = 'Sync completed successfully';
|
||||
if (progressCallback) progressCallback(this.syncStatus);
|
||||
|
||||
} catch (error) {
|
||||
this.syncStatus.status = 'error';
|
||||
this.syncStatus.message = `Sync error: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
if (progressCallback) progressCallback(this.syncStatus);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user's exercise events from Nostr
|
||||
*/
|
||||
private async fetchUserExercises(pubkey: string, ndk: any): Promise<NostrEvent[]> {
|
||||
// Use NDK subscription to fetch exercise events (kind 33401)
|
||||
return new Promise((resolve) => {
|
||||
const exercises: NostrEvent[] = [];
|
||||
const filter = { kinds: [33401], authors: [pubkey] };
|
||||
const subscription = ndk.subscribe(filter);
|
||||
|
||||
subscription.on('event', (event: NostrEvent) => {
|
||||
exercises.push(event);
|
||||
});
|
||||
|
||||
subscription.on('eose', () => {
|
||||
resolve(exercises);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process and store an exercise event
|
||||
*/
|
||||
private async processExercise(event: NostrEvent): Promise<void> {
|
||||
// 1. Check if we already have this event
|
||||
const existingEvent = await this.eventCache.getEvent(event.id);
|
||||
if (existingEvent) return;
|
||||
|
||||
// 2. Store the event
|
||||
await this.eventCache.setEvent(event);
|
||||
|
||||
// 3. Convert to Exercise and store in exercises table
|
||||
const exercise = convertNostrToExercise(event);
|
||||
await this.exerciseService.createExercise(exercise);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. UI Components
|
||||
|
||||
### 3.1 Modify SettingsDrawer.tsx
|
||||
|
||||
Update the existing SettingsDrawer component to include the new cache-related menu items:
|
||||
|
||||
```typescript
|
||||
// Add these imports
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { CacheService, CacheClearLevel } from '@/lib/services/CacheService';
|
||||
import { NostrSyncService } from '@/lib/services/NostrSyncService';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
|
||||
// Update the menuItems array to include Data Management options:
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
// ... existing menu items
|
||||
|
||||
// Replace the "Data Sync" item with this:
|
||||
{
|
||||
id: 'data-management',
|
||||
icon: Database,
|
||||
label: 'Data Management',
|
||||
onPress: () => {
|
||||
closeDrawer();
|
||||
router.push('/settings/data-management');
|
||||
},
|
||||
},
|
||||
|
||||
// ... other menu items
|
||||
];
|
||||
```
|
||||
|
||||
### 3.2 Create DataManagementScreen Component
|
||||
|
||||
Create a new screen for data management:
|
||||
|
||||
```typescript
|
||||
// app/settings/data-management.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, ScrollView, ActivityIndicator } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { useNDK, useNDKCurrentUser } from '@/lib/hooks/useNDK';
|
||||
import { CacheService, CacheClearLevel } from '@/lib/services/CacheService';
|
||||
import { NostrSyncService, SyncProgress } from '@/lib/services/NostrSyncService';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import {
|
||||
RefreshCw, Trash2, Database, AlertTriangle, CheckCircle, AlertCircle
|
||||
} from 'lucide-react-native';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
export default function DataManagementScreen() {
|
||||
const db = useSQLiteContext();
|
||||
const { ndk } = useNDK();
|
||||
const { currentUser, isAuthenticated } = useNDKCurrentUser();
|
||||
|
||||
const [storageStats, setStorageStats] = useState({
|
||||
userContent: 0,
|
||||
networkContent: 0,
|
||||
temporaryCache: 0,
|
||||
total: 0
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncProgress, setSyncProgress] = useState<SyncProgress>({
|
||||
total: 0,
|
||||
processed: 0,
|
||||
status: 'idle'
|
||||
});
|
||||
|
||||
const [showClearCacheAlert, setShowClearCacheAlert] = useState(false);
|
||||
const [clearCacheLevel, setClearCacheLevel] = useState<CacheClearLevel>(CacheClearLevel.RELAY_CACHE);
|
||||
const [clearCacheLoading, setClearCacheLoading] = useState(false);
|
||||
|
||||
// Auto-sync settings
|
||||
const [autoSyncEnabled, setAutoSyncEnabled] = useState(true);
|
||||
|
||||
// Load storage stats
|
||||
useEffect(() => {
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const cacheService = new CacheService(db);
|
||||
const stats = await cacheService.getStorageStats();
|
||||
setStorageStats(stats);
|
||||
} catch (error) {
|
||||
console.error('Error loading storage stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadStats();
|
||||
}, [db]);
|
||||
|
||||
// Handle manual sync
|
||||
const handleSync = async () => {
|
||||
if (!isAuthenticated || !currentUser?.pubkey || !ndk) return;
|
||||
|
||||
try {
|
||||
setSyncing(true);
|
||||
|
||||
const syncService = new NostrSyncService(db);
|
||||
await syncService.syncUserLibrary(
|
||||
currentUser.pubkey,
|
||||
ndk,
|
||||
(progress) => {
|
||||
setSyncProgress(progress);
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Sync error:', error);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger clear cache alert
|
||||
const handleClearCacheClick = (level: CacheClearLevel) => {
|
||||
setClearCacheLevel(level);
|
||||
setShowClearCacheAlert(true);
|
||||
};
|
||||
|
||||
// Handle clear cache action
|
||||
const handleClearCache = async () => {
|
||||
if (!isAuthenticated && clearCacheLevel !== CacheClearLevel.RELAY_CACHE) {
|
||||
return; // Only allow clearing relay cache if not authenticated
|
||||
}
|
||||
|
||||
try {
|
||||
setClearCacheLoading(true);
|
||||
|
||||
const cacheService = new CacheService(db);
|
||||
await cacheService.clearCache(
|
||||
clearCacheLevel,
|
||||
isAuthenticated ? currentUser?.pubkey : undefined
|
||||
);
|
||||
|
||||
// Refresh stats
|
||||
const stats = await cacheService.getStorageStats();
|
||||
setStorageStats(stats);
|
||||
|
||||
setShowClearCacheAlert(false);
|
||||
} catch (error) {
|
||||
console.error('Error clearing cache:', error);
|
||||
} finally {
|
||||
setClearCacheLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate sync progress percentage
|
||||
const syncPercentage = syncProgress.total > 0
|
||||
? Math.round((syncProgress.processed / syncProgress.total) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-background p-4">
|
||||
<Text className="text-xl font-semibold mb-4">Data Management</Text>
|
||||
|
||||
{/* Storage Usage Section */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="pt-6">
|
||||
<View className="flex-row items-center mb-4">
|
||||
<Database size={20} className="text-primary mr-2" />
|
||||
<Text className="text-lg font-semibold">Storage Usage</Text>
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
<View className="items-center py-4">
|
||||
<ActivityIndicator size="small" className="mb-2" />
|
||||
<Text className="text-muted-foreground">Loading storage statistics...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View className="mb-4">
|
||||
<Text className="text-sm mb-1">User Content</Text>
|
||||
<View className="flex-row justify-between mb-2">
|
||||
<Progress value={storageStats.userContent / storageStats.total * 100} className="flex-1 mr-2" />
|
||||
<Text className="text-sm text-muted-foreground w-20 text-right">
|
||||
{formatBytes(storageStats.userContent)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text className="text-sm mb-1">Network Content</Text>
|
||||
<View className="flex-row justify-between mb-2">
|
||||
<Progress value={storageStats.networkContent / storageStats.total * 100} className="flex-1 mr-2" />
|
||||
<Text className="text-sm text-muted-foreground w-20 text-right">
|
||||
{formatBytes(storageStats.networkContent)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text className="text-sm mb-1">Temporary Cache</Text>
|
||||
<View className="flex-row justify-between mb-2">
|
||||
<Progress value={storageStats.temporaryCache / storageStats.total * 100} className="flex-1 mr-2" />
|
||||
<Text className="text-sm text-muted-foreground w-20 text-right">
|
||||
{formatBytes(storageStats.temporaryCache)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Separator className="mb-4" />
|
||||
|
||||
<View className="flex-row justify-between items-center">
|
||||
<Text className="font-medium">Total Storage</Text>
|
||||
<Text className="font-medium">{formatBytes(storageStats.total)}</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sync Section */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="pt-6">
|
||||
<View className="flex-row items-center mb-4">
|
||||
<RefreshCw size={20} className="text-primary mr-2" />
|
||||
<Text className="text-lg font-semibold">Sync</Text>
|
||||
</View>
|
||||
|
||||
{!isAuthenticated ? (
|
||||
<Text className="text-muted-foreground mb-4">
|
||||
Login with Nostr to sync your library across devices.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
{/* Auto-sync settings */}
|
||||
<View className="flex-row justify-between items-center mb-4">
|
||||
<View>
|
||||
<Text className="font-medium">Auto-sync on startup</Text>
|
||||
<Text className="text-sm text-muted-foreground">
|
||||
Automatically sync data when you open the app
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
checked={autoSyncEnabled}
|
||||
onCheckedChange={setAutoSyncEnabled}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Separator className="mb-4" />
|
||||
|
||||
{/* Sync status and controls */}
|
||||
{syncing ? (
|
||||
<View className="mb-4">
|
||||
<View className="flex-row justify-between mb-2">
|
||||
<Text className="text-sm">
|
||||
{syncProgress.message || 'Syncing...'}
|
||||
</Text>
|
||||
<Text className="text-sm text-muted-foreground">
|
||||
{syncProgress.processed}/{syncProgress.total}
|
||||
</Text>
|
||||
</View>
|
||||
<Progress value={syncPercentage} className="mb-2" />
|
||||
<Text className="text-xs text-muted-foreground text-center">
|
||||
{syncPercentage}% complete
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{syncProgress.status === 'complete' && (
|
||||
<View className="flex-row items-center mb-4">
|
||||
<CheckCircle size={16} className="text-primary mr-2" />
|
||||
<Text className="text-sm">Last sync completed successfully</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{syncProgress.status === 'error' && (
|
||||
<View className="flex-row items-center mb-4">
|
||||
<AlertCircle size={16} className="text-destructive mr-2" />
|
||||
<Text className="text-sm">{syncProgress.message}</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onPress={handleSync}
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing ? (
|
||||
<>
|
||||
<ActivityIndicator size="small" color="#fff" className="mr-2" />
|
||||
<Text className="text-primary-foreground">Syncing...</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw size={16} className="mr-2 text-primary-foreground" />
|
||||
<Text className="text-primary-foreground">Sync Now</Text>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cache Section */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="pt-6">
|
||||
<View className="flex-row items-center mb-4">
|
||||
<Trash2 size={20} className="text-primary mr-2" />
|
||||
<Text className="text-lg font-semibold">Clear Cache</Text>
|
||||
</View>
|
||||
|
||||
<View className="space-y-4">
|
||||
<View>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mb-2"
|
||||
onPress={() => handleClearCacheClick(CacheClearLevel.RELAY_CACHE)}
|
||||
>
|
||||
<Text>Clear Temporary Cache</Text>
|
||||
</Button>
|
||||
<Text className="text-xs text-muted-foreground">
|
||||
Clears temporary data without affecting your workouts, exercises, or templates.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mb-2"
|
||||
onPress={() => handleClearCacheClick(CacheClearLevel.NETWORK_CONTENT)}
|
||||
disabled={!isAuthenticated}
|
||||
>
|
||||
<Text>Clear Network Content</Text>
|
||||
</Button>
|
||||
<Text className="text-xs text-muted-foreground">
|
||||
Clears exercises and templates from other users while keeping your own content.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full mb-2"
|
||||
onPress={() => handleClearCacheClick(CacheClearLevel.EVERYTHING)}
|
||||
disabled={!isAuthenticated}
|
||||
>
|
||||
<Text className="text-destructive-foreground">Reset All Data</Text>
|
||||
</Button>
|
||||
<Text className="text-xs text-muted-foreground">
|
||||
Warning: This will delete ALL your local data. Your Nostr identity will be preserved,
|
||||
but you'll need to re-sync your library from the network.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Clear Cache Alert Dialog */}
|
||||
<AlertDialog open={showClearCacheAlert} onOpenChange={setShowClearCacheAlert}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{clearCacheLevel === CacheClearLevel.RELAY_CACHE && "Clear Temporary Cache?"}
|
||||
{clearCacheLevel === CacheClearLevel.NETWORK_CONTENT && "Clear Network Content?"}
|
||||
{clearCacheLevel === CacheClearLevel.EVERYTHING && "Reset All Data?"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{clearCacheLevel === CacheClearLevel.RELAY_CACHE && (
|
||||
<Text>
|
||||
This will clear temporary data from the app. Your workouts, exercises, and templates will not be affected.
|
||||
</Text>
|
||||
)}
|
||||
{clearCacheLevel === CacheClearLevel.NETWORK_CONTENT && (
|
||||
<Text>
|
||||
This will clear exercises and templates from other users. Your own content will be preserved.
|
||||
</Text>
|
||||
)}
|
||||
{clearCacheLevel === CacheClearLevel.EVERYTHING && (
|
||||
<View className="space-y-2">
|
||||
<View className="flex-row items-center">
|
||||
<AlertTriangle size={16} className="text-destructive mr-2" />
|
||||
<Text className="text-destructive font-semibold">Warning: This is destructive!</Text>
|
||||
</View>
|
||||
<Text>
|
||||
This will delete ALL your local data. Your Nostr identity will be preserved,
|
||||
but you'll need to re-sync your library from the network.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onPress={() => setShowClearCacheAlert(false)}>
|
||||
<Text>Cancel</Text>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onPress={handleClearCache}
|
||||
className={clearCacheLevel === CacheClearLevel.EVERYTHING ? "bg-destructive" : ""}
|
||||
>
|
||||
<Text className={clearCacheLevel === CacheClearLevel.EVERYTHING ? "text-destructive-foreground" : ""}>
|
||||
{clearCacheLoading ? "Clearing..." : "Clear"}
|
||||
</Text>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Add Formatting Utility
|
||||
|
||||
Create a utility function to format byte sizes:
|
||||
|
||||
```typescript
|
||||
// utils/format.ts
|
||||
|
||||
/**
|
||||
* Format bytes to a human-readable string (KB, MB, etc.)
|
||||
*/
|
||||
export function formatBytes(bytes: number, decimals: number = 2): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 Add Progress Component
|
||||
|
||||
If you don't have a Progress component yet, create one:
|
||||
|
||||
```typescript
|
||||
// components/ui/progress.tsx
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ProgressProps {
|
||||
value?: number;
|
||||
max?: number;
|
||||
className?: string;
|
||||
indicatorClassName?: string;
|
||||
}
|
||||
|
||||
export function Progress({
|
||||
value = 0,
|
||||
max = 100,
|
||||
className,
|
||||
indicatorClassName,
|
||||
...props
|
||||
}: ProgressProps) {
|
||||
const theme = useTheme();
|
||||
const percentage = Math.min(Math.max(0, (value / max) * 100), 100);
|
||||
|
||||
return (
|
||||
<View
|
||||
className={cn("h-2 w-full overflow-hidden rounded-full bg-secondary", className)}
|
||||
{...props}
|
||||
>
|
||||
<View
|
||||
className={cn("h-full bg-primary", indicatorClassName)}
|
||||
style={[
|
||||
styles.indicator,
|
||||
{ width: `${percentage}%` }
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
indicator: {
|
||||
transition: 'width 0.2s ease-in-out',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## 4. Implementation Steps
|
||||
|
||||
### 4.1 Database Modifications
|
||||
|
||||
1. Ensure your schema has the necessary tables:
|
||||
- `nostr_events` - for storing raw Nostr events
|
||||
- `event_tags` - for storing event tags
|
||||
- `cache_metadata` - for tracking cache item usage
|
||||
|
||||
2. Add cache-related columns to existing tables:
|
||||
- Add `source` to exercises table (if not already present)
|
||||
- Add `last_accessed` timestamp where relevant
|
||||
|
||||
### 4.2 Implement Services
|
||||
|
||||
1. Create `CacheService.ts` with methods for:
|
||||
- Getting storage statistics
|
||||
- Clearing different levels of cache
|
||||
- Resetting database
|
||||
|
||||
2. Create `NostrSyncService.ts` with methods for:
|
||||
- Syncing user's library from Nostr
|
||||
- Tracking sync progress
|
||||
- Processing different types of Nostr events
|
||||
|
||||
### 4.3 Add UI Components
|
||||
|
||||
1. Update `SettingsDrawer.tsx` to include a "Data Management" option
|
||||
2. Create `/settings/data-management.tsx` screen with:
|
||||
- Storage usage visualization
|
||||
- Sync controls
|
||||
- Cache clearing options
|
||||
|
||||
3. Create supporting components:
|
||||
- Progress bar
|
||||
- Alert dialogs for confirming destructive actions
|
||||
|
||||
### 4.4 Integration with NDK
|
||||
|
||||
1. Update the login flow to trigger library sync after successful login
|
||||
2. Implement background sync based on user preferences
|
||||
3. Add event handling to track when new events come in from subscriptions
|
||||
|
||||
## 5. Testing Considerations
|
||||
|
||||
1. Test with both small and large datasets:
|
||||
- Create test accounts with varying amounts of data
|
||||
- Test sync and clear operations with hundreds or thousands of events
|
||||
|
||||
2. Test edge cases:
|
||||
- Network disconnections during sync
|
||||
- Interruptions during cache clearing
|
||||
- Database corruption recovery
|
||||
|
||||
3. Performance testing:
|
||||
- Measure sync time for different dataset sizes
|
||||
- Monitor memory usage during sync operations
|
||||
- Test on low-end devices to ensure performance is acceptable
|
||||
|
||||
4. Cross-platform testing:
|
||||
- Ensure SQLite operations work consistently on iOS, Android, and web
|
||||
- Test UI rendering on different screen sizes
|
||||
- Verify that progress indicators update correctly on all platforms
|
||||
|
||||
5. Data integrity testing:
|
||||
- Verify that user content is preserved after clearing network cache
|
||||
- Confirm that identity information persists after database reset
|
||||
- Test that synced data matches what's available on relays
|
||||
|
||||
## 6. User Experience Considerations
|
||||
|
||||
1. Feedback and transparency:
|
||||
- Always show clear feedback during long-running operations
|
||||
- Display last sync time and status
|
||||
- Make it obvious what will happen with each cache-clearing option
|
||||
|
||||
2. Error handling:
|
||||
- Provide clear error messages when sync fails
|
||||
- Offer retry options for failed operations
|
||||
- Include options to report sync issues
|
||||
|
||||
3. Progressive disclosure:
|
||||
- Hide advanced/dangerous options unless explicitly expanded
|
||||
- Use appropriate warning colors for destructive actions
|
||||
- Implement confirmation dialogs with clear explanations
|
||||
|
||||
4. Accessibility:
|
||||
- Ensure progress indicators have appropriate ARIA labels
|
||||
- Maintain adequate contrast for all text and UI elements
|
||||
- Support screen readers for all status updates
|
||||
|
||||
## 7. Future Enhancements
|
||||
|
||||
1. Selective sync:
|
||||
- Allow users to choose which content types to sync (exercises, templates, etc.)
|
||||
- Implement priority-based sync for most important content first
|
||||
|
||||
2. Smart caching:
|
||||
- Automatically prune rarely-used network content
|
||||
- Keep frequently accessed content even when clearing other cache
|
||||
|
||||
3. Backup and restore:
|
||||
- Add export/import functionality for local backup
|
||||
- Implement scheduled automatic backups
|
||||
|
||||
4. Advanced sync controls:
|
||||
- Allow selection of specific relays for sync operations
|
||||
- Implement bandwidth usage limits for sync
|
||||
|
||||
5. Conflict resolution:
|
||||
- Develop a UI for handling conflicts when the same event has different versions
|
||||
- Add options for manual content merging
|
||||
|
||||
## 8. Conclusion
|
||||
|
||||
This implementation provides a robust solution for managing cache and synchronization in the POWR fitness app. By giving users clear control over their data and implementing efficient sync mechanisms, the app can provide a better experience across devices while respecting user preferences and device constraints.
|
||||
|
||||
The approach keeps user data secure while allowing for flexible network content management, ensuring that the app remains responsive and efficient even as the user's library grows.
|
@ -1,4 +1,4 @@
|
||||
# NIP-XX: Workout Events
|
||||
# NIP-4e: Workout Events
|
||||
|
||||
`draft` `optional`
|
||||
|
||||
@ -25,52 +25,144 @@ The event kinds in this NIP follow Nostr protocol conventions:
|
||||
### Exercise Template (kind: 33401)
|
||||
Defines reusable exercise definitions. These should remain public to enable discovery and sharing. The `content` field contains detailed form instructions and notes.
|
||||
|
||||
#### Required Tags
|
||||
* `d` - UUID for template identification
|
||||
* `title` - Exercise name
|
||||
* `format` - Defines data structure for exercise tracking (possible parameters: `weight`, `reps`, `rpe`, `set_type`)
|
||||
* `format_units` - Defines units for each parameter (possible formats: "kg", "count", "0-10", "warmup|normal|drop|failure")
|
||||
* `equipment` - Equipment type (possible values: `barbell`, `dumbbell`, `bodyweight`, `machine`, `cardio`)
|
||||
#### Format
|
||||
|
||||
#### Optional Tags
|
||||
* `difficulty` - Skill level (possible values: `beginner`, `intermediate`, `advanced`)
|
||||
* `imeta` - Media metadata for form demonstrations following NIP-92 format
|
||||
* `t` - Hashtags for categorization such as muscle group or body movement (possible values: `chest`, `legs`, `push`, `pull`)
|
||||
The format uses an _addressable event_ of `kind:33401`.
|
||||
|
||||
The `.content` of these events SHOULD be detailed instructions for proper exercise form. It is required but can be an empty string.
|
||||
|
||||
The list of tags are as follows:
|
||||
|
||||
* `d` (required) - universally unique identifier (UUID). Generated by the client creating the exercise template.
|
||||
* `title` (required) - Exercise name
|
||||
* `format` (required) - Defines data structure for exercise tracking (possible parameters: `weight`, `reps`, `rpe`, `set_type`)
|
||||
* `format_units` (required) - Defines units for each parameter (possible formats: "kg", "count", "0-10", "warmup|normal|drop|failure")
|
||||
* `equipment` (required) - Equipment type (possible values: `barbell`, `dumbbell`, `bodyweight`, `machine`, `cardio`)
|
||||
* `difficulty` (optional) - Skill level (possible values: `beginner`, `intermediate`, `advanced`)
|
||||
* `imeta` (optional) - Media metadata for form demonstrations following NIP-92 format
|
||||
* `t` (optional, repeated) - Hashtags for categorization such as muscle group or body movement (possible values: `chest`, `legs`, `push`, `pull`)
|
||||
|
||||
```
|
||||
{
|
||||
"id": <32-bytes lowercase hex-encoded SHA-256 of the serialized event data>,
|
||||
"pubkey": <32-bytes lowercase hex-encoded public key of the event creator>,
|
||||
"created_at": <Unix timestamp in seconds>,
|
||||
"kind": 33401,
|
||||
"content": "<detailed form instructions and notes>",
|
||||
"tags": [
|
||||
["d", "<UUID>"],
|
||||
["title", "<exercise name>"],
|
||||
["format", "<parameter>", "<parameter>", "<parameter>", "<parameter>"],
|
||||
["format_units", "<unit>", "<unit>", "<unit>", "<unit>"],
|
||||
["equipment", "<equipment type>"],
|
||||
["difficulty", "<skill level>"],
|
||||
["imeta",
|
||||
"url <url to demonstration media>",
|
||||
"m <media type>",
|
||||
"dim <dimensions>",
|
||||
"alt <alt text>"
|
||||
],
|
||||
["t", "<hashtag>"],
|
||||
["t", "<hashtag>"],
|
||||
["t", "<hashtag>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Workout Template (kind: 33402)
|
||||
Defines a complete workout plan. The `content` field contains workout notes and instructions. Workout templates can prescribe specific parameters while leaving others configurable by the user performing the workout.
|
||||
|
||||
#### Required Tags
|
||||
* `d` - UUID for template identification
|
||||
* `title` - Workout name
|
||||
* `type` - Type of workout (possible values: `strength`, `circuit`, `emom`, `amrap`)
|
||||
* `exercise` - Exercise reference and prescription. Format: ["exercise", "kind:pubkey:d-tag", "relay-url", ...parameters matching exercise template format]
|
||||
#### Format
|
||||
|
||||
#### Optional Tags
|
||||
* `rounds` - Number of rounds for repeating formats
|
||||
* `duration` - Total workout duration in seconds
|
||||
* `interval` - Duration of each exercise portion in seconds (for timed workouts)
|
||||
* `rest_between_rounds` - Rest time between rounds in seconds
|
||||
* `t` - Hashtags for categorization
|
||||
The format uses an _addressable event_ of `kind:33402`.
|
||||
|
||||
The `.content` of these events SHOULD contain workout notes and instructions. It is required but can be an empty string.
|
||||
|
||||
The list of tags are as follows:
|
||||
|
||||
* `d` (required) - universally unique identifier (UUID). Generated by the client creating the workout template.
|
||||
* `title` (required) - Workout name
|
||||
* `type` (required) - Type of workout (possible values: `strength`, `circuit`, `emom`, `amrap`)
|
||||
* `exercise` (required, repeated) - Exercise reference and prescription. Format: ["exercise", "<kind>:<pubkey>:<d-tag>", "<relay-url>", ...parameters matching exercise template format]
|
||||
* `rounds` (optional) - Number of rounds for repeating formats
|
||||
* `duration` (optional) - Total workout duration in seconds
|
||||
* `interval` (optional) - Duration of each exercise portion in seconds (for timed workouts)
|
||||
* `rest_between_rounds` (optional) - Rest time between rounds in seconds
|
||||
* `t` (optional, repeated) - Hashtags for categorization
|
||||
|
||||
```
|
||||
{
|
||||
"id": <32-bytes lowercase hex-encoded SHA-256 of the serialized event data>,
|
||||
"pubkey": <32-bytes lowercase hex-encoded public key of the event creator>,
|
||||
"created_at": <Unix timestamp in seconds>,
|
||||
"kind": 33402,
|
||||
"content": "<workout notes and instructions>",
|
||||
"tags": [
|
||||
["d", "<UUID>"],
|
||||
["title", "<workout name>"],
|
||||
["type", "<workout type>"],
|
||||
["rounds", "<number of rounds>"],
|
||||
["duration", "<duration in seconds>"],
|
||||
["interval", "<interval in seconds>"],
|
||||
["rest_between_rounds", "<rest time in seconds>"],
|
||||
["exercise", "<kind>:<pubkey>:<d-tag>", "<relay-url>", "<param1>", "<param2>", "<param3>", "<param4>"],
|
||||
["exercise", "<kind>:<pubkey>:<d-tag>", "<relay-url>", "<param1>", "<param2>", "<param3>", "<param4>"],
|
||||
["t", "<hashtag>"],
|
||||
["t", "<hashtag>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Workout Record (kind: 1301)
|
||||
Records a completed workout session. The `content` field contains notes about the workout.
|
||||
|
||||
#### Required Tags
|
||||
* `d` - UUID for record identification
|
||||
* `title` - Workout name
|
||||
* `type` - Type of workout (possible values: `strength`, `circuit`, `emom`, `amrap`)
|
||||
* `exercise` - Exercise reference and completion data. Format: ["exercise", "kind:pubkey:d-tag", "relay-url", ...parameters matching exercise template format]
|
||||
* `start` - Unix timestamp in seconds for workout start
|
||||
* `end` - Unix timestamp in seconds for workout end
|
||||
* `completed` - Boolean indicating if workout was completed as planned
|
||||
#### Format
|
||||
|
||||
#### Optional Tags
|
||||
* `rounds_completed` - Number of rounds completed
|
||||
* `interval` - Duration of each exercise portion in seconds (for timed workouts)
|
||||
* `template` - Reference to the workout template used, if any. Format: ["template", "33402:<pubkey>:<d-tag>", "<relay-url>"]
|
||||
* `pr` - Personal Record achieved during workout. Format: "kind:pubkey:d-tag,metric,value". Used to track when a user achieves their best performance for a given exercise and metric (e.g., heaviest weight lifted, most reps completed, fastest time)
|
||||
* `t` - Hashtags for categorization
|
||||
The format uses a standard event of `kind:1301`.
|
||||
|
||||
The `.content` of these events SHOULD contain notes about the workout experience. It is required but can be an empty string.
|
||||
|
||||
The list of tags are as follows:
|
||||
|
||||
* `d` (required) - universally unique identifier (UUID). Generated by the client creating the workout record.
|
||||
* `title` (required) - Workout name
|
||||
* `type` (required) - Type of workout (possible values: `strength`, `circuit`, `emom`, `amrap`)
|
||||
* `exercise` (required, repeated) - Exercise reference and completion data. Format: ["exercise", "<kind>:<pubkey>:<d-tag>", "<relay-url>", ...parameters matching exercise template format]
|
||||
* `start` (required) - Unix timestamp in seconds for workout start
|
||||
* `end` (required) - Unix timestamp in seconds for workout end
|
||||
* `completed` (required) - Boolean indicating if workout was completed as planned
|
||||
* `rounds_completed` (optional) - Number of rounds completed
|
||||
* `interval` (optional) - Duration of each exercise portion in seconds (for timed workouts)
|
||||
* `template` (optional) - Reference to the workout template used, if any. Format: ["template", "<kind>:<pubkey>:<d-tag>", "<relay-url>"]
|
||||
* `pr` (optional, repeated) - Personal Record achieved during workout. Format: "<kind>:<pubkey>:<d-tag>,<metric>,<value>"
|
||||
* `t` (optional, repeated) - Hashtags for categorization
|
||||
|
||||
```
|
||||
{
|
||||
"id": <32-bytes lowercase hex-encoded SHA-256 of the serialized event data>,
|
||||
"pubkey": <32-bytes lowercase hex-encoded public key of the event creator>,
|
||||
"created_at": <Unix timestamp in seconds>,
|
||||
"kind": 1301,
|
||||
"content": "<workout notes>",
|
||||
"tags": [
|
||||
["d", "<UUID>"],
|
||||
["title", "<workout name>"],
|
||||
["type", "<workout type>"],
|
||||
["rounds_completed", "<number of rounds completed>"],
|
||||
["start", "<Unix timestamp in seconds>"],
|
||||
["end", "<Unix timestamp in seconds>"],
|
||||
|
||||
["exercise", "<kind>:<pubkey>:<d-tag>", "<relay-url>", "<weight>", "<reps>", "<rpe>", "<set_type>"],
|
||||
["exercise", "<kind>:<pubkey>:<d-tag>", "<relay-url>", "<weight>", "<reps>", "<rpe>", "<set_type>"],
|
||||
|
||||
["template", "<kind>:<pubkey>:<d-tag>", "<relay-url>"],
|
||||
["pr", "<kind>:<pubkey>:<d-tag>,<metric>,<value>"],
|
||||
["completed", "<true/false>"],
|
||||
["t", "<hashtag>"],
|
||||
["t", "<hashtag>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Exercise Parameters
|
||||
|
||||
@ -122,12 +214,12 @@ Sets where technical failure was reached before completing prescribed reps. Thes
|
||||
## Examples
|
||||
|
||||
### Exercise Template
|
||||
```json
|
||||
```
|
||||
{
|
||||
"kind": 33401,
|
||||
"content": "Stand with feet hip-width apart, barbell over midfoot. Hinge at hips, grip bar outside knees. Flatten back, brace core. Drive through floor, keeping bar close to legs.\n\nForm demonstration: https://powr.me/exercises/deadlift-demo.mp4",
|
||||
"tags": [
|
||||
["d", "bb-deadlift-template"],
|
||||
["d", "<UUID-deadlift>"],
|
||||
["title", "Barbell Deadlift"],
|
||||
["format", "weight", "reps", "rpe", "set_type"],
|
||||
["format_units", "kg", "count", "0-10", "warmup|normal|drop|failure"],
|
||||
@ -147,20 +239,20 @@ Sets where technical failure was reached before completing prescribed reps. Thes
|
||||
```
|
||||
|
||||
### EMOM Workout Template
|
||||
```json
|
||||
```
|
||||
{
|
||||
"kind": 33402,
|
||||
"content": "20 minute EMOM alternating between squats and deadlifts every 30 seconds. Scale weight as needed to complete all reps within each interval.",
|
||||
"tags": [
|
||||
["d", "lower-body-emom-template"],
|
||||
["d", "<UUID-emom-template>"],
|
||||
["title", "20min Squat/Deadlift EMOM"],
|
||||
["type", "emom"],
|
||||
["duration", "1200"],
|
||||
["rounds", "20"],
|
||||
["interval", "30"],
|
||||
|
||||
["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-back-squat-template", "wss://powr.me", "", "5", "7", "normal"],
|
||||
["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-deadlift-template", "wss://powr.me", "", "4", "7", "normal"],
|
||||
["exercise", "33401:<pubkey>:<UUID-squat>", "<relay-url>", "", "5", "7", "normal"],
|
||||
["exercise", "33401:<pubkey>:<UUID-deadlift>", "<relay-url>", "", "4", "7", "normal"],
|
||||
|
||||
["t", "conditioning"],
|
||||
["t", "legs"]
|
||||
@ -169,25 +261,23 @@ Sets where technical failure was reached before completing prescribed reps. Thes
|
||||
```
|
||||
|
||||
### Circuit Workout Record
|
||||
```json
|
||||
```
|
||||
{
|
||||
"kind": 1301,
|
||||
"content": "Completed first round as prescribed. Second round showed form deterioration on deadlifts.",
|
||||
"tags": [
|
||||
["d", "workout-20250128"],
|
||||
["d", "<UUID-workout-record>"],
|
||||
["title", "Leg Circuit"],
|
||||
["type", "circuit"],
|
||||
["rounds_completed", "1.5"],
|
||||
["start", "1706454000"],
|
||||
["end", "1706455800"],
|
||||
|
||||
// Round 1 - Completed as prescribed
|
||||
["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-back-squat-template", "wss://powr.me", "80", "12", "7", "normal"],
|
||||
["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-deadlift-template", "wss://powr.me", "100", "10", "7", "normal"],
|
||||
["exercise", "33401:<pubkey>:<UUID-squat>", "<relay-url>", "80", "12", "7", "normal"],
|
||||
["exercise", "33401:<pubkey>:<UUID-deadlift>", "<relay-url>", "100", "10", "7", "normal"],
|
||||
|
||||
// Round 2 - Failed on deadlifts
|
||||
["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-back-squat-template", "wss://powr.me", "80", "12", "8", "normal"],
|
||||
["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-deadlift-template", "wss://powr.me", "100", "4", "10", "failure"],
|
||||
["exercise", "33401:<pubkey>:<UUID-squat>", "<relay-url>", "80", "12", "8", "normal"],
|
||||
["exercise", "33401:<pubkey>:<UUID-deadlift>", "<relay-url>", "100", "4", "10", "failure"],
|
||||
|
||||
["completed", "false"],
|
||||
["t", "legs"]
|
||||
@ -197,16 +287,17 @@ Sets where technical failure was reached before completing prescribed reps. Thes
|
||||
|
||||
## Implementation Guidelines
|
||||
|
||||
1. All workout records SHOULD include accurate start and end times
|
||||
1. All workout records MUST include accurate start and end times
|
||||
2. Templates MAY prescribe specific parameters while leaving others as empty strings for user input
|
||||
3. Records MUST include actual values for all parameters defined in exercise format
|
||||
4. Failed sets SHOULD be marked with `failure` set_type
|
||||
5. Records SHOULD be marked as `false` for completed if prescribed work wasn't completed
|
||||
6. PRs SHOULD only be tracked in workout records, not templates
|
||||
7. Exercise references SHOULD use the format "kind:pubkey:d-tag" to ensure proper attribution and versioning
|
||||
7. Exercise references MUST use the format "kind:pubkey:d-tag" to ensure proper attribution and versioning
|
||||
|
||||
## References
|
||||
|
||||
This NIP draws inspiration from:
|
||||
- [NIP-01: Basic Protocol Flow Description](https://github.com/nostr-protocol/nips/blob/master/01.md)
|
||||
- [NIP-52: Calendar Events](https://github.com/nostr-protocol/nips/blob/master/52.md)
|
||||
- [NIP-92: Media Attachments](https://github.com/nostr-protocol/nips/blob/master/92.md#nip-92)
|
@ -9,10 +9,11 @@ const PRIVATE_KEY_STORAGE_KEY = 'nostr_privkey';
|
||||
|
||||
// Default relays
|
||||
const DEFAULT_RELAYS = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://purplepag.es',
|
||||
'wss://nos.lol'
|
||||
'ws://localhost:8080', // Add your local test relay
|
||||
//'wss://relay.damus.io',
|
||||
//'wss://relay.nostr.band',
|
||||
//'wss://purplepag.es',
|
||||
//'wss://nos.lol'
|
||||
];
|
||||
|
||||
// Helper function to convert Array/Uint8Array to hex string
|
||||
|
1
package-lock.json
generated
1
package-lock.json
generated
@ -10,6 +10,7 @@
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@expo/cli": "^0.22.16",
|
||||
"@noble/secp256k1": "^2.2.3",
|
||||
"@nostr-dev-kit/ndk": "^2.12.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@react-native-clipboard/clipboard": "^1.16.1",
|
||||
|
@ -24,6 +24,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/cli": "^0.22.16",
|
||||
"@noble/secp256k1": "^2.2.3",
|
||||
"@nostr-dev-kit/ndk": "^2.12.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@react-native-clipboard/clipboard": "^1.16.1",
|
||||
|
@ -1,32 +1,43 @@
|
||||
// types/nostr.ts
|
||||
export interface NostrEvent {
|
||||
id?: string;
|
||||
pubkey?: string;
|
||||
content: string;
|
||||
created_at: number;
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
sig?: string;
|
||||
}
|
||||
id?: string;
|
||||
pubkey?: string;
|
||||
content: string;
|
||||
created_at: number;
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
sig?: string;
|
||||
}
|
||||
|
||||
export enum NostrEventKind {
|
||||
EXERCISE = 33401,
|
||||
TEMPLATE = 33402,
|
||||
WORKOUT = 1301 // Updated from 33403 to 1301
|
||||
}
|
||||
|
||||
export interface NostrTag {
|
||||
name: string;
|
||||
value: string;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
export function getTagValue(tags: string[][], name: string): string | undefined {
|
||||
const tag = tags.find(t => t[0] === name);
|
||||
return tag ? tag[1] : undefined;
|
||||
}
|
||||
|
||||
export function getTagValues(tags: string[][], name: string): string[] {
|
||||
return tags.filter(t => t[0] === name).map(t => t[1]);
|
||||
}
|
||||
|
||||
// New helper function for template tags
|
||||
export function getTemplateTag(tags: string[][]): { reference: string, relay: string } | undefined {
|
||||
const templateTag = tags.find(t => t[0] === 'template');
|
||||
if (!templateTag) return undefined;
|
||||
|
||||
export enum NostrEventKind {
|
||||
EXERCISE = 33401,
|
||||
TEMPLATE = 33402,
|
||||
WORKOUT = 33403
|
||||
}
|
||||
|
||||
export interface NostrTag {
|
||||
name: string;
|
||||
value: string;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
export function getTagValue(tags: string[][], name: string): string | undefined {
|
||||
const tag = tags.find(t => t[0] === name);
|
||||
return tag ? tag[1] : undefined;
|
||||
}
|
||||
|
||||
export function getTagValues(tags: string[][], name: string): string[] {
|
||||
return tags.filter(t => t[0] === name).map(t => t[1]);
|
||||
}
|
||||
return {
|
||||
reference: templateTag[1],
|
||||
relay: templateTag[2]
|
||||
};
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user