feat: scaffold luggage list expo app with core local MVP
Some checks failed
Build App / release (push) Has been cancelled
Build App / build-web (push) Has been cancelled
Build App / build-android (push) Has been cancelled

This commit is contained in:
2026-04-18 11:51:58 +02:00
parent 3900755ad1
commit 7de77d2878
27 changed files with 11393 additions and 0 deletions

134
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,134 @@
name: Build App
on:
push:
branches:
- main
jobs:
build-android:
runs-on: ubuntu-latest
steps:
- name: 🏗 Setup repo
uses: actions/checkout@v2
- name: 🏗 Setup Node
uses: actions/setup-node@v2
with:
node-version: 22
- name: 🏗 Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
- name: 🏗 Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: 🏗 Setup Android SDK
uses: android-actions/setup-android@v3
- name: 🏗 Setup Expo and EAS
uses: expo/expo-github-action@v8
with:
token: ${{ secrets.EXPO_TOKEN }}
eas-version: latest
packager: pnpm
- name: 📦 Install dependencies
run: pnpm install
- name: 👷 Build app
run: |
eas build --local \
--non-interactive \
--output=./app-build \
--platform=android \
--profile=preview
- name: 📝 Rename build to APK
run: mv app-build app-release.apk
- name: 📤 Upload build artifact
uses: actions/upload-artifact@v3
with:
name: android-preview-build
path: app-release.apk
if-no-files-found: error
build-web:
runs-on: ubuntu-latest
steps:
- name: 🏗 Setup repo
uses: actions/checkout@v2
- name: 🏗 Setup Node
uses: actions/setup-node@v2
with:
node-version: 22
- name: 🏗 Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
- name: 🏗 Setup Expo and EAS
uses: expo/expo-github-action@v8
with:
token: ${{ secrets.EXPO_TOKEN }}
eas-version: latest
packager: pnpm
- name: 📦 Install dependencies
run: pnpm install
- name: 👷 Build web
run: npx expo export --platform web
- name: 📦 Zip dist
run: cd dist && zip -r ../dist.zip .
- name: 📤 Upload build artifact
uses: actions/upload-artifact@v3
with:
name: web-build
path: dist.zip
if-no-files-found: error
release:
runs-on: ubuntu-latest
needs: [build-android, build-web]
steps:
- name: 🏗 Setup repo
uses: actions/checkout@v2
- name: 📥 Download Android artifact
uses: actions/download-artifact@v3
with:
name: android-preview-build
- name: 📥 Download Web artifact
uses: actions/download-artifact@v3
with:
name: web-build
- name: 🏷 Create tag
run: |
TAG="build-$(git rev-parse --short HEAD)"
git tag "$TAG"
git push origin "$TAG"
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
- name: 🚀 Create release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ env.RELEASE_TAG }}
name: ${{ env.RELEASE_TAG }}
files: |
app-release.apk
dist.zip
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

28
.gitea/workflows/dev.yml Normal file
View File

@@ -0,0 +1,28 @@
name: Dev Branch Check
on:
push:
branches-ignore:
- main
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: 🏗 Setup repo
uses: actions/checkout@v2
- name: 🏗 Setup Node
uses: actions/setup-node@v2
with:
node-version: 22
- name: 🏗 Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
- name: 📦 Install dependencies
run: pnpm install
- name: 🧪 Check for linting/type errors
run: npx expo export --platform web

View File

@@ -0,0 +1,57 @@
name: Manual APK Build
on:
workflow_dispatch:
jobs:
build-android:
runs-on: ubuntu-latest
steps:
- name: 🏗 Setup repo
uses: actions/checkout@v2
- name: 🏗 Setup Node
uses: actions/setup-node@v2
with:
node-version: 22
- name: 🏗 Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
- name: 🏗 Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: 🏗 Setup Android SDK
uses: android-actions/setup-android@v3
- name: 🏗 Setup Expo and EAS
uses: expo/expo-github-action@v8
with:
token: ${{ secrets.EXPO_TOKEN }}
eas-version: latest
packager: pnpm
- name: 📦 Install dependencies
run: pnpm install
- name: 👷 Build app
run: |
eas build --local \
--non-interactive \
--output=./app-build \
--platform=android \
--profile=preview
- name: 📝 Rename build to APK
run: mv app-build time-until-manual.apk
- name: 📤 Upload build artifact
uses: actions/upload-artifact@v3
with:
name: manual-apk-build
path: time-until-manual.apk
if-no-files-found: error

