initial commit

This commit is contained in:
parovin committed 2026-10-11 14:12:10 +03:00
commit 39fdd43d8e
127 files changed
+13919

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
__pycache__/
*.pyc
.venv/
.env
node_modules/
dist/
.DS_Store
*.xlsx.bak
_draft_dump.json
frontend/android/.gradle/
frontend/android/app/build/
frontend/android/build/
frontend/android/local.properties
*.apk
*.aab
*.keystore
+41
View File
@@ -0,0 +1,41 @@
# Benchpress Secret
Мобильный web-интерфейс программы жима лёжа на 5 недель.
## Стек
- Frontend: React + Vite + TypeScript
- Backend: FastAPI
- DB: PostgreSQL
- Docker: `frontend` + `backend` + `db`
## Запуск
```bash
docker compose up --build
```
Открыть: http://localhost:3000
API: http://localhost:8000/api/health
## Локальная разработка без Docker UI
```bash
docker compose up db backend --build
cd frontend
npm install
npm run dev
```
Vite proxy: `/api` → `http://localhost:8000`
## Первый вход
1. Задать пароль
2. Ввести 1ПМ (шаг 1.25 кг)
3. Выбрать 3 дня недели с перерывом ≥1 день
4. Выбрать дату старта
## Настройки
Импорт/экспорт JSON и сброс прогресса — в разделе «Настройки».
+4
View File
@@ -0,0 +1,4 @@
__pycache__
*.pyc
.venv
.env
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
View File
Whitespace-only changes.
+24
View File
@@ -0,0 +1,24 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str = "postgresql+psycopg://benchpress:benchpress@localhost:5432/benchpress"
secret_key: str = "dev-secret-key-change-me"
access_token_expire_minutes: int = 60 * 24 * 30
cors_origins: str = (
"http://localhost:5173,http://localhost:3000,http://localhost,"
"https://localhost,capacitor://localhost,http://10.0.2.2:8000"
)
plate_step_kg: float = 1.25
pause_limit_minutes: int = 15
rest_min_days: int = 3
rest_max_days: int = 5
@property
def cors_origin_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
settings = Settings()
File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.database import Base, engine
from app.routers import actions, auth, workouts
app = FastAPI(title="Benchpress Secret", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(workouts.router)
app.include_router(actions.router)
@app.on_event("startup")
def on_startup() -> None:
Base.metadata.create_all(bind=engine)
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok"}
+118
View File
@@ -0,0 +1,118 @@
from __future__ import annotations
from datetime import date, datetime
from typing import Any, Optional
from sqlalchemy import Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class Profile(Base):
__tablename__ = "profiles"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
one_rm_kg: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
training_weekdays: Mapped[Optional[list[Any]]] = mapped_column(JSONB, nullable=True)
start_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
onboarded: Mapped[bool] = mapped_column(Boolean, default=False)
phase: Mapped[str] = mapped_column(String(32), default="active")
# active | rest | probe | completed_cycle
rest_until: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
probe_available_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
cycle_number: Mapped[int] = mapped_column(Integer, default=1)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class Workout(Base):
__tablename__ = "workouts"
__table_args__ = (UniqueConstraint("scheduled_date", "cycle_number", name="uq_workout_date_cycle"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
cycle_number: Mapped[int] = mapped_column(Integer, default=1)
week_number: Mapped[int] = mapped_column(Integer, nullable=False)
day_index: Mapped[int] = mapped_column(Integer, nullable=False) # 0..2 within week
scheduled_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
status: Mapped[str] = mapped_column(String(32), default="planned")
# planned | in_progress | paused | completed | abandoned
started_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
paused_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
current_exercise_index: Mapped[int] = mapped_column(Integer, default=0)
current_set_index: Mapped[int] = mapped_column(Integer, default=0)
rest_started_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
exercises: Mapped[list[WorkoutExercise]] = relationship(
back_populates="workout", cascade="all, delete-orphan", order_by="WorkoutExercise.order_index"
)
class WorkoutExercise(Base):
__tablename__ = "workout_exercises"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
workout_id: Mapped[int] = mapped_column(ForeignKey("workouts.id", ondelete="CASCADE"), index=True)
order_index: Mapped[int] = mapped_column(Integer, nullable=False)
name: Mapped[str] = mapped_column(String(128), nullable=False)
kind: Mapped[str] = mapped_column(String(32), nullable=False) # bench | accessory | failure
prescribed_weight_kg: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
weight_locked: Mapped[bool] = mapped_column(Boolean, default=False)
needs_weight_input: Mapped[bool] = mapped_column(Boolean, default=False)
pending_prompt: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
# None | choose_weight_missed | choose_weight_easy | confirm_easy
workout: Mapped[Workout] = relationship(back_populates="exercises")
sets: Mapped[list[WorkoutSet]] = relationship(
back_populates="exercise", cascade="all, delete-orphan", order_by="WorkoutSet.set_index"
)
class WorkoutSet(Base):
__tablename__ = "workout_sets"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
exercise_id: Mapped[int] = mapped_column(ForeignKey("workout_exercises.id", ondelete="CASCADE"), index=True)
set_index: Mapped[int] = mapped_column(Integer, nullable=False)
target_percent: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
target_reps: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
target_weight_kg: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
is_failure: Mapped[bool] = mapped_column(Boolean, default=False)
completed: Mapped[bool] = mapped_column(Boolean, default=False)
actual_reps: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
actual_weight_kg: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
exercise: Mapped[WorkoutExercise] = relationship(back_populates="sets")
class AccessoryState(Base):
"""Tracks working weight and lock status for accessory exercises within a cycle."""
__tablename__ = "accessory_states"
__table_args__ = (UniqueConstraint("cycle_number", "exercise_name", name="uq_accessory_cycle_name"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
cycle_number: Mapped[int] = mapped_column(Integer, default=1)
exercise_name: Mapped[str] = mapped_column(String(128), nullable=False)
weight_kg: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
locked: Mapped[bool] = mapped_column(Boolean, default=False)
pending_weight_kg: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class AppMeta(Base):
__tablename__ = "app_meta"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
key: Mapped[str] = mapped_column(String(64), unique=True)
value: Mapped[str] = mapped_column(Text, nullable=False)
View File
Whitespace-only changes.
+159
View File
@@ -0,0 +1,159 @@
from __future__ import annotations
from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User, Workout
from app.routers.auth import require_auth
from app.schemas import (
AdjustSetRequest,
CompleteSetRequest,
MoveWorkoutRequest,
PromptChoiceRequest,
SetWeightRequest,
WorkoutDetail,
)
from app.services.auth import get_or_create_profile
from app.services.reschedule import plan_move
from app.services.workout import (
adjust_completed_set,
complete_set,
load_workout,
pause_workout,
resolve_prompt,
resume_workout,
set_exercise_weight,
start_workout,
workout_detail,
)
router = APIRouter(prefix="/api/workouts", tags=["workout-actions"])
@router.post("/{workout_id}/start", response_model=WorkoutDetail)
def api_start(workout_id: int, _: User = Depends(require_auth), db: Session = Depends(get_db)) -> WorkoutDetail:
w = start_workout(db, load_workout(db, workout_id))
return workout_detail(db, w)
@router.post("/{workout_id}/pause", response_model=WorkoutDetail)
def api_pause(workout_id: int, _: User = Depends(require_auth), db: Session = Depends(get_db)) -> WorkoutDetail:
w = pause_workout(db, load_workout(db, workout_id))
return workout_detail(db, w)
@router.post("/{workout_id}/resume", response_model=WorkoutDetail)
def api_resume(workout_id: int, _: User = Depends(require_auth), db: Session = Depends(get_db)) -> WorkoutDetail:
w = resume_workout(db, load_workout(db, workout_id))
return workout_detail(db, w)
@router.post("/{workout_id}/complete-set", response_model=WorkoutDetail)
def api_complete_set(
workout_id: int,
body: CompleteSetRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> WorkoutDetail:
w = complete_set(
db,
load_workout(db, workout_id),
actual_reps=body.actual_reps,
actual_weight_kg=body.actual_weight_kg,
)
return workout_detail(db, w)
@router.post("/{workout_id}/exercises/{exercise_id}/weight", response_model=WorkoutDetail)
def api_set_weight(
workout_id: int,
exercise_id: int,
body: SetWeightRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> WorkoutDetail:
workout = load_workout(db, workout_id)
exercise = next((e for e in workout.exercises if e.id == exercise_id), None)
if not exercise:
raise HTTPException(404, "Упражнение не найдено")
w = set_exercise_weight(db, workout, exercise, body.weight_kg)
return workout_detail(db, w)
@router.post("/{workout_id}/exercises/{exercise_id}/sets/{set_id}", response_model=WorkoutDetail)
def api_adjust_set(
workout_id: int,
exercise_id: int,
set_id: int,
body: AdjustSetRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> WorkoutDetail:
workout = load_workout(db, workout_id)
exercise = next((e for e in workout.exercises if e.id == exercise_id), None)
if not exercise:
raise HTTPException(404, "Упражнение не найдено")
workout_set = next((s for s in exercise.sets if s.id == set_id), None)
if not workout_set:
raise HTTPException(404, "Подход не найден")
w = adjust_completed_set(
db,
workout,
exercise,
workout_set,
actual_weight_kg=body.actual_weight_kg,
actual_reps=body.actual_reps,
)
return workout_detail(db, w)
@router.post("/{workout_id}/move", response_model=WorkoutDetail)
def api_move(
workout_id: int,
body: MoveWorkoutRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> WorkoutDetail:
workout = load_workout(db, workout_id)
profile = get_or_create_profile(db)
weekdays = profile.training_weekdays or []
if len(weekdays) != 3:
raise HTTPException(400, "Не заданы дни тренировок")
rows = db.query(Workout).filter(Workout.cycle_number == workout.cycle_number).all()
assigned = plan_move(rows, workout_id, body.scheduled_date, list(weekdays))
for row in rows:
if row.id in assigned:
row.scheduled_date = date(2099, 1, 1) + timedelta(days=row.id % 300)
db.flush()
by_id = {row.id: row for row in rows}
for workout_row_id, scheduled in assigned.items():
by_id[workout_row_id].scheduled_date = scheduled
db.commit()
return workout_detail(db, load_workout(db, workout_id))
@router.post("/{workout_id}/exercises/{exercise_id}/prompt", response_model=WorkoutDetail)
def api_prompt(
workout_id: int,
exercise_id: int,
body: PromptChoiceRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> WorkoutDetail:
workout = load_workout(db, workout_id)
exercise = next((e for e in workout.exercises if e.id == exercise_id), None)
if not exercise:
raise HTTPException(404, "Упражнение не найдено")
w = resolve_prompt(
db,
workout,
exercise,
choice=body.choice,
weight_kg=body.weight_kg,
increase_kg=body.increase_kg,
decrease_kg=body.decrease_kg,
)
return workout_detail(db, w)
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User
from app.schemas import LoginRequest, SetupPasswordRequest, StatusResponse, TokenResponse
from app.services.auth import (
create_access_token,
decode_token,
get_or_create_profile,
get_user,
has_password,
hash_password,
verify_password,
)
router = APIRouter(prefix="/api/auth", tags=["auth"])
security = HTTPBearer(auto_error=False)
def require_auth(
creds: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: Session = Depends(get_db),
) -> User:
if not creds:
raise HTTPException(401, "Требуется авторизация")
sub = decode_token(creds.credentials)
if not sub:
raise HTTPException(401, "Недействительный токен")
user = get_user(db)
if not user:
raise HTTPException(401, "Пользователь не найден")
return user
@router.get("/status", response_model=StatusResponse)
def auth_status(db: Session = Depends(get_db)) -> StatusResponse:
profile = get_or_create_profile(db)
return StatusResponse(
has_password=has_password(db),
onboarded=profile.onboarded,
phase=profile.phase,
)
@router.post("/setup", response_model=TokenResponse)
def setup_password(body: SetupPasswordRequest, db: Session = Depends(get_db)) -> TokenResponse:
if has_password(db):
raise HTTPException(400, "Пароль уже задан")
user = User(password_hash=hash_password(body.password), created_at=datetime.utcnow())
db.add(user)
get_or_create_profile(db)
db.commit()
profile = get_or_create_profile(db)
return TokenResponse(
access_token=create_access_token(),
needs_setup=False,
onboarded=profile.onboarded,
)
@router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
user = get_user(db)
if not user:
raise HTTPException(400, "Сначала задайте пароль")
if not verify_password(body.password, user.password_hash):
raise HTTPException(401, "Неверный пароль")
profile = get_or_create_profile(db)
return TokenResponse(
access_token=create_access_token(),
onboarded=profile.onboarded,
)
+366
View File
@@ -0,0 +1,366 @@
from __future__ import annotations
from datetime import date, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session, joinedload
from app.database import get_db
from app.models import AccessoryState, Profile, User, Workout, WorkoutExercise
from app.routers.auth import require_auth
from app.schemas import (
CalendarResponse,
ExportResponse,
ImportRequest,
MessageResponse,
OnboardingRequest,
ProbeRequest,
ProfileResponse,
StartRestRequest,
TrainingDaysRequest,
WorkoutDetail,
WorkoutSummary,
)
from app.services.reschedule import plan_weekday_change
from app.services.auth import get_or_create_profile
from app.services.schedule import clear_cycle_workouts, generate_schedule
from app.services.workout import (
load_workout,
profile_response,
start_rest_phase,
submit_probe,
workout_detail,
workout_summary,
)
from app.services.weights import round_to_plate
router = APIRouter(prefix="/api", tags=["app"])
@router.get("/profile", response_model=ProfileResponse)
def get_profile(_: User = Depends(require_auth), db: Session = Depends(get_db)) -> ProfileResponse:
return profile_response(get_or_create_profile(db))
@router.post("/onboarding", response_model=ProfileResponse)
def onboarding(
body: OnboardingRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> ProfileResponse:
profile = get_or_create_profile(db)
if profile.onboarded:
raise HTTPException(400, "Онбординг уже пройден. Используйте сброс в настройках.")
one_rm = round_to_plate(body.one_rm_kg)
profile.one_rm_kg = one_rm
profile.training_weekdays = body.training_weekdays
profile.start_date = body.start_date
profile.onboarded = True
profile.phase = "active"
profile.cycle_number = 1
clear_cycle_workouts(db, 1)
generate_schedule(
db,
one_rm_kg=one_rm,
training_weekdays=body.training_weekdays,
start_date=body.start_date,
cycle_number=1,
)
db.commit()
db.refresh(profile)
return profile_response(profile)
@router.get("/calendar", response_model=CalendarResponse)
def calendar(
year: int,
month: int,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> CalendarResponse:
if month < 1 or month > 12:
raise HTTPException(400, "Неверный месяц")
profile = get_or_create_profile(db)
workouts = (
db.query(Workout)
.options(joinedload(Workout.exercises))
.filter(
Workout.cycle_number == profile.cycle_number,
)
.order_by(Workout.scheduled_date)
.all()
)
# filter by month for display but also include all cycle for week strip — return month filtered
month_workouts = [w for w in workouts if w.scheduled_date.year == year and w.scheduled_date.month == month]
return CalendarResponse(
year=year,
month=month,
workouts=[workout_summary(w) for w in month_workouts],
profile=profile_response(profile),
)
@router.post("/training-days", response_model=ProfileResponse)
def update_training_days(
body: TrainingDaysRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> ProfileResponse:
profile = get_or_create_profile(db)
if not profile.onboarded:
raise HTTPException(400, "Сначала создайте программу")
rows = db.query(Workout).filter(Workout.cycle_number == profile.cycle_number).all()
done = [w.scheduled_date for w in rows if w.status == "completed"]
start = date.today()
if done:
start = max(start, max(done) + timedelta(days=1))
assigned = plan_weekday_change(rows, list(body.training_weekdays), start)
for row in rows:
if row.id in assigned:
row.scheduled_date = date(2099, 1, 1) + timedelta(days=row.id % 300)
db.flush()
by_id = {row.id: row for row in rows}
for workout_row_id, scheduled in assigned.items():
by_id[workout_row_id].scheduled_date = scheduled
profile.training_weekdays = list(body.training_weekdays)
db.commit()
db.refresh(profile)
return profile_response(profile)
@router.get("/workouts/upcoming", response_model=list[WorkoutSummary])
def upcoming(
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> list[WorkoutSummary]:
profile = get_or_create_profile(db)
workouts = (
db.query(Workout)
.options(joinedload(Workout.exercises))
.filter(Workout.cycle_number == profile.cycle_number)
.order_by(Workout.scheduled_date)
.all()
)
return [workout_summary(w) for w in workouts]
@router.get("/workouts/{workout_id}", response_model=WorkoutDetail)
def get_workout(
workout_id: int,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> WorkoutDetail:
return workout_detail(db, load_workout(db, workout_id))
@router.post("/rest/start", response_model=ProfileResponse)
def rest_start(
body: StartRestRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> ProfileResponse:
profile = get_or_create_profile(db)
return profile_response(start_rest_phase(db, profile, body.rest_days))
@router.post("/probe", response_model=ProfileResponse)
def probe(
body: ProbeRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> ProfileResponse:
profile = get_or_create_profile(db)
return profile_response(
submit_probe(
db,
profile,
one_rm_kg=body.one_rm_kg,
start_date=body.start_date,
training_weekdays=body.training_weekdays,
)
)
@router.get("/export", response_model=ExportResponse)
def export_data(_: User = Depends(require_auth), db: Session = Depends(get_db)) -> ExportResponse:
profile = get_or_create_profile(db)
workouts = (
db.query(Workout)
.options(joinedload(Workout.exercises).joinedload(WorkoutExercise.sets))
.order_by(Workout.scheduled_date)
.all()
)
accessories = db.query(AccessoryState).all()
def dump_workout(w: Workout) -> dict:
return {
"cycle_number": w.cycle_number,
"week_number": w.week_number,
"day_index": w.day_index,
"scheduled_date": w.scheduled_date.isoformat(),
"status": w.status,
"started_at": w.started_at.isoformat() if w.started_at else None,
"completed_at": w.completed_at.isoformat() if w.completed_at else None,
"current_exercise_index": w.current_exercise_index,
"current_set_index": w.current_set_index,
"exercises": [
{
"order_index": e.order_index,
"name": e.name,
"kind": e.kind,
"prescribed_weight_kg": e.prescribed_weight_kg,
"weight_locked": e.weight_locked,
"needs_weight_input": e.needs_weight_input,
"pending_prompt": e.pending_prompt,
"sets": [
{
"set_index": s.set_index,
"target_percent": s.target_percent,
"target_reps": s.target_reps,
"target_weight_kg": s.target_weight_kg,
"is_failure": s.is_failure,
"completed": s.completed,
"actual_reps": s.actual_reps,
"actual_weight_kg": s.actual_weight_kg,
"completed_at": s.completed_at.isoformat() if s.completed_at else None,
}
for s in sorted(e.sets, key=lambda x: x.set_index)
],
}
for e in sorted(w.exercises, key=lambda x: x.order_index)
],
}
return ExportResponse(
exported_at=datetime.utcnow(),
data={
"profile": {
"one_rm_kg": profile.one_rm_kg,
"training_weekdays": profile.training_weekdays,
"start_date": profile.start_date.isoformat() if profile.start_date else None,
"onboarded": profile.onboarded,
"phase": profile.phase,
"rest_until": profile.rest_until.isoformat() if profile.rest_until else None,
"probe_available_at": profile.probe_available_at.isoformat()
if profile.probe_available_at
else None,
"cycle_number": profile.cycle_number,
},
"workouts": [dump_workout(w) for w in workouts],
"accessories": [
{
"cycle_number": a.cycle_number,
"exercise_name": a.exercise_name,
"weight_kg": a.weight_kg,
"locked": a.locked,
"pending_weight_kg": a.pending_weight_kg,
}
for a in accessories
],
},
)
@router.post("/import", response_model=MessageResponse)
def import_data(
body: ImportRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> MessageResponse:
if body.version != 1:
raise HTTPException(400, "Неподдерживаемая версия экспорта")
data = body.data
# full replace of progress
db.query(Workout).delete()
db.query(AccessoryState).delete()
profile = get_or_create_profile(db)
p = data.get("profile", {})
profile.one_rm_kg = p.get("one_rm_kg")
profile.training_weekdays = p.get("training_weekdays")
profile.start_date = datetime.fromisoformat(p["start_date"]).date() if p.get("start_date") else None
profile.onboarded = bool(p.get("onboarded"))
profile.phase = p.get("phase") or "active"
profile.rest_until = datetime.fromisoformat(p["rest_until"]) if p.get("rest_until") else None
profile.probe_available_at = (
datetime.fromisoformat(p["probe_available_at"]) if p.get("probe_available_at") else None
)
profile.cycle_number = int(p.get("cycle_number") or 1)
from app.models import WorkoutSet
for a in data.get("accessories", []):
db.add(
AccessoryState(
cycle_number=a["cycle_number"],
exercise_name=a["exercise_name"],
weight_kg=a.get("weight_kg"),
locked=bool(a.get("locked")),
pending_weight_kg=a.get("pending_weight_kg"),
)
)
for w in data.get("workouts", []):
workout = Workout(
cycle_number=w["cycle_number"],
week_number=w["week_number"],
day_index=w["day_index"],
scheduled_date=datetime.fromisoformat(w["scheduled_date"]).date(),
status=w.get("status") or "planned",
started_at=datetime.fromisoformat(w["started_at"]) if w.get("started_at") else None,
completed_at=datetime.fromisoformat(w["completed_at"]) if w.get("completed_at") else None,
current_exercise_index=w.get("current_exercise_index") or 0,
current_set_index=w.get("current_set_index") or 0,
)
db.add(workout)
db.flush()
for e in w.get("exercises", []):
ex = WorkoutExercise(
workout_id=workout.id,
order_index=e["order_index"],
name=e["name"],
kind=e["kind"],
prescribed_weight_kg=e.get("prescribed_weight_kg"),
weight_locked=bool(e.get("weight_locked")),
needs_weight_input=bool(e.get("needs_weight_input")),
pending_prompt=e.get("pending_prompt"),
)
db.add(ex)
db.flush()
for s in e.get("sets", []):
db.add(
WorkoutSet(
exercise_id=ex.id,
set_index=s["set_index"],
target_percent=s.get("target_percent"),
target_reps=s.get("target_reps"),
target_weight_kg=s.get("target_weight_kg"),
is_failure=bool(s.get("is_failure")),
completed=bool(s.get("completed")),
actual_reps=s.get("actual_reps"),
actual_weight_kg=s.get("actual_weight_kg"),
completed_at=datetime.fromisoformat(s["completed_at"])
if s.get("completed_at")
else None,
)
)
db.commit()
return MessageResponse(message="Данные импортированы")
@router.post("/reset", response_model=MessageResponse)
def reset_all(_: User = Depends(require_auth), db: Session = Depends(get_db)) -> MessageResponse:
db.query(Workout).delete()
db.query(AccessoryState).delete()
profile = get_or_create_profile(db)
profile.one_rm_kg = None
profile.training_weekdays = None
profile.start_date = None
profile.onboarded = False
profile.phase = "active"
profile.rest_until = None
profile.probe_available_at = None
profile.cycle_number = 1
db.commit()
return MessageResponse(message="Прогресс удалён. Пройдите онбординг заново.")
+193
View File
@@ -0,0 +1,193 @@
from __future__ import annotations
from datetime import date, datetime
from typing import Any, Literal, Optional
from pydantic import BaseModel, Field, field_validator
Weekday = Literal[0, 1, 2, 3, 4, 5, 6] # Mon=0 .. Sun=6
class SetupPasswordRequest(BaseModel):
password: str = Field(min_length=4, max_length=128)
class LoginRequest(BaseModel):
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
needs_setup: bool = False
onboarded: bool = False
class StatusResponse(BaseModel):
has_password: bool
onboarded: bool
phase: str = "active"
class OnboardingRequest(BaseModel):
one_rm_kg: float = Field(gt=0)
training_weekdays: list[int] = Field(min_length=3, max_length=3)
start_date: date
@field_validator("training_weekdays")
@classmethod
def validate_days(cls, v: list[int]) -> list[int]:
if len(set(v)) != 3:
raise ValueError("Нужно выбрать 3 разных дня")
for d in v:
if d < 0 or d > 6:
raise ValueError("День недели должен быть 0–6")
sorted_days = sorted(v)
# check circular gap of at least 1 day between consecutive selected days
for i in range(3):
a = sorted_days[i]
b = sorted_days[(i + 1) % 3]
if i < 2:
gap = b - a - 1
else:
gap = (a + 7) - b - 1
if gap < 1:
raise ValueError("Между выбранными днями нужен минимум 1 день перерыва")
return sorted(v)
class ProfileResponse(BaseModel):
one_rm_kg: Optional[float]
training_weekdays: Optional[list[int]]
start_date: Optional[date]
onboarded: bool
phase: str
rest_until: Optional[datetime]
probe_available_at: Optional[datetime]
cycle_number: int
rest_countdown_seconds: Optional[int] = None
can_probe: bool = False
class SetResponse(BaseModel):
id: int
set_index: int
target_percent: Optional[float]
target_reps: Optional[int]
target_weight_kg: Optional[float]
is_failure: bool
completed: bool
actual_reps: Optional[int]
actual_weight_kg: Optional[float]
class ExerciseResponse(BaseModel):
id: int
order_index: int
name: str
kind: str
prescribed_weight_kg: Optional[float]
weight_locked: bool
needs_weight_input: bool
pending_prompt: Optional[str]
weight_step_kg: float = 1.25
sets: list[SetResponse]
class WorkoutSummary(BaseModel):
id: int
cycle_number: int
week_number: int
day_index: int
scheduled_date: date
status: str
exercise_count: int = 0
class WorkoutDetail(BaseModel):
id: int
cycle_number: int
week_number: int
day_index: int
scheduled_date: date
status: str
started_at: Optional[datetime]
paused_at: Optional[datetime]
completed_at: Optional[datetime]
current_exercise_index: int
current_set_index: int
rest_started_at: Optional[datetime]
rest_elapsed_seconds: int = 0
can_start: bool = False
can_resume: bool = False
pause_expires_at: Optional[datetime] = None
exercises: list[ExerciseResponse]
class CalendarResponse(BaseModel):
year: int
month: int
workouts: list[WorkoutSummary]
profile: ProfileResponse
class CompleteSetRequest(BaseModel):
actual_reps: Optional[int] = None
actual_weight_kg: Optional[float] = None
class SetWeightRequest(BaseModel):
weight_kg: float = Field(gt=0)
class AdjustSetRequest(BaseModel):
actual_weight_kg: float = Field(gt=0)
actual_reps: int = Field(ge=0)
class PromptChoiceRequest(BaseModel):
choice: Literal["easy_yes", "easy_no"]
weight_kg: Optional[float] = None
increase_kg: Optional[float] = None
decrease_kg: Optional[float] = None
class StartRestRequest(BaseModel):
rest_days: int = Field(ge=3, le=5)
class ProbeRequest(BaseModel):
one_rm_kg: float = Field(gt=0)
start_date: date
training_weekdays: Optional[list[int]] = None
class ExportResponse(BaseModel):
version: int = 1
exported_at: datetime
data: dict[str, Any]
class ImportRequest(BaseModel):
version: int
data: dict[str, Any]
class MessageResponse(BaseModel):
message: str
workout: Optional[WorkoutDetail] = None
profile: Optional[ProfileResponse] = None
class MoveWorkoutRequest(BaseModel):
scheduled_date: date
class TrainingDaysRequest(BaseModel):
training_weekdays: list[int] = Field(min_length=3, max_length=3)
@field_validator("training_weekdays")
@classmethod
def validate_days(cls, v: list[int]) -> list[int]:
return OnboardingRequest.validate_days(v)
View File
Whitespace-only changes.
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
from datetime import date, datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from app.config import settings
from app.models import Profile, User
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
return pwd_context.verify(password, password_hash)
def create_access_token(subject: str = "user") -> str:
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
return jwt.encode(
{"sub": subject, "exp": expire},
settings.secret_key,
algorithm="HS256",
)
def decode_token(token: str) -> Optional[str]:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
return payload.get("sub")
except JWTError:
return None
def get_or_create_profile(db: Session) -> Profile:
profile = db.query(Profile).first()
if not profile:
profile = Profile()
db.add(profile)
db.commit()
db.refresh(profile)
return profile
def has_password(db: Session) -> bool:
return db.query(User).first() is not None
def get_user(db: Session) -> Optional[User]:
return db.query(User).first()
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
from typing import Any
PROGRAM_PATH = Path(__file__).resolve().parent.parent / "data" / "program.json"
@lru_cache(maxsize=1)
def load_program() -> dict[str, Any]:
with PROGRAM_PATH.open(encoding="utf-8") as f:
return json.load(f)
def get_week(week_number: int) -> dict[str, Any]:
program = load_program()
for week in program["weeks"]:
if week["week"] == week_number:
return week
raise ValueError(f"Неделя {week_number} не найдена")
+114
View File
@@ -0,0 +1,114 @@
from __future__ import annotations
from datetime import date, timedelta
from fastapi import HTTPException
def _add(d: date, days: int) -> date:
return d + timedelta(days=days)
def _step(d: date, weekdays: list[int], direction: int) -> date:
selected = set(weekdays)
cursor = _add(d, direction)
for _ in range(28):
if cursor.weekday() in selected:
return cursor
cursor = _add(cursor, direction)
raise HTTPException(400, "Не удалось найти тренировочный день")
def _on_or_after(d: date, weekdays: list[int]) -> date:
if d.weekday() in set(weekdays):
return d
return _step(_add(d, -1), weekdays, 1)
def _order(week: int, day_index: int) -> int:
return week * 10 + day_index
def _next_training(after: date, weekdays: list[int], taken: set[date]) -> date:
value = _step(after, weekdays, 1)
while value in taken:
value = _step(value, weekdays, 1)
return value
def _place(desired: date, previous: date | None, weekdays: list[int], taken: set[date]) -> date:
value = desired
if previous is not None and value <= previous:
value = _next_training(previous, weekdays, taken)
while value in taken:
value = _next_training(value, weekdays, taken)
return value
def plan_move(workouts: list, moved_id: int, new_date: date, weekdays: list[int]) -> dict[int, date]:
moved = next((w for w in workouts if w.id == moved_id), None)
if moved is None:
raise HTTPException(404, "Тренировка не найдена")
if moved.status == "completed":
raise HTTPException(400, "Выполненную тренировку переносить нельзя")
if new_date < date.today():
raise HTTPException(400, "В закрытый период переносить нельзя")
sorted_items = sorted(workouts, key=lambda w: (_order(w.week_number, w.day_index), w.id))
completed = [w for w in sorted_items if w.status == "completed"]
if any(w.scheduled_date == new_date for w in completed):
raise HTTPException(400, "На эту дату уже есть выполненная тренировка")
moved_order = _order(moved.week_number, moved.day_index)
earlier = [w for w in completed if _order(w.week_number, w.day_index) < moved_order]
later = [w for w in completed if _order(w.week_number, w.day_index) > moved_order]
if earlier and new_date <= earlier[-1].scheduled_date:
raise HTTPException(400, "Дата должна быть позже уже выполненной тренировки")
if later and new_date >= later[0].scheduled_date:
raise HTTPException(400, "Дата должна быть раньше уже выполненной следующей тренировки")
taken = {w.scheduled_date for w in completed}
assigned: dict[int, date] = {}
index = 0
while index < len(sorted_items):
if sorted_items[index].status == "completed":
index += 1
continue
start = index
while index < len(sorted_items) and sorted_items[index].status != "completed":
index += 1
segment = sorted_items[start:index]
prev_anchor = sorted_items[start - 1] if start > 0 else None
next_anchor = sorted_items[index] if index < len(sorted_items) else None
proposed = sorted(new_date if item.id == moved_id else item.scheduled_date for item in segment)
dates: list[date] = []
for raw in proposed:
value = _place(raw, dates[-1] if dates else None, weekdays, taken)
if prev_anchor is not None and value <= prev_anchor.scheduled_date:
raise HTTPException(400, "Дата должна быть позже уже выполненной тренировки")
if next_anchor is not None and value >= next_anchor.scheduled_date:
raise HTTPException(400, "Дата должна быть раньше уже выполненной следующей тренировки")
dates.append(value)
taken.add(value)
for item, value in zip(segment, dates):
assigned[item.id] = value
return assigned
def plan_weekday_change(workouts: list, weekdays: list[int], start_from: date) -> dict[int, date]:
open_items = sorted(
(w for w in workouts if w.status != "completed"),
key=lambda w: _order(w.week_number, w.day_index),
)
taken = {w.scheduled_date for w in workouts if w.status == "completed"}
assigned: dict[int, date] = {}
cursor = start_from
for item in open_items:
date_value = _on_or_after(cursor, weekdays)
while date_value in taken:
date_value = _step(date_value, weekdays, 1)
assigned[item.id] = date_value
taken.add(date_value)
cursor = _add(date_value, 1)
return assigned
+197
View File
@@ -0,0 +1,197 @@
from __future__ import annotations
from datetime import date, timedelta
from sqlalchemy.orm import Session
from app.models import AccessoryState, Workout, WorkoutExercise, WorkoutSet
from app.services.program import load_program
from app.services.weights import bench_weight
WEEKDAY_NAMES = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
def next_dates_for_weekdays(start: date, weekdays: list[int], count: int) -> list[date]:
"""Return `count` dates on selected weekdays starting from start (inclusive if matches)."""
selected = set(weekdays)
dates: list[date] = []
cursor = start
# safety limit
for _ in range(400):
if cursor.weekday() in selected:
dates.append(cursor)
if len(dates) >= count:
break
cursor += timedelta(days=1)
if len(dates) < count:
raise ValueError("Не удалось построить расписание")
return dates
def clear_cycle_workouts(db: Session, cycle_number: int) -> None:
workouts = db.query(Workout).filter(Workout.cycle_number == cycle_number).all()
for w in workouts:
db.delete(w)
db.query(AccessoryState).filter(AccessoryState.cycle_number == cycle_number).delete()
db.flush()
def generate_schedule(
db: Session,
*,
one_rm_kg: float,
training_weekdays: list[int],
start_date: date,
cycle_number: int,
) -> list[Workout]:
program = load_program()
total_days = sum(len(w["days"]) for w in program["weeks"])
dates = next_dates_for_weekdays(start_date, training_weekdays, total_days)
# Map chronological day_index within week order from template (day 0,1,2 of each week)
date_idx = 0
created: list[Workout] = []
for week in program["weeks"]:
for day_index, day in enumerate(week["days"]):
scheduled = dates[date_idx]
date_idx += 1
workout = Workout(
cycle_number=cycle_number,
week_number=week["week"],
day_index=day_index,
scheduled_date=scheduled,
status="planned",
)
db.add(workout)
db.flush()
for order_index, ex in enumerate(day["exercises"]):
kind = ex["kind"]
accessory_state = None
needs_weight = False
prescribed = None
locked = False
if kind == "accessory":
accessory_state = (
db.query(AccessoryState)
.filter(
AccessoryState.cycle_number == cycle_number,
AccessoryState.exercise_name == ex["name"],
)
.first()
)
if accessory_state and accessory_state.locked and accessory_state.weight_kg is not None:
prescribed = accessory_state.weight_kg
locked = True
elif accessory_state and accessory_state.pending_weight_kg is not None:
prescribed = accessory_state.pending_weight_kg
needs_weight = False
elif accessory_state and accessory_state.weight_kg is not None and not accessory_state.locked:
prescribed = accessory_state.weight_kg
needs_weight = False
else:
needs_weight = True
we = WorkoutExercise(
workout_id=workout.id,
order_index=order_index,
name=ex["name"],
kind=kind,
prescribed_weight_kg=prescribed,
weight_locked=locked,
needs_weight_input=needs_weight,
)
db.add(we)
db.flush()
for set_index, s in enumerate(ex["sets"]):
if kind == "bench":
tw = bench_weight(one_rm_kg, s["percent"])
ws = WorkoutSet(
exercise_id=we.id,
set_index=set_index,
target_percent=s["percent"],
target_reps=s["reps"],
target_weight_kg=tw,
is_failure=False,
)
elif kind == "failure":
ws = WorkoutSet(
exercise_id=we.id,
set_index=set_index,
target_reps=None,
target_weight_kg=None,
is_failure=True,
)
else:
reps = s.get("reps") if isinstance(s, dict) else None
ws = WorkoutSet(
exercise_id=we.id,
set_index=set_index,
target_reps=reps,
target_weight_kg=prescribed,
is_failure=False,
)
db.add(ws)
created.append(workout)
db.flush()
return created
def apply_accessory_weight_to_future(
db: Session,
*,
cycle_number: int,
exercise_name: str,
weight_kg: float,
from_date: date,
lock: bool,
) -> None:
state = (
db.query(AccessoryState)
.filter(
AccessoryState.cycle_number == cycle_number,
AccessoryState.exercise_name == exercise_name,
)
.first()
)
if not state:
state = AccessoryState(cycle_number=cycle_number, exercise_name=exercise_name)
db.add(state)
if lock:
state.weight_kg = weight_kg
state.locked = True
state.pending_weight_kg = None
else:
state.pending_weight_kg = weight_kg
state.weight_kg = weight_kg
state.locked = False
# Update future workouts' exercises of same name
future = (
db.query(WorkoutExercise)
.join(Workout)
.filter(
Workout.cycle_number == cycle_number,
Workout.scheduled_date >= from_date,
Workout.status != "completed",
WorkoutExercise.name == exercise_name,
WorkoutExercise.kind == "accessory",
)
.all()
)
for we in future:
if we.sets and all(s.completed for s in we.sets):
continue
we.prescribed_weight_kg = weight_kg
we.weight_locked = lock
we.needs_weight_input = False
for s in we.sets:
if not s.completed:
s.target_weight_kg = weight_kg
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from app.config import settings
# Шаг корректировки/ввода веса вспомогательных (прибавка и снижение).
ACCESSORY_STEP_KG = 0.5
def weight_step_for(exercise_name: str | None = None, *, kind: str | None = None) -> float:
"""Жим — блины (config). Вспомогательные — 0.5 кг."""
if kind == "bench" or (exercise_name == "Жим"):
return settings.plate_step_kg
if kind in ("accessory", "failure"):
return ACCESSORY_STEP_KG
if exercise_name:
return ACCESSORY_STEP_KG
return settings.plate_step_kg
def round_to_plate(weight_kg: float, step: float | None = None) -> float:
step = step if step is not None else settings.plate_step_kg
if weight_kg <= 0:
return 0.0
return round(round(weight_kg / step) * step, 2)
def round_exercise_weight(weight_kg: float, exercise_name: str, *, kind: str | None = None) -> float:
return round_to_plate(weight_kg, weight_step_for(exercise_name, kind=kind))
def bench_weight(one_rm_kg: float, percent: float) -> float:
"""Жим: округление как раньше — по plate_step_kg (1.25)."""
return round_to_plate(one_rm_kg * (percent / 100.0), settings.plate_step_kg)
+589
View File
@@ -0,0 +1,589 @@
from __future__ import annotations
from datetime import date, datetime, timedelta
from typing import Optional
from fastapi import HTTPException
from sqlalchemy.orm import Session, joinedload
from app.config import settings
from app.models import AccessoryState, Profile, Workout, WorkoutExercise, WorkoutSet
from app.schemas import (
ExerciseResponse,
ProfileResponse,
SetResponse,
WorkoutDetail,
WorkoutSummary,
)
from app.services.schedule import apply_accessory_weight_to_future, generate_schedule
from app.services.weights import round_exercise_weight, round_to_plate, weight_step_for
def profile_response(profile: Profile, now: Optional[datetime] = None) -> ProfileResponse:
now = now or datetime.utcnow()
rest_countdown = None
can_probe = False
if profile.phase == "rest" and profile.probe_available_at:
delta = (profile.probe_available_at - now).total_seconds()
if delta > 0:
rest_countdown = int(delta)
else:
can_probe = True
elif profile.phase == "probe":
can_probe = True
return ProfileResponse(
one_rm_kg=profile.one_rm_kg,
training_weekdays=profile.training_weekdays,
start_date=profile.start_date,
onboarded=profile.onboarded,
phase=profile.phase if not (profile.phase == "rest" and can_probe) else "probe",
rest_until=profile.rest_until,
probe_available_at=profile.probe_available_at,
cycle_number=profile.cycle_number,
rest_countdown_seconds=rest_countdown,
can_probe=can_probe,
)
def set_response(s: WorkoutSet) -> SetResponse:
return SetResponse(
id=s.id,
set_index=s.set_index,
target_percent=s.target_percent,
target_reps=s.target_reps,
target_weight_kg=s.target_weight_kg,
is_failure=s.is_failure,
completed=s.completed,
actual_reps=s.actual_reps,
actual_weight_kg=s.actual_weight_kg,
)
def exercise_response(ex: WorkoutExercise) -> ExerciseResponse:
return ExerciseResponse(
id=ex.id,
order_index=ex.order_index,
name=ex.name,
kind=ex.kind,
prescribed_weight_kg=ex.prescribed_weight_kg,
weight_locked=ex.weight_locked,
needs_weight_input=ex.needs_weight_input,
pending_prompt=ex.pending_prompt,
weight_step_kg=weight_step_for(ex.name, kind=ex.kind),
sets=[set_response(s) for s in sorted(ex.sets, key=lambda x: x.set_index)],
)
def workout_summary(w: Workout) -> WorkoutSummary:
return WorkoutSummary(
id=w.id,
cycle_number=w.cycle_number,
week_number=w.week_number,
day_index=w.day_index,
scheduled_date=w.scheduled_date,
status=w.status,
exercise_count=len(w.exercises) if w.exercises is not None else 0,
)
def maybe_expire_pause(db: Session, workout: Workout, now: Optional[datetime] = None) -> Workout:
now = now or datetime.utcnow()
if workout.status == "paused" and workout.paused_at:
limit = workout.paused_at + timedelta(minutes=settings.pause_limit_minutes)
if now > limit:
workout.status = "abandoned"
workout.paused_at = None
workout.rest_started_at = None
db.commit()
db.refresh(workout)
return workout
def workout_detail(db: Session, workout: Workout, today: Optional[date] = None) -> WorkoutDetail:
today = today or date.today()
now = datetime.utcnow()
workout = maybe_expire_pause(db, workout, now)
active = (
db.query(Workout)
.filter(
Workout.cycle_number == workout.cycle_number,
Workout.id != workout.id,
Workout.status.in_(["in_progress", "paused"]),
)
.first()
)
can_start = workout.status == "planned" and workout.scheduled_date == today and active is None
can_resume = False
pause_expires_at = None
if workout.status == "paused" and workout.paused_at:
pause_expires_at = workout.paused_at + timedelta(minutes=settings.pause_limit_minutes)
can_resume = now <= pause_expires_at and workout.scheduled_date == today
rest_elapsed = 0
if workout.status == "in_progress" and workout.rest_started_at:
rest_elapsed = max(0, int((now - workout.rest_started_at).total_seconds()))
return WorkoutDetail(
id=workout.id,
cycle_number=workout.cycle_number,
week_number=workout.week_number,
day_index=workout.day_index,
scheduled_date=workout.scheduled_date,
status=workout.status,
started_at=workout.started_at,
paused_at=workout.paused_at,
completed_at=workout.completed_at,
current_exercise_index=workout.current_exercise_index,
current_set_index=workout.current_set_index,
rest_started_at=workout.rest_started_at,
rest_elapsed_seconds=rest_elapsed,
can_start=can_start,
can_resume=can_resume,
pause_expires_at=pause_expires_at,
exercises=[exercise_response(e) for e in sorted(workout.exercises, key=lambda x: x.order_index)],
)
def load_workout(db: Session, workout_id: int) -> Workout:
def _fetch() -> Workout | None:
return (
db.query(Workout)
.options(joinedload(Workout.exercises).joinedload(WorkoutExercise.sets))
.filter(Workout.id == workout_id)
.first()
)
workout = _fetch()
if not workout:
raise HTTPException(404, "Тренировка не найдена")
if sync_accessory_weights(db, workout):
db.commit()
workout = _fetch()
assert workout is not None
return workout
def sync_accessory_weights(db: Session, workout: Workout) -> bool:
"""Подтянуть сохранённый вес каждого вспомогательного из AccessoryState."""
changed = False
for ex in workout.exercises:
if ex.kind != "accessory":
continue
if any(s.completed for s in ex.sets):
continue
state = (
db.query(AccessoryState)
.filter(
AccessoryState.cycle_number == workout.cycle_number,
AccessoryState.exercise_name == ex.name,
)
.first()
)
if not state:
continue
weight = state.pending_weight_kg if state.pending_weight_kg is not None else state.weight_kg
if weight is None:
continue
if (
ex.prescribed_weight_kg != weight
or ex.needs_weight_input
or ex.weight_locked != bool(state.locked)
):
ex.prescribed_weight_kg = weight
ex.needs_weight_input = False
ex.weight_locked = bool(state.locked)
for s in ex.sets:
if not s.completed:
s.target_weight_kg = weight
changed = True
return changed
def start_workout(db: Session, workout: Workout, today: Optional[date] = None) -> Workout:
today = today or date.today()
if workout.scheduled_date != today:
raise HTTPException(400, "Начать можно только сегодняшнюю тренировку")
if workout.status not in ("planned",):
raise HTTPException(400, "Тренировку нельзя начать")
active = (
db.query(Workout)
.filter(
Workout.cycle_number == workout.cycle_number,
Workout.status.in_(["in_progress", "paused"]),
)
.first()
)
if active:
raise HTTPException(400, "Уже есть активная тренировка")
workout.status = "in_progress"
workout.started_at = datetime.utcnow()
workout.current_exercise_index = 0
workout.current_set_index = 0
db.commit()
db.refresh(workout)
return load_workout(db, workout.id)
def pause_workout(db: Session, workout: Workout) -> Workout:
if workout.status != "in_progress":
raise HTTPException(400, "Пауза доступна только во время тренировки")
workout.status = "paused"
workout.paused_at = datetime.utcnow()
workout.rest_started_at = None
db.commit()
return load_workout(db, workout.id)
def resume_workout(db: Session, workout: Workout, today: Optional[date] = None) -> Workout:
today = today or date.today()
workout = maybe_expire_pause(db, workout)
if workout.status == "abandoned":
raise HTTPException(400, "Тренировка остановлена: пауза дольше 15 минут")
if workout.status != "paused":
raise HTTPException(400, "Тренировка не на паузе")
if workout.scheduled_date != today:
raise HTTPException(400, "Продолжить можно только в тот же день")
assert workout.paused_at
if datetime.utcnow() > workout.paused_at + timedelta(minutes=settings.pause_limit_minutes):
workout.status = "abandoned"
db.commit()
raise HTTPException(400, "Тренировка остановлена: пауза дольше 15 минут")
workout.status = "in_progress"
workout.paused_at = None
db.commit()
return load_workout(db, workout.id)
def set_exercise_weight(db: Session, workout: Workout, exercise: WorkoutExercise, weight_kg: float) -> Workout:
if exercise.kind != "accessory":
raise HTTPException(400, "Вес задаётся только для вспомогательных")
if workout.status == "completed":
raise HTTPException(400, "Выполненную тренировку менять нельзя")
if exercise.sets and all(s.completed for s in exercise.sets):
raise HTTPException(400, "Это упражнение уже выполнено")
weight = round_exercise_weight(weight_kg, exercise.name, kind=exercise.kind)
exercise.prescribed_weight_kg = weight
exercise.needs_weight_input = False
for s in exercise.sets:
if not s.completed:
s.target_weight_kg = weight
apply_accessory_weight_to_future(
db,
cycle_number=workout.cycle_number,
exercise_name=exercise.name,
weight_kg=weight,
from_date=workout.scheduled_date,
lock=False,
)
db.commit()
return load_workout(db, workout.id)
def adjust_completed_set(
db: Session,
workout: Workout,
exercise: WorkoutExercise,
workout_set: WorkoutSet,
*,
actual_weight_kg: float,
actual_reps: int,
) -> Workout:
if exercise.kind != "accessory":
raise HTTPException(400, "Править можно только вспомогательные")
if not workout_set.completed:
raise HTTPException(400, "Можно править только выполненный подход")
workout_set.actual_weight_kg = round_exercise_weight(actual_weight_kg, exercise.name, kind=exercise.kind)
workout_set.actual_reps = actual_reps
db.commit()
return load_workout(db, workout.id)
def _advance_pointer(workout: Workout, exercises: list[WorkoutExercise]) -> bool:
"""Advance to next incomplete set. Returns True if workout completed."""
for ei, ex in enumerate(exercises):
for s in sorted(ex.sets, key=lambda x: x.set_index):
if not s.completed:
workout.current_exercise_index = ei
workout.current_set_index = s.set_index
return False
return True
def _analyze_accessory(db: Session, workout: Workout, exercise: WorkoutExercise) -> None:
if exercise.kind != "accessory":
return
if exercise.weight_locked:
return
if any(not s.completed for s in exercise.sets):
return
all_hit = all(
(s.actual_reps or 0) >= (s.target_reps or 0) for s in exercise.sets if s.target_reps is not None
)
if not all_hit:
exercise.pending_prompt = "choose_weight_missed"
else:
exercise.pending_prompt = "confirm_easy"
def complete_set(
db: Session,
workout: Workout,
*,
actual_reps: Optional[int] = None,
actual_weight_kg: Optional[float] = None,
) -> Workout:
if workout.status != "in_progress":
raise HTTPException(400, "Тренировка не активна")
exercises = sorted(workout.exercises, key=lambda x: x.order_index)
if workout.current_exercise_index >= len(exercises):
raise HTTPException(400, "Нет текущего упражнения")
exercise = exercises[workout.current_exercise_index]
if exercise.pending_prompt:
raise HTTPException(400, "Сначала ответьте на вопрос по упражнению")
if exercise.kind == "accessory" and exercise.needs_weight_input:
raise HTTPException(400, "Сначала введите рабочий вес")
sets = sorted(exercise.sets, key=lambda x: x.set_index)
current = next((s for s in sets if s.set_index == workout.current_set_index), None)
if not current or current.completed:
# find first incomplete
current = next((s for s in sets if not s.completed), None)
if not current:
raise HTTPException(400, "Нет активного подхода")
if exercise.kind == "bench" or exercise.kind == "failure":
current.completed = True
current.completed_at = datetime.utcnow()
current.actual_weight_kg = current.target_weight_kg
current.actual_reps = current.target_reps
else:
if actual_reps is None:
raise HTTPException(400, "Укажите выполненные повторения")
weight = actual_weight_kg if actual_weight_kg is not None else exercise.prescribed_weight_kg
if weight is None:
raise HTTPException(400, "Не задан вес")
current.completed = True
current.completed_at = datetime.utcnow()
current.actual_reps = actual_reps
current.actual_weight_kg = round_exercise_weight(weight, exercise.name, kind=exercise.kind)
# Check if exercise finished → maybe prompt
if all(s.completed for s in sets):
_analyze_accessory(db, workout, exercise)
if exercise.pending_prompt:
workout.rest_started_at = None
db.commit()
return load_workout(db, workout.id)
done = _advance_pointer(workout, exercises)
if done:
workout.status = "completed"
workout.completed_at = datetime.utcnow()
workout.rest_started_at = None
_maybe_finish_cycle(db, workout)
else:
workout.rest_started_at = datetime.utcnow()
db.commit()
return load_workout(db, workout.id)
def resolve_prompt(
db: Session,
workout: Workout,
exercise: WorkoutExercise,
*,
choice: str,
weight_kg: Optional[float] = None,
increase_kg: Optional[float] = None,
decrease_kg: Optional[float] = None,
) -> Workout:
if workout.status != "in_progress":
raise HTTPException(400, "Тренировка не активна")
if not exercise.pending_prompt:
raise HTTPException(400, "Нет активного вопроса")
prompt = exercise.pending_prompt
today = workout.scheduled_date
current_weight = exercise.prescribed_weight_kg
step = weight_step_for(exercise.name, kind=exercise.kind)
if prompt == "confirm_easy":
if choice == "easy_no":
if current_weight is None:
raise HTTPException(400, "Вес не задан")
apply_accessory_weight_to_future(
db,
cycle_number=workout.cycle_number,
exercise_name=exercise.name,
weight_kg=current_weight,
from_date=today,
lock=True,
)
exercise.weight_locked = True
exercise.pending_prompt = None
elif choice == "easy_yes":
exercise.pending_prompt = "choose_weight_easy"
db.commit()
return load_workout(db, workout.id)
else:
raise HTTPException(400, "Неверный выбор")
elif prompt == "choose_weight_easy":
if current_weight is None:
raise HTTPException(400, "Текущий вес не задан")
if increase_kg is None or increase_kg <= 0:
raise HTTPException(400, "Укажите, на сколько поднять вес")
bump = round_to_plate(increase_kg, step)
if bump < step:
bump = step
weight = round_to_plate(current_weight + bump, step)
if weight <= current_weight:
weight = round_to_plate(current_weight + step, step)
apply_accessory_weight_to_future(
db,
cycle_number=workout.cycle_number,
exercise_name=exercise.name,
weight_kg=weight,
from_date=today + timedelta(days=1),
lock=False,
)
exercise.pending_prompt = None
elif prompt == "choose_weight_missed":
if current_weight is None:
raise HTTPException(400, "Текущий вес не задан")
if decrease_kg is None or decrease_kg <= 0:
raise HTTPException(400, "Укажите, на сколько снизить вес")
drop = round_to_plate(decrease_kg, step)
if drop < step:
drop = step
weight = round_to_plate(current_weight - drop, step)
if weight >= current_weight:
weight = round_to_plate(current_weight - step, step)
if weight < step:
weight = step
apply_accessory_weight_to_future(
db,
cycle_number=workout.cycle_number,
exercise_name=exercise.name,
weight_kg=weight,
from_date=today + timedelta(days=1),
lock=False,
)
exercise.pending_prompt = None
else:
raise HTTPException(400, "Неизвестный вопрос")
exercises = sorted(workout.exercises, key=lambda x: x.order_index)
done = _advance_pointer(workout, exercises)
if done:
workout.status = "completed"
workout.completed_at = datetime.utcnow()
workout.rest_started_at = None
_maybe_finish_cycle(db, workout)
else:
workout.rest_started_at = datetime.utcnow()
db.commit()
return load_workout(db, workout.id)
def _maybe_finish_cycle(db: Session, workout: Workout) -> None:
remaining = (
db.query(Workout)
.filter(
Workout.cycle_number == workout.cycle_number,
Workout.status.in_(["planned", "in_progress", "paused"]),
Workout.id != workout.id,
)
.count()
)
# also count if this one not yet marked - it's being completed
unfinished_others = (
db.query(Workout)
.filter(
Workout.cycle_number == workout.cycle_number,
Workout.id != workout.id,
Workout.status.notin_(["completed", "abandoned"]),
)
.count()
)
if unfinished_others == 0:
profile = db.query(Profile).first()
if profile:
profile.phase = "rest"
# wait for user to choose rest days via API; temporarily set probe far
profile.probe_available_at = None
profile.rest_until = None
def start_rest_phase(db: Session, profile: Profile, rest_days: int) -> Profile:
if profile.phase not in ("rest", "probe"):
# allow if cycle just finished
unfinished = (
db.query(Workout)
.filter(
Workout.cycle_number == profile.cycle_number,
Workout.status.notin_(["completed", "abandoned"]),
)
.count()
)
if unfinished > 0 and profile.phase == "active":
raise HTTPException(400, "Цикл ещё не завершён")
if rest_days < settings.rest_min_days or rest_days > settings.rest_max_days:
raise HTTPException(400, f"Отдых от {settings.rest_min_days} до {settings.rest_max_days} дней")
now = datetime.utcnow()
profile.phase = "rest"
profile.probe_available_at = now + timedelta(days=rest_days)
profile.rest_until = profile.probe_available_at
db.commit()
db.refresh(profile)
return profile
def submit_probe(
db: Session,
profile: Profile,
*,
one_rm_kg: float,
start_date: date,
training_weekdays: Optional[list[int]] = None,
) -> Profile:
now = datetime.utcnow()
if profile.phase == "rest" and profile.probe_available_at and now < profile.probe_available_at:
raise HTTPException(400, "Ещё рано для проходки")
profile.phase = "probe" if profile.phase == "rest" else profile.phase
weekdays = training_weekdays or profile.training_weekdays
if not weekdays:
raise HTTPException(400, "Не заданы дни тренировок")
one_rm = round_to_plate(one_rm_kg)
new_cycle = profile.cycle_number + 1
profile.one_rm_kg = one_rm
profile.training_weekdays = weekdays
profile.start_date = start_date
profile.cycle_number = new_cycle
profile.phase = "active"
profile.rest_until = None
profile.probe_available_at = None
profile.onboarded = True
generate_schedule(
db,
one_rm_kg=one_rm,
training_weekdays=weekdays,
start_date=start_date,
cycle_number=new_cycle,
)
db.commit()
db.refresh(profile)
return profile
+10
View File
@@ -0,0 +1,10 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
psycopg[binary]==3.2.3
pydantic==2.10.4
pydantic-settings==2.7.0
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
python-jose[cryptography]==3.3.0
python-multipart==0.0.20
+36
View File
@@ -0,0 +1,36 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: benchpress
POSTGRES_PASSWORD: benchpress
POSTGRES_DB: benchpress
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U benchpress -d benchpress"]
interval: 5s
timeout: 5s
retries: 10
backend:
build: ./backend
environment:
DATABASE_URL: postgresql+psycopg://benchpress:benchpress@db:5432/benchpress
SECRET_KEY: change-me-in-production-benchpress-secret
CORS_ORIGINS: http://localhost:5173,http://localhost:3000,http://localhost,https://localhost,capacitor://localhost
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
frontend:
build: ./frontend
ports:
- "3000:80"
depends_on:
- backend
volumes:
pgdata:
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.git
*.md
+2
View File
@@ -0,0 +1,2 @@
# Offline V2: данные на устройстве (IndexedDB), сервер не нужен.
VITE_OFFLINE=true
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+32
View File
@@ -0,0 +1,32 @@
# Сборка Android APK (Capacitor) — Offline V2
APK работает **без сервера**: PIN, программа и прогресс хранятся на устройстве (IndexedDB).
## JDK для Gradle
Нужен **JDK 21** (не Java 25 из Android Studio).
Уже скачан в: `%USERPROFILE%\.jdks\temurin-21`
```powershell
$env:JAVA_HOME = "$env:USERPROFILE\.jdks\temurin-21"
$env:ANDROID_HOME = "$env:LOCALAPPDATA\Android\Sdk"
```
## Сборка APK
```powershell
cd C:\Dev\Benchpress\frontend
npm run cap:sync
cd android
$env:JAVA_HOME = "$env:USERPROFILE\.jdks\temurin-21"
$env:ANDROID_HOME = "$env:LOCALAPPDATA\Android\Sdk"
.\gradlew.bat assembleDebug
```
APK:
`C:\Dev\Benchpress\frontend\android\app\build\outputs\apk\debug\app-debug.apk`
`.env.android` содержит `VITE_OFFLINE=true` — сервер и `VITE_API_BASE_URL` не нужны.
## Online-режим (опционально)
Уберите `VITE_OFFLINE` / поставьте `false` и задайте `VITE_API_BASE_URL`, затем снова `cap:sync` + сборка. Для этого нужен запущенный backend (`docker compose up -d`).
+11
View File
@@ -0,0 +1,11 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+101
View File
@@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
+2
View File
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
+54
View File
@@ -0,0 +1,54 @@
apply plugin: 'com.android.application'
android {
namespace = "com.benchpress.secret"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.benchpress.secret"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
@@ -0,0 +1,19 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:screenOrientation="portrait"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -0,0 +1,15 @@
package com.benchpress.secret;
import android.os.Bundle;
import androidx.core.view.WindowCompat;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
WindowCompat.setDecorFitsSystemWindows(getWindow(), true);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Benchpress Secret</string>
<string name="title_activity_main">Benchpress Secret</string>
<string name="package_name">com.benchpress.secret</string>
<string name="custom_url_scheme">com.benchpress.secret</string>
</resources>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
<item name="android:statusBarColor">#0f1412</item>
<item name="android:navigationBarColor">#0c100e</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
</domain-config>
</network-security-config>
@@ -0,0 +1,18 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}
+54
View File
@@ -0,0 +1,54 @@
# Builds a debug APK with a JDK that Gradle 8.14 / AGP 8.13 can run.
# The Java on PATH is Java 8. Android Studio's bundled JBR is Java 25,
# which this Gradle version does not support. Temurin 21 is the right runtime.
$ErrorActionPreference = "Stop"
Set-Location $PSScriptRoot
function Get-JavaMajor([string]$JdkDir) {
$release = Join-Path $JdkDir "release"
if (-not (Test-Path $release)) { return 0 }
$line = Select-String -Path $release -Pattern 'JAVA_VERSION="(\d+)' | Select-Object -First 1
if (-not $line) { return 0 }
return [int]$line.Matches[0].Groups[1].Value
}
function Find-Jdk {
$candidates = @(
"$env:USERPROFILE\.jdks\temurin-21",
"$env:USERPROFILE\.jdks\temurin-17",
${env:JAVA_HOME}
) | Where-Object { $_ }
foreach ($dir in $candidates) {
$java = Join-Path $dir "bin\java.exe"
if (-not (Test-Path $java)) { continue }
$major = Get-JavaMajor $dir
if ($major -ge 17 -and $major -le 24) {
return $dir
}
}
return $null
}
$jdk = Find-Jdk
if (-not $jdk) {
Write-Error @"
No JDK 17-21 found.
Install Temurin 21, or point JAVA_HOME at one.
Android Studio's bundled JBR is Java 25; Gradle 8.14.3 cannot use it.
The Java on PATH is Java 8; AGP 8.13 cannot use it either.
"@
}
$env:JAVA_HOME = $jdk
$env:PATH = "$(Join-Path $jdk 'bin');$env:PATH"
Write-Host "JAVA_HOME=$env:JAVA_HOME"
& (Join-Path $PSScriptRoot "gradlew.bat") assembleDebug @args
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$apk = Join-Path $PSScriptRoot "app\build\outputs\apk\debug\app-debug.apk"
if (Test-Path $apk) {
Write-Host "APK: $apk"
}
+29
View File
@@ -0,0 +1,29 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
@@ -0,0 +1,3 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
+22
View File
@@ -0,0 +1,22 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
Binary file not shown.
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+5
View File
@@ -0,0 +1,5 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'
+16
View File
@@ -0,0 +1,16 @@
ext {
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
androidxAppCompatVersion = '1.7.1'
androidxCoordinatorLayoutVersion = '1.3.0'
androidxCoreVersion = '1.17.0'
androidxFragmentVersion = '1.8.9'
coreSplashScreenVersion = '1.2.0'
androidxWebkitVersion = '1.14.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
}
+15
View File
@@ -0,0 +1,15 @@
import type { CapacitorConfig } from "@capacitor/cli";
const config: CapacitorConfig = {
appId: "com.benchpress.secret",
appName: "Benchpress Secret",
webDir: "dist",
server: {
androidScheme: "https",
},
android: {
allowMixedContent: true,
},
};
export default config;
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0f1412" />
<title>Benchpress Secret</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+2617
View File
File diff suppressed because it is too large. Load diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build:android": "tsc -b && vite build --mode android",
"lint": "oxlint",
"preview": "vite preview",
"cap:sync": "npm run build:android && npx cap sync android",
"cap:open": "npx cap open android"
},
"dependencies": {
"@capacitor/android": "^8.5.2",
"@capacitor/core": "^8.5.2",
"dexie": "^4.4.6",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.4"
},
"devDependencies": {
"@capacitor/cli": "^8.5.2",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+7
View File
@@ -0,0 +1,7 @@
# Собирает debug APK. JDK подставляет android\build-apk.ps1 (Temurin 21).
# Запуск: powershell -ExecutionPolicy Bypass -File C:\Dev\Benchpress\frontend\scripts\build-apk.ps1
$ErrorActionPreference = "Stop"
$gradleScript = Join-Path $PSScriptRoot "..\android\build-apk.ps1"
& $gradleScript @args
exit $LASTEXITCODE
+65
View File
@@ -0,0 +1,65 @@
# Ставит собранный debug APK на эмулятор и открывает приложение.
# Если виртуалка выключена, запускает первый AVD и ждёт загрузки.
# Запуск: powershell -ExecutionPolicy Bypass -File C:\Dev\Benchpress\frontend\scripts\install-emulator.ps1
$ErrorActionPreference = "Stop"
$sdk = Join-Path $env:LOCALAPPDATA "Android\Sdk"
$adb = Join-Path $sdk "platform-tools\adb.exe"
$emulator = Join-Path $sdk "emulator\emulator.exe"
$apk = Resolve-Path (Join-Path $PSScriptRoot "..\android\app\build\outputs\apk\debug\app-debug.apk") -ErrorAction SilentlyContinue
$package = "com.benchpress.secret"
if (-not (Test-Path $adb)) {
Write-Error "adb не найден: $adb"
}
if (-not $apk) {
Write-Error "APK ещё нет. Сначала запусти scripts\build-apk.ps1"
}
function Get-EmulatorSerial {
$lines = & $adb devices
foreach ($line in $lines) {
if ($line -match "^(emulator-\d+)\s+device\s*$") {
return $Matches[1]
}
}
return $null
}
$serial = Get-EmulatorSerial
if (-not $serial) {
if (-not (Test-Path $emulator)) {
Write-Error "Эмулятор не найден: $emulator"
}
$avds = @(& $emulator -list-avds | Where-Object { $_ -and $_.Trim() })
if ($avds.Count -eq 0) {
Write-Error "Нет виртуальных устройств. Создай его в Android Studio: Device Manager."
}
$avd = $avds[0].Trim()
Write-Host "Запуск эмулятора $avd ..."
Start-Process -FilePath $emulator -ArgumentList @("-avd", $avd)
& $adb wait-for-device
$deadline = (Get-Date).AddMinutes(4)
do {
Start-Sleep -Seconds 2
$serial = Get-EmulatorSerial
if (-not $serial) { continue }
$boot = (& $adb -s $serial shell getprop sys.boot_completed | Out-String).Trim()
if ($boot -eq "1") { break }
} while ((Get-Date) -lt $deadline)
$serial = Get-EmulatorSerial
if (-not $serial) {
Write-Error "Эмулятор не успел загрузиться."
}
}
Write-Host "Установка на $serial ..."
& $adb -s $serial install -r $apk
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
& $adb -s $serial shell am force-stop $package
& $adb -s $serial shell am start -n "$package/.MainActivity"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "Приложение открыто на $serial"
+12
View File
@@ -0,0 +1,12 @@
# После правок в Cursor: собирает веб-приложение и копирует его в Android-проект.
# Запуск: powershell -ExecutionPolicy Bypass -File C:\Dev\Benchpress\frontend\scripts\update-app.ps1
$ErrorActionPreference = "Stop"
$frontend = Resolve-Path (Join-Path $PSScriptRoot "..")
Set-Location $frontend
Write-Host "Сборка веб-части и cap sync..."
npm run cap:sync
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "Готово. Дальше: .\build-apk.ps1, затем .\install-emulator.ps1"
+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+57
View File
@@ -0,0 +1,57 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider, useAuth } from "./auth";
import { HomePage } from "./pages/HomePage";
import { LoginPage } from "./pages/LoginPage";
import { OnboardingPage } from "./pages/OnboardingPage";
import { SettingsPage } from "./pages/SettingsPage";
import { WorkoutPage } from "./pages/WorkoutPage";
function Guard({ children }: { children: React.ReactNode }) {
const { loading, token, hasPassword, onboarded } = useAuth();
if (loading) return <div className="page"><p className="muted">Загрузка…</p></div>;
if (!hasPassword || !token) return <LoginPage />;
if (!onboarded) return <OnboardingPage />;
return <>{children}</>;
}
function AppRoutes() {
return (
<Routes>
<Route
path="/"
element={
<Guard>
<HomePage />
</Guard>
}
/>
<Route
path="/workout/:id"
element={
<Guard>
<WorkoutPage />
</Guard>
}
/>
<Route
path="/settings"
element={
<Guard>
<SettingsPage />
</Guard>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
export default function App() {
return (
<AuthProvider>
<div className="app-shell">
<AppRoutes />
</div>
</AuthProvider>
);
}
+172
View File
@@ -0,0 +1,172 @@
import { localApi } from "./local/api";
import { ApiError, type Profile, type WorkoutDetail, type WorkoutSummary } from "./apiTypes";
export { ApiError };
export type { Exercise, Profile, WorkoutDetail, WorkoutSet, WorkoutSummary } from "./apiTypes";
const TOKEN_KEY = "bps_token";
const OFFLINE = import.meta.env.VITE_OFFLINE === "true";
/** Пустой = относительные /api (web/nginx). Для online APK: полный URL. Offline — не используется. */
const API_BASE = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace(/\/$/, "") || "";
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string | null) {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers || {});
headers.set("Content-Type", "application/json");
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const url = path.startsWith("http") ? path : `${API_BASE}${path}`;
const res = await fetch(url, { ...options, headers });
if (!res.ok) {
let msg = res.statusText;
try {
const data = await res.json();
msg = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || data);
} catch {
/* ignore */
}
throw new ApiError(res.status, msg);
}
if (res.status === 204) return undefined as T;
return res.json();
}
const remoteApi = {
status: () => request<{ has_password: boolean; onboarded: boolean; phase: string }>("/api/auth/status"),
setup: (password: string) =>
request<{ access_token: string; onboarded: boolean }>("/api/auth/setup", {
method: "POST",
body: JSON.stringify({ password }),
}),
login: (password: string) =>
request<{ access_token: string; onboarded: boolean }>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ password }),
}),
profile: () => request<Profile>("/api/profile"),
onboarding: (body: { one_rm_kg: number; training_weekdays: number[]; start_date: string }) =>
request<Profile>("/api/onboarding", { method: "POST", body: JSON.stringify(body) }),
calendar: (year: number, month: number) =>
request<{ year: number; month: number; workouts: WorkoutSummary[]; profile: Profile }>(
`/api/calendar?year=${year}&month=${month}`,
),
upcoming: () => request<WorkoutSummary[]>("/api/workouts/upcoming"),
workout: (id: number) => request<WorkoutDetail>(`/api/workouts/${id}`),
start: (id: number) => request<WorkoutDetail>(`/api/workouts/${id}/start`, { method: "POST" }),
pause: (id: number) => request<WorkoutDetail>(`/api/workouts/${id}/pause`, { method: "POST" }),
resume: (id: number) => request<WorkoutDetail>(`/api/workouts/${id}/resume`, { method: "POST" }),
completeSet: (id: number, body: { actual_reps?: number; actual_weight_kg?: number } = {}) =>
request<WorkoutDetail>(`/api/workouts/${id}/complete-set`, {
method: "POST",
body: JSON.stringify(body),
}),
setWeight: (workoutId: number, exerciseId: number, weight_kg: number) =>
request<WorkoutDetail>(`/api/workouts/${workoutId}/exercises/${exerciseId}/weight`, {
method: "POST",
body: JSON.stringify({ weight_kg }),
}),
adjustSet: (
workoutId: number,
exerciseId: number,
setId: number,
body: { actual_weight_kg: number; actual_reps: number },
) =>
request<WorkoutDetail>(`/api/workouts/${workoutId}/exercises/${exerciseId}/sets/${setId}`, {
method: "POST",
body: JSON.stringify(body),
}),
moveWorkout: (id: number, scheduledDate: string) =>
request<WorkoutDetail>(`/api/workouts/${id}/move`, {
method: "POST",
body: JSON.stringify({ scheduled_date: scheduledDate }),
}),
updateTrainingDays: (training_weekdays: number[]) =>
request<Profile>("/api/training-days", {
method: "POST",
body: JSON.stringify({ training_weekdays }),
}),
prompt: (
workoutId: number,
exerciseId: number,
body: {
choice: "easy_yes" | "easy_no";
weight_kg?: number;
increase_kg?: number;
decrease_kg?: number;
},
) =>
request<WorkoutDetail>(`/api/workouts/${workoutId}/exercises/${exerciseId}/prompt`, {
method: "POST",
body: JSON.stringify(body),
}),
startRest: (rest_days: number) =>
request<Profile>("/api/rest/start", { method: "POST", body: JSON.stringify({ rest_days }) }),
probe: (body: { one_rm_kg: number; start_date: string; training_weekdays?: number[] }) =>
request<Profile>("/api/probe", { method: "POST", body: JSON.stringify(body) }),
export: () => request<{ version: number; exported_at: string; data: unknown }>("/api/export"),
import: (payload: { version: number; data: unknown }) =>
request<{ message: string }>("/api/import", { method: "POST", body: JSON.stringify(payload) }),
reset: () => request<{ message: string }>("/api/reset", { method: "POST" }),
};
export const api = OFFLINE ? localApi : remoteApi;
export const isOfflineMode = OFFLINE;
export const WEEKDAY_LABELS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
export const WEEKDAY_FULL = [
"Понедельник",
"Вторник",
"Среда",
"Четверг",
"Пятница",
"Суббота",
"Воскресенье",
];
export function formatKg(v: number | null | undefined): string {
if (v == null) return "—";
return Number.isInteger(v) ? `${v}` : `${v}`;
}
export function formatTimer(totalSeconds: number): string {
const s = Math.max(0, Math.floor(totalSeconds));
const m = Math.floor(s / 60);
const r = s % 60;
return `${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}`;
}
export function formatRestCountdown(totalSeconds: number): string {
const s = Math.max(0, Math.floor(totalSeconds));
const days = Math.floor(s / 86400);
const hours = Math.floor((s % 86400) / 3600);
const mins = Math.floor((s % 3600) / 60);
const secs = s % 60;
const n10 = days % 10;
const n100 = days % 100;
const word = n10 === 1 && n100 !== 11 ? "день" : n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14) ? "дня" : "дней";
return `${String(days).padStart(2, "0")} ${word} ${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
}
export function formatDhms(totalSeconds: number): string {
const s = Math.max(0, Math.floor(totalSeconds));
const days = Math.floor(s / 86400);
const hours = Math.floor((s % 86400) / 3600);
const mins = Math.floor((s % 3600) / 60);
const secs = s % 60;
return `${String(days).padStart(2, "0")}, ${String(hours).padStart(2, "0")}. ${String(mins).padStart(2, "0")}. ${String(secs).padStart(2, "0")}`;
}
export function formatDateRu(isoDate: string): string {
const d = new Date(isoDate + "T12:00:00");
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "long" });
}
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more