运动实验室

记录运动

适配设备:Pro

作者:rikka

下载量:27 · 点赞:1

可信源码风险提示:保存或下载前请阅读源码和权限声明。
SHA-256225b328ee25ade76c90389dd998efbd6dfacb4bb522d2382c3b5037821431b4f
const DIR = 'internal://files/activity/';
const MAX_FILES = 8;
const MAX_HISTORY = 20;
const MIN_STEP_GAP = 260;
const SENS = { 低: 1.4, 标准: 1.0, 高: 0.7 };
const S = {
  mode: 'idle',
  startedAt: 0, pausedMs: 0, pausedAt: 0,
  steps: 0, goal: 3000, sens: '标准',
  gravity: 0, rms: 0, smooth: 0, prevDyn: 0, lastStepAt: 0,
  stepTimes: [], samples: 0, peakDyn: 0,
  p0: null, p1: null,
  log: [],
  history: [],
  goalHit: false, ticker: null,
};
const tick = ui.signal(0);
const touch = () => tick.update(n => n + 1);
const alertBox = msg => dialog.alert({ title: '运动实验室', message: String(msg) });
const confirmBox = async msg => {
  const r = await dialog.confirm({ title: '运动实验室', message: msg });
  return r === true || !!(r && r.action && r.action !== 'cancel' && r.action !== 'dismiss');
};
const pad = n => (n < 10 ? '0' : '') + n;
const clock = ms => {
  const s = Math.floor(ms / 1000);
  return pad(Math.floor(s / 60)) + ':' + pad(s % 60);
};
const elapsed = () => {
  if (!S.startedAt) return 0;
  const end = S.mode === 'paused' ? S.pausedAt : Date.now();
  return Math.max(0, end - S.startedAt - S.pausedMs);
};
const bar = (v, max, w) => {
  const n = Math.max(0, Math.min(w, Math.round((v / max) * w)));
  return '#'.repeat(n) + '-'.repeat(w - n);
};
const cadence = () => {
  const t = S.stepTimes;
  if (t.length < 4) return 0;
  const span = t[t.length - 1] - t[0];
  return span > 0 ? Math.round(((t.length - 1) * 60000) / span) : 0;
};
const intensity = c =>
  c === 0 ? '静止' : c < 70 ? '慢走' : c < 110 ? '步行' : c < 140 ? '快走' : '跑步';
const altitudeDelta = () =>
  S.p0 && S.p1 ? ((S.p0 - S.p1) * 8.43).toFixed(1) + ' 米' : '无数据';