45
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,45 @@
# Project Guidelines
## Code Style
- Keep components as functional React components and use hooks-based state.
- Follow existing naming and structure in `src/screens` and `src/components`:
- Screen files: `*Screen.js`
- Shared UI pieces: `src/components/*.js`
- Keep styling centralized in `src/styles.js` via `createStyles()` and use theme-driven inline color overrides in screens/components.
- Reuse `getTheme(darkMode, pinkMode)` from `src/theme.js` for color values; do not hardcode alternate palettes in individual screens.
## Architecture
- Root orchestration lives in `App.js`:
- App-level state includes current screen, focus mode, theme toggles, and timer/countdown state.
- Screen switching is controlled by the `screen` state (`home`, `timeuntil`, `timer`).
- Screen responsibilities:
- `HomeScreen`: mode selection and top controls.
- `TimeUntilScreen`: target clock-time countdown flow.
- `TimerScreen`: duration countdown flow.
- `FocusScreen`: minimal fullscreen countdown UI.
- Shared presentational components:
- `TopControls`: dark/pink/fullscreen/focus controls.
- `CountdownRow`: reusable HH:MM:SS display.
## Build And Run
- Install dependencies: `npm install`
- Start dev server: `npm start`
- Run on Android: `npm run android`
- Run on iOS: `npm run ios`
- Run on web: `npm run web`
- EAS builds use `eas.json` profiles (`development`, `preview`, `production`).
## Conventions
- Keep timer/countdown behavior in `App.js` unless intentionally refactoring architecture.
- Preserve current time behavior:
- `now` updates every second with `setInterval`.
- Time-until target is based on local device time and rolls to next day when target time has passed.
- Maintain platform guards:
- Web-only fullscreen uses `document.fullscreenElement` APIs.
- Android hardware back behavior is handled in `App.js` and should keep focus/screen fallback behavior.
- For countdown display, continue using `CountdownRow` and 2-digit padded units for consistency.
## Pitfalls
- This project has no test scripts configured; do not claim tests were run unless you add and run them.
- Be careful with web-only globals (`document`) and keep `Platform.OS` guards.
- Avoid introducing timezone assumptions without explicit product requirements; current logic is local-time based.

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

1091
App.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
# Luggage List
Minimal local-first luggage management app built with Expo.
## MVP Implemented
- No auth, no server, local storage only
- Trips with name, location, dates, optional image
- Auto-select active trip when current date is in trip range
- Luggage items with:
- name, description, category
- status: packed, unpacked, lost, left-behind, lent-to
- placement: suitcase, backpack, with-user, other
- optional image
- Check-up snapshots per trip
- Check-up history view
- JSON export
- Default trip template (copy-only, not linked)
## Run
```bash
npm install
npm run start
```
## Build
EAS + Gitea workflows are included in `.gitea/workflows`.

24
TODO.md Normal file
View File

@@ -0,0 +1,24 @@
# TODO - Luggage List
## In Progress
- [x] Bootstrap project from `/root/projects/time-until`
- [x] Set app metadata to Luggage List
- [x] Add first MVP implementation shell
## MVP Feature Checklist
- [x] Local-only storage (no auth/no backend)
- [x] Trip creation/selection with active trip auto-selection
- [x] Trip image support
- [x] Luggage items CRUD with category
- [x] Item statuses: packed, unpacked, lost, left-behind, lent-to (+ person name)
- [x] Item placement tracking: suitcase, backpack, with-user, other
- [x] Item image support
- [x] Create check-up snapshots
- [x] Check-up history per trip
- [x] JSON export
- [x] Default luggage list template (copy into new trip)
## Remaining
- [ ] Adjust CI/CD workflow naming and artifact naming for this project
- [ ] Validate app builds (web export smoke check)
- [ ] Polish README and commit final notes

34
app.json Normal file
View File

@@ -0,0 +1,34 @@
{
"expo": {
"name": "Luggage List",
"slug": "luggage-list",
"version": "1.0.0",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#f5f5f7"
},
"ios": {
"bundleIdentifier": "dev.reversed.luggagelist",
"supportsTablet": true,
"requireFullScreen": false
},
"android": {
"package": "dev.reversed.luggagelist",
"adaptiveIcon": {
"foregroundImage": "./assets/icon.png",
"backgroundColor": "#f5f5f7"
}
},
"web": {
"favicon": "./assets/icon.png",
"name": "Luggage List",
"themeColor": "#f5f5f7",
"backgroundColor": "#f5f5f7"
},
"owner": "spacebanane"
}
}

