feat: scaffold luggage list expo app with core local MVP
This commit is contained in:
134
.gitea/workflows/ci.yml
Normal file
134
.gitea/workflows/ci.yml
Normal 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
28
.gitea/workflows/dev.yml
Normal 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
|
||||||
57
.gitea/workflows/manual-build.yml
Normal file
57
.gitea/workflows/manual-build.yml
Normal 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
45
.github/copilot-instructions.md
vendored
Normal 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
41
.gitignore
vendored
Normal 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
|
||||||
29
README.md
29
README.md
@@ -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
24
TODO.md
Normal 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
34
app.json
Normal 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
BIN
assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
BIN
assets/alert.mp3
Normal file
BIN
assets/alert.mp3
Normal file
Binary file not shown.
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
BIN
assets/splash-icon.png
Normal file
BIN
assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
8
docker-compose.yml
Normal file
8
docker-compose.yml
Normal 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
30
eas.json
Normal 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
29
entrypoint.sh
Normal 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
8
index.js
Normal 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
8958
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal 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
|
||||||
|
}
|
||||||
40
src/components/CountdownRow.js
Normal file
40
src/components/CountdownRow.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/components/TopControls.js
Normal file
70
src/components/TopControls.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/screens/FocusScreen.js
Normal file
86
src/screens/FocusScreen.js
Normal 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
91
src/screens/HomeScreen.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
src/screens/TimeUntilScreen.js
Normal file
173
src/screens/TimeUntilScreen.js
Normal 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
195
src/screens/TimerScreen.js
Normal 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
167
src/styles.js
Normal 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
31
src/theme.js
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user