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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
strategy:
fail-fast: false
matrix:
app: [tictactogether, stocksync, orderup, cowrite, outagenet, yardgate]
app: [tictactogether, stocksync, orderup, cowrite, outagenet, yardgate, eventfloor]
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
Expand Down Expand Up @@ -87,6 +87,8 @@ jobs:
scheme: OutageNet
- app: yardgate
scheme: YardGate
- app: eventfloor
scheme: EventFloor
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ accounts or server, and shows a different way to keep shared state in sync.
| Cowrite | [`apps/cowrite`](./apps/cowrite) | Shared plain-text document with live cursors, built on the SDK's replicated documents (`DataStore`) in an MLS group. |
| OutageNet | [`apps/outagenet`](./apps/outagenet) | Public-sector style outage coordination: status + handoff accept over Bluetooth, mock HQ sync with idempotent ingest. |
| YardGate | [`apps/yardgate`](./apps/yardgate) | Logistics yard check-in: MeshServices discover + invoke, gate officer approve/deny, optional relay hop. |
| Event Floor | [`apps/eventfloor`](./apps/eventfloor) | Live events: Flow A staff MLS dispatch + Flow B gate admission ledger and mock platform sync (`scanId` dedupe). |

Each app's README explains how to run it, how its sync works and what its tests cover.

Expand Down Expand Up @@ -77,7 +78,11 @@ pnpm --filter @offline-app-examples/stocksync ios -- --device
pnpm --filter @offline-app-examples/stocksync android
```

Replace `stocksync` with `tictactogether`, `orderup`, `cowrite`, `outagenet`, or `yardgate` for the other apps.
Replace `stocksync` with `tictactogether`, `orderup`, `cowrite`, `outagenet`, `yardgate`, or `eventfloor` for the other apps.

### Event Floor

Record `demo-staff.gif` and `demo-gate.gif` in [`apps/eventfloor`](./apps/eventfloor) and embed here when catalogue-ready (see app README).
For iOS you can also open the app's `ios/*.xcworkspace` in Xcode, choose your team
under Signing & Capabilities, and run on each phone. Debug builds load JavaScript
from Metro; install a Release build to use an app away from your computer.
Expand Down
4 changes: 4 additions & 0 deletions apps/eventfloor/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: '@react-native',
};
16 changes: 16 additions & 0 deletions apps/eventfloor/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
node_modules/
.expo/
dist/
build/
ios/Pods/
ios/build/
android/app/build/
android/build/
android/.gradle/
**/.cxx/
android/local.properties
*.jsbundle
.DS_Store
.env*
!.env.example
ios/.xcode.env.local
1 change: 1 addition & 0 deletions apps/eventfloor/.prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"singleQuote":true,"trailingComma":"all","bracketSpacing":true}
297 changes: 297 additions & 0 deletions apps/eventfloor/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
import '@offline-app-examples/ui/global.css';
import { Button, Text } from '@offline-app-examples/ui';
import React, { useState } from 'react';
import { Pressable, StatusBar, TextInput, View } from 'react-native';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
import { GateFlow, type GateRole } from './src/flows/GateFlow';
import { StaffFlow, type StaffRole } from './src/flows/StaffFlow';
import { signInStaff, STAFF_ROSTER, type StaffSession } from './src/domain/staff';
import { DEMO_EVENT } from './src/domain/tickets';
import { EventHero } from './src/ui/illustrations';

type Flow = 'home' | 'staff' | 'gate';
type StaffSetup = { role: StaffRole; session: StaffSession | null; label: string };
type GateSetup = { role: GateRole; gateName: string };