BIN
assets/adaptive-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
assets/alert.mp3 Normal file

Binary file not shown.

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
assets/splash-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

8
docker-compose.yml Normal file
View File

@@ -0,0 +1,8 @@
services:
web:
image: nginx:alpine
ports:
- "45554:80"
volumes:
- ./entrypoint.sh:/entrypoint.sh:ro
entrypoint: ["/bin/sh", "/entrypoint.sh"]

30
eas.json Normal file
View File

@@ -0,0 +1,30 @@
{
"cli": {
"version": ">= 16.0.0",
"appVersionSource": "local"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"android": {
"buildType": "apk"
},
"ios": {
"simulator": true
}
},
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"production": {
"autoIncrement": true,
"android": {
"buildType": "app-bundle"
}
}
}
}

29
entrypoint.sh Normal file
View File

@@ -0,0 +1,29 @@
#!/bin/sh
set -e
WEBROOT="/usr/share/nginx/html"
GITEA_URL="https://gitea.reversed.dev"
GITEA_REPO="space/time-until"
echo "Fetching latest release"
RELEASE_JSON=$(wget -qO- \
"$GITEA_URL/api/v1/repos/$GITEA_REPO/releases/latest")
ASSET_URL=$(echo "$RELEASE_JSON" | sed -n 's/.*"browser_download_url" *: *"\([^"]*dist\.zip[^"]*\)".*/\1/p' | head -1)
if [ -z "$ASSET_URL" ]; then
echo "ERROR: No dist.zip found in latest release"
exit 1
fi
echo "Downloading $ASSET_URL ..."
wget -qO /tmp/dist.zip --header="$AUTH_HEADER" "$ASSET_URL"
echo "Extracting to $WEBROOT ..."
rm -rf "${WEBROOT:?}"/*
unzip -o /tmp/dist.zip -d "$WEBROOT"
rm /tmp/dist.zip
echo "Starting nginx ..."
exec nginx -g "daemon off;"

8
index.js Normal file
View File

@@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

8958
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "luggage-list",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"expo": "~54.0.33",
"expo-file-system": "~19.0.17",
"expo-image-picker": "~17.0.8",
"expo-sharing": "~14.0.7",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-web": "^0.21.0"
},
"private": true
}

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { Text, View } from 'react-native';
function pad(n) {
return String(n).padStart(2, '0');
}
export default function CountdownRow({ styles, cd, accent, subText, pinkMode, numStyle, sepStyle }) {
const outline = pinkMode
? {
borderColor: '#ff4fa3',
borderWidth: 1.5,
borderRadius: 10,
paddingHorizontal: 8,
}
: {};
return (
<View style={styles.countdownRow}>
{cd.hours > 0 && (
<>
<View style={styles.timeBlock}>
<Text style={[numStyle, { color: accent }, outline]}>{pad(cd.hours)}</Text>
<Text style={[styles.countdownUnit, { color: subText }]}>HRS</Text>
</View>
<Text style={[sepStyle, { color: accent }]}>:</Text>
</>
)}
<View style={styles.timeBlock}>
<Text style={[numStyle, { color: accent }, outline]}>{pad(cd.minutes)}</Text>
<Text style={[styles.countdownUnit, { color: subText }]}>MIN</Text>
</View>
<Text style={[sepStyle, { color: accent }]}>:</Text>
<View style={styles.timeBlock}>
<Text style={[numStyle, { color: accent }, outline]}>{pad(cd.seconds)}</Text>
<Text style={[styles.countdownUnit, { color: subText }]}>SEC</Text>
</View>
</View>
);
}

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { Platform, Text, TouchableOpacity, View } from 'react-native';
export default function TopControls({
styles,
accent,
pink,
darkMode,
pinkMode,
isFullscreen,
showBackToMenu,
showFocus,
onBackToMenu,
onToggleDark,
onTogglePink,
onToggleFullscreen,
onFocus,
}) {
return (
<View style={styles.topRow}>
{showBackToMenu && (
<TouchableOpacity
style={[styles.backMenuBtn, { borderColor: accent, backgroundColor: `${accent}1f` }]}
onPress={onBackToMenu}
>
<Text style={[styles.toggleText, { color: accent }]}>Back To Menu</Text>
</TouchableOpacity>
)}
<View style={styles.toggleRow}>
<TouchableOpacity
style={[styles.toggleBtn, { borderColor: accent, backgroundColor: `${accent}1f` }]}
onPress={onToggleDark}
>
<Text style={[styles.toggleText, { color: accent }]}>{darkMode ? 'Light' : 'Dark'}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.toggleBtn,
{ borderColor: pink, backgroundColor: pinkMode ? `${pink}33` : 'transparent' },
]}
onPress={onTogglePink}
>
<Text style={[styles.toggleText, { color: pink }]}>Pink</Text>
</TouchableOpacity>
{Platform.OS === 'web' && (
<TouchableOpacity
style={[styles.toggleBtn, { borderColor: accent, backgroundColor: `${accent}14` }]}
onPress={onToggleFullscreen}
>
<Text style={[styles.toggleText, { color: accent }]}>
{isFullscreen ? 'Exit Full' : 'Fullscreen'}
</Text>
</TouchableOpacity>
)}
{showFocus && (
<TouchableOpacity
style={[styles.toggleBtn, { borderColor: accent, backgroundColor: `${accent}1f` }]}
onPress={onFocus}
>
<Text style={[styles.toggleText, { color: accent }]}>Focus</Text>
</TouchableOpacity>
)}
</View>
</View>
);
}

View File

@@ -0,0 +1,86 @@
import React from 'react';
import { StatusBar, Text, TouchableOpacity, View } from 'react-native';
import CountdownRow from '../components/CountdownRow';
export default function FocusScreen({
styles,
theme,
screen,
pinkMode,
tuIsOver,
tuCountdown,
targetTime,
timerDone,
timerHr,
timerMin,
timerSec,
onShowUI,
}) {
const pinkOutlineText = pinkMode
? {
borderColor: theme.pink,
borderWidth: 2,
borderRadius: 12,
paddingHorizontal: 20,
paddingVertical: 8,
}
: {};
let focusContent = null;
if (screen === 'timeuntil') {
focusContent = tuIsOver ? (
<Text style={[styles.overText, styles.focusOverText, { color: pinkMode ? theme.pink : theme.danger }, pinkOutlineText]}>
Time's Up!
</Text>
) : tuCountdown ? (
<View style={styles.focusCountdown}>
<Text style={[styles.focusLabel, { color: theme.subText }]}>
until {targetTime?.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Text>
<CountdownRow
styles={styles}
cd={tuCountdown}
accent={theme.accent}
subText={theme.subText}
pinkMode={pinkMode}
numStyle={styles.focusNum}
sepStyle={styles.focusSep}
/>
</View>
) : (
<Text style={[styles.placeholder, { color: theme.subText }]}>No timer set</Text>
);
}
if (screen === 'timer') {
focusContent = timerDone ? (
<Text style={[styles.overText, styles.focusOverText, { color: pinkMode ? theme.pink : theme.danger }, pinkOutlineText]}>
Done!
</Text>
) : (
<View style={styles.focusCountdown}>
<Text style={[styles.focusLabel, { color: theme.subText }]}>TIMER</Text>
<CountdownRow
styles={styles}
cd={{ hours: timerHr, minutes: timerMin, seconds: timerSec }}
accent={theme.accent}
subText={theme.subText}
pinkMode={pinkMode}
numStyle={styles.focusNum}
sepStyle={styles.focusSep}
/>
</View>
);
}
return (
<View style={[styles.root, styles.focusRoot, { backgroundColor: theme.bg }]}>
<StatusBar hidden />
{focusContent}
<TouchableOpacity style={[styles.focusExitBtn, { borderColor: theme.accent }]} onPress={onShowUI}>
<Text style={[styles.focusExitText, { color: theme.accent }]}>Show UI</Text>
</TouchableOpacity>
</View>
);
}

91
src/screens/HomeScreen.js Normal file
View File

@@ -0,0 +1,91 @@
import React from 'react';
import { ScrollView, Text, TouchableOpacity, View, Image } from 'react-native';
import TopControls from '../components/TopControls';
export default function HomeScreen({
styles,
theme,
now,
pinkMode,
darkMode,
isFullscreen,
onToggleDark,
onTogglePink,
onToggleFullscreen,
onSelectTimeUntil,
onSelectTimer,
}) {
return (
<View style={[styles.root, { backgroundColor: theme.bg }]}>
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
<Image
source={require('../../assets/icon.png')}
style={[styles.logo, pinkMode && { borderColor: theme.accent, borderWidth: 2 }]}
/>
<Text
style={[
styles.title,
{ color: theme.accent },
pinkMode && {
borderColor: theme.accent,
borderWidth: 2,
borderRadius: 10,
paddingHorizontal: 16,
paddingVertical: 4,
},
]}
>
Time Until
</Text>
<TopControls
styles={styles}
accent={theme.accent}
pink={theme.pink}
darkMode={darkMode}
pinkMode={pinkMode}
isFullscreen={isFullscreen}
showBackToMenu={false}
showFocus={false}
onBackToMenu={() => {}}
onToggleDark={onToggleDark}
onTogglePink={onTogglePink}
onToggleFullscreen={onToggleFullscreen}
onFocus={() => {}}
/>
<Text style={[styles.modeSelectLabel, { color: theme.subText }]}>SELECT MODE</Text>
<TouchableOpacity
style={[styles.modeCard, { backgroundColor: theme.cardBg, borderColor: theme.accent }]}
onPress={onSelectTimeUntil}
activeOpacity={0.75}
>
<Text style={[styles.modeTitle, { color: theme.accent }]}>Mode 1: Time Until</Text>
<Text style={[styles.modeDesc, { color: theme.subText }]}>
Set a target clock time and count down to it
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modeCard, { backgroundColor: theme.cardBg, borderColor: theme.border }]}
onPress={onSelectTimer}
activeOpacity={0.75}
>
<Text style={[styles.modeTitle, { color: theme.accent }]}>Mode 2: Timer</Text>
<Text style={[styles.modeDesc, { color: theme.subText }]}>
Set duration in hours, minutes, and seconds
</Text>
</TouchableOpacity>
<Text style={[styles.clock, { color: theme.subText, marginTop: 32 }]}>
{now.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</Text>
</ScrollView>
</View>
);
}

View File

@@ -0,0 +1,173 @@
import React from 'react';
import { ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native';
import CountdownRow from '../components/CountdownRow';
import TopControls from '../components/TopControls';
export default function TimeUntilScreen({
styles,
theme,
now,
darkMode,
pinkMode,
isFullscreen,
targetTime,
tuHour,
tuMinute,
tuIsOver,
tuCountdown,
onChangeHour,
onChangeMinute,
onSetTimer,
onResetTimer,
onBackToMenu,
onToggleDark,
onTogglePink,
onToggleFullscreen,
onFocus,
}) {
const isCountingDown = Boolean(tuCountdown) && !tuIsOver;
const pinkOutlineText = pinkMode
? {
borderColor: theme.pink,
borderWidth: 2,
borderRadius: 12,
paddingHorizontal: 20,
paddingVertical: 8,
}
: {};
return (
<View style={[styles.root, { backgroundColor: theme.bg }]}>
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
<Text
style={[
styles.title,
{ color: theme.accent },
pinkMode && {
borderColor: theme.accent,
borderWidth: 2,
borderRadius: 10,
paddingHorizontal: 16,
paddingVertical: 4,
},
]}
>
Time Until
</Text>
<TopControls
styles={styles}
accent={theme.accent}
pink={theme.pink}
darkMode={darkMode}
pinkMode={pinkMode}
isFullscreen={isFullscreen}
showBackToMenu
showFocus={targetTime !== null || tuIsOver}
onBackToMenu={onBackToMenu}
onToggleDark={onToggleDark}
onTogglePink={onTogglePink}
onToggleFullscreen={onToggleFullscreen}
onFocus={onFocus}
/>
<View style={[styles.card, { backgroundColor: theme.cardBg, borderColor: theme.border }]}>
{!isCountingDown && (
<>
<Text style={[styles.inputLabel, { color: theme.subText }]}>Set target time (24h)</Text>
<View style={styles.inputRow}>
<TextInput
style={[
styles.timeInput,
{ color: theme.text, borderColor: theme.accent, backgroundColor: theme.inputBg },
]}
placeholder="HH"
placeholderTextColor={darkMode ? '#5a6886' : '#98a1ba'}
value={tuHour}
onChangeText={onChangeHour}
keyboardType="numeric"
maxLength={2}
/>
<Text style={[styles.colon, { color: theme.accent }]}>:</Text>
<TextInput
style={[
styles.timeInput,
{ color: theme.text, borderColor: theme.accent, backgroundColor: theme.inputBg },
]}
placeholder="MM"
placeholderTextColor={darkMode ? '#5a6886' : '#98a1ba'}
value={tuMinute}
onChangeText={onChangeMinute}
keyboardType="numeric"
maxLength={2}
/>
</View>
<View style={styles.btnRow}>
<TouchableOpacity style={[styles.setBtn, { backgroundColor: theme.accent }]} onPress={onSetTimer}>
<Text style={styles.setBtnText}>Set Timer</Text>
</TouchableOpacity>
{targetTime && (
<TouchableOpacity
style={[styles.resetBtn, { borderColor: theme.accent }]}
onPress={onResetTimer}
>
<Text style={[styles.resetBtnText, { color: theme.accent }]}>Reset</Text>
</TouchableOpacity>
)}
</View>
</>
)}
{isCountingDown && targetTime && (
<View style={styles.btnRow}>
<TouchableOpacity
style={[styles.resetBtn, { borderColor: theme.accent }]}
onPress={onResetTimer}
>
<Text style={[styles.resetBtnText, { color: theme.accent }]}>Reset</Text>
</TouchableOpacity>
</View>
)}
{tuIsOver ? (
<View style={styles.overContainer}>
<Text style={[styles.overText, { color: pinkMode ? theme.pink : theme.danger }, pinkOutlineText]}>
Time's Up!
</Text>
<Text style={[styles.overSub, { color: theme.subText }]}>
{targetTime?.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} has been reached
</Text>
</View>
) : tuCountdown ? (
<View style={styles.countdownContainer}>
<Text style={[styles.countdownLabel, { color: theme.subText }]}>
until {targetTime?.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Text>
<CountdownRow
styles={styles}
cd={tuCountdown}
accent={theme.accent}
subText={theme.subText}
pinkMode={pinkMode}
numStyle={styles.countdownNum}
sepStyle={styles.sep}
/>
</View>
) : (
<Text style={[styles.placeholder, { color: theme.subText }]}>Enter a time above to start counting down</Text>
)}
</View>
<Text style={[styles.clock, { color: theme.subText }]}>
{now.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</Text>
</ScrollView>
</View>
);
}

195
src/screens/TimerScreen.js Normal file
View File

@@ -0,0 +1,195 @@
import React from 'react';
import { ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native';
import CountdownRow from '../components/CountdownRow';
import TopControls from '../components/TopControls';
export default function TimerScreen({
styles,
theme,
now,
darkMode,
pinkMode,
isFullscreen,
timerRunning,
timerDone,
timerRemaining,
timerHInput,
timerMInput,
timerSInput,
timerHr,
timerMin,
timerSec,
onChangeH,
onChangeM,
onChangeS,
onStart,
onPause,
onResume,
onReset,
onBackToMenu,
onToggleDark,
onTogglePink,
onToggleFullscreen,
onFocus,
}) {
const timerActive = timerRunning || timerRemaining > 0 || timerDone;
const isCountingDown = timerRemaining > 0;
const pinkOutlineText = pinkMode
? {
borderColor: theme.pink,
borderWidth: 2,
borderRadius: 12,
paddingHorizontal: 20,
paddingVertical: 8,
}
: {};
return (
<View style={[styles.root, { backgroundColor: theme.bg }]}>
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
<Text
style={[
styles.title,
{ color: theme.accent },
pinkMode && {
borderColor: theme.accent,
borderWidth: 2,
borderRadius: 10,
paddingHorizontal: 16,
paddingVertical: 4,
},
]}
>
Timer
</Text>
<TopControls
styles={styles}
accent={theme.accent}
pink={theme.pink}
darkMode={darkMode}
pinkMode={pinkMode}
isFullscreen={isFullscreen}
showBackToMenu
showFocus={timerActive}
onBackToMenu={onBackToMenu}
onToggleDark={onToggleDark}
onTogglePink={onTogglePink}
onToggleFullscreen={onToggleFullscreen}
onFocus={onFocus}
/>
<View style={[styles.card, { backgroundColor: theme.cardBg, borderColor: theme.border }]}>
{!isCountingDown && !timerDone ? (
<>
<Text style={[styles.inputLabel, { color: theme.subText }]}>Set duration</Text>
<View style={styles.inputRow}>
<View style={styles.timerInputGroup}>
<TextInput
style={[
styles.timeInput,
{ color: theme.text, borderColor: theme.accent, backgroundColor: theme.inputBg },
]}
placeholder="00"
placeholderTextColor={darkMode ? '#5a6886' : '#98a1ba'}
value={timerHInput}
onChangeText={onChangeH}
keyboardType="numeric"
maxLength={2}
/>
<Text style={[styles.inputUnit, { color: theme.subText }]}>HRS</Text>
</View>
<Text style={[styles.colon, { color: theme.accent }]}>:</Text>
<View style={styles.timerInputGroup}>
<TextInput
style={[
styles.timeInput,
{ color: theme.text, borderColor: theme.accent, backgroundColor: theme.inputBg },
]}
placeholder="00"
placeholderTextColor={darkMode ? '#5a6886' : '#98a1ba'}
value={timerMInput}
onChangeText={onChangeM}
keyboardType="numeric"
maxLength={2}
/>
<Text style={[styles.inputUnit, { color: theme.subText }]}>MIN</Text>
</View>
<Text style={[styles.colon, { color: theme.accent }]}>:</Text>
<View style={styles.timerInputGroup}>
<TextInput
style={[
styles.timeInput,
{ color: theme.text, borderColor: theme.accent, backgroundColor: theme.inputBg },
]}
placeholder="00"
placeholderTextColor={darkMode ? '#5a6886' : '#98a1ba'}
value={timerSInput}
onChangeText={onChangeS}
keyboardType="numeric"
maxLength={2}
/>
<Text style={[styles.inputUnit, { color: theme.subText }]}>SEC</Text>
</View>
</View>
<TouchableOpacity style={[styles.setBtn, { backgroundColor: theme.accent }]} onPress={onStart}>
<Text style={styles.setBtnText}>Start</Text>
</TouchableOpacity>
</>
) : timerDone ? (
<View style={styles.overContainer}>
<Text style={[styles.overText, { color: pinkMode ? theme.pink : theme.danger }, pinkOutlineText]}>
Done!
</Text>
<TouchableOpacity
style={[styles.setBtn, { backgroundColor: theme.accent, marginTop: 20 }]}
onPress={onReset}
>
<Text style={styles.setBtnText}>New Timer</Text>
</TouchableOpacity>
</View>
) : (
<View style={styles.countdownContainer}>
<View>
<CountdownRow
styles={styles}
cd={{ hours: timerHr, minutes: timerMin, seconds: timerSec }}
accent={theme.accent}
subText={theme.subText}
pinkMode={pinkMode}
numStyle={styles.countdownNum}
sepStyle={styles.sep}
/>
</View>
<View style={styles.btnRow}>
{timerRunning ? (
<TouchableOpacity style={[styles.resetBtn, { borderColor: theme.accent }]} onPress={onPause}>
<Text style={[styles.resetBtnText, { color: theme.accent }]}>Pause</Text>
</TouchableOpacity>
) : (
<TouchableOpacity style={[styles.setBtn, { backgroundColor: theme.accent }]} onPress={onResume}>
<Text style={styles.setBtnText}>Resume</Text>
</TouchableOpacity>
)}
<TouchableOpacity style={[styles.resetBtn, { borderColor: theme.danger }]} onPress={onReset}>
<Text style={[styles.resetBtnText, { color: theme.danger }]}>Reset</Text>
</TouchableOpacity>
</View>
</View>
)}
</View>
<Text style={[styles.clock, { color: theme.subText }]}>
{now.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</Text>
</ScrollView>
</View>
);
}

167
src/styles.js Normal file
View File

@@ -0,0 +1,167 @@
import { StyleSheet } from 'react-native';
export function createStyles() {
return StyleSheet.create({
root: { flex: 1 },
scroll: {
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 24,
paddingVertical: 48,
},
title: {
fontSize: 44,
fontWeight: '900',
marginBottom: 20,
letterSpacing: 1,
},
logo: {
width: 100,
height: 100,
marginBottom: 20,
borderRadius: 22,
},
topRow: {
width: '100%',
maxWidth: 640,
alignItems: 'center',
gap: 10,
marginBottom: 20,
},
backMenuBtn: {
paddingHorizontal: 18,
paddingVertical: 10,
borderRadius: 14,
borderWidth: 1.5,
},
toggleRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
justifyContent: 'center',
},
toggleBtn: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
borderWidth: 1.5,
},
toggleText: { fontSize: 14, fontWeight: '600' },
modeSelectLabel: {
fontSize: 12,
letterSpacing: 2,
textTransform: 'uppercase',
marginBottom: 20,
},
modeCard: {
width: '100%',
maxWidth: 440,
borderRadius: 22,
padding: 28,
borderWidth: 2,
alignItems: 'center',
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.18,
shadowRadius: 14,
elevation: 7,
},
modeTitle: {
fontSize: 26,
fontWeight: '800',
marginBottom: 8,
letterSpacing: 0.5,
},
modeDesc: {
fontSize: 14,
textAlign: 'center',
lineHeight: 22,
},
card: {
width: '100%',
maxWidth: 440,
borderRadius: 22,
padding: 28,
borderWidth: 1.5,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.2,
shadowRadius: 16,
elevation: 8,
},
inputLabel: {
fontSize: 12,
letterSpacing: 1,
textTransform: 'uppercase',
marginBottom: 10,
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
marginBottom: 16,
},
timerInputGroup: { alignItems: 'center', gap: 4 },
inputUnit: { fontSize: 11, fontWeight: '600', letterSpacing: 1.5 },
timeInput: {
width: 72,
height: 58,
borderRadius: 12,
borderWidth: 2,
textAlign: 'center',
fontSize: 28,
fontWeight: '800',
},
colon: { fontSize: 34, fontWeight: '900' },
btnRow: {
flexDirection: 'row',
gap: 12,
marginTop: 20,
alignItems: 'center',
},
setBtn: {
paddingHorizontal: 32,
paddingVertical: 14,
borderRadius: 14,
},
setBtnText: { color: '#fff', fontSize: 16, fontWeight: '700', letterSpacing: 0.5 },
resetBtn: {
paddingHorizontal: 20,
paddingVertical: 13,
borderRadius: 14,
borderWidth: 1.5,
},
resetBtnText: { fontSize: 15, fontWeight: '600' },
overContainer: { alignItems: 'center', paddingVertical: 12, gap: 10 },
overText: { fontSize: 52, fontWeight: '900', letterSpacing: 2 },
overSub: { fontSize: 15 },
countdownContainer: { alignItems: 'center', gap: 12 },
countdownLabel: { fontSize: 13, letterSpacing: 0.5, textTransform: 'uppercase' },
countdownRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 6, justifyContent: 'center' },
timeBlock: { alignItems: 'center', gap: 4 },
countdownNum: { fontSize: 58, fontWeight: '800', lineHeight: 66 },
countdownUnit: { fontSize: 11, fontWeight: '600', letterSpacing: 1.5 },
sep: { fontSize: 50, fontWeight: '800', marginTop: 4 },
placeholder: { fontSize: 15, textAlign: 'center', paddingVertical: 24, lineHeight: 24 },
clock: { marginTop: 24, fontSize: 13, letterSpacing: 1.5 },
focusRoot: { alignItems: 'center', justifyContent: 'center', paddingHorizontal: 10 },
focusCountdown: { alignItems: 'center', gap: 16, width: '100%' },
focusLabel: { fontSize: 16, letterSpacing: 0.5, textTransform: 'uppercase' },
focusNum: { fontSize: 72, fontWeight: '800', lineHeight: 80 },
focusSep: { fontSize: 56, fontWeight: '800', marginTop: 8 },
focusOverText: { fontSize: 72 },
focusExitBtn: {
position: 'absolute',
bottom: 40,
right: 32,
paddingHorizontal: 18,
paddingVertical: 10,
borderRadius: 20,
borderWidth: 1.5,
},
focusExitText: { fontSize: 14, fontWeight: '600' },
});
}

31
src/theme.js Normal file
View File

@@ -0,0 +1,31 @@
export function getTheme(darkMode, pinkMode) {
const dark = {
bg: '#0b1020',
cardBg: '#131a2a',
text: '#e6ecff',
subText: '#93a0bf',
accent: '#5fb0ff',
border: '#24304a',
inputBg: '#0f1727',
};
const light = {
bg: '#f7f8fc',
cardBg: '#ffffff',
text: '#1d2433',
subText: '#5f6b85',
accent: '#1f67ff',
border: '#d5def2',
inputBg: '#eef3ff',
};
const base = darkMode ? dark : light;
const accent = pinkMode ? '#ff4fa3' : base.accent;
return {
...base,
accent,
danger: '#ef4444',
pink: '#ff4fa3',
};
}