# Layer Separation — 휴식 감지 케이스 스터디

하나의 기능이 프로토콜부터 앱 화면까지 여러 계층을 관통할 때, 각
계층이 어디에 살아야 하는지 구체적으로 보여주는 예시. 새로운 기능을
추가할 때 이 구조를 따라가면 b2c / b2b 가 로직을 중복 소유하지 않는다.

## TL;DR

| 관점 | 어느 계층? | 어느 파일? |
|------|-----------|-----------|
| "MCU 가 어떤 바이트를 보내나?" | **1. Protocol** | `packages/roomfit_protocol` |
| "그 바이트를 상태로 어떻게 뽑나?" | **2. Device** | `packages/roomfit_device`, `roomfit_ble` |
| "상태 전이가 의미하는 비즈니스 이벤트는?" | **3. Domain** | `packages/roomfit_workout` (또는 도메인별 패키지) |
| "여러 앱이 동일하게 보여줘야 하는 화면 요소?" | **4a. 공유 UI** | `packages/design_system` |
| "앱마다 고유한 프레임워크 결속 + 화면 조립" | **4b. 앱 wiring** | `apps/{b2c,b2b}/lib/features/.../` |

**공유 되어야 하는 모든 로직은 3, 4a 에 있어야 한다.** 앱 features 아래에
도메인 클래스 · 공용 다이얼로그를 만드는 건 설계 실수다.

## 계층별 역할

### 1. Protocol — MCU 와이어 사양

`packages/roomfit_protocol` — 순수 Dart, `meta` 만 의존.

- Command / Response 클래스 (`SetAutoWeightCommand`, `SetWeightPowerCommand`)
- 상수 (`BleUuids`, `CommandCodes`, `WeightMode` enum)
- 패킷 인코딩 (`PacketBuilder`, `FrameCodec`)

휴식 감지와 관련된 원시 신호:

- `SetAutoWeightCommand(isActive: 1)` — 이 명령을 세트 시작 시 켜야 MCU
  가 `AutoWeightStatus.offStarted` 이벤트를 발신. 끄면 신호 자체가 안 옴.
- `SetWeightPowerCommand(onOff: 1)` — WeightOn 버튼이 재전송할 명령.
- `ReportResponse` 의 `weight.isOn` 비트 — 36B dev report 가 매 50ms 로
  전달하는 전원 상태.

이 계층은 "휴식"이라는 단어 자체를 모른다. 단지 하드웨어 약속.

### 2. Device — 바이트 → 상태

`packages/roomfit_device` (+ `roomfit_ble` 가 BLE transport 를 담당).

- `DeviceGateway.state : Stream<DeviceState>` — 매 report 를 구조화된
  상태로 방출.
- `DeviceState.weight.isOn : bool` — Report 의 해당 비트를 그대로 노출.
- 상태 전이 판단도 이 계층엔 없음. "isOn 이 true 였는데 false 됐네"를
  해석하는 건 다음 계층.

여기까지가 "하드웨어" 면. 어떤 앱이 어떤 제품 시나리오로 쓰든
동일해야 한다.

### 3. Domain — 하드웨어 → 비즈니스 이벤트

`packages/roomfit_workout/lib/src/domain/use_cases/rest_detector.dart`.

순수 Dart. Flutter / Riverpod / Supabase 무관. 이게 핵심 분리 규칙 —
**도메인 로직은 앱에 의존해서는 안 된다.** 앱 상관 없이 같은 MCU 를
쓰는 모든 클라이언트가 재사용 가능해야 함.

```dart
class RestDetector {
  RestDetector({
    Duration cooldown = const Duration(seconds: 5),
    DateTime Function()? clock,
  });

  Stream<DateTime> get restDetected;

  void observe({required bool isOn, required bool hasActiveSet});
  void setSuspended(bool value);
  void reset();
  void dispose();
}
```

- `observe()` — 외부가 (`DeviceState.weight.isOn`, `set이 활성인가`)
  두 값을 매 전이마다 공급.
- `restDetected` 스트림 — true→false 전이 + 활성 세트 + 쿨다운 통과를
  모두 만족할 때만 방출.
- `setSuspended` — `completeSet` 이 보내는 SetWeightPower(0) 이 유발하는
  가짜 전이를 걸러내기 위해 앱이 호출.
- `reset` — 세트 시작 시 쿨다운 초기화.

같은 패키지에 `SetCutoffTrigger.userEndedFromRest` 도 추가. DB 에 저장될
때 이 enum 값을 보고 "휴식으로 종료"와 "수동 종료"를 구분.

**유닛 테스트 쉬움** — Flutter 없이 `flutter test` 가능. 6/6 케이스.

### 4a. 공유 UI — Flutter 위젯

`packages/design_system/lib/src/components/molecules/rest_pause_dialog.dart`.

Flutter 이 필요하니 protocol/device/workout 패키지에는 못 들어감. 하지만
b2c / b2b 가 동일한 UX 를 보여야 하니 앱 안에도 넣을 수 없음. 답은
`design_system` — 이미 두 앱 모두 의존.

```dart
enum RestDialogAction { weightOn, endSet }

Future<RestDialogAction?> showRestPauseDialog(BuildContext context);
```