export default function App() {
const [flow, setFlow] = useState<Flow>('home');
const [staffSetup, setStaffSetup] = useState<StaffSetup | null>(null);
const [gateSetup, setGateSetup] = useState<GateSetup | null>(null);

if (flow === 'staff' && staffSetup) {
return (
<SafeAreaProvider>
<StatusBar barStyle="dark-content" />
<StaffFlow
role={staffSetup.role}
session={staffSetup.session}
displayName={staffSetup.label}
onLeave={() => {
setFlow('home');
setStaffSetup(null);
}}
/>
</SafeAreaProvider>
);
}

if (flow === 'gate' && gateSetup) {
return (
<SafeAreaProvider>
<StatusBar barStyle="dark-content" />
<GateFlow
role={gateSetup.role}
gateName={gateSetup.gateName}
onLeave={() => {
setFlow('home');
setGateSetup(null);
}}
/>
</SafeAreaProvider>
);
}

return (
<SafeAreaProvider>
<StatusBar barStyle="dark-content" />
<HomeLobby
onStaff={(setup) => {
setStaffSetup(setup);
setFlow('staff');
}}
onGate={(setup) => {
setGateSetup(setup);
setFlow('gate');
}}
/>
</SafeAreaProvider>
);
}

function HomeLobby({
onStaff,
onGate,
}: {
onStaff: (setup: StaffSetup) => void;
onGate: (setup: GateSetup) => void;
}) {
const [mode, setMode] = useState<'pick' | 'staff' | 'gate'>('pick');

if (mode === 'staff') {
return <StaffLobby onBack={() => setMode('pick')} onStart={onStaff} />;
}
if (mode === 'gate') {
return <GateLobby onBack={() => setMode('pick')} onStart={onGate} />;
}

return (
<SafeAreaView className="bg-background flex-1" edges={['top', 'left', 'right']}>
<View className="flex-1 px-6 pt-4">
<Text className="font-serif text-4xl">Event Floor</Text>
<Text className="text-muted-foreground mt-2 text-base leading-6">
{DEMO_EVENT.name} — offline-capable staff dispatch and gate admission over Bluetooth mesh
(SDK 0.27).
</Text>
<View className="my-6 items-center">
<EventHero size={160} />
</View>
<Pressable
onPress={() => setMode('staff')}
className="border-foreground mb-3 rounded-xl border-2 px-4 py-4"
>
<Text className="font-serif text-2xl">Flow A — Staff dispatch</Text>
<Text className="text-muted-foreground mt-1 text-sm">
MLS staff group, crowd relay, demo staff sign-in
</Text>
</Pressable>
<Pressable
onPress={() => setMode('gate')}
className="border-foreground rounded-xl border-2 px-4 py-4"
>
<Text className="font-serif text-2xl">Flow B — Gate sync</Text>
<Text className="text-muted-foreground mt-1 text-sm">
Shared admission ledger + mock platform ingest with scanId dedupe
</Text>
</Pressable>
</View>
</SafeAreaView>
);
}

function StaffLobby({
onBack,
onStart,
}: {
onBack: () => void;
onStart: (setup: StaffSetup) => void;
}) {
const [role, setRole] = useState<StaffRole>('sender');
const [staffId, setStaffId] = useState(STAFF_ROSTER[0].staffId);
const [pin, setPin] = useState('');
const [relayName, setRelayName] = useState('Crowd relay');
const [error, setError] = useState('');

return (
<SafeAreaView className="bg-background flex-1" edges={['top', 'left', 'right']}>
<View className="flex-1 px-6 pt-4">
<Button variant="outline" size="sm" onPress={onBack}>
<Text>Back</Text>
</Button>
<Text className="font-serif mt-4 text-3xl">Staff channel</Text>
<RolePick
options={[
{ id: 'sender', title: 'Staff sender', hint: 'Hosts encrypted staff group' },
{ id: 'receiver', title: 'Staff receiver', hint: 'Receives and acks alerts' },
{ id: 'relay', title: 'Crowd relay', hint: 'Forward only — no staff sign-in' },
]}
selected={role}
onSelect={(id) => setRole(id as StaffRole)}
/>
{role === 'relay' ? (
<>
<Text className="text-muted-foreground mb-2 text-xs font-bold uppercase tracking-widest">
Relay label
</Text>
<TextInput
className="border-foreground text-foreground mb-4 rounded-lg border-2 px-3 py-3"
value={relayName}
onChangeText={setRelayName}
placeholder="Crowd relay"
/>
</>
) : (
<StaffSignIn staffId={staffId} onStaffId={setStaffId} pin={pin} onPin={setPin} />
)}
{error ? <Text className="text-destructive text-sm">{error}</Text> : null}
<Button
className="mt-4"
onPress={() => {
if (role === 'relay') {
onStart({ role, session: null, label: relayName.trim() || 'Relay' });
return;
}
const session = signInStaff(staffId, pin);
if (!session) {
setError('Invalid staff PIN for this demo roster.');
return;
}
setError('');
onStart({ role, session, label: session.displayName });
}}
>
<Text>Start</Text>
</Button>
</View>
</SafeAreaView>
);
}

