소킹 중 '1.4 대비 버벅임 증가' 보고의 원인 수정 (동작·수치 완전 불변): - HealthCache/HealthIntervals 인메모리 캐시(세대 카운터 무효화) + workoutKeys 이중 load 제거 - DayMath.calendar 저장 프로퍼티화 (접근마다 Calendar 사본 생성 제거) - 수면 백필 O(400일×샘플수) → 정렬+포인터 스위프 (퍼즈 3,000회 등가 증명) - refreshRecent 캐시 기록 배칭 (일수×2회 → 총 2회) - QuestProgress.spanValue/spanRawValue 하루 버킷화 (게이지의 미래 기록 즉시 반영 스펙 보존) - 위젯 라이브 타임라인 makeEntry refresh 파라미터 (엔트리 7개×컨테이너 오픈 → 1회) - 일기 건강 카드 칩 value() 이중 호출 제거 검증: 옛/새 빌드 A/B — 덤프 3종 값 동일·4화면 픽셀 동일, 자가 검증 62/20/27/10 ALL PASS(26.5+18.5), 벌크 1만 건 렌더 정상, 3종 빌드 그린, 카탈로그 무변경. whats-new-1.5 3언어에 성능 개선 줄 추가. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
463 lines
20 KiB
Swift
463 lines
20 KiB
Swift
//
|
||
// HealthMetric.swift
|
||
// Haru_Danim
|
||
//
|
||
// 건강 지표 정의 (1.5 — Docs/plan-1.5.md §3)
|
||
// - 모음 탭 타일·건강 다짐이 공유하는 순수 값 타입 (HealthKit 비의존 — 위젯 확장도 컴파일)
|
||
// - 값의 표준 단위: 시간류(운동·수면·마음챙김·운동 종목)=초, 걸음=보, 에너지=kcal,
|
||
// 거리=m, 스탠드=시간, 물=ml. HealthKit 조회·캐시·다짐 목표량·표기가 전부 이 단위를 쓴다
|
||
// - HealthCache: 지표×논리적 하루 값의 App Group 캐시 — 메인 앱만 쓰고(HealthStore),
|
||
// 위젯·시리 확장·QuestProgress는 여기서만 읽는다 (⚠️ 5.1.3 — CloudKit 저장 금지)
|
||
//
|
||
|
||
import Foundation
|
||
|
||
nonisolated enum HealthMetric: String, CaseIterable, Identifiable, Codable {
|
||
case steps, exerciseMinutes, sleep, activeEnergy, distance, standHours, mindfulMinutes, water
|
||
|
||
var id: String { rawValue }
|
||
|
||
var name: String {
|
||
switch self {
|
||
case .steps: return String(localized: "걸음 수")
|
||
case .exerciseMinutes: return String(localized: "운동 시간")
|
||
case .sleep: return String(localized: "수면")
|
||
case .activeEnergy: return String(localized: "활동 에너지")
|
||
case .distance: return String(localized: "이동 거리")
|
||
case .standHours: return String(localized: "일어서기")
|
||
case .mindfulMinutes: return String(localized: "마음챙김")
|
||
case .water: return String(localized: "물 섭취")
|
||
}
|
||
}
|
||
|
||
var symbolName: String {
|
||
switch self {
|
||
case .steps: return "figure.walk"
|
||
case .exerciseMinutes: return "figure.run"
|
||
case .sleep: return "bed.double.fill"
|
||
case .activeEnergy: return "flame.fill"
|
||
case .distance: return "map.fill"
|
||
case .standHours: return "figure.stand"
|
||
case .mindfulMinutes: return "brain.head.profile"
|
||
case .water: return "drop.fill"
|
||
}
|
||
}
|
||
|
||
/// 시간류(초 단위) 지표 여부 — 표기·다짐 목표량 입력 방식 분기용
|
||
var isDuration: Bool {
|
||
switch self {
|
||
case .exerciseMinutes, .sleep, .mindfulMinutes: return true
|
||
default: return false
|
||
}
|
||
}
|
||
|
||
/// 표준 단위 값 → 사용자 표기 (타일·다짐·위젯 공통)
|
||
func valueLabel(_ value: Double) -> String {
|
||
switch self {
|
||
case .steps:
|
||
return String(localized: "\(Int(value).formatted())보")
|
||
case .exerciseMinutes, .sleep, .mindfulMinutes:
|
||
return Format.durationShort(value)
|
||
case .activeEnergy:
|
||
return String(localized: "\(Int(value).formatted())kcal")
|
||
case .distance:
|
||
let km = value / 1000
|
||
let text = km.formatted(.number.precision(.fractionLength(km >= 10 ? 0 : 1)))
|
||
return String(localized: "\(text)km")
|
||
case .standHours:
|
||
return String(localized: "\(Int(value))시간")
|
||
case .water:
|
||
// 표준 단위 ml → L 표기
|
||
let liters = value / 1000
|
||
let text = liters.formatted(.number.precision(.fractionLength(0...2)))
|
||
return String(localized: "\(text)L")
|
||
}
|
||
}
|
||
|
||
/// 타일 기본 표시 세트 (걸음·운동·수면 — 사용자 선택으로 확장)
|
||
static let defaultTileMetrics: [HealthMetric] = [.steps, .exerciseMinutes, .sleep]
|
||
|
||
/// 저장된 선택(순서 포함) 해석 — 비어 있으면 기본 세트
|
||
static func selectedList(raw: String) -> [HealthMetric] {
|
||
let parsed = raw.split(separator: ",").compactMap { HealthMetric(rawValue: String($0)) }
|
||
return parsed.isEmpty ? defaultTileMetrics : parsed
|
||
}
|
||
}
|
||
|
||
// MARK: - 운동 종목 (건강 다짐 전용 — 사용자 핵심 니즈, plan §3.4)
|
||
|
||
/// 종목별 운동 다짐의 큐레이션 목록 (20종). HKWorkoutActivityType 이름은 API가 현지화를
|
||
/// 제공하지 않아 직접 명명한다. 종목 추가 = 케이스 + HealthStore 매핑 + 문자열 3언어.
|
||
nonisolated enum HealthWorkoutKind: String, CaseIterable, Identifiable {
|
||
case running, walking, cycling, swimming, strength, yoga, pilates, hiking
|
||
case soccer, baseball, basketball, tennis, badminton, golf
|
||
case jumpRope, boxing, dance, stretching, hiit, other
|
||
|
||
var id: String { rawValue }
|
||
|
||
var name: String {
|
||
switch self {
|
||
case .running: return String(localized: "달리기")
|
||
case .walking: return String(localized: "걷기 운동")
|
||
case .cycling: return String(localized: "사이클")
|
||
case .swimming: return String(localized: "수영")
|
||
case .strength: return String(localized: "근력 운동")
|
||
case .yoga: return String(localized: "요가")
|
||
case .pilates: return String(localized: "필라테스")
|
||
case .hiking: return String(localized: "등산")
|
||
case .soccer: return String(localized: "축구")
|
||
case .baseball: return String(localized: "야구")
|
||
case .basketball: return String(localized: "농구")
|
||
case .tennis: return String(localized: "테니스")
|
||
case .badminton: return String(localized: "배드민턴")
|
||
case .golf: return String(localized: "골프")
|
||
case .jumpRope: return String(localized: "줄넘기")
|
||
case .boxing: return String(localized: "복싱")
|
||
case .dance: return String(localized: "댄스")
|
||
case .stretching: return String(localized: "스트레칭")
|
||
case .hiit: return String(localized: "고강도 인터벌(HIIT)")
|
||
case .other: return String(localized: "기타 운동")
|
||
}
|
||
}
|
||
|
||
var symbolName: String {
|
||
switch self {
|
||
case .running: return "figure.run"
|
||
case .walking: return "figure.walk"
|
||
case .cycling: return "figure.outdoor.cycle"
|
||
case .swimming: return "figure.pool.swim"
|
||
case .strength: return "dumbbell.fill"
|
||
case .yoga: return "figure.yoga"
|
||
case .pilates: return "figure.pilates"
|
||
case .hiking: return "figure.hiking"
|
||
case .soccer: return "figure.indoor.soccer"
|
||
case .baseball: return "figure.baseball"
|
||
case .basketball: return "figure.basketball"
|
||
case .tennis: return "figure.tennis"
|
||
case .badminton: return "figure.badminton"
|
||
case .golf: return "figure.golf"
|
||
case .jumpRope: return "figure.jumprope"
|
||
case .boxing: return "figure.boxing"
|
||
case .dance: return "figure.dance"
|
||
case .stretching: return "figure.flexibility"
|
||
case .hiit: return "figure.highintensity.intervaltraining"
|
||
case .other: return "figure.mixed.cardio"
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 건강 다짐 대상 (Quest.healthMetricRaw 해석)
|
||
|
||
/// 건강 다짐이 겨누는 대상 — 일반 지표 또는 운동 종목.
|
||
/// raw 인코딩: 지표는 HealthMetric raw 그대로, 종목은 "workout.<kind>" (한 필드로 CloudKit 안전)
|
||
nonisolated enum HealthQuestTarget: Equatable {
|
||
case metric(HealthMetric)
|
||
case workout(HealthWorkoutKind)
|
||
|
||
init?(raw: String) {
|
||
guard !raw.isEmpty else { return nil }
|
||
if let metric = HealthMetric(rawValue: raw) {
|
||
self = .metric(metric)
|
||
} else if raw.hasPrefix("workout."),
|
||
let kind = HealthWorkoutKind(rawValue: String(raw.dropFirst("workout.".count))) {
|
||
self = .workout(kind)
|
||
} else {
|
||
// 미래 버전이 만든 미지의 지표 — 구버전과 같은 '대상 없음' 강등 (크래시 없는 안전 경로)
|
||
return nil
|
||
}
|
||
}
|
||
|
||
var raw: String {
|
||
switch self {
|
||
case .metric(let metric): return metric.rawValue
|
||
case .workout(let kind): return "workout.\(kind.rawValue)"
|
||
}
|
||
}
|
||
|
||
var name: String {
|
||
switch self {
|
||
case .metric(let metric): return metric.name
|
||
case .workout(let kind): return kind.name
|
||
}
|
||
}
|
||
|
||
var symbolName: String {
|
||
switch self {
|
||
case .metric(let metric): return metric.symbolName
|
||
case .workout(let kind): return kind.symbolName
|
||
}
|
||
}
|
||
|
||
/// 시간류(초 단위) 여부 — 목표량 입력·표기 분기 (운동 종목은 항상 시간)
|
||
var isDuration: Bool {
|
||
switch self {
|
||
case .metric(let metric): return metric.isDuration
|
||
case .workout: return true
|
||
}
|
||
}
|
||
|
||
/// 표준 단위 값 → 사용자 표기
|
||
func valueLabel(_ value: Double) -> String {
|
||
switch self {
|
||
case .metric(let metric): return metric.valueLabel(value)
|
||
case .workout: return Format.durationShort(value)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 지표×논리적 하루 값 캐시 (App Group — 위젯·확장의 유일한 값 공급원)
|
||
|
||
nonisolated enum HealthCache {
|
||
static let storageKey = "health.cache.v1"
|
||
/// 연속 계산 상한(§4.2 400일)에 맞춘 지표당 보존 일수
|
||
static let retentionDays = 400
|
||
|
||
/// dayKey(달력일 자정 Date) → "yyyyMMdd" 토큰
|
||
static func dayToken(_ dayKey: Date) -> String {
|
||
let parts = Calendar.current.dateComponents([.year, .month, .day], from: dayKey)
|
||
let y = parts.year ?? 0, m = parts.month ?? 0, d = parts.day ?? 0
|
||
let mm = m < 10 ? "0\(m)" : "\(m)"
|
||
let dd = d < 10 ? "0\(d)" : "\(d)"
|
||
return "\(y)\(mm)\(dd)"
|
||
}
|
||
|
||
// MARK: 인메모리 캐시 (1.5(9) 최적화 — 값·동작 불변)
|
||
//
|
||
// 예전에는 value() 호출마다 defaults.dictionary가 전체 캐시(지표 10여 종 × 400일 ≈
|
||
// 수천 엔트리)를 통째로 Swift 딕셔너리로 브리징했다. 건강 다짐의 진행률은 "하루 값 ×
|
||
// 일수"라 월간 게이지 하나에 이 전체 변환이 ~30회, 다짐 행 하나에 ~38회+, 워치 스냅숏
|
||
// 한 번에 다짐당 ~76회 일어나 1.5 버벅임의 몸통이었다. 브리징 결과를 프로세스 안에
|
||
// 캐시하고, 쓰기(메인 앱 단일 작성자)마다 defaults의 세대 카운터를 올려 무효화한다 —
|
||
// 위젯 확장(별도 프로세스)이 오래 살아남아도 세대 비교(작은 double 1개 읽기)로
|
||
// 항상 최신 값을 읽으므로 표시 결과는 예전과 완전히 동일하다.
|
||
private static let generationKey = "health.cache.gen"
|
||
private static let cacheLock = NSLock()
|
||
nonisolated(unsafe) private static var cached: [String: [String: Double]]?
|
||
nonisolated(unsafe) private static var cachedGeneration: Double = -1
|
||
|
||
private static func load() -> [String: [String: Double]] {
|
||
let generation = AppGroup.defaults.double(forKey: generationKey)
|
||
cacheLock.lock()
|
||
if let cached, cachedGeneration == generation {
|
||
let result = cached
|
||
cacheLock.unlock()
|
||
return result
|
||
}
|
||
cacheLock.unlock()
|
||
let fresh = (AppGroup.defaults.dictionary(forKey: storageKey) as? [String: [String: Double]]) ?? [:]
|
||
cacheLock.lock()
|
||
cached = fresh
|
||
cachedGeneration = generation
|
||
cacheLock.unlock()
|
||
return fresh
|
||
}
|
||
|
||
/// 전체 저장 + 세대 상향 + 인메모리 반영 (쓰기 3곳의 단일 종점).
|
||
/// 데이터를 먼저 쓰고 세대를 올린다 — 다른 프로세스가 새 세대를 본 시점엔 새 데이터가 있다
|
||
private static func store(_ all: [String: [String: Double]]?) {
|
||
if let all {
|
||
AppGroup.defaults.set(all, forKey: storageKey)
|
||
} else {
|
||
AppGroup.defaults.removeObject(forKey: storageKey)
|
||
}
|
||
let generation = AppGroup.defaults.double(forKey: generationKey) + 1
|
||
AppGroup.defaults.set(generation, forKey: generationKey)
|
||
cacheLock.lock()
|
||
cached = all ?? [:]
|
||
cachedGeneration = generation
|
||
cacheLock.unlock()
|
||
}
|
||
|
||
static func value(_ metricRaw: String, dayKey: Date) -> Double? {
|
||
load()[metricRaw]?[dayToken(dayKey)]
|
||
}
|
||
|
||
/// 한 날짜의 여러 지표 값을 일괄 기록 (+지표당 보존 일수 초과분 정리)
|
||
static func setValues(_ values: [String: Double], dayKey: Date) {
|
||
guard !values.isEmpty else { return }
|
||
var all = load()
|
||
let token = dayToken(dayKey)
|
||
for (metricRaw, value) in values {
|
||
var days = all[metricRaw] ?? [:]
|
||
days[token] = value
|
||
if days.count > retentionDays {
|
||
let sorted = days.keys.sorted(by: >)
|
||
for old in sorted.dropFirst(retentionDays) { days.removeValue(forKey: old) }
|
||
}
|
||
all[metricRaw] = days
|
||
}
|
||
store(all)
|
||
}
|
||
|
||
/// 여러 날짜 × 여러 지표 일괄 기록 (백필용 — [지표: [토큰: 값]])
|
||
static func merge(_ values: [String: [Date: Double]]) {
|
||
guard !values.isEmpty else { return }
|
||
var all = load()
|
||
for (metricRaw, days) in values {
|
||
var stored = all[metricRaw] ?? [:]
|
||
for (dayKey, value) in days {
|
||
stored[dayToken(dayKey)] = value
|
||
}
|
||
if stored.count > retentionDays {
|
||
let sorted = stored.keys.sorted(by: >)
|
||
for old in sorted.dropFirst(retentionDays) { stored.removeValue(forKey: old) }
|
||
}
|
||
all[metricRaw] = stored
|
||
}
|
||
store(all)
|
||
}
|
||
|
||
/// 전체 초기화(데이터 정리)용 — 값은 어차피 건강 앱 소유라 캐시만 비운다
|
||
static func clearAll() {
|
||
store(nil)
|
||
HealthIntervals.clearAll()
|
||
}
|
||
}
|
||
|
||
// MARK: - 시간 구간 캐시 (1.5 추가분 — 일기 타임테이블의 수면·운동 블록)
|
||
|
||
/// 하루 '합계'만 담는 HealthCache와 달리, 타임테이블 표시용 실제 시간 구간을 담는다.
|
||
/// 키: "sleep" 또는 "workout.<kind>", 값: dayToken → [[시작 epoch, 끝 epoch], ...].
|
||
/// 구간은 그 논리적 하루와 겹치는 부분으로 클리핑해 저장 — 자정을 걸친 잠은 이틀에 나뉜다
|
||
/// (다짐의 '깨어난 날 통귀속'과 다른, 사실 그대로의 표시 규칙 — 의도된 차이).
|
||
/// ⚠️ HealthCache와 동일하게 기기 로컬 defaults 전용 — CloudKit 저장 금지 (5.1.3)
|
||
nonisolated enum HealthIntervals {
|
||
static let storageKey = "health.intervals.v1"
|
||
static let retentionDays = HealthCache.retentionDays
|
||
|
||
static let sleepKey = "sleep"
|
||
static func workoutKey(_ kind: HealthWorkoutKind) -> String { "workout.\(kind.rawValue)" }
|
||
|
||
// 인메모리 캐시 — HealthCache와 같은 세대 무효화 문법 (1.5(9) 최적화, 값·동작 불변).
|
||
// 타임테이블 블록은 하루당 3~5회, 기록 탭 주간 보기는 ×7일로 이 저장소를 읽는다
|
||
private static let generationKey = "health.intervals.gen"
|
||
private static let cacheLock = NSLock()
|
||
nonisolated(unsafe) private static var cached: [String: [String: [[Double]]]]?
|
||
nonisolated(unsafe) private static var cachedGeneration: Double = -1
|
||
|
||
private static func load() -> [String: [String: [[Double]]]] {
|
||
let generation = AppGroup.defaults.double(forKey: generationKey)
|
||
cacheLock.lock()
|
||
if let cached, cachedGeneration == generation {
|
||
let result = cached
|
||
cacheLock.unlock()
|
||
return result
|
||
}
|
||
cacheLock.unlock()
|
||
let fresh = (AppGroup.defaults.dictionary(forKey: storageKey) as? [String: [String: [[Double]]]]) ?? [:]
|
||
cacheLock.lock()
|
||
cached = fresh
|
||
cachedGeneration = generation
|
||
cacheLock.unlock()
|
||
return fresh
|
||
}
|
||
|
||
private static func store(_ all: [String: [String: [[Double]]]]?) {
|
||
if let all {
|
||
AppGroup.defaults.set(all, forKey: storageKey)
|
||
} else {
|
||
AppGroup.defaults.removeObject(forKey: storageKey)
|
||
}
|
||
let generation = AppGroup.defaults.double(forKey: generationKey) + 1
|
||
AppGroup.defaults.set(generation, forKey: generationKey)
|
||
cacheLock.lock()
|
||
cached = all ?? [:]
|
||
cachedGeneration = generation
|
||
cacheLock.unlock()
|
||
}
|
||
|
||
/// 한 종류·한 날짜의 구간 목록 (시작 오름차순)
|
||
static func intervals(_ key: String, dayKey: Date) -> [(start: Date, end: Date)] {
|
||
let stored = load()[key]?[HealthCache.dayToken(dayKey)] ?? []
|
||
return stored.compactMap { pair in
|
||
guard pair.count == 2, pair[1] > pair[0] else { return nil }
|
||
return (Date(timeIntervalSince1970: pair[0]), Date(timeIntervalSince1970: pair[1]))
|
||
}.sorted { $0.start < $1.start }
|
||
}
|
||
|
||
/// 그날 저장된 운동 종류 키 목록 (표시 순서는 호출부가 정함)
|
||
static func workoutKeys(dayKey: Date) -> [String] {
|
||
let token = HealthCache.dayToken(dayKey)
|
||
// load()는 1회만 — 예전에는 필터 클로저 안에서 키 개수만큼 다시 로드했다 (1.5(9))
|
||
let all = load()
|
||
return all.keys.filter { $0.hasPrefix("workout.") && !(all[$0]?[token] ?? []).isEmpty }
|
||
}
|
||
|
||
/// 여러 날짜 × 여러 종류 일괄 기록 (백필·최근 갱신 공용).
|
||
/// values: [종류 키: [dayKey: [[시작 epoch, 끝 epoch]]]] — 빈 배열은 "그날 없음" 기록으로 유효
|
||
static func merge(_ values: [String: [Date: [[Double]]]]) {
|
||
guard !values.isEmpty else { return }
|
||
var all = load()
|
||
for (key, days) in values {
|
||
var stored = all[key] ?? [:]
|
||
for (dayKey, list) in days {
|
||
let token = HealthCache.dayToken(dayKey)
|
||
if list.isEmpty {
|
||
stored.removeValue(forKey: token)
|
||
} else {
|
||
stored[token] = list
|
||
}
|
||
}
|
||
if stored.count > retentionDays {
|
||
let sorted = stored.keys.sorted(by: >)
|
||
for old in sorted.dropFirst(retentionDays) { stored.removeValue(forKey: old) }
|
||
}
|
||
all[key] = stored
|
||
}
|
||
store(all)
|
||
}
|
||
|
||
static func clearAll() {
|
||
store(nil)
|
||
}
|
||
}
|
||
|
||
// MARK: - 기기 가용성 (§8ⓒⓕ — 데이터 없는 기기의 모수 제외·판정 보류)
|
||
|
||
nonisolated enum HealthSupport {
|
||
static let deviceAvailableKey = "health.deviceAvailable"
|
||
|
||
/// 이 기기에서 건강 데이터를 쓸 수 있는지 — 메인 앱이 시작 때 기록(맥=false).
|
||
/// 기록이 아직 없으면(위젯이 앱보다 먼저 도는 최초 상태) 낙관적으로 true.
|
||
static var deviceAvailable: Bool {
|
||
guard AppGroup.defaults.object(forKey: deviceAvailableKey) != nil else { return true }
|
||
return AppGroup.defaults.bool(forKey: deviceAvailableKey)
|
||
}
|
||
}
|
||
|
||
// MARK: - 건강 다짐 값 공급 (QuestProgress가 읽는 유일한 경로 — 캐시 합산)
|
||
|
||
nonisolated enum HealthQuestValues {
|
||
#if DEBUG
|
||
/// 자가 검증용 오버라이드(-progressSelfTest H 시리즈) — 실 캐시·기기 설정 무영향
|
||
nonisolated(unsafe) static var testOverride: [String: [String: Double]]?
|
||
#endif
|
||
|
||
/// 다짐의 캐시 키 — 수면은 다짐별 수면 구간이 값 자체를 바꾸므로 구간을 키에 포함
|
||
static func cacheKey(target: HealthQuestTarget, quest: Quest) -> String {
|
||
if case .metric(.sleep) = target {
|
||
return "sleep@\(quest.sleepWindowStartMinutes)-\(quest.sleepWindowEndMinutes)"
|
||
}
|
||
return target.raw
|
||
}
|
||
|
||
static func dayValue(target: HealthQuestTarget, quest: Quest, dayKey: Date) -> Double? {
|
||
let key = cacheKey(target: target, quest: quest)
|
||
#if DEBUG
|
||
if let override = testOverride {
|
||
return override[key]?[HealthCache.dayToken(dayKey)]
|
||
}
|
||
#endif
|
||
return HealthCache.value(key, dayKey: dayKey)
|
||
}
|
||
|
||
/// range에 걸친 논리적 하루들의 캐시 값 합.
|
||
/// 건강 다짐은 하루 단위 전용이라 호출부의 range는 논리적 하루(또는 그 판정 클리핑)다 —
|
||
/// 오늘의 부분 클리핑은 "오늘 캐시 = 마지막 갱신 시점까지의 값"이라 자연히 now 근사가 된다.
|
||
static func sum(target: HealthQuestTarget, quest: Quest, in range: Range<Date>, math: DayMath) -> Double {
|
||
math.dayKeys(in: range).reduce(0) { partial, key in
|
||
partial + (dayValue(target: target, quest: quest, dayKey: key) ?? 0)
|
||
}
|
||
}
|
||
}
|