이 다이얼로그는 도메인을 모른다. "WeightOn 을 누르면 무엇을 해야 하는지"
는 호출하는 앱 쪽의 책임.

### 4b. 앱 wiring — Riverpod + Navigator

`apps/b2b/lib/features/workout/domain/providers/workout_providers.dart`

```dart
final restDetectorProvider = Provider<RestDetector>((ref) {
  final detector = RestDetector();
  ref.onDispose(detector.dispose);
  return detector;
});

final restDetectedProvider = StreamProvider.autoDispose<DateTime>((ref) {
  final detector = ref.watch(restDetectorProvider);
  ref.listen<AsyncValue<DeviceState>>(deviceStateViewProvider, (prev, next) {
    next.whenData((state) {
      final hasActive = ref.read(setExecutorProvider) != null;
      detector.observe(isOn: state.weight.isOn, hasActiveSet: hasActive);
    });
  }, fireImmediately: true);
  return detector.restDetected;
});
```

`LiveWorkoutScreen` 에서 `ref.listen(restDetectedProvider, ...)` 로 이벤트를
받아 `showRestPauseDialog()` 호출 → action 분기 (SetWeightPower(1) 재전송
또는 `setExecutor.completeSet(trigger: userEndedFromRest)`).

b2c 에 연결하려면 동일한 두 provider 를 `apps/b2c` 아래에 복사하고
`workout_live_screen.dart` 에 `ref.listen` 두 줄만 추가. 도메인 클래스
(`RestDetector`, `SetCutoffTrigger`, `RestDialogAction`) 는 이미 공유
패키지에 있으니 건드릴 필요 없음.

## 의존성 그래프

```
apps/b2b/features/workout/ (Riverpod providers, LiveWorkoutScreen)
  │
  ├──► packages/design_system        (Flutter)   — RestPauseDialog
  │
  └──► packages/roomfit_workout      (pure Dart) — RestDetector, SetCutoffTrigger
           │
           └──► packages/roomfit_device (pure Dart) — DeviceState.weight.isOn
                    │
                    └──► packages/roomfit_protocol (pure Dart) — ReportResponse, Commands
```

b2c 도 동일한 그래프. 앱 features 는 그래프의 **종점**이지 **중간 계층**
이 아니다.

## 안티 패턴 (하지 말 것)

### ❌ 앱 features 에 도메인 클래스 넣기
```dart
// apps/b2b/lib/features/workout/domain/rest_detector.dart  ← 금지
class RestDetector { ... }
```

b2c 가 같은 기능을 원하면 복사-붙여넣기 → 2 곳에서 버그 관리. 유닛
테스트도 앱 의존성을 끌고 와야 해서 느려짐.

### ❌ design_system 에 앱 특화 로직 넣기
```dart
// design_system/components/rest_pause_dialog.dart  ← 다이얼로그는 OK
// design_system/components/rest_detector.dart      ← 금지 (Flutter 불필요)
```

design_system 은 UI 계층이다. 도메인 로직은 `roomfit_workout` 에.

### ❌ domain 패키지에 Flutter import
```dart
// packages/roomfit_workout/lib/src/domain/use_cases/rest_detector.dart
import 'package:flutter/material.dart';  // ← 순수 Dart 원칙 위반
```

이 순간부터 b2c 의 dart-only 로직에서 재사용 불가, 유닛 테스트에 Flutter
바인딩 강제. pubspec 의 dependency 를 Flutter 가 들어오지 않는 형태로
유지하는 게 원칙.

### ❌ Protocol 계층에 상태 해석 넣기
```dart
// packages/roomfit_protocol/commands/emergency_stop.dart
if (userIsResting) { ... }  // ← 금지 — 여기서는 세션 상태를 모름
```

Protocol 은 와이어만 안다. "사용자가 쉬고 있다"는 해석은 Domain.

## 새 기능 추가 시 체크리스트

1. "MCU 가 새 바이트를 보내야 하는가?" → YES면 `roomfit_protocol` 에 Command/Response 추가
2. "그 바이트가 새 상태 필드를 만드는가?" → YES면 `roomfit_device` `DeviceState` 에 필드 추가
3. "이 상태 전이가 새 비즈니스 이벤트를 의미하는가?" → YES면 `roomfit_workout` (또는 맞는 도메인 패키지)에 use case 추가. **순수 Dart 로.**
4. "두 앱이 같은 UI 를 보여야 하는가?" → YES면 `design_system` 에 위젯/다이얼로그
5. "앱마다 다른 화면 조립 / Riverpod 바인딩이 필요한가?" → 앱 features/ 에

각 단계마다 **"이걸 앱 안에 넣어도 되나?"** 를 스스로 묻고, 두 앱이 공유
가능한지 확인. 공유 가능하면 무조건 공유 패키지로.

## 레퍼런스

- 구현 PR: [#61](https://github.com/intelli-bruce/RoomfitV2/pull/61)
- 핵심 커밋:
  - `4cc2a75` feat(workout rest-detection): RestDetector + PauseDialog (shared)
- 테스트:
  - `packages/roomfit_workout/test/domain/rest_detector_test.dart` (6 cases)
  - `apps/b2b/integration_test/app_e2e_test.dart` (mid-set rest 시나리오 2개)