const can = name => typeof script.canUse === 'function' && script.canUse(name);
const buzz = mode => { try { system.vibration.vibrate({ mode }); } catch (e) {} };
function onSample(d) {
  const now = Date.now();
  const m = Math.sqrt(d.x * d.x + d.y * d.y + d.z * d.z);
  S.samples++;
  if (!S.gravity) S.gravity = m;
  S.gravity += 0.02 * (m - S.gravity);
  const dyn = m - S.gravity;
  S.smooth += 0.35 * (dyn - S.smooth);
  S.rms += 0.01 * (S.smooth * S.smooth - S.rms);
  const thr = Math.max(0.05 * S.gravity, Math.sqrt(S.rms) * 1.1) * SENS[S.sens];
  if (S.prevDyn < thr && S.smooth >= thr && now - S.lastStepAt > MIN_STEP_GAP) {
    S.steps++;
    S.lastStepAt = now;
    S.stepTimes.push(now);
    if (S.stepTimes.length > 12) S.stepTimes.shift();
    S.peakDyn = Math.max(S.peakDyn, S.smooth);
    if (S.steps % 100 === 0) buzz('short');
    if (!S.goalHit && S.steps >= S.goal) { S.goalHit = true; buzz('long'); script.toast('已达成目标!', 2000); }
  }
  S.prevDyn = S.smooth;
  if (S.samples % 5 === 0 && S.log.length < 1500) {
    S.log.push([now - S.startedAt, S.smooth.toFixed(3), S.steps]);
  }
  if (S.stepTimes.length && now - S.lastStepAt > 3000) S.stepTimes = [];
}
function start() {
  if (!can('@system.sensor.subscribeAccelerometer')) {
    return alertBox('当前设备不支持加速度传感器。');
  }
  Object.assign(S, {
    mode: 'running', startedAt: Date.now(), pausedMs: 0, steps: 0, gravity: 0,
    rms: 0, smooth: 0, prevDyn: 0, lastStepAt: 0, stepTimes: [], samples: 0,
    peakDyn: 0, p0: null, p1: null, log: [], goalHit: false,
  });
  system.sensors.subscribeAccelerometer({
    interval: 'ui',
    callback: onSample,
    fail: (msg, code) => console.error('加速度传感器异常', code, msg),
  });
  if (can('@system.sensor.subscribePressure')) {
    system.sensors.subscribePressure({
      callback: r => { if (S.p0 === null) S.p0 = r.pressure; S.p1 = r.pressure; },
    });
  }
  S.ticker = setInterval(touch, 1000);
  buzz('short');
  touch();
}
function pauseResume() {
  if (S.mode === 'running') {
    S.mode = 'paused'; S.pausedAt = Date.now();
    system.sensors.unsubscribeAccelerometer();
  } else if (S.mode === 'paused') {
    S.pausedMs += Date.now() - S.pausedAt; S.mode = 'running';
    S.prevDyn = 0; S.smooth = 0;
    system.sensors.subscribeAccelerometer({ interval: 'ui', callback: onSample });
  }
  touch();
}
async function stop() {
  const ok = await confirmBox('结束并保存本次运动记录?');
  if (!ok) return;
  if (S.mode === 'running') system.sensors.unsubscribeAccelerometer();
  if (can('@system.sensor.subscribePressure')) system.sensors.unsubscribePressure();
  clearInterval(S.ticker);
  const dur = elapsed();
  const c = dur > 0 ? Math.round((S.steps * 60000) / dur) : 0;
  const rec = {
    id: system.crypto.hashDigest({ data: S.startedAt + ':' + S.steps, algo: 'SHA256' }).slice(0, 8),
    at: S.startedAt, dur, steps: S.steps, avgCadence: c,
    kind: intensity(c), alt: altitudeDelta(),
  };
  S.mode = 'idle';
  await saveHistory(rec);
  await exportCsv(rec);
  syncToPhone(rec);
  await alertBox(`${rec.steps} 步\n${clock(dur)}  ${c}/分钟\n${rec.kind}`);
  touch();
}
async function loadState() {
  S.history = JSON.parse(await script.data.get('history', '[]'));
  S.goal = Number(await script.data.get('goal', 3000)) || 3000;
  const sv = await script.data.get('sens', '标准');
  S.sens = sv in SENS ? sv : '标准';
}
async function saveHistory(rec) {
  S.history.unshift(rec);
  S.history = S.history.slice(0, MAX_HISTORY);
  await script.data.set('history', JSON.stringify(S.history));
}
function call(fn, opts) {
  return new Promise(res => fn(Object.assign({}, opts, {
    success: d => res({ ok: true, d }), fail: (m, c) => res({ ok: false, m, c }),
  })));
}
async function exportCsv(rec) {
  await call(system.files.mkdir, { uri: DIR, recursive: true });
  const head = 't_ms,dyn,steps\n';
  const body = S.log.map(r => r.join(',')).join('\n');
  const r = await call(system.files.writeText, {
    uri: DIR + rec.id + '.csv', text: head + body + '\n',
  });
  if (!r.ok) console.warn('CSV文件写入失败', r.c);
  const l = await call(system.files.list, { uri: DIR });
  if (l.ok && l.d.fileList.length > MAX_FILES) {
    const old = l.d.fileList.sort((a, b) => a.lastModifiedTime - b.lastModifiedTime)
      .slice(0, l.d.fileList.length - MAX_FILES);
    for (const f of old) await call(system.files.delete, { uri: f.uri });
  }
}
function syncToPhone(rec) {
  try {
    const conn = system.companion.instance();
    conn.send({ data: { type: 'session', rec }, fail: () => {} });
  } catch (e) {}
}
async function settings() {
  const g = await dialog.number({ title: '每日步数目标', value: S.goal, min: 100, max: 100000, decimals: 0 });
  if (g && g.action !== 'cancel' && g.value > 0) {
    S.goal = Math.round(g.value);
    await script.data.set('goal', String(S.goal));
  }
  const r = await dialog.select({
    title: '灵敏度',
    items: Object.keys(SENS).map(k => ({ label: k, value: k })),
  });
  const pick = r && r.action !== 'cancel' ? (Array.isArray(r.value) ? r.value[0] : r.value) : null;
  if (pick && SENS[pick]) { S.sens = pick; await script.data.set('sens', pick); }
  touch();
}
async function showHistory() {
  if (!S.history.length) return alertBox('暂无运动记录。');
  const total = S.history.reduce((a, r) => a + r.steps, 0);
  const best = S.history.reduce((a, r) => (r.steps > a.steps ? r : a), S.history[0]);
  const lines = S.history.slice(0, 5).map(r => {
    const d = new Date(r.at);
    return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${r.steps} ${r.kind}`;
  });
  await alertBox(lines.join('\n') + `\n总步数 ${total}\n最高单次 ${best.steps}`);
}
function screen() {
  tick.get();
  const c = cadence();
  const live = S.mode !== 'idle';
  const pct = Math.min(100, Math.round((S.steps / S.goal) * 100));
  const nodes = [
    ui.heading(live ? S.steps + ' 步' : '运动实验室', { id: 'title' }),
    ui.text(live
      ? `${clock(elapsed())}   ${c}/分钟   ${intensity(c)}`
      : `目标 ${S.goal}   灵敏度 ${S.sens}`, { id: 'line1', lines: 1 }),
    ui.progress('目标 ' + pct + '%', pct, { id: 'goal-progress' }),
  ];
  if (live) nodes.push(ui.text('海拔变化 ' + altitudeDelta() + '  峰值 ' + S.peakDyn.toFixed(2), { id: 'line2', lines: 1 }));
  if (S.mode === 'idle') {
    nodes.push(ui.button('开始', start, { id: 'start', tone: 'success' }));
    nodes.push(ui.button('历史记录', showHistory, { id: 'history' }));
    nodes.push(ui.button('设置', settings, { id: 'settings' }));
  } else {
    nodes.push(ui.button(S.mode === 'running' ? '暂停' : '继续', pauseResume, { id: 'pause', tone: 'warning' }));
    nodes.push(ui.button('结束', stop, { id: 'finish', tone: 'danger' }));
  }
  return nodes;
}
(async function main() {
  ui.setTitle('运动实验室');
  ui.render(screen);
  try { await loadState(); touch(); } catch (e) { console.error('读取存储失败', e); }
})();