function GateLobby({
onBack,
onStart,
}: {
onBack: () => void;
onStart: (setup: GateSetup) => void;
}) {
const [role, setRole] = useState<GateRole>('host');
const [gateName, setGateName] = useState('North gate');

return (
<SafeAreaView className="bg-background flex-1" edges={['top', 'left', 'right']}>
<View className="flex-1 px-6 pt-4">
<Button variant="outline" size="sm" onPress={onBack}>
<Text>Back</Text>
</Button>
<Text className="font-serif mt-4 text-3xl">Admissions</Text>
<RolePick
options={[
{ id: 'host', title: 'Lead scanner', hint: 'Authoritative admission ledger' },
{ id: 'scanner', title: 'Gate scanner', hint: 'Joins lead, scans tickets' },
{ id: 'relay', title: 'Gate relay', hint: 'Optional hop between scanners' },
]}
selected={role}
onSelect={(id) => setRole(id as GateRole)}
/>
<Text className="text-muted-foreground mb-2 text-xs font-bold uppercase tracking-widest">
Gate name
</Text>
<TextInput
className="border-foreground text-foreground mb-4 rounded-lg border-2 px-3 py-3"
value={gateName}
onChangeText={setGateName}
placeholder="North gate"
/>
<Button
onPress={() =>
onStart({ role, gateName: gateName.trim() || (role === 'host' ? 'Lead gate' : 'Gate') })
}
>
<Text>Start</Text>
</Button>
</View>
</SafeAreaView>
);
}

function StaffSignIn({
staffId,
onStaffId,
pin,
onPin,
}: {
staffId: string;
onStaffId: (id: string) => void;
pin: string;
onPin: (p: string) => void;
}) {
return (
<View className="mt-4 gap-3">
<Text className="text-muted-foreground text-xs font-bold uppercase tracking-widest">
Demo staff sign-in (OfflineID stand-in)
</Text>
{STAFF_ROSTER.map((s) => (
<Pressable
key={s.staffId}
onPress={() => onStaffId(s.staffId)}
className={`rounded-lg border-2 px-3 py-2 ${staffId === s.staffId ? 'border-foreground bg-muted' : 'border-muted'}`}
>
<Text>{s.displayName}</Text>
<Text className="text-muted-foreground font-mono text-xs">PIN {s.pin}</Text>
</Pressable>
))}
<TextInput
className="border-foreground text-foreground rounded-lg border-2 px-3 py-3"
value={pin}
onChangeText={onPin}
placeholder="Enter PIN"
keyboardType="number-pad"
secureTextEntry
/>
</View>
);
}

function RolePick({
options,
selected,
onSelect,
}: {
options: { id: string; title: string; hint: string }[];
selected: string;
onSelect: (id: string) => void;
}) {
return (
<View className="my-4 gap-2">
{options.map((o) => (
<Pressable
key={o.id}
onPress={() => onSelect(o.id)}
className={`rounded-xl border-2 px-4 py-3 ${selected === o.id ? 'border-foreground bg-muted' : 'border-muted'}`}
>
<Text className="font-serif text-xl">{o.title}</Text>
<Text className="text-muted-foreground text-sm">{o.hint}</Text>
</Pressable>
))}
</View>
);
}
17 changes: 17 additions & 0 deletions apps/eventfloor/Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
source 'https://rubygems.org'

# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version
ruby ">= 2.6.10"

# Exclude problematic versions of cocoapods and activesupport that causes build failures.
gem 'cocoapods', '~> 1.16.0'
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
gem 'xcodeproj', '>= 1.25.0', '< 1.28.0'
gem 'concurrent-ruby', '< 1.3.4'

# Ruby 3.4.0 has removed some libraries from the standard library.
gem 'bigdecimal'
gem 'logger'
gem 'benchmark'
gem 'mutex_m'
gem 'nkf'
Loading
Loading