initial commit
No files matched your search
@@ -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
|
||||
@@ -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 и сброс прогресса — в разделе «Настройки».
|
||||
@@ -0,0 +1,4 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.venv
|
||||
.env
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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="Прогресс удалён. Пройдите онбординг заново.")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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} не найдена")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.md
|
||||
@@ -0,0 +1,2 @@
|
||||
# Offline V2: данные на устройстве (IndexedDB), сервер не нужен.
|
||||
VITE_OFFLINE=true
|
||||
@@ -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?
|
||||
@@ -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 }]
|
||||
}
|
||||
}
|
||||
@@ -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`).
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
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>
|
||||
|
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>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" "$@"
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -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 |
@@ -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 |
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||