From e212487cdd5e290ec04f6e820168403ab3463434 Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 27 Oct 2026 03:46:01 +0900 Subject: [PATCH 01/17] =?UTF-8?q?docs:=20App=20Store=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=ED=98=84=EC=A7=80=ED=99=94=20=EC=B4=88?= =?UTF-8?q?=EC=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- docs/index.md | 6 +- docs/store-listing.md | 378 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 docs/store-listing.md diff --git a/docs/index.md b/docs/index.md index cf755db..5ed7bc9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,7 +15,11 @@ ### `docs/` 디렉토리 (보조) -현재 본 인덱스 외에 다른 문서는 없습니다. 향후 다음과 같은 주제별 상세 문서가 추가될 수 있습니다: +| 파일 | 역할 | 주 독자 | +|---|---|---| +| [store-listing.md](store-listing.md) | App Store 등록 정보 현지화 초안 (로케일별 이름·부제·키워드·설명, 영어 스크린샷 문구) | 운영자 | + +향후 다음과 같은 주제별 상세 문서가 추가될 수 있습니다: - `docs/iap-receipt-validation.md` — StoreKit 1 영수증 검증 흐름 상세 (`hotfix/1.0.12-iapverify` 머지 후) - `docs/widget-data-flow.md` — App ↔ Widget 데이터 동기화 시퀀스 diff --git a/docs/store-listing.md b/docs/store-listing.md new file mode 100644 index 0000000..0acf360 --- /dev/null +++ b/docs/store-listing.md @@ -0,0 +1,378 @@ +# App Store 등록 정보 현지화 초안 + +> **초안입니다. ASC에는 아직 올리지 않았습니다.** 사용자 확인 뒤 코디네이터가 입력합니다. 대상 앱: AchaCharge(`com.arex.achaCharge`), 다음 버전 기능 기준. + +## 근거와 운영 메모 + +- **데이터 근거** (ASC, 2023-09 ~ 2026-10): 최초 다운로드의 98%가 한국 밖에서 나옵니다. 다운로드의 90%가 App Store 검색 유입입니다. 그런데 등록 정보는 ko 하나뿐이라 다른 나라 스토어에서도 한국어 이름·설명이 보입니다. 노출 대비 전환율은 0.56%로 유틸리티 동종 앱 하위 25%입니다. +- **로케일 선택 이유**: 다운로드 상위 국가에 맞췄습니다. 미국 15%(en-US)에 이어 러시아(ru), 브라질(pt-BR), 우크라이나(ru로 갈음), 튀르키예(tr), 프랑스(fr-FR), 독일(de-DE), 사우디(en-US로 갈음, ar는 이번 범위 밖)입니다. 여기에 앱 UI 지원 언어(ko·en·ja·es·pt-BR·ru·tr·fr·de)와 맞추려고 ja와 es-MX를 넣었습니다. +- **운영 메모**: 이름·부제는 새 버전 제출 때만 바뀝니다(설명·키워드·프로모션 텍스트는 버전 제출 때 함께 바뀌고, 프로모션 텍스트만 심사 없이 수시로 바꿀 수 있습니다). 따라서 이 초안의 이름·부제는 다음 버전 제출에 맞춰 넣어야 합니다. +- **작성 규칙**: 가격·통화를 쓰지 않습니다(스토어가 현지 가격을 보여 줌). 구독 자동 갱신 고지와 개인정보 처리방침·이용약관 링크는 각 언어로 유지했습니다. 공식 지원 컨트롤러는 DualSense뿐이며, 다른 컨트롤러는 "배터리 정보를 제공하는 컨트롤러"로 조건을 붙였습니다. 키워드의 xbox·ps5는 검색어이며 설명에서 지원을 단정하지 않습니다. 키워드는 이름·부제에 이미 있는 단어를 뺐습니다. +- **글자 수 검증**: 이름·부제·프로모션·설명은 문자 수, 키워드는 `printf '%s' "<키워드>" | wc -c` 바이트 수입니다. +- **개인정보 처리방침 URL**: `https://dy-solution.kr/privacy.html#acha-charge` (현재 원문 그대로) + +## en-US — 영어 (미국) + +| 항목 | 값 | 수 | +|---|---|---| +| 이름 | Ah Charge! Controller Battery | 29/30자 | +| 부제 | DualSense battery alerts | 24/30자 | +| 키워드 | `gamepad,ps5,xbox,genshin,low,level,widget,tester,input,notification,remind,joystick,lock,screen` | 95/100바이트 | +| 프로모션 텍스트 | Check your controller's battery at a glance. Pro warns you before it runs out, even while you use other apps. Watch an ad for 3 hours of Pro, or subscribe to go ad-free. | 169/170자 | +| 설명 | 아래 | 1230/4000자 | + +설명: + +```text +Ever had your controller die mid-game? +Ah Charge! shows your controller's battery level so you can charge it before it's too late. + +[Free] +◼ See your controller's battery % inside the app +◼ Button input tester +◼ Home Screen widget +◼ Lock Screen widget +◼ Connection guide screen + +[Pro] (subscription, or watch an ad for 3 hours of access) +◼ Get an alert when the battery drops below your threshold (20% by default), even while you use other apps +◼ Live Activity on the Lock Screen and Dynamic Island (iOS 16.1+) +◼ No ads + +Officially supported controller: Sony DualSense. Other controllers that report battery information may also work, but support is not guaranteed. + +Auto-renewable subscription +Payment will be charged to your Apple ID account at confirmation of purchase. The subscription renews automatically unless it is canceled at least 24 hours before the end of the current period. Your account will be charged for renewal within 24 hours prior to the end of the current period. You can manage and cancel your subscriptions in your App Store account settings after purchase. +Plans: weekly, monthly, yearly + +◼ Privacy Policy: https://dy-solution.kr/privacy.html#acha-charge +◼ Terms of Use: https://dy-solution.kr/terms.html +``` + +## ja — 일본어 + +| 항목 | 값 | 수 | +|---|---|---| +| 이름 | 忘れないで!コントローラー電池 | 15/30자 | +| 부제 | DualSenseの電池を通知でお知らせ | 20/30자 | +| 키워드 | `ゲーム,ゲームパッド,PS5,Xbox,原神,ウィジェット,ボタン,テスト,ロック画面` | 99/100바이트 | +| 프로모션 텍스트 | コントローラーの電池残量がひと目でわかります。Proなら他のアプリを使用中でも、残量が少なくなると通知。広告を見て3時間Proを体験、または購読で広告なしに。 | 79/170자 | +| 설명 | 아래 | 646/4000자 | + +설명: + +```text +ゲーム中にコントローラーの電池が切れた経験はありませんか? +「忘れないで!」は電池残量を見やすく表示し、切れる前に充電できるようお手伝いします。 + +【無料】 +◼ アプリ内でコントローラーの電池残量(%)を確認 +◼ ボタン入力テスター +◼ ホーム画面ウィジェット +◼ ロック画面ウィジェット +◼ 接続ガイド画面 + +【Pro】(購読、または広告視聴で3時間利用可能) +◼ 他のアプリの使用中でも、電池残量が設定した基準値(初期値20%)以下になると通知 +◼ ロック画面とDynamic IslandのLive Activity(iOS 16.1以降) +◼ 広告なし + +公式対応コントローラー:Sony DualSense。電池情報を提供するその他のコントローラーでも利用できる場合がありますが、動作は保証されません。 + +自動更新サブスクリプション +購入の確認時に、お支払いはApple IDアカウントに請求されます。サブスクリプションは、現在の期間が終了する24時間前までに解約しない限り、自動的に更新されます。更新料金は、現在の期間が終了する24時間以内に請求されます。購入後は、App Storeのアカウント設定でサブスクリプションの管理や解約ができます。 +プラン:週間・月間・年間 + +◼ プライバシーポリシー: https://dy-solution.kr/privacy.html#acha-charge +◼ 利用規約: https://dy-solution.kr/terms.html +``` + +## es-MX — 스페인어 (멕시코) + +| 항목 | 값 | 수 | +|---|---|---| +| 이름 | Ah Charge! Batería de mando | 27/30자 | +| 부제 | Alertas de batería DualSense | 28/30자 | +| 키워드 | `control,gamepad,ps5,xbox,genshin,widget,probador,botones,carga,notificación,juegos,joystick,bloqueo` | 100/100바이트 | +| 프로모션 텍스트 | Mira la batería de tu mando de un vistazo. Pro te avisa antes de que se agote, incluso en otras apps. Mira un anuncio y obtén 3 horas de Pro, o suscríbete sin anuncios. | 168/170자 | +| 설명 | 아래 | 1328/4000자 | + +설명: + +```text +¿Te ha pasado que el mando se apaga en plena partida? +Ah Charge! te muestra el nivel de batería de tu mando para que lo cargues a tiempo. + +[Gratis] +◼ Consulta el % de batería del mando dentro de la app +◼ Probador de botones +◼ Widget en la pantalla de inicio +◼ Widget en la pantalla de bloqueo +◼ Pantalla de guía de conexión + +[Pro] (suscripción, o mira un anuncio para obtener 3 horas de acceso) +◼ Recibe un aviso cuando la batería baje del nivel que elijas (20 % por defecto), incluso mientras usas otras apps +◼ Live Activity en la pantalla de bloqueo y en la Dynamic Island (iOS 16.1 o posterior) +◼ Sin anuncios + +Mando con soporte oficial: Sony DualSense. Otros mandos que ofrezcan información de batería podrían funcionar, pero no se garantiza. + +Suscripción con renovación automática +El pago se cargará a tu cuenta de Apple ID al confirmar la compra. La suscripción se renueva automáticamente a menos que se cancele al menos 24 horas antes de que termine el periodo actual. Se cobrará la renovación dentro de las 24 horas previas al fin del periodo actual. Puedes administrar y cancelar tus suscripciones en los ajustes de tu cuenta de App Store después de la compra. +Planes: semanal, mensual y anual + +◼ Aviso de privacidad: https://dy-solution.kr/privacy.html#acha-charge +◼ Términos de uso: https://dy-solution.kr/terms.html +``` + +## pt-BR — 포르투갈어 (브라질) + +| 항목 | 값 | 수 | +|---|---|---| +| 이름 | Ah Charge! Bateria do Controle | 30/30자 | +| 부제 | Alerta de bateria DualSense | 27/30자 | +| 키워드 | `gamepad,joystick,ps5,xbox,genshin,widget,teste,botões,carga,notificação,jogos,tela,bloqueio` | 94/100바이트 | +| 프로모션 텍스트 | Veja a bateria do controle num relance. No Pro, você é avisado antes de acabar, mesmo em outros apps. Assista a um anúncio e ganhe 3 horas de Pro, ou assine sem anúncios. | 170/170자 | +| 설명 | 아래 | 1264/4000자 | + +설명: + +```text +Já teve o controle descarregado no meio do jogo? +O Ah Charge! mostra o nível de bateria do seu controle para você carregar a tempo. + +[Grátis] +◼ Veja a % de bateria do controle dentro do app +◼ Testador de botões +◼ Widget na tela de início +◼ Widget na tela de bloqueio +◼ Tela de guia de conexão + +[Pro] (assinatura, ou assista a um anúncio para ter 3 horas de acesso) +◼ Receba um aviso quando a bateria ficar abaixo do nível definido (20% por padrão), mesmo usando outros apps +◼ Live Activity na tela de bloqueio e na Dynamic Island (iOS 16.1 ou superior) +◼ Sem anúncios + +Controle com suporte oficial: Sony DualSense. Outros controles que informem o nível de bateria podem funcionar, mas não há garantia. + +Assinatura com renovação automática +O pagamento será cobrado na sua conta do Apple ID na confirmação da compra. A assinatura é renovada automaticamente, a menos que seja cancelada pelo menos 24 horas antes do fim do período atual. A renovação será cobrada nas 24 horas anteriores ao fim do período atual. Você pode gerenciar e cancelar suas assinaturas nos ajustes da conta da App Store após a compra. +Planos: semanal, mensal e anual + +◼ Política de Privacidade: https://dy-solution.kr/privacy.html#acha-charge +◼ Termos de Uso: https://dy-solution.kr/terms.html +``` + +## ru — 러시아어 + +| 항목 | 값 | 수 | +|---|---|---| +| 이름 | Ah Charge! Заряд геймпада | 25/30자 | +| 부제 | Заряд DualSense в уведомлениях | 30/30자 | +| 키워드 | `джойстик,ps5,xbox,genshin,виджет,тест,кнопки,игры` | 77/100바이트 | +| 프로모션 텍스트 | Заряд геймпада виден сразу. В Pro предупреждение придёт, даже когда вы в другом приложении. Посмотрите рекламу — 3 часа Pro, или оформите подписку без рекламы. | 159/170자 | +| 설명 | 아래 | 1306/4000자 | + +설명: + +```text +Бывало, что геймпад разряжался прямо посреди игры? +Ah Charge! показывает уровень заряда геймпада, чтобы вы успели поставить его на зарядку. + +[Бесплатно] +◼ Уровень заряда геймпада в процентах прямо в приложении +◼ Тестер нажатий кнопок +◼ Виджет на экране «Домой» +◼ Виджет на экране блокировки +◼ Экран с подсказками по подключению + +[Pro] (подписка или просмотр рекламы — 3 часа доступа) +◼ Уведомление, когда заряд опускается ниже заданного порога (по умолчанию 20%), даже если вы пользуетесь другим приложением +◼ Live Activity на экране блокировки и в Dynamic Island (iOS 16.1 и новее) +◼ Без рекламы + +Официально поддерживается геймпад Sony DualSense. Другие геймпады, передающие данные о заряде, могут работать, но поддержка не гарантируется. + +Подписка с автоматическим продлением +Оплата списывается с вашей учётной записи Apple ID при подтверждении покупки. Подписка продлевается автоматически, если не отменить её минимум за 24 часа до окончания текущего периода. Плата за продление списывается в течение 24 часов до окончания текущего периода. Управлять подписками и отменять их можно в настройках учётной записи App Store после покупки. +Варианты: неделя, месяц, год + +◼ Политика конфиденциальности: https://dy-solution.kr/privacy.html#acha-charge +◼ Условия использования: https://dy-solution.kr/terms.html +``` + +## tr — 튀르키예어 + +| 항목 | 값 | 수 | +|---|---|---| +| 이름 | Ah Charge! Oyun Kolu Pili | 25/30자 | +| 부제 | DualSense pil uyarıları | 23/30자 | +| 키워드 | `gamepad,ps5,xbox,genshin,widget,şarj,bildirim,test,düğme,joystick,kontrolcü,batarya` | 87/100바이트 | +| 프로모션 텍스트 | Oyun kolunun pil durumunu tek bakışta gör. Pro ile başka uygulamadayken bile pil azalmadan uyarı al. Reklam izleyip 3 saat Pro dene ya da abone olup reklamsız kullan. | 166/170자 | +| 설명 | 아래 | 1220/4000자 | + +설명: + +```text +Oyunun ortasında kolunun pili bitti mi hiç? +Ah Charge!, oyun kolunun pil seviyesini gösterir; geç olmadan şarj edebilirsin. + +[Ücretsiz] +◼ Uygulama içinde oyun kolunun pil yüzdesini görme +◼ Düğme giriş test aracı +◼ Ana ekran widget'ı +◼ Kilit ekranı widget'ı +◼ Bağlantı rehberi ekranı + +[Pro] (abonelik veya reklam izleyerek 3 saatlik erişim) +◼ Pil, belirlediğin eşiğin (varsayılan %20) altına düşünce, başka uygulamaları kullanırken bile bildirim +◼ Kilit ekranı ve Dynamic Island'da Live Activity (iOS 16.1 ve üzeri) +◼ Reklamsız kullanım + +Resmi olarak desteklenen oyun kolu: Sony DualSense. Pil bilgisi sağlayan diğer oyun kolları çalışabilir, ancak destek garanti edilmez. + +Otomatik yenilenen abonelik +Satın alma onaylandığında ödeme Apple ID hesabınızdan tahsil edilir. Abonelik, mevcut dönemin bitiminden en az 24 saat önce iptal edilmediği sürece otomatik olarak yenilenir. Yenileme ücreti, mevcut dönemin bitiminden önceki 24 saat içinde tahsil edilir. Satın aldıktan sonra aboneliklerinizi App Store hesap ayarlarından yönetebilir ve iptal edebilirsiniz. +Planlar: haftalık, aylık, yıllık + +◼ Gizlilik Politikası: https://dy-solution.kr/privacy.html#acha-charge +◼ Kullanım Koşulları: https://dy-solution.kr/terms.html +``` + +## fr-FR — 프랑스어 (프랑스) + +| 항목 | 값 | 수 | +|---|---|---| +| 이름 | Ah Charge! Batterie manette | 27/30자 | +| 부제 | Alertes pour DualSense | 22/30자 | +| 키워드 | `jeu,gamepad,ps5,xbox,genshin,widget,test,boutons,niveau,notification,joystick,écran,verrouillage` | 97/100바이트 | +| 프로모션 텍스트 | Voyez la batterie de votre manette d'un coup d'œil. Avec Pro, soyez alerté avant la panne, même dans une autre app. Une pub = 3 h de Pro, ou abonnez-vous sans pub. | 163/170자 | +| 설명 | 아래 | 1395/4000자 | + +설명: + +```text +Votre manette s'est déjà éteinte en pleine partie ? +Ah Charge! affiche le niveau de batterie de votre manette pour que vous puissiez la recharger à temps. + +[Gratuit] +◼ Niveau de batterie de la manette en % dans l'app +◼ Testeur de boutons +◼ Widget de l'écran d'accueil +◼ Widget de l'écran verrouillé +◼ Écran d'aide à la connexion + +[Pro] (abonnement, ou regardez une publicité pour 3 heures d'accès) +◼ Alerte quand la batterie passe sous le seuil choisi (20 % par défaut), même pendant que vous utilisez d'autres apps +◼ Live Activity sur l'écran verrouillé et dans la Dynamic Island (iOS 16.1 ou ultérieur) +◼ Aucune publicité + +Manette officiellement prise en charge : Sony DualSense. D'autres manettes fournissant l'état de la batterie peuvent fonctionner, sans garantie. + +Abonnement à renouvellement automatique +Le paiement sera débité de votre compte Apple ID à la confirmation de l'achat. L'abonnement se renouvelle automatiquement, sauf s'il est annulé au moins 24 heures avant la fin de la période en cours. Le renouvellement sera facturé dans les 24 heures précédant la fin de la période en cours. Vous pouvez gérer et résilier vos abonnements dans les réglages de votre compte App Store après l'achat. +Formules : hebdomadaire, mensuelle, annuelle + +◼ Politique de confidentialité: https://dy-solution.kr/privacy.html#acha-charge +◼ Conditions d'utilisation: https://dy-solution.kr/terms.html +``` + +## de-DE — 독일어 + +| 항목 | 값 | 수 | +|---|---|---| +| 이름 | Ah Charge! Controller-Akku | 26/30자 | +| 부제 | DualSense-Akku Warnungen | 24/30자 | +| 키워드 | `gamepad,ps5,xbox,genshin,widget,test,tasten,laden,benachrichtigung,spiele,joystick` | 82/100바이트 | +| 프로모션 텍스트 | Den Akkustand deines Controllers auf einen Blick. Pro warnt dich rechtzeitig, auch in anderen Apps. Werbung ansehen für 3 Std. Pro oder abonnieren und werbefrei nutzen. | 168/170자 | +| 설명 | 아래 | 1345/4000자 | + +설명: + +```text +Controller mitten im Spiel leer gewesen? +Ah Charge! zeigt dir den Akkustand deines Controllers, damit du ihn rechtzeitig laden kannst. + +[Kostenlos] +◼ Akkustand des Controllers in % direkt in der App +◼ Tasten-Tester +◼ Home-Bildschirm-Widget +◼ Sperrbildschirm-Widget +◼ Verbindungsanleitung + +[Pro] (Abo, oder Werbung ansehen für 3 Stunden Zugang) +◼ Benachrichtigung, wenn der Akku unter deinen Schwellenwert fällt (standardmäßig 20 %), auch während du andere Apps nutzt +◼ Live Activity auf dem Sperrbildschirm und in der Dynamic Island (iOS 16.1 oder neuer) +◼ Keine Werbung + +Offiziell unterstützter Controller: Sony DualSense. Andere Controller, die Akkuinformationen liefern, funktionieren möglicherweise, eine Garantie gibt es dafür nicht. + +Automatisch verlängerndes Abonnement +Die Zahlung wird bei Kaufbestätigung deinem Apple-ID-Account belastet. Das Abonnement verlängert sich automatisch, sofern es nicht mindestens 24 Stunden vor Ende des aktuellen Zeitraums gekündigt wird. Die Verlängerung wird innerhalb von 24 Stunden vor Ende des aktuellen Zeitraums berechnet. Nach dem Kauf kannst du deine Abos in den Account-Einstellungen des App Store verwalten und kündigen. +Optionen: wöchentlich, monatlich, jährlich + +◼ Datenschutzerklärung: https://dy-solution.kr/privacy.html#acha-charge +◼ Nutzungsbedingungen: https://dy-solution.kr/terms.html +``` + +## ko — 한국어 (개정안) + +| 항목 | 값 | 수 | +|---|---|---| +| 이름 | 아차 충전 ! 컨트롤러 배터리 | 16/30자 | +| 부제 | DualSense 배터리 알림 | 16/30자 | +| 키워드 | `game,gamepad,ps5,xbox,genshin,원신,위젯,버튼,테스터,게임,패드,잠금화면,듀얼센스` | 100/100바이트 | +| 프로모션 텍스트 | 컨트롤러 배터리를 한눈에 확인하세요. Pro는 다른 앱을 쓰는 중에도 배터리가 떨어지면 알려 드려요. 광고를 보면 3시간 동안 Pro 무료, 구독하면 광고 없이 이용해요. | 95/170자 | +| 설명 | 아래 | 664/4000자 | + +설명: + +```text +게임 중에 컨트롤러 배터리가 방전된 적 있나요? +아차 충전 !이 컨트롤러 배터리를 보여 줘서, 늦기 전에 충전할 수 있어요. + +[무료 기능] +◼ 앱 안에서 컨트롤러 배터리 % 확인 +◼ 버튼 입력 테스터 +◼ 홈 화면 위젯 +◼ 잠금 화면 위젯 +◼ 연결 안내 화면 + +[Pro 기능] (구독, 또는 광고 시청 시 3시간 이용권) +◼ 다른 앱을 쓰는 중에도 배터리가 정한 기준값(기본 20%) 이하로 떨어지면 알림 +◼ 잠금 화면·Dynamic Island Live Activity (iOS 16.1 이상) +◼ 광고 없이 이용 + +공식 지원 컨트롤러: Sony DualSense. 배터리 정보를 제공하는 다른 컨트롤러는 동작할 수도 있지만, 지원을 보장하지는 않아요. + +자동 갱신 구독 +구매 확인 시 결제는 Apple ID 계정에 청구됩니다. 구독은 현재 기간이 종료되기 적어도 24시간 전에 취소하지 않으면 자동으로 갱신됩니다. 갱신 요금은 현재 기간이 종료되기 24시간 이내에 청구됩니다. 구독 후 App Store 계정 설정에서 구독을 관리하고 취소할 수 있습니다. +구독 기간: 1주일, 1개월, 1년 + +◼ 개인 정보 보호 정책: https://dy-solution.kr/privacy.html#acha-charge +◼ 이용 약관: https://dy-solution.kr/terms.html +``` + +## ko 개정 요약 (현재 원문 대비) + +- 키워드 오타 `kensin` → `genshin`. +- 띄어쓰기·맞춤법 정리: `원할하게` → `원활하게` 등. (개정안은 문장을 새로 써서 해당 표현 자체가 없어졌습니다.) +- 가격 하드코딩(₩/$) 제거. 구독 기간만 표기. +- `[무료기능]`·`[유료기능]` 한 줄 붙임 문제를 줄바꿈으로 분리. 광고 시청 3시간 이용권, 잠금 화면 위젯, 연결 안내 화면 추가. +- 프로모션 텍스트는 현재 비어 있어(null) 새로 작성. + +## 영어 스크린샷 문구 (다음 버전 화면 기준) + +| # | 화면 | 헤드라인 | 글자 수 | +|---|---|---|---| +| 1 | 배터리 링 | Know your battery at a glance | 29 | +| 2 | 연결 안내 화면 | Connect in a few easy steps | 27 | +| 3 | 잠금 화면 위젯 | Battery on your Lock Screen | 27 | +| 4 | 저전력 알림 (Pro) | Get warned before it dies | 25 | +| 5 | 버튼 테스터 | Test every button instantly | 27 | + +저전력 알림 장은 Pro 기능입니다. 헤드라인에 가격·수치·과장 표현을 쓰지 않았습니다. + +--- + +*기준 브랜치: `feature/store-listing` · 최종 갱신: 2026-10-27* From 2440c311197e76149c3c004974c815bcdd011436 Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 27 Oct 2026 03:46:42 +0900 Subject: [PATCH 02/17] =?UTF-8?q?chore:=20DEBUG=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=B0=EC=83=B7=20=ED=94=BD=EC=8A=A4=EC=B2=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시뮬레이터에는 컨트롤러를 연결할 수 없어 화면 상태를 실행 인자로 만든다. -ACScreenshot YES 로 시작 흐름(알림 권한·광고 동의·ATT·광고·Pro 팝업)을 건너뛰고, -ACMockBattery/-ACMockCharging/-ACMockVendor 로 연결 상태를, -ACTab 으로 시작 탭을, -ACPaywall 로 구매 화면을 띄운다. 릴리스 빌드에는 들어가지 않는다. Co-Authored-By: Claude Opus 5.5 --- .../Controllers.xcodeproj/project.pbxproj | 12 ++++ .../Applications/AppDelegate.swift | 7 +++ .../Applications/SceneDelegate.swift | 23 ++++++++ .../Sources/Controller/ControllerModel.swift | 23 ++++++++ .../Sources/Debug/ScreenshotFixture.swift | 58 +++++++++++++++++++ .../Controllers/Sources/MainTabView.swift | 16 ++++- .../Sources/Tester/TesterModel.swift | 8 +++ 7 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 Controllers/Controllers/Sources/Debug/ScreenshotFixture.swift diff --git a/Controllers/Controllers.xcodeproj/project.pbxproj b/Controllers/Controllers.xcodeproj/project.pbxproj index 8af76aa..ccf8d65 100644 --- a/Controllers/Controllers.xcodeproj/project.pbxproj +++ b/Controllers/Controllers.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + ACDBACDB000000000000F002 /* ScreenshotFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACDBACDB000000000000F001 /* ScreenshotFixture.swift */; }; ADADADAD000000000000C004 /* AdConsentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADADADAD000000000000C003 /* AdConsentManager.swift */; }; ADADADAD000000000000C006 /* AppOpenAdManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADADADAD000000000000C005 /* AppOpenAdManager.swift */; }; ADADADAD000000000000C008 /* ProPromotionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADADADAD000000000000C007 /* ProPromotionManager.swift */; }; @@ -110,6 +111,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + ACDBACDB000000000000F001 /* ScreenshotFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotFixture.swift; sourceTree = ""; }; ADADADAD000000000000C003 /* AdConsentManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdConsentManager.swift; sourceTree = ""; }; ADADADAD000000000000C005 /* AppOpenAdManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppOpenAdManager.swift; sourceTree = ""; }; ADADADAD000000000000C007 /* ProPromotionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProPromotionManager.swift; sourceTree = ""; }; @@ -225,6 +227,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + ACDBACDB000000000000F003 /* Debug */ = { + isa = PBXGroup; + children = ( + ACDBACDB000000000000F001 /* ScreenshotFixture.swift */, + ); + path = Debug; + sourceTree = ""; + }; B511FEDE2B20C78F00742111 /* Component */ = { isa = PBXGroup; children = ( @@ -339,6 +349,7 @@ isa = PBXGroup; children = ( B511FEDE2B20C78F00742111 /* Component */, + ACDBACDB000000000000F003 /* Debug */, EF93623D2A9B9A010009E8E8 /* Extensions */, EF93623E2A9B9A1E0009E8E8 /* Models */, EF9362292A9645100009E8E8 /* MainTabView.swift */, @@ -623,6 +634,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + ACDBACDB000000000000F002 /* ScreenshotFixture.swift in Sources */, B58A16DD2DE46D19005CEA4F /* DPadView.swift in Sources */, B511FEE02B20C7A600742111 /* ImageButton.swift in Sources */, EF93624C2A9BA8DA0009E8E8 /* Array+.swift in Sources */, diff --git a/Controllers/Controllers/Applications/AppDelegate.swift b/Controllers/Controllers/Applications/AppDelegate.swift index 11cad9b..ca6d440 100644 --- a/Controllers/Controllers/Applications/AppDelegate.swift +++ b/Controllers/Controllers/Applications/AppDelegate.swift @@ -34,6 +34,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } + #if DEBUG + // 스크린샷을 찍을 때는 알림 권한 팝업을 띄우지 않는다. + if ScreenshotFixture.isEnabled { + isNotificationAuthorizationAnswered = true + return true + } + #endif Task { await requestNotificationAuthorization() isNotificationAuthorizationAnswered = true diff --git a/Controllers/Controllers/Applications/SceneDelegate.swift b/Controllers/Controllers/Applications/SceneDelegate.swift index 5f59b0e..a76c536 100644 --- a/Controllers/Controllers/Applications/SceneDelegate.swift +++ b/Controllers/Controllers/Applications/SceneDelegate.swift @@ -31,6 +31,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // 제어 센터·알림 센터를 내렸다 올린 경우는 백그라운드를 거치지 않아 false다. let isReturningFromBackground = isBackground isBackground = false + #if DEBUG + // 스크린샷을 찍을 때는 광고 동의·ATT·광고·Pro 팝업을 모두 건너뛴다. + if ScreenshotFixture.isEnabled { + presentScreenshotPaywallIfNeeded() + return + } + #endif if launchTask == nil { launchTask = Task { await runLaunchFlow() } } @@ -86,3 +93,19 @@ extension SceneDelegate { } } + +#if DEBUG +// MARK: - Screenshot Fixture +extension SceneDelegate { + /// `-ACPaywall YES` 로 실행하면 구매 화면을 한 번 띄운다. + private func presentScreenshotPaywallIfNeeded() { + guard ScreenshotFixture.showsPaywall, + launchTask == nil, + let rootViewController = window?.rootViewController else { return } + launchTask = Task {} + let onboardingVC = IAPOnboardingViewController() + onboardingVC.modalPresentationStyle = .overFullScreen + rootViewController.present(onboardingVC, animated: false) + } +} +#endif diff --git a/Controllers/Controllers/Sources/Controller/ControllerModel.swift b/Controllers/Controllers/Sources/Controller/ControllerModel.swift index 0d64b02..cc7e554 100644 --- a/Controllers/Controllers/Sources/Controller/ControllerModel.swift +++ b/Controllers/Controllers/Sources/Controller/ControllerModel.swift @@ -22,6 +22,9 @@ final class ControllerModel: ObservableObject { init() { UserDefaults.shared.setValue(false, forKey: StringKey.CONTROLLER_CONNECTED) addControllerObservers() + #if DEBUG + applyScreenshotFixture() + #endif } @@ -31,6 +34,10 @@ final class ControllerModel: ObservableObject { // MARK: - Life Cycle public func updateControllerInfo() { + #if DEBUG + // 스크린샷용 모의 상태를 실제 조회(시뮬레이터에서는 항상 없음)로 덮어쓰지 않는다. + if ScreenshotFixture.mockController != nil { return } + #endif guard let info = manager.getControlelrInfo() else { batteryLevel = 0.0 return @@ -87,3 +94,19 @@ extension ControllerModel: GameControllerDelegate { LiveActivityManager.shared.end() } } + +#if DEBUG +// MARK: - Screenshot Fixture +extension ControllerModel { + /// 스크린샷용 실행 인자가 있으면 모의 컨트롤러 상태를 반영한다. + private func applyScreenshotFixture() { + guard ScreenshotFixture.isEnabled else { return } + if let mock = ScreenshotFixture.mockController { + isConnected = true + vendorName = mock.vendorName + batteryLevel = mock.batteryLevel + } + ScreenshotFixture.writeWidgetState() + } +} +#endif diff --git a/Controllers/Controllers/Sources/Debug/ScreenshotFixture.swift b/Controllers/Controllers/Sources/Debug/ScreenshotFixture.swift new file mode 100644 index 0000000..8b559be --- /dev/null +++ b/Controllers/Controllers/Sources/Debug/ScreenshotFixture.swift @@ -0,0 +1,58 @@ +// +// ScreenshotFixture.swift +// Controllers +// +// DEBUG 빌드에서 실행 인자로 화면 상태를 만들어 스크린샷을 찍는다. +// 시뮬레이터에는 컨트롤러를 연결할 수 없어서, 연결된 상태를 흉내 낼 때 쓴다. +// +// xcrun simctl launch com.arex.achaCharge -ACScreenshot YES \ +// -ACMockBattery 0.42 -ACMockCharging YES -ACMockVendor "DualSense Wireless Controller" \ +// -ACTab controller|tester|setting -ACPaywall YES +// + +#if DEBUG +import Foundation +import WidgetKit + +enum ScreenshotFixture { + /// 실행 인자는 UserDefaults 의 argument 도메인으로 들어온다. + private static let arguments = UserDefaults.standard + + /// 켜져 있으면 시작 흐름(알림 권한·광고 동의·ATT·광고·Pro 팝업)을 건너뛴다. + static var isEnabled: Bool { + arguments.bool(forKey: "ACScreenshot") + } + + /// `-ACMockBattery` 를 주면 그 잔량으로 컨트롤러가 연결된 것처럼 보인다. 없으면 미연결 상태다. + static var mockController: MockController? { + guard isEnabled, arguments.object(forKey: "ACMockBattery") != nil else { return nil } + return MockController(batteryLevel: arguments.float(forKey: "ACMockBattery"), + isCharging: arguments.bool(forKey: "ACMockCharging"), + vendorName: arguments.string(forKey: "ACMockVendor") ?? "DualSense Wireless Controller") + } + + /// 시작 탭: controller, tester, setting + static var initialTab: String? { + isEnabled ? arguments.string(forKey: "ACTab") : nil + } + + /// 켜져 있으면 실행하자마자 구매 화면을 띄운다. + static var showsPaywall: Bool { + isEnabled && arguments.bool(forKey: "ACPaywall") + } + + struct MockController { + let batteryLevel: Float + let isCharging: Bool + let vendorName: String + } + + /// 위젯도 같은 상태를 그리도록 App Group 에 쓰고 타임라인을 다시 읽게 한다. + static func writeWidgetState() { + let mock = mockController + UserDefaults.shared.setValue(mock != nil, forKey: StringKey.CONTROLLER_CONNECTED) + UserDefaults.shared.setValue(mock?.batteryLevel ?? 0, forKey: StringKey.BATTERY_LEVEL) + WidgetCenter.shared.reloadAllTimelines() + } +} +#endif diff --git a/Controllers/Controllers/Sources/MainTabView.swift b/Controllers/Controllers/Sources/MainTabView.swift index 6dd8244..d2a337e 100644 --- a/Controllers/Controllers/Sources/MainTabView.swift +++ b/Controllers/Controllers/Sources/MainTabView.swift @@ -9,8 +9,10 @@ import UIKit import SwiftUI struct MainTabView: View { + @State private var selection: TabType = MainTabView.initialTab + var body: some View { - TabView { + TabView(selection: $selection) { ForEach(TabType.allCases, id: \.self) { type in tabView(for: type) .tag(type) @@ -37,6 +39,18 @@ struct MainTabView: View { } extension MainTabView { + /// 앱을 켰을 때 보이는 탭. DEBUG 에서는 스크린샷용 실행 인자(`-ACTab`)로 바꿀 수 있다. + private static var initialTab: TabType { + #if DEBUG + switch ScreenshotFixture.initialTab { + case "tester": return .tester + case "setting": return .setting + default: break + } + #endif + return .controlelr + } + enum TabType: CaseIterable { case controlelr case tester diff --git a/Controllers/Controllers/Sources/Tester/TesterModel.swift b/Controllers/Controllers/Sources/Tester/TesterModel.swift index 929d53f..a26468c 100644 --- a/Controllers/Controllers/Sources/Tester/TesterModel.swift +++ b/Controllers/Controllers/Sources/Tester/TesterModel.swift @@ -55,6 +55,14 @@ final class TesterModel: ObservableObject { if manager.getControlelrInfo() != nil { didConnectedController() } + #if DEBUG + // 스크린샷용 실행 인자가 있으면 모의 컨트롤러가 연결된 것처럼 보인다. + if let mock = ScreenshotFixture.mockController { + isConnected = true + vendorName = mock.vendorName + batteryLevel = mock.batteryLevel + } + #endif } func setupController() { From 3c5beb0e09b52db732f8ece41b1a78918bd725fc Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 27 Oct 2026 03:55:29 +0900 Subject: [PATCH 03/17] =?UTF-8?q?fix:=20en/ja=20=EB=B2=88=EC=97=AD=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=EB=B3=B4=EC=B6=A9,=20=ED=95=98=EB=93=9C?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20=EB=AC=B8=EA=B5=AC=20=ED=82=A4=ED=99=94,?= =?UTF-8?q?=20Tester=C2=B7=EA=B5=AC=EB=A7=A4=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=B0=98=EC=9D=91=ED=98=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit en/ja에 빠진 키 때문에 구매 화면에 키 이름이 그대로 노출됐다. 최초 다운로드의 98%가 해외라 영향이 크다. Tester 연결 상태와 구매 화면 제목을 키로 바꾸고, 고정 크기(spacing 400, 40/26pt 폰트)를 가용 폭·Dynamic Type·readableContentGuide 기준으로 바꿨다. Co-Authored-By: Claude Sonnet 5.5 --- .../Setting/IAPOnboardingViewController.swift | 53 ++++++++++++------- .../Sources/Tester/TesterView.swift | 10 ++-- .../en.lproj/Localizable.strings | 15 +++++- .../ja.lproj/Localizable.strings | 14 ++++- .../ko.lproj/Localizable.strings | 5 ++ 5 files changed, 74 insertions(+), 23 deletions(-) diff --git a/Controllers/Controllers/Sources/Setting/IAPOnboardingViewController.swift b/Controllers/Controllers/Sources/Setting/IAPOnboardingViewController.swift index 98031ed..2aefc07 100644 --- a/Controllers/Controllers/Sources/Setting/IAPOnboardingViewController.swift +++ b/Controllers/Controllers/Sources/Setting/IAPOnboardingViewController.swift @@ -61,10 +61,12 @@ final class IAPOnboardingViewController: UIViewController { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false - label.text = "Upgrade to Pro" + label.text = "Upgrade to Pro".localized + label.numberOfLines = 0 + label.adjustsFontForContentSizeCategory = true label.textAlignment = .center label.textColor = .label - label.font = .systemFont(ofSize: 40) + label.font = IAPOnboardingViewController.boldFont(.largeTitle) return label }() @@ -79,7 +81,8 @@ final class IAPOnboardingViewController: UIViewController { label.lineBreakMode = .byWordWrapping label.textAlignment = .center label.textColor = .label - label.font = .systemFont(ofSize: 26) + label.font = .preferredFont(forTextStyle: .title3) + label.adjustsFontForContentSizeCategory = true return label }() @@ -134,6 +137,8 @@ final class IAPOnboardingViewController: UIViewController { button.setTitle("termOfUse".localized, for: .normal) button.setTitleColor(.label, for: .normal) button.setUnderline() + button.titleLabel?.adjustsFontSizeToFitWidth = true + button.titleLabel?.minimumScaleFactor = 0.7 return button }() @@ -146,6 +151,8 @@ final class IAPOnboardingViewController: UIViewController { button.setTitle("privacy Policy".localized, for: .normal) button.setTitleColor(.label, for: .normal) button.setUnderline() + button.titleLabel?.adjustsFontSizeToFitWidth = true + button.titleLabel?.minimumScaleFactor = 0.7 return button }() @@ -213,11 +220,18 @@ final class IAPOnboardingViewController: UIViewController { label.lineBreakMode = .byWordWrapping label.textAlignment = .center label.textColor = .label - label.font = .systemFont(ofSize: 25) + label.font = .preferredFont(forTextStyle: .title3) + label.adjustsFontForContentSizeCategory = true return label }() + private static func boldFont(_ style: UIFont.TextStyle) -> UIFont { + let base = UIFont.preferredFont(forTextStyle: style) + let descriptor = base.fontDescriptor.withSymbolicTraits(.traitBold) ?? base.fontDescriptor + return UIFont(descriptor: descriptor, size: 0) + } + // MARK: - Properties private let storKitManager: StoreKitManager = StoreKitManager.shared private var priceInfo: String = "" { @@ -271,6 +285,7 @@ extension IAPOnboardingViewController { } private func addConstraints() { + let readable = view.readableContentGuide NSLayoutConstraint.activate([ dimmedView.topAnchor.constraint(equalTo: view.topAnchor), dimmedView.trailingAnchor.constraint(equalTo: view.trailingAnchor), @@ -305,8 +320,8 @@ extension IAPOnboardingViewController { premiumContentsView.heightAnchor.constraint(equalToConstant: 150), startPremiumButton.topAnchor.constraint(equalTo: premiumContentsView.safeAreaLayoutGuide.topAnchor, constant: 15), - startPremiumButton.leadingAnchor.constraint(equalTo: premiumContentsView.safeAreaLayoutGuide.leadingAnchor, constant: 15), - startPremiumButton.trailingAnchor.constraint(equalTo: premiumContentsView.safeAreaLayoutGuide.trailingAnchor, constant: -15), + startPremiumButton.leadingAnchor.constraint(equalTo: readable.leadingAnchor), + startPremiumButton.trailingAnchor.constraint(equalTo: readable.trailingAnchor), startPremiumButton.heightAnchor.constraint(equalToConstant: 70), rewardedPassButton.topAnchor.constraint(equalTo: startPremiumButton.bottomAnchor, constant: 10), @@ -321,35 +336,37 @@ extension IAPOnboardingViewController { NSLayoutConstraint.activate([ titleLabel.topAnchor.constraint(equalTo: closeButton.centerYAnchor), - titleLabel.leadingAnchor.constraint(equalTo: scrollContentsView.leadingAnchor, constant: 20), - titleLabel.trailingAnchor.constraint(equalTo: scrollContentsView.trailingAnchor, constant: -20), + titleLabel.leadingAnchor.constraint(equalTo: readable.leadingAnchor), + titleLabel.trailingAnchor.constraint(equalTo: readable.trailingAnchor), infoLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 50), - infoLabel.leadingAnchor.constraint(equalTo: scrollContentsView.leadingAnchor, constant: 15), - infoLabel.trailingAnchor.constraint(equalTo: scrollContentsView.trailingAnchor, constant: -15), + infoLabel.leadingAnchor.constraint(equalTo: readable.leadingAnchor), + infoLabel.trailingAnchor.constraint(equalTo: readable.trailingAnchor), weeklyButton.topAnchor.constraint(equalTo: infoLabel.bottomAnchor, constant: 50), weeklyButton.heightAnchor.constraint(equalToConstant: view.frame.height / 10), - weeklyButton.leadingAnchor.constraint(equalTo: scrollContentsView.leadingAnchor, constant: 15), - weeklyButton.trailingAnchor.constraint(equalTo: scrollContentsView.trailingAnchor, constant: -15), + weeklyButton.leadingAnchor.constraint(equalTo: readable.leadingAnchor), + weeklyButton.trailingAnchor.constraint(equalTo: readable.trailingAnchor), weeklyButton.bottomAnchor.constraint(equalTo: monthlyButton.topAnchor, constant: -15), monthlyButton.heightAnchor.constraint(equalToConstant: view.frame.height / 10), - monthlyButton.leadingAnchor.constraint(equalTo: scrollContentsView.leadingAnchor, constant: 15), - monthlyButton.trailingAnchor.constraint(equalTo: scrollContentsView.trailingAnchor, constant: -15), + monthlyButton.leadingAnchor.constraint(equalTo: readable.leadingAnchor), + monthlyButton.trailingAnchor.constraint(equalTo: readable.trailingAnchor), monthlyButton.bottomAnchor.constraint(equalTo: yearlyButton.topAnchor, constant: -15), yearlyButton.heightAnchor.constraint(equalToConstant: view.frame.height / 10), - yearlyButton.leadingAnchor.constraint(equalTo: scrollContentsView.leadingAnchor, constant: 15), - yearlyButton.trailingAnchor.constraint(equalTo: scrollContentsView.trailingAnchor, constant: -15), + yearlyButton.leadingAnchor.constraint(equalTo: readable.leadingAnchor), + yearlyButton.trailingAnchor.constraint(equalTo: readable.trailingAnchor), yearlyButton.bottomAnchor.constraint(equalTo: termOfUseButton.safeAreaLayoutGuide.topAnchor, constant: -15), termOfUseButton.heightAnchor.constraint(equalTo: yearlyButton.heightAnchor, multiplier: 0.5), termOfUseButton.trailingAnchor.constraint(equalTo: yearlyButton.centerXAnchor, constant: -15), + termOfUseButton.leadingAnchor.constraint(greaterThanOrEqualTo: readable.leadingAnchor), termOfUseButton.bottomAnchor.constraint(equalTo: adPrivacyButton.topAnchor, constant: -4), privacyPolicyButton.heightAnchor.constraint(equalTo: yearlyButton.heightAnchor, multiplier: 0.5), privacyPolicyButton.leadingAnchor.constraint(equalTo: yearlyButton.centerXAnchor, constant: 15), + privacyPolicyButton.trailingAnchor.constraint(lessThanOrEqualTo: readable.trailingAnchor), privacyPolicyButton.bottomAnchor.constraint(equalTo: adPrivacyButton.topAnchor, constant: -4), adPrivacyButton.bottomAnchor.constraint(equalTo: scrollContentsView.bottomAnchor, constant: -16), @@ -357,8 +374,8 @@ extension IAPOnboardingViewController { ]) NSLayoutConstraint.activate([ - priceLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 15), - priceLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -15), + priceLabel.leadingAnchor.constraint(equalTo: readable.leadingAnchor), + priceLabel.trailingAnchor.constraint(equalTo: readable.trailingAnchor), priceLabel.bottomAnchor.constraint(equalTo: startPremiumButton.safeAreaLayoutGuide.topAnchor, constant: -20), ]) } diff --git a/Controllers/Controllers/Sources/Tester/TesterView.swift b/Controllers/Controllers/Sources/Tester/TesterView.swift index 8f106d5..7376485 100644 --- a/Controllers/Controllers/Sources/Tester/TesterView.swift +++ b/Controllers/Controllers/Sources/Tester/TesterView.swift @@ -30,15 +30,17 @@ struct TesterView: View { VStack { // 트리거 버튼 (L2, R2) - HStack(spacing: 400) { + HStack(spacing: 0) { TriggerButtonView(systemName: "l2.button.roundedtop.horizontal.fill", value: model.triggerL2) + Spacer() TriggerButtonView(systemName: "r2.button.roundedtop.horizontal.fill", value: model.triggerR2) } // 어깨 버튼 (L1, R1) - HStack(spacing: 400) { + HStack(spacing: 0) { ControllerButton(systemName: "l1.button.roundedbottom.horizontal.fill", isPressed: model.buttonL1) + Spacer() ControllerButton(systemName: "r1.button.roundedbottom.horizontal.fill", isPressed: model.buttonR1) } } @@ -99,8 +101,10 @@ struct TesterView: View { Circle() .fill(model.isConnected ? Color.green : Color.red) .frame(width: 12, height: 12) - Text(model.isConnected ? "\(model.vendorName) 연결됨" : "컨트롤러 연결 안됨") + Text(model.isConnected ? "%@ connected".localized(with: [model.vendorName]) : "Controller not connected".localized) .font(.subheadline) + .lineLimit(1) + .minimumScaleFactor(0.7) } Spacer() diff --git a/Controllers/Controllers/Supporting Files/en.lproj/Localizable.strings b/Controllers/Controllers/Supporting Files/en.lproj/Localizable.strings index 9c7e3cf..b0c60d9 100644 --- a/Controllers/Controllers/Supporting Files/en.lproj/Localizable.strings +++ b/Controllers/Controllers/Supporting Files/en.lproj/Localizable.strings @@ -6,7 +6,6 @@ */ "Controllers" = "Ah Charge !"; -"아차 충전 !" = "Ah Charge !"; "Controller" = "Controller"; "Tester" = "Tester"; @@ -50,3 +49,17 @@ "See Pro Plans" = "See Pro Plans"; "Not Now" = "Not Now"; "Pro No Ads" = "Use the app without ads."; + +// MARK: - Tester & Paywall +"%@ connected" = "%@ connected"; +"Controller not connected" = "Controller not connected"; +"Upgrade to Pro" = "Upgrade to Pro"; +"Already Subscribing !" = "You're already subscribed!"; +"Current Battery is %@" = "Current battery is %@!"; +"Month Plan" = "Monthly Plan"; +"Notifications can also be sent to the controller's battery information during game play" = "Get notified about your controller's battery level even while you play."; +"privacy Policy" = "Privacy Policy"; +"Start Premium" = "Start Premium"; +"termOfUse" = "Terms of Use"; +"Week Plan" = "Weekly Plan"; +"Year Plan" = "Yearly Plan"; diff --git a/Controllers/Controllers/Supporting Files/ja.lproj/Localizable.strings b/Controllers/Controllers/Supporting Files/ja.lproj/Localizable.strings index 811b4ed..ce72ed7 100644 --- a/Controllers/Controllers/Supporting Files/ja.lproj/Localizable.strings +++ b/Controllers/Controllers/Supporting Files/ja.lproj/Localizable.strings @@ -6,7 +6,6 @@ */ "Controllers" = "忘れないで!"; -"아차 충전 !" = "忘れないで!"; "Controller" = "コントローラー"; "Tester" = "テスター"; "Setting" = "設定"; @@ -51,3 +50,16 @@ "See Pro Plans" = "Proプランを見る"; "Not Now" = "あとで"; "Pro No Ads" = "広告なしでご利用いただけます。"; + +// MARK: - Tester & Paywall +"%@ connected" = "%@に接続中"; +"Controller not connected" = "コントローラー未接続"; +"Upgrade to Pro" = "Proにアップグレード"; +"Already Subscribing !" = "すでに登録済みです!"; +"Month Plan" = "月額プラン"; +"Notifications can also be sent to the controller's battery information during game play" = "ゲームのプレイ中でも、コントローラーのバッテリー残量を通知でお知らせします。"; +"privacy Policy" = "プライバシーポリシー"; +"Start Premium" = "プレミアムを始める"; +"termOfUse" = "利用規約"; +"Week Plan" = "週額プラン"; +"Year Plan" = "年額プラン"; diff --git a/Controllers/Controllers/Supporting Files/ko.lproj/Localizable.strings b/Controllers/Controllers/Supporting Files/ko.lproj/Localizable.strings index 36c5cb9..7fce306 100644 --- a/Controllers/Controllers/Supporting Files/ko.lproj/Localizable.strings +++ b/Controllers/Controllers/Supporting Files/ko.lproj/Localizable.strings @@ -58,3 +58,8 @@ "See Pro Plans" = "Pro 구독 보기"; "Not Now" = "나중에"; "Pro No Ads" = "광고 없이 이용할 수 있습니다."; + +// MARK: - Tester & Paywall +"%@ connected" = "%@ 연결됨"; +"Controller not connected" = "컨트롤러 연결 안됨"; +"Upgrade to Pro" = "Pro로 업그레이드"; From f23d18c14341103893cb29543fbce9abe5301e8c Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 27 Oct 2026 03:56:39 +0900 Subject: [PATCH 04/17] =?UTF-8?q?feat:=20=EC=9C=84=EC=A0=AF=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=20=EC=83=81=ED=83=9C=EB=A5=BC=20=ED=95=9C=EA=B3=B3?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=93=B0=EA=B3=A0=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=EC=9D=84=20=EA=B0=B1=EC=8B=A0=ED=95=98?= =?UTF-8?q?=EB=8A=94=20WidgetSharedState=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 위젯이 연결 여부와 잔량만 받아서 미연결도 0%로 보이고, 충전 여부·기기 이름·갱신 시각을 알 수 없었다. 앱이 App Group 에 연결·잔량·충전 상태·기기 이름·갱신 시각을 함께 쓰고 위젯 타임라인을 다시 읽게 한다. 미연결이면 마지막으로 알던 값은 남겨 위젯이 "마지막: 42%"를 보여 줄 수 있게 한다. 같은 상태(잔량 1% 미만 변화)면 15분 안에는 reload 를 생략해 WidgetKit 갱신 예산을 아낀다. 포어그라운드(ControllerModel)·백그라운드(FetchGameControllerOperation)·DEBUG 스크린샷 픽스처가 모두 이 헬퍼를 쓴다. Co-Authored-By: Claude Opus 5.5 --- .../Controllers.xcodeproj/project.pbxproj | 4 ++ .../Sources/Controller/ControllerModel.swift | 4 +- .../Sources/Debug/ScreenshotFixture.swift | 13 +++--- .../Sources/Extensions/StringKey+.swift | 3 ++ .../Models/FetchGameControllerOperation.swift | 3 ++ .../Sources/Models/WidgetSharedState.swift | 41 +++++++++++++++++++ 6 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 Controllers/Controllers/Sources/Models/WidgetSharedState.swift diff --git a/Controllers/Controllers.xcodeproj/project.pbxproj b/Controllers/Controllers.xcodeproj/project.pbxproj index ccf8d65..b09a002 100644 --- a/Controllers/Controllers.xcodeproj/project.pbxproj +++ b/Controllers/Controllers.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + ACDB0003000000000000B002 /* WidgetSharedState.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACDB0003000000000000B001 /* WidgetSharedState.swift */; }; ACDBACDB000000000000F002 /* ScreenshotFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACDBACDB000000000000F001 /* ScreenshotFixture.swift */; }; ADADADAD000000000000C004 /* AdConsentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADADADAD000000000000C003 /* AdConsentManager.swift */; }; ADADADAD000000000000C006 /* AppOpenAdManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADADADAD000000000000C005 /* AppOpenAdManager.swift */; }; @@ -111,6 +112,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + ACDB0003000000000000B001 /* WidgetSharedState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSharedState.swift; sourceTree = ""; }; ACDBACDB000000000000F001 /* ScreenshotFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotFixture.swift; sourceTree = ""; }; ADADADAD000000000000C003 /* AdConsentManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdConsentManager.swift; sourceTree = ""; }; ADADADAD000000000000C005 /* AppOpenAdManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppOpenAdManager.swift; sourceTree = ""; }; @@ -410,6 +412,7 @@ ADADADAD000000000000C003 /* AdConsentManager.swift */, ADADADAD000000000000C005 /* AppOpenAdManager.swift */, ADADADAD000000000000C007 /* ProPromotionManager.swift */, + ACDB0003000000000000B001 /* WidgetSharedState.swift */, ); path = Models; sourceTree = ""; @@ -665,6 +668,7 @@ ADADADAD000000000000C004 /* AdConsentManager.swift in Sources */, ADADADAD000000000000C006 /* AppOpenAdManager.swift in Sources */, ADADADAD000000000000C008 /* ProPromotionManager.swift in Sources */, + ACDB0003000000000000B002 /* WidgetSharedState.swift in Sources */, EF38A0972A4C745A00DF1AF9 /* UserDefaults+.swift in Sources */, B5E3BDC72DCF901F000622FD /* ProgressBarView.swift in Sources */, B5C7FAE62DDE24C0009F6BD1 /* Bundle+.swift in Sources */, diff --git a/Controllers/Controllers/Sources/Controller/ControllerModel.swift b/Controllers/Controllers/Sources/Controller/ControllerModel.swift index cc7e554..b682787 100644 --- a/Controllers/Controllers/Sources/Controller/ControllerModel.swift +++ b/Controllers/Controllers/Sources/Controller/ControllerModel.swift @@ -39,6 +39,7 @@ final class ControllerModel: ObservableObject { if ScreenshotFixture.mockController != nil { return } #endif guard let info = manager.getControlelrInfo() else { + WidgetSharedState.save(isConnected: false) batteryLevel = 0.0 return } @@ -46,7 +47,8 @@ final class ControllerModel: ObservableObject { NSLog("batteryLevel: \(info.batteryLevel)") vendorName = info.vendorName batteryLevel = info.batteryLevel - UserDefaults.shared.setValue(info.batteryLevel, forKey: StringKey.BATTERY_LEVEL) + WidgetSharedState.save(isConnected: true, batteryLevel: info.batteryLevel, + batteryState: info.batteryState.rawValue, vendorName: info.vendorName) // 활성화된 Live Activity 가 있으면 배터리 정보를 갱신한다. LiveActivityManager.shared.update(batteryLevel: info.batteryLevel, diff --git a/Controllers/Controllers/Sources/Debug/ScreenshotFixture.swift b/Controllers/Controllers/Sources/Debug/ScreenshotFixture.swift index 8b559be..94db6b1 100644 --- a/Controllers/Controllers/Sources/Debug/ScreenshotFixture.swift +++ b/Controllers/Controllers/Sources/Debug/ScreenshotFixture.swift @@ -12,7 +12,7 @@ #if DEBUG import Foundation -import WidgetKit +import ControllerKit enum ScreenshotFixture { /// 실행 인자는 UserDefaults 의 argument 도메인으로 들어온다. @@ -49,10 +49,13 @@ enum ScreenshotFixture { /// 위젯도 같은 상태를 그리도록 App Group 에 쓰고 타임라인을 다시 읽게 한다. static func writeWidgetState() { - let mock = mockController - UserDefaults.shared.setValue(mock != nil, forKey: StringKey.CONTROLLER_CONNECTED) - UserDefaults.shared.setValue(mock?.batteryLevel ?? 0, forKey: StringKey.BATTERY_LEVEL) - WidgetCenter.shared.reloadAllTimelines() + guard let mock = mockController else { + WidgetSharedState.save(isConnected: false) + return + } + let state: BatteryState = mock.isCharging ? .charging : .discharging + WidgetSharedState.save(isConnected: true, batteryLevel: mock.batteryLevel, + batteryState: state.rawValue, vendorName: mock.vendorName) } } #endif diff --git a/Controllers/Controllers/Sources/Extensions/StringKey+.swift b/Controllers/Controllers/Sources/Extensions/StringKey+.swift index f98779d..912e2ac 100644 --- a/Controllers/Controllers/Sources/Extensions/StringKey+.swift +++ b/Controllers/Controllers/Sources/Extensions/StringKey+.swift @@ -13,6 +13,9 @@ final class StringKey { static let CONTROLLER_CONNECTED: String = "isConnected" static let BATTERY_LEVEL: String = "batteryLevel" static let BATTERY_STATE: String = "batteryState" + static let VENDOR_NAME: String = "vendorName" + static let LAST_UPDATED: String = "lastUpdated" + static let WIDGET_KIND: String = "ControllersWidget" static let BATTERY_IDENTIFIER: String = "com.controller.battery" static let IS_SUBSCRIBED: String = "isSubscribed" diff --git a/Controllers/Controllers/Sources/Models/FetchGameControllerOperation.swift b/Controllers/Controllers/Sources/Models/FetchGameControllerOperation.swift index 8fe77e9..5ab12bb 100644 --- a/Controllers/Controllers/Sources/Models/FetchGameControllerOperation.swift +++ b/Controllers/Controllers/Sources/Models/FetchGameControllerOperation.swift @@ -24,6 +24,9 @@ class FetchGameControllerOperation: Operation { let level = Int(info.batteryLevel * 100) + WidgetSharedState.save(isConnected: true, batteryLevel: info.batteryLevel, + batteryState: info.batteryState.rawValue, vendorName: info.vendorName) + // 백그라운드에서도 잠금화면 Live Activity 가 최신 배터리 정보를 반영하도록 갱신한다. LiveActivityManager.shared.update(batteryLevel: info.batteryLevel, batteryState: info.batteryState.rawValue) diff --git a/Controllers/Controllers/Sources/Models/WidgetSharedState.swift b/Controllers/Controllers/Sources/Models/WidgetSharedState.swift new file mode 100644 index 0000000..89be1d8 --- /dev/null +++ b/Controllers/Controllers/Sources/Models/WidgetSharedState.swift @@ -0,0 +1,41 @@ +// +// WidgetSharedState.swift +// Controllers +// +// 위젯이 그릴 컨트롤러 상태를 App Group 에 쓰고 타임라인을 다시 읽게 한다. (앱 → 위젯 단방향) +// + +import Foundation +import WidgetKit +import ControllerKit + +enum WidgetSharedState { + /// 상태가 그대로면 이 간격 안에는 타임라인을 다시 읽히지 않는다. (WidgetKit 갱신 예산 절약) + private static let reloadInterval: TimeInterval = 15 * 60 + private static var lastReload: (isConnected: Bool, batteryLevel: Float, batteryState: Int, date: Date)? + + /// 미연결이면 연결 여부만 쓴다. 마지막으로 알던 잔량·기기 이름·갱신 시각은 남겨 위젯이 "Last: 42%"로 보여 준다. + static func save(isConnected: Bool, + batteryLevel: Float = 0, + batteryState: Int = BatteryState.unknown.rawValue, + vendorName: String = "") { + let defaults = UserDefaults.shared + defaults.setValue(isConnected, forKey: StringKey.CONTROLLER_CONNECTED) + if isConnected { + defaults.setValue(batteryLevel, forKey: StringKey.BATTERY_LEVEL) + defaults.setValue(batteryState, forKey: StringKey.BATTERY_STATE) + defaults.setValue(vendorName, forKey: StringKey.VENDOR_NAME) + defaults.setValue(Date().timeIntervalSince1970, forKey: StringKey.LAST_UPDATED) + } + + if let last = lastReload, + last.isConnected == isConnected, + last.batteryState == batteryState, + abs(last.batteryLevel - batteryLevel) < 0.01, + Date().timeIntervalSince(last.date) < reloadInterval { + return + } + lastReload = (isConnected, batteryLevel, batteryState, Date()) + WidgetCenter.shared.reloadTimelines(ofKind: StringKey.WIDGET_KIND) + } +} From 09d238f5c5498194194c13284feb5b34de17e98f Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 27 Oct 2026 03:56:40 +0900 Subject: [PATCH 05/17] =?UTF-8?q?feat:=20=EC=9E=A0=EA=B8=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=9C=84=EC=A0=AF=20=EC=B6=94=EA=B0=80=EC=99=80=20?= =?UTF-8?q?=ED=99=88=20=EC=9C=84=EC=A0=AF=20=EC=83=81=ED=83=9C=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=C2=B7=ED=98=84=EC=A7=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자는 앱을 거의 열지 않고 위젯으로 확인하는데, 위젯이 홈 화면 크기만 지원하고 미연결·충전·갱신 시각을 구분하지 못했다. - small/medium: 잔량 링·퍼센트·충전 번개·갱신 시각, 미연결이면 흐린 컨트롤러 아이콘과 마지막 잔량 - iOS 16+ 잠금 화면 accessoryCircular/Rectangular/Inline 추가 (iOS 15 에서는 홈 크기만) - iOS 17+ containerBackground 적용 - 타임라인이 25개 엔트리를 모두 같은 시각으로 만들던 버그를 엔트리 1개 + 30분 뒤 갱신으로 정리 - 갤러리 이름·설명과 위젯 문구를 위젯 타깃 Localizable(ko/en/ja)로 옮김 (사용자 98%가 해외) - 링 색은 충전 알림 기준값 이하에서 빨강 Co-Authored-By: Claude Opus 5.5 --- .../Controllers.xcodeproj/project.pbxproj | 16 + .../ControllersWidget/ControllersWidget.swift | 336 ++++++++++++++---- .../ControllersWidget/ProgressBar.swift | 24 +- .../en.lproj/Localizable.strings | 9 + .../ja.lproj/Localizable.strings | 9 + .../ko.lproj/Localizable.strings | 9 + 6 files changed, 313 insertions(+), 90 deletions(-) create mode 100644 Controllers/ControllersWidget/en.lproj/Localizable.strings create mode 100644 Controllers/ControllersWidget/ja.lproj/Localizable.strings create mode 100644 Controllers/ControllersWidget/ko.lproj/Localizable.strings diff --git a/Controllers/Controllers.xcodeproj/project.pbxproj b/Controllers/Controllers.xcodeproj/project.pbxproj index b09a002..426b7a7 100644 --- a/Controllers/Controllers.xcodeproj/project.pbxproj +++ b/Controllers/Controllers.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ ACDB0003000000000000B002 /* WidgetSharedState.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACDB0003000000000000B001 /* WidgetSharedState.swift */; }; + ACDB0003000000000000B004 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = ACDB0003000000000000B003 /* Localizable.strings */; }; ACDBACDB000000000000F002 /* ScreenshotFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACDBACDB000000000000F001 /* ScreenshotFixture.swift */; }; ADADADAD000000000000C004 /* AdConsentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADADADAD000000000000C003 /* AdConsentManager.swift */; }; ADADADAD000000000000C006 /* AppOpenAdManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADADADAD000000000000C005 /* AppOpenAdManager.swift */; }; @@ -113,6 +114,9 @@ /* Begin PBXFileReference section */ ACDB0003000000000000B001 /* WidgetSharedState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSharedState.swift; sourceTree = ""; }; + ACDB0003000000000000B005 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + ACDB0003000000000000B006 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; + ACDB0003000000000000B007 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; ACDBACDB000000000000F001 /* ScreenshotFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotFixture.swift; sourceTree = ""; }; ADADADAD000000000000C003 /* AdConsentManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdConsentManager.swift; sourceTree = ""; }; ADADADAD000000000000C005 /* AppOpenAdManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppOpenAdManager.swift; sourceTree = ""; }; @@ -331,6 +335,7 @@ EF789B942A30ABFA006EC6D8 /* ControllersWidget.swift */, ACACACAC000000000000C001 /* ControllerLiveActivity.swift */, 369B34082A4C511E00DBCC29 /* ProgressBar.swift */, + ACDB0003000000000000B003 /* Localizable.strings */, EF789B962A30ABFB006EC6D8 /* Assets.xcassets */, EF789B982A30ABFB006EC6D8 /* Info.plist */, ACACACAC000000000000E001 /* PrivacyInfo.xcprivacy */, @@ -611,6 +616,7 @@ files = ( EF789B972A30ABFB006EC6D8 /* Assets.xcassets in Resources */, ACACACAC000000000000E002 /* PrivacyInfo.xcprivacy in Resources */, + ACDB0003000000000000B004 /* Localizable.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -719,6 +725,16 @@ /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ + ACDB0003000000000000B003 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + ACDB0003000000000000B005 /* en */, + ACDB0003000000000000B006 /* ja */, + ACDB0003000000000000B007 /* ko */, + ); + name = Localizable.strings; + sourceTree = ""; + }; B5BE54292BF9D993009BD3EF /* Main.storyboard */ = { isa = PBXVariantGroup; children = ( diff --git a/Controllers/ControllersWidget/ControllersWidget.swift b/Controllers/ControllersWidget/ControllersWidget.swift index 78e2560..c3f0e5f 100644 --- a/Controllers/ControllersWidget/ControllersWidget.swift +++ b/Controllers/ControllersWidget/ControllersWidget.swift @@ -10,116 +10,298 @@ import SwiftUI import ControllerKit struct Provider: TimelineProvider { - func placeholder(in context: Context) -> SimpleEntry { - if let info = GameControllerManager.shared.getBatteryInfo(), info.state != -1 { - return SimpleEntry(date: Date(), batteryLevel: info.level) - } else { - let level = UserDefaults.shared.value(forKey: StringKey.BATTERY_LEVEL) as? Float - return SimpleEntry(date: Date(), batteryLevel: level ?? 0.0) + func placeholder(in context: Context) -> ControllerEntry { + .sample + } + + func getSnapshot(in context: Context, completion: @escaping (ControllerEntry) -> ()) { + // 위젯 갤러리에서는 실제 상태 대신 샘플을 보여 준다. + completion(context.isPreview ? .sample : currentEntry()) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { + // 상태가 바뀌면 앱이 타임라인을 다시 읽게 하므로(WidgetSharedState) 여기서는 30분마다 다시 확인만 한다. + let entry = currentEntry() + completion(Timeline(entries: [entry], policy: .after(entry.date.addingTimeInterval(30 * 60)))) + } + + /// 위젯 프로세스에서 컨트롤러를 직접 읽을 수 있으면 그 값을, 아니면 앱이 App Group 에 남긴 값을 쓴다. + private func currentEntry() -> ControllerEntry { + let defaults = UserDefaults.shared + let updated = defaults.double(forKey: StringKey.LAST_UPDATED) + var entry = ControllerEntry(date: Date(), + isConnected: defaults.bool(forKey: StringKey.CONTROLLER_CONNECTED), + batteryLevel: defaults.float(forKey: StringKey.BATTERY_LEVEL), + isCharging: defaults.integer(forKey: StringKey.BATTERY_STATE) == BatteryState.charging.rawValue, + vendorName: defaults.string(forKey: StringKey.VENDOR_NAME), + lastUpdated: updated > 0 ? Date(timeIntervalSince1970: updated) : nil) + if let info = GameControllerManager.shared.getBatteryInfo(), info.state != BatteryState.unknown.rawValue { + entry.isConnected = true + entry.batteryLevel = info.level + entry.isCharging = info.state == BatteryState.charging.rawValue + entry.lastUpdated = entry.date } + return entry } - - func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) { - if let info = GameControllerManager.shared.getBatteryInfo(), info.state != -1 { - let entry = SimpleEntry(date: Date(), batteryLevel: info.level) - completion(entry) +} + +struct ControllerEntry: TimelineEntry { + let date: Date + var isConnected: Bool + var batteryLevel: Float + var isCharging: Bool + var vendorName: String? + var lastUpdated: Date? + + static var sample: ControllerEntry { + ControllerEntry(date: Date(), isConnected: true, batteryLevel: 0.76, isCharging: false, + vendorName: "DualSense Wireless Controller", lastUpdated: Date()) + } + + var percentText: String { + "\(Int(batteryLevel * 100))%" + } + + var nameText: Text { + if let vendorName, !vendorName.isEmpty { return Text(verbatim: vendorName) } + return Text("widget.controller") + } + + /// "5분 전 갱신" + var updatedText: Text? { + lastUpdated.map { Text("widget.updated \(Text($0, style: .relative))") } + } + + /// 미연결일 때 마지막으로 알던 잔량 "마지막: 42%" + var lastLevelText: Text? { + lastUpdated == nil ? nil : Text("widget.last \(percentText)") + } +} + +// MARK: - Views + +struct ControllersWidgetEntryView: View { + @Environment(\.widgetFamily) private var family + let entry: ControllerEntry + + var body: some View { + if #available(iOS 17.0, *) { + content.containerBackground(for: .widget) { Color(UIColor.systemBackground) } + } else if family == .systemSmall || family == .systemMedium { + content.padding() } else { - let level = UserDefaults.shared.value(forKey: StringKey.BATTERY_LEVEL) as? Float - let entry = SimpleEntry(date: Date(), batteryLevel: level ?? 0.0) - completion(entry) + content + } + } + + @ViewBuilder private var content: some View { + switch family { + case .systemSmall: + SmallWidgetView(entry: entry) + case .systemMedium: + MediumWidgetView(entry: entry) + default: + if #available(iOS 16.0, *) { + AccessoryWidgetView(entry: entry) + } } } - - func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { - var entries: [SimpleEntry] = [] - // Generate a timeline consisting of five entries an hour apart, starting from the current date. - let isConnected = UserDefaults.shared.value(forKey: StringKey.CONTROLLER_CONNECTED) as? Bool ?? false - print("filter", #function, "isConnected: \(isConnected)") - if isConnected { - if let info = GameControllerManager.shared.getBatteryInfo(), info.state != -1 { - let level = info.level - - let currentDate = Date() - for _ in 0 ..< 25 { - let entryDate = Calendar.current.date(byAdding: .minute, value: 30, to: currentDate)! - let entry = SimpleEntry(date: entryDate, batteryLevel: level) - entries.append(entry) +} + +/// 잔량 링과 가운데 퍼센트. 미연결이면 흐린 컨트롤러 아이콘만 그린다. +private struct BatteryRingView: View { + let entry: ControllerEntry + + var body: some View { + ZStack { + ProgressBar(progress: entry.isConnected ? entry.batteryLevel : 0, lineWidth: 8, color: color) + if entry.isConnected { + VStack(spacing: 0) { + Image(systemName: entry.isCharging ? "bolt.fill" : "gamecontroller.fill") + .font(.caption) + Text(verbatim: entry.percentText) + .font(.title2.bold()) + .lineLimit(1) + .minimumScaleFactor(0.6) } - - let timeline = Timeline(entries: entries, policy: .atEnd) - print("filter", "entries.count: \(entries.count)") - completion(timeline) } else { - let level = (UserDefaults.shared.value(forKey: StringKey.BATTERY_LEVEL) as? Float) ?? 0 - - let currentDate = Date() - for _ in 0 ..< 25 { - let entryDate = Calendar.current.date(byAdding: .minute, value: 30, to: currentDate)! - let entry = SimpleEntry(date: entryDate, batteryLevel: level) - entries.append(entry) - } - let timeline = Timeline(entries: entries, policy: .atEnd) - print("filter", "entries.count: \(entries.count)") - completion(timeline) + Image(systemName: "gamecontroller") + .font(.title) + .foregroundColor(.secondary) } - } else { - let currentDate = Date() - for _ in 0 ..< 25 { - let entryDate = Calendar.current.date(byAdding: .minute, value: 30, to: currentDate)! - let entry = SimpleEntry(date: entryDate, batteryLevel: 0.0) - entries.append(entry) + } + } + + /// 충전 알림 기준값 이하면 빨간 링으로 보여 준다. + private var color: Color { + guard entry.isConnected else { return .gray } + return entry.batteryLevel * 100 <= Float(UserDefaults.shared.batteryNotificationThreshold) ? .red : .green + } +} + +private struct SmallWidgetView: View { + let entry: ControllerEntry + + var body: some View { + VStack(spacing: 6) { + BatteryRingView(entry: entry) + .frame(width: 84, height: 84) + if entry.isConnected { + entry.updatedText? + .font(.caption2) + .foregroundColor(.secondary) + } else { + Text("widget.notConnected") + .font(.caption.bold()) + entry.lastLevelText? + .font(.caption2) + .foregroundColor(.secondary) } - - let timeline = Timeline(entries: entries, policy: .atEnd) - print("filter", "entries.count: \(entries.count)") - completion(timeline) } + .lineLimit(1) + .minimumScaleFactor(0.8) } } -struct SimpleEntry: TimelineEntry { - let date: Date - let batteryLevel: Float +private struct MediumWidgetView: View { + let entry: ControllerEntry + + var body: some View { + HStack(spacing: 16) { + BatteryRingView(entry: entry) + .frame(width: 100, height: 100) + VStack(alignment: .leading, spacing: 4) { + entry.nameText + .font(.headline) + if entry.isConnected { + Text(verbatim: entry.percentText) + .font(.largeTitle.bold()) + Group { + if entry.isCharging { + Label("widget.charging", systemImage: "bolt.fill") + } else { + Text("widget.notCharging") + } + } + .font(.subheadline) + } else { + Text("widget.notConnected") + .font(.title3.bold()) + entry.lastLevelText? + .font(.subheadline) + .foregroundColor(.secondary) + } + entry.updatedText? + .font(.caption2) + .foregroundColor(.secondary) + } + .lineLimit(1) + Spacer(minLength: 0) + } + } } -struct ControllersWidgetEntryView : View { - var entry: Provider.Entry - let size: CGFloat = 100.0 +@available(iOS 16.0, *) +private struct AccessoryWidgetView: View { + @Environment(\.widgetFamily) private var family + let entry: ControllerEntry var body: some View { - ZStack { - VStack { + switch family { + case .accessoryCircular: + circular + case .accessoryRectangular: + rectangular + default: + inline + } + } + + @ViewBuilder private var circular: some View { + if entry.isConnected { + Gauge(value: Double(entry.batteryLevel)) { Image(systemName: "gamecontroller.fill") - Text("\(Int(entry.batteryLevel * 100))%") + } currentValueLabel: { + VStack(spacing: 0) { + if entry.isCharging { + Image(systemName: "bolt.fill") + .font(.system(size: 9)) + } + Text(verbatim: entry.percentText) + .minimumScaleFactor(0.5) + } + } + .gaugeStyle(.accessoryCircularCapacity) + } else { + ZStack { + AccessoryWidgetBackground() + VStack(spacing: 0) { + Image(systemName: "gamecontroller") + Text(verbatim: "—") + } + } + } + } + + private var rectangular: some View { + VStack(alignment: .leading, spacing: 2) { + Label { entry.nameText } icon: { Image(systemName: "gamecontroller.fill") } + .font(.headline) + if entry.isConnected { + HStack(spacing: 2) { + Text(verbatim: entry.percentText) + if entry.isCharging { + Image(systemName: "bolt.fill") + } + } + Gauge(value: Double(entry.batteryLevel)) { EmptyView() } + .gaugeStyle(.accessoryLinearCapacity) + } else { + Text("widget.notConnected") + entry.lastLevelText? + .foregroundColor(.secondary) } - - VStack { - // 위젯은 onAppear 가 실행되지 않아 @State 로 갱신하면 항상 초기값(0.8)이 그려졌음 → 엔트리 값을 직접 사용 - ProgressBar(progress: .constant(entry.batteryLevel)) - .frame(width: size, height: size) - .padding(40.0) - - Spacer() + } + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder private var inline: some View { + if entry.isConnected { + Label { + entry.isCharging + ? Text(verbatim: entry.percentText) + Text(Image(systemName: "bolt.fill")) + : Text(verbatim: entry.percentText) + } icon: { + Image(systemName: "gamecontroller.fill") } + } else { + Label("widget.notConnected", systemImage: "gamecontroller") } } } struct ControllersWidget: Widget { - let kind: String = "ControllersWidget" - var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: Provider()) { entry in + StaticConfiguration(kind: StringKey.WIDGET_KIND, provider: Provider()) { entry in ControllersWidgetEntryView(entry: entry) } - .configurationDisplayName("연결 및 배터리 상태 확인") - .description("배터리 상태를 확인하세요 !") + .configurationDisplayName("widget.name") + .description("widget.description") + .supportedFamilies(supportedFamilies) + } + + private var supportedFamilies: [WidgetFamily] { + if #available(iOS 16.0, *) { + return [.systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular, .accessoryInline] + } + return [.systemSmall, .systemMedium] } } struct ControllersWidget_Previews: PreviewProvider { static var previews: some View { - ControllersWidgetEntryView(entry: SimpleEntry(date: Date(), batteryLevel: 0)) + ControllersWidgetEntryView(entry: .sample) .previewContext(WidgetPreviewContext(family: .systemSmall)) } } diff --git a/Controllers/ControllersWidget/ProgressBar.swift b/Controllers/ControllersWidget/ProgressBar.swift index 91abfb3..6b935c2 100644 --- a/Controllers/ControllersWidget/ProgressBar.swift +++ b/Controllers/ControllersWidget/ProgressBar.swift @@ -7,26 +7,24 @@ import SwiftUI +/// 배터리 잔량 링. 위젯은 애니메이션·onAppear 가 돌지 않으므로 값만 그린다. struct ProgressBar: View { - @Binding var progress: Float - @State var isAnimating: Bool = false - + let progress: Float + var lineWidth: CGFloat = 11.0 + var color: Color = .green + var body: some View { ZStack { Circle() - .stroke(lineWidth: 11.0) + .stroke(lineWidth: lineWidth) .opacity(0.3) - .foregroundColor(Color.green) - + .foregroundColor(color) + Circle() - .trim(from: 0.0, to: CGFloat(self.progress)) - .stroke(style: StrokeStyle(lineWidth: 11.0, lineCap: .round, lineJoin: .round)) - .foregroundColor(Color.green) + .trim(from: 0.0, to: CGFloat(progress)) + .stroke(style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) + .foregroundColor(color) .rotationEffect(Angle(degrees: 270.0)) - .animation(.linear(duration: 2.0), value: isAnimating) - } - .onAppear { - isAnimating = true } } } diff --git a/Controllers/ControllersWidget/en.lproj/Localizable.strings b/Controllers/ControllersWidget/en.lproj/Localizable.strings new file mode 100644 index 0000000..f309e3a --- /dev/null +++ b/Controllers/ControllersWidget/en.lproj/Localizable.strings @@ -0,0 +1,9 @@ +/* ControllersWidget */ +"widget.name" = "Controller Battery"; +"widget.description" = "See your controller's connection and battery at a glance."; +"widget.controller" = "Controller"; +"widget.notConnected" = "Not connected"; +"widget.charging" = "Charging"; +"widget.notCharging" = "Not charging"; +"widget.last %@" = "Last: %@"; +"widget.updated %@" = "Updated %@ ago"; diff --git a/Controllers/ControllersWidget/ja.lproj/Localizable.strings b/Controllers/ControllersWidget/ja.lproj/Localizable.strings new file mode 100644 index 0000000..9c6f1a7 --- /dev/null +++ b/Controllers/ControllersWidget/ja.lproj/Localizable.strings @@ -0,0 +1,9 @@ +/* ControllersWidget */ +"widget.name" = "コントローラーのバッテリー"; +"widget.description" = "コントローラーの接続とバッテリー残量をひと目で確認できます。"; +"widget.controller" = "コントローラー"; +"widget.notConnected" = "未接続"; +"widget.charging" = "充電中"; +"widget.notCharging" = "充電していません"; +"widget.last %@" = "前回: %@"; +"widget.updated %@" = "%@前に更新"; diff --git a/Controllers/ControllersWidget/ko.lproj/Localizable.strings b/Controllers/ControllersWidget/ko.lproj/Localizable.strings new file mode 100644 index 0000000..dd9489a --- /dev/null +++ b/Controllers/ControllersWidget/ko.lproj/Localizable.strings @@ -0,0 +1,9 @@ +/* ControllersWidget */ +"widget.name" = "컨트롤러 배터리"; +"widget.description" = "컨트롤러 연결과 배터리 상태를 한눈에 확인하세요."; +"widget.controller" = "컨트롤러"; +"widget.notConnected" = "연결 안 됨"; +"widget.charging" = "충전 중"; +"widget.notCharging" = "충전 중 아님"; +"widget.last %@" = "마지막: %@"; +"widget.updated %@" = "%@ 전 갱신"; From 7aad71c645ea528049b6594dbae56dd5512ef729 Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 27 Oct 2026 04:04:11 +0900 Subject: [PATCH 06/17] =?UTF-8?q?feat:=20=EC=B6=A9=EC=A0=84=20=EC=A4=91=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=EC=99=80=20=EC=84=B8=20=EB=B2=88=EC=A7=B8=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=EB=B6=80=ED=84=B0=20=ED=8F=89=EA=B0=80=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 평가가 2건뿐이라, 컨트롤러를 세 번 이상 연결한(앱이 쓸모 있었던) 사용자에게 버전당 한 번 SKStoreReviewController로 평가를 요청한다. Pro 팝업·광고·시작 흐름 팝업과 겹치지 않게 2초 뒤 조건을 다시 보고, 요청·건너뜀은 NSLog로 남긴다. DEBUG에서 -ACReviewTest YES로 경로를 탈 수 있다. 충전 중이면 퍼센트 옆에 번개를 보여 주고, 스크린샷 픽스처의 isCharging도 반영한다. 픽스처 모드에서는 시뮬레이터의 가상 컨트롤러 연결을 무시해 '모의 값 없음 = 미연결'을 지킨다. Co-Authored-By: Claude Opus 5.5 --- .../Sources/Controller/ControllerModel.swift | 48 +++++++++++++++++++ .../Sources/Extensions/StringKey+.swift | 2 + .../Sources/Models/ProPromotionManager.swift | 3 ++ 3 files changed, 53 insertions(+) diff --git a/Controllers/Controllers/Sources/Controller/ControllerModel.swift b/Controllers/Controllers/Sources/Controller/ControllerModel.swift index cc7e554..c6df473 100644 --- a/Controllers/Controllers/Sources/Controller/ControllerModel.swift +++ b/Controllers/Controllers/Sources/Controller/ControllerModel.swift @@ -6,6 +6,7 @@ // import SwiftUI +import StoreKit import ControllerKit final class ControllerModel: ObservableObject { @@ -16,6 +17,7 @@ final class ControllerModel: ObservableObject { } @Published var batteryLevel: Float = 0.0 @Published var vendorName: String = "" + @Published var isCharging: Bool = false private let manager = GameControllerManager.shared @@ -24,6 +26,12 @@ final class ControllerModel: ObservableObject { addControllerObservers() #if DEBUG applyScreenshotFixture() + // -ACReviewTest YES: 누적 연결 횟수를 2로 맞추고 연결을 한 번 흉내 내 평가 요청 경로를 탄다. + if UserDefaults.standard.bool(forKey: "ACReviewTest") { + UserDefaults.standard.set(2, forKey: StringKey.CONTROLLER_CONNECT_COUNT) + UserDefaults.standard.removeObject(forKey: StringKey.LAST_REVIEW_REQUEST_VERSION) + countConnectionAndRequestReview() + } #endif } @@ -40,12 +48,14 @@ final class ControllerModel: ObservableObject { #endif guard let info = manager.getControlelrInfo() else { batteryLevel = 0.0 + isCharging = false return } NSLog("batteryLevel: \(info.batteryLevel)") vendorName = info.vendorName batteryLevel = info.batteryLevel + isCharging = info.batteryState == .charging UserDefaults.shared.setValue(info.batteryLevel, forKey: StringKey.BATTERY_LEVEL) // 활성화된 Live Activity 가 있으면 배터리 정보를 갱신한다. @@ -79,6 +89,10 @@ extension ControllerModel { // MARK: - GameControllerDelegate Method extension ControllerModel: GameControllerDelegate { func didConnectedController() { + #if DEBUG + // 시뮬레이터가 알리는 가상 컨트롤러 대신 스크린샷용 모의 상태를 유지한다. + if ScreenshotFixture.isEnabled { return } + #endif isConnected = true updateControllerInfo() startLiveActivity() @@ -86,6 +100,7 @@ extension ControllerModel: GameControllerDelegate { Task { @MainActor in ProPromotionManager.shared.controllerDidConnect() } + countConnectionAndRequestReview() } func didDisConnectedController() { @@ -95,6 +110,38 @@ extension ControllerModel: GameControllerDelegate { } } +// MARK: - Review Request +extension ControllerModel { + /// 연결 횟수를 세고, 세 번째 연결부터 버전당 한 번 평가를 요청한다. + private func countConnectionAndRequestReview() { + let defaults = UserDefaults.standard + let count = defaults.integer(forKey: StringKey.CONTROLLER_CONNECT_COUNT) + 1 + defaults.set(count, forKey: StringKey.CONTROLLER_CONNECT_COUNT) + let version = Bundle.main.versionString + guard count >= 3, defaults.string(forKey: StringKey.LAST_REVIEW_REQUEST_VERSION) != version else { return } + + // 연결 직후 뜨는 Pro 팝업·시스템 팝업과 겹치지 않게 조금 기다린다. + Task { @MainActor in + try? await Task.sleep(nanoseconds: 2_000_000_000) + guard ProPromotionManager.shared.isLaunchFlowFinished, + !ProPromotionManager.shared.didPresentAlert, + !AppOpenAdManager.shared.isShowingAd, + !RewardedPassManager.shared.isShowingAd, + UIApplication.shared.applicationState == .active, + let topViewController = UIApplication.topViewController(), + topViewController.presentedViewController == nil, + !(topViewController is UIAlertController), + let windowScene = topViewController.view.window?.windowScene else { + NSLog("Review request skipped (connect count: \(count))") + return + } + defaults.set(version, forKey: StringKey.LAST_REVIEW_REQUEST_VERSION) + NSLog("Review requested (connect count: \(count), version: \(version))") + SKStoreReviewController.requestReview(in: windowScene) + } + } +} + #if DEBUG // MARK: - Screenshot Fixture extension ControllerModel { @@ -105,6 +152,7 @@ extension ControllerModel { isConnected = true vendorName = mock.vendorName batteryLevel = mock.batteryLevel + isCharging = mock.isCharging } ScreenshotFixture.writeWidgetState() } diff --git a/Controllers/Controllers/Sources/Extensions/StringKey+.swift b/Controllers/Controllers/Sources/Extensions/StringKey+.swift index f98779d..d8b2649 100644 --- a/Controllers/Controllers/Sources/Extensions/StringKey+.swift +++ b/Controllers/Controllers/Sources/Extensions/StringKey+.swift @@ -18,6 +18,8 @@ final class StringKey { static let IS_SUBSCRIBED: String = "isSubscribed" static let PRO_PASS_EXPIRATION: String = "proPassExpiration" static let LAST_APP_OPEN_AD_DATE: String = "lastAppOpenAdDate" + static let CONTROLLER_CONNECT_COUNT: String = "controllerConnectCount" + static let LAST_REVIEW_REQUEST_VERSION: String = "lastReviewRequestVersion" static let BATTERY_NOTIFICATION_THRESHOLD: String = "batteryNotificationThreshold" } diff --git a/Controllers/Controllers/Sources/Models/ProPromotionManager.swift b/Controllers/Controllers/Sources/Models/ProPromotionManager.swift index 65846be..bd32d61 100644 --- a/Controllers/Controllers/Sources/Models/ProPromotionManager.swift +++ b/Controllers/Controllers/Sources/Models/ProPromotionManager.swift @@ -22,6 +22,8 @@ final class ProPromotionManager { private var isPending = false /// 이번 실행에서 이미 처리했으면 true. 앱을 완전히 종료했다 켜면 다시 띄운다. private var isDone = false + /// 이번 실행에서 팝업을 실제로 띄웠으면 true. 평가 요청이 겹치지 않게 본다. + private(set) var didPresentAlert = false private init() {} @@ -56,6 +58,7 @@ final class ProPromotionManager { } private func presentAlert(from viewController: UIViewController) { + didPresentAlert = true let threshold = "\(UserDefaults.shared.batteryNotificationThreshold)%" let alert = UIAlertController(title: "Pro Promo Title".localized, message: "Pro Promo Message %@".localized(with: [threshold]), From 9d6d10d0ac1091d35161aec656e5db4a56a4b580 Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 27 Oct 2026 04:04:11 +0900 Subject: [PATCH 07/17] =?UTF-8?q?feat:=20=EB=AF=B8=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EC=97=B0=EA=B2=B0=20=EC=95=88=EB=82=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20=EC=97=B0=EA=B2=B0=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=B0=98=EC=9D=91=ED=98=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 미연결 상태가 스피너와 'Not connected..'만 보여 줘서, 앱을 거의 열지 않는 사용자에게 첫 화면에서 연결 방법을 알려 주지 못했다. 3단계 페어링 안내와 대기 표시, 공식 지원 문구를 넣었다. 블루투스 설정으로 바로 가는 공개 API가 없어 설정 이동 버튼은 두지 않았다. 배터리 링이 400pt로 고정돼 iPhone SE에서 넘치고 iPad에서 작아 보여서, 가용 영역에 맞춰 지름(최대 360pt)·아이콘·선 굵기를 정하고 텍스트 스타일로 Dynamic Type을 따르게 했다. Co-Authored-By: Claude Opus 5.5 --- .../Controllers.xcodeproj/project.pbxproj | 4 + .../Controller/ConnectionGuideView.swift | 89 +++++++++++++++ .../Sources/Controller/ControllerView.swift | 103 +++++++++--------- .../Sources/Controller/ProgressBarView.swift | 5 +- .../en.lproj/Localizable.strings | 10 ++ .../ja.lproj/Localizable.strings | 10 ++ .../ko.lproj/Localizable.strings | 10 ++ 7 files changed, 180 insertions(+), 51 deletions(-) create mode 100644 Controllers/Controllers/Sources/Controller/ConnectionGuideView.swift diff --git a/Controllers/Controllers.xcodeproj/project.pbxproj b/Controllers/Controllers.xcodeproj/project.pbxproj index ccf8d65..4e3fe78 100644 --- a/Controllers/Controllers.xcodeproj/project.pbxproj +++ b/Controllers/Controllers.xcodeproj/project.pbxproj @@ -36,6 +36,7 @@ B58A16E52DE46D58005CEA4F /* TesterModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58A16E42DE46D58005CEA4F /* TesterModel.swift */; }; B58A16E72DE46D98005CEA4F /* TriggerButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58A16E62DE46D98005CEA4F /* TriggerButtonView.swift */; }; B58AA1B32BFB4A7300EAC37F /* ControllerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58AA1B22BFB4A7300EAC37F /* ControllerView.swift */; }; + ACDB0001000000000000A002 /* ConnectionGuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACDB0001000000000000A001 /* ConnectionGuideView.swift */; }; B5BE54242BF9D992009BD3EF /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BE54232BF9D992009BD3EF /* AppDelegate.swift */; }; B5BE54262BF9D992009BD3EF /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BE54252BF9D992009BD3EF /* ViewController.swift */; }; B5BE54282BF9D993009BD3EF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5BE54272BF9D993009BD3EF /* Assets.xcassets */; }; @@ -138,6 +139,7 @@ B58A16E42DE46D58005CEA4F /* TesterModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TesterModel.swift; sourceTree = ""; }; B58A16E62DE46D98005CEA4F /* TriggerButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerButtonView.swift; sourceTree = ""; }; B58AA1B22BFB4A7300EAC37F /* ControllerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControllerView.swift; sourceTree = ""; }; + ACDB0001000000000000A001 /* ConnectionGuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionGuideView.swift; sourceTree = ""; }; B5BE54212BF9D992009BD3EF /* Controllers-macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Controllers-macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; B5BE54232BF9D992009BD3EF /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; B5BE54252BF9D992009BD3EF /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; @@ -365,6 +367,7 @@ children = ( B5E3BDC42DCF8C2A000622FD /* ControllerModel.swift */, B58AA1B22BFB4A7300EAC37F /* ControllerView.swift */, + ACDB0001000000000000A001 /* ConnectionGuideView.swift */, B5E3BDC62DCF901F000622FD /* ProgressBarView.swift */, ); path = Controller; @@ -652,6 +655,7 @@ B55974362ACDC33100FC01FE /* ProductIdentifiers.swift in Sources */, EF9362382A9B4A860009E8E8 /* UIColor+.swift in Sources */, B58AA1B32BFB4A7300EAC37F /* ControllerView.swift in Sources */, + ACDB0001000000000000A002 /* ConnectionGuideView.swift in Sources */, B58A16DF2DE46D26005CEA4F /* ThumbstickView.swift in Sources */, B58A16E32DE46D43005CEA4F /* ControllerButton.swift in Sources */, EF789B742A24D6DC006EC6D8 /* SceneDelegate.swift in Sources */, diff --git a/Controllers/Controllers/Sources/Controller/ConnectionGuideView.swift b/Controllers/Controllers/Sources/Controller/ConnectionGuideView.swift new file mode 100644 index 0000000..37fd791 --- /dev/null +++ b/Controllers/Controllers/Sources/Controller/ConnectionGuideView.swift @@ -0,0 +1,89 @@ +// +// ConnectionGuideView.swift +// Controllers +// +// 컨트롤러가 연결되지 않았을 때 페어링 방법을 안내한다. +// 블루투스 설정으로 바로 보내는 공개 API가 없어 설정 이동 버튼은 두지 않는다. +// + +import SwiftUI + +struct ConnectionGuideView: View { + let onRefresh: () -> Void + @ScaledMetric private var badgeSize: CGFloat = 28 + + private let steps: [(symbol: String, key: String)] = [ + ("hand.tap.fill", "Connect Guide Step 1"), + ("gearshape.fill", "Connect Guide Step 2"), + ("battery.100.bolt", "Connect Guide Step 3"), + ] + + var body: some View { + ScrollView { + VStack(spacing: 24) { + VStack(spacing: 12) { + Image(systemName: "gamecontroller.fill") + .font(.system(size: 56)) + .foregroundColor(.accentColor) + .accessibilityHidden(true) + Text("Connect Guide Title".localized) + .font(.title2.bold()) + Text("Connect Guide Subtitle".localized) + .font(.subheadline) + .foregroundColor(.secondary) + } + .multilineTextAlignment(.center) + + VStack(alignment: .leading, spacing: 16) { + ForEach(steps.indices, id: \.self) { index in + HStack(alignment: .top, spacing: 12) { + Text("\(index + 1)") + .font(.headline) + .foregroundColor(.white) + .frame(width: badgeSize, height: badgeSize) + .background(Circle().fill(Color.accentColor)) + Image(systemName: steps[index].symbol) + .font(.title3) + .foregroundColor(.accentColor) + .frame(width: badgeSize) + .accessibilityHidden(true) + Text(steps[index].key.localized) + .font(.body) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 16).fill(Color(.secondarySystemBackground))) + + HStack(spacing: 8) { + ProgressView() + Text("Connect Guide Waiting".localized) + } + .font(.subheadline) + .foregroundColor(.secondary) + + Text("Connect Guide Support Note".localized) + .font(.footnote) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + Button(action: onRefresh) { + Label("Refresh".localized, systemImage: "arrow.clockwise") + .font(.footnote) + } + .buttonStyle(.bordered) + } + .padding(.horizontal, 20) + .padding(.vertical, 32) + .frame(maxWidth: 500) + .frame(maxWidth: .infinity) + } + } +} + +@available(iOS 17.0, *) +#Preview { + ConnectionGuideView(onRefresh: {}) +} diff --git a/Controllers/Controllers/Sources/Controller/ControllerView.swift b/Controllers/Controllers/Sources/Controller/ControllerView.swift index e50ba6c..d51963c 100644 --- a/Controllers/Controllers/Sources/Controller/ControllerView.swift +++ b/Controllers/Controllers/Sources/Controller/ControllerView.swift @@ -12,64 +12,69 @@ struct ControllerView: View { @StateObject private var model = ControllerModel() var body: some View { - VStack { - if !model.isConnected { - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) - .scaleEffect(1.5) - .frame(height: 120) - .frame(maxWidth: .infinity) + Group { + if model.isConnected { + connectedView + } else { + ConnectionGuideView(onRefresh: onRefresh) } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemBackground)) + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + model.updateControllerInfo() + } + } - Spacer() - - ZStack { - Image(systemName: "gamecontroller.fill") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 180, height: 130) - .foregroundColor(.primary) + /// 링 지름은 가용 영역에 맞춘다. 아래 글자 자리를 남기려고 높이는 덜 쓴다. + private var connectedView: some View { + GeometryReader { proxy in + let diameter = min(min(proxy.size.width, 500) * 0.7, proxy.size.height * 0.55, 360) + ScrollView { + VStack(spacing: 16) { + ZStack { + Image(systemName: "gamecontroller.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: diameter * 0.45) + .foregroundColor(.primary) - if model.isConnected { - ProgressBarView( - progress: $model.batteryLevel, - isAnimating: true - ) - .frame(width: 400, height: 400) - } - } + ProgressBarView( + progress: $model.batteryLevel, + isAnimating: true, + lineWidth: max(diameter * 0.03, 6) + ) + } + .frame(width: diameter, height: diameter) - Text(model.isConnected - ? "\(Int((model.batteryLevel) * 100)) %" - : "Not connected..".localized) - .font(.system(size: 25, weight: .bold)) - .padding(.top, 36) + HStack(spacing: 6) { + Text("\(Int((model.batteryLevel) * 100)) %") + if model.isCharging { + Image(systemName: "bolt.fill") + .foregroundColor(.green) + .accessibilityLabel("Charging".localized) + } + } + .font(.largeTitle.bold()) - Text(model.vendorName) - .font(.system(size: 16, weight: .bold)) - .padding(.top, 57) + Text(model.vendorName) + .font(.headline) + .multilineTextAlignment(.center) - Button(action: onRefresh) { - Label("Refresh".localized, systemImage: "arrow.clockwise") - .font(.system(size: 24, weight: .semibold)) - .foregroundColor(Color.primary) - .padding(10) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(Color.primary, lineWidth: 1) - ) + Button(action: onRefresh) { + Label("Refresh".localized, systemImage: "arrow.clockwise") + .font(.headline) + } + .buttonStyle(.bordered) + .tint(.primary) + } + .padding() + .frame(maxWidth: 500) + .frame(maxWidth: .infinity, minHeight: proxy.size.height) } - .padding(.top, 20) - - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(.systemBackground)) - .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in - model.updateControllerInfo() } } - + /// 새로고침 버튼 액션 private func onRefresh() { model.updateControllerInfo() diff --git a/Controllers/Controllers/Sources/Controller/ProgressBarView.swift b/Controllers/Controllers/Sources/Controller/ProgressBarView.swift index fbafdb8..5fc0f76 100644 --- a/Controllers/Controllers/Sources/Controller/ProgressBarView.swift +++ b/Controllers/Controllers/Sources/Controller/ProgressBarView.swift @@ -10,17 +10,18 @@ import SwiftUI struct ProgressBarView: View { @Binding var progress: Float @State var isAnimating: Bool = false + var lineWidth: CGFloat = 11.0 var body: some View { ZStack { Circle() - .stroke(lineWidth: 11.0) + .stroke(lineWidth: lineWidth) .opacity(0.3) .foregroundColor(Color.green) Circle() .trim(from: 0.0, to: CGFloat(self.progress)) - .stroke(style: StrokeStyle(lineWidth: 11.0, lineCap: .round, lineJoin: .round)) + .stroke(style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) .foregroundColor(Color.green) .rotationEffect(Angle(degrees: 270.0)) .animation(.linear(duration: 2.0), value: isAnimating) diff --git a/Controllers/Controllers/Supporting Files/en.lproj/Localizable.strings b/Controllers/Controllers/Supporting Files/en.lproj/Localizable.strings index 9c7e3cf..0f88efd 100644 --- a/Controllers/Controllers/Supporting Files/en.lproj/Localizable.strings +++ b/Controllers/Controllers/Supporting Files/en.lproj/Localizable.strings @@ -50,3 +50,13 @@ "See Pro Plans" = "See Pro Plans"; "Not Now" = "Not Now"; "Pro No Ads" = "Use the app without ads."; + +// MARK: - Connection Guide +"Connect Guide Title" = "Connect your controller"; +"Connect Guide Subtitle" = "Pair it once and the battery level shows up here."; +"Connect Guide Step 1" = "Hold the PS and Create buttons together until the light bar blinks."; +"Connect Guide Step 2" = "Go to Settings > Bluetooth and select \"DualSense Wireless Controller\"."; +"Connect Guide Step 3" = "Once connected, the battery level appears here automatically."; +"Connect Guide Waiting" = "Waiting for connection…"; +"Connect Guide Support Note" = "Officially supported: DualSense. Other controllers may not show battery info."; +"Charging" = "Charging"; diff --git a/Controllers/Controllers/Supporting Files/ja.lproj/Localizable.strings b/Controllers/Controllers/Supporting Files/ja.lproj/Localizable.strings index 811b4ed..bb9477e 100644 --- a/Controllers/Controllers/Supporting Files/ja.lproj/Localizable.strings +++ b/Controllers/Controllers/Supporting Files/ja.lproj/Localizable.strings @@ -51,3 +51,13 @@ "See Pro Plans" = "Proプランを見る"; "Not Now" = "あとで"; "Pro No Ads" = "広告なしでご利用いただけます。"; + +// MARK: - Connection Guide +"Connect Guide Title" = "コントローラーを接続してください"; +"Connect Guide Subtitle" = "一度ペアリングすれば、バッテリー残量がここに表示されます。"; +"Connect Guide Step 1" = "ライトバーが点滅するまで、PSボタンとクリエイトボタンを同時に長押しします。"; +"Connect Guide Step 2" = "設定 > Bluetoothで「DualSense Wireless Controller」を選択します。"; +"Connect Guide Step 3" = "接続されると、ここにバッテリー残量が自動で表示されます。"; +"Connect Guide Waiting" = "接続を待っています…"; +"Connect Guide Support Note" = "公式サポート: DualSense。他のコントローラーではバッテリー情報が表示されない場合があります。"; +"Charging" = "充電中"; diff --git a/Controllers/Controllers/Supporting Files/ko.lproj/Localizable.strings b/Controllers/Controllers/Supporting Files/ko.lproj/Localizable.strings index 36c5cb9..98e44dd 100644 --- a/Controllers/Controllers/Supporting Files/ko.lproj/Localizable.strings +++ b/Controllers/Controllers/Supporting Files/ko.lproj/Localizable.strings @@ -58,3 +58,13 @@ "See Pro Plans" = "Pro 구독 보기"; "Not Now" = "나중에"; "Pro No Ads" = "광고 없이 이용할 수 있습니다."; + +// MARK: - Connection Guide +"Connect Guide Title" = "컨트롤러를 연결하세요"; +"Connect Guide Subtitle" = "한 번만 페어링하면 배터리 잔량이 여기에 표시돼요."; +"Connect Guide Step 1" = "라이트 바가 깜빡일 때까지 PS 버튼과 만들기(Create) 버튼을 함께 길게 누르세요."; +"Connect Guide Step 2" = "설정 > Bluetooth에서 \"DualSense Wireless Controller\"를 선택하세요."; +"Connect Guide Step 3" = "연결되면 여기에 배터리가 자동으로 표시돼요."; +"Connect Guide Waiting" = "연결을 기다리는 중…"; +"Connect Guide Support Note" = "공식 지원: DualSense. 다른 컨트롤러는 배터리 정보가 표시되지 않을 수 있어요."; +"Charging" = "충전 중"; From b4537cded0c3276720c2433bb9cbe1c1541fcbda Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 27 Oct 2026 04:04:23 +0900 Subject: [PATCH 08/17] =?UTF-8?q?fix:=20=EC=8A=A4=ED=81=AC=EB=A6=B0?= =?UTF-8?q?=EC=83=B7=20=EB=AA=A8=EB=93=9C=EC=97=90=EC=84=9C=20=EC=8B=9C?= =?UTF-8?q?=EB=AE=AC=EB=A0=88=EC=9D=B4=ED=84=B0=20=EA=B0=80=EC=83=81=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EB=AC=B4=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS 27 시뮬레이터는 배터리 정보가 없는 가상 컨트롤러("Gamepad")를 연결된 것으로 넘겨준다. 스크린샷 모드에서는 GameControllerManager 이벤트를 받지 않고 실행 인자 상태만 보여 준다. Co-Authored-By: Claude Opus 5.5 --- .../Sources/Controller/ControllerModel.swift | 8 ++++++-- .../Sources/Tester/TesterModel.swift | 19 +++++++++++-------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/Controllers/Controllers/Sources/Controller/ControllerModel.swift b/Controllers/Controllers/Sources/Controller/ControllerModel.swift index cc7e554..3efeda2 100644 --- a/Controllers/Controllers/Sources/Controller/ControllerModel.swift +++ b/Controllers/Controllers/Sources/Controller/ControllerModel.swift @@ -35,8 +35,8 @@ final class ControllerModel: ObservableObject { // MARK: - Life Cycle public func updateControllerInfo() { #if DEBUG - // 스크린샷용 모의 상태를 실제 조회(시뮬레이터에서는 항상 없음)로 덮어쓰지 않는다. - if ScreenshotFixture.mockController != nil { return } + // 스크린샷 모드에서는 실행 인자 상태를 실제 조회로 덮어쓰지 않는다. + if ScreenshotFixture.isEnabled { return } #endif guard let info = manager.getControlelrInfo() else { batteryLevel = 0.0 @@ -67,6 +67,10 @@ extension ControllerModel { extension ControllerModel { private func addControllerObservers() { + #if DEBUG + // 스크린샷 모드에서는 시뮬레이터가 넘겨주는 가상 컨트롤러("Gamepad")를 무시하고 실행 인자 상태만 보여 준다. + if ScreenshotFixture.isEnabled { return } + #endif manager.addDelegate(self) // 컨트롤러가 연결된 채로 광고 이용권을 받으면 바로 Live Activity 를 띄운다. NotificationCenter.default.addObserver(forName: .proPassGranted, object: nil, queue: .main) { [weak self] _ in diff --git a/Controllers/Controllers/Sources/Tester/TesterModel.swift b/Controllers/Controllers/Sources/Tester/TesterModel.swift index a26468c..ae2f6c4 100644 --- a/Controllers/Controllers/Sources/Tester/TesterModel.swift +++ b/Controllers/Controllers/Sources/Tester/TesterModel.swift @@ -48,6 +48,17 @@ final class TesterModel: ObservableObject { private let manager = GameControllerManager.shared init() { + #if DEBUG + // 스크린샷 모드에서는 시뮬레이터의 가상 컨트롤러를 무시하고 실행 인자 상태만 보여 준다. + if ScreenshotFixture.isEnabled { + if let mock = ScreenshotFixture.mockController { + isConnected = true + vendorName = mock.vendorName + batteryLevel = mock.batteryLevel + } + return + } + #endif manager.addDelegate(self) manager.inputDelegate = self setupController() @@ -55,14 +66,6 @@ final class TesterModel: ObservableObject { if manager.getControlelrInfo() != nil { didConnectedController() } - #if DEBUG - // 스크린샷용 실행 인자가 있으면 모의 컨트롤러가 연결된 것처럼 보인다. - if let mock = ScreenshotFixture.mockController { - isConnected = true - vendorName = mock.vendorName - batteryLevel = mock.batteryLevel - } - #endif } func setupController() { From c0c38b07a8f6c73b4d3f6fa5343554cba553a0b3 Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 27 Oct 2026 04:04:23 +0900 Subject: [PATCH 09/17] =?UTF-8?q?fix:=20=EC=8A=A4=ED=81=AC=EB=A6=B0?= =?UTF-8?q?=EC=83=B7=20=EB=AA=A8=EB=93=9C=EC=97=90=EC=84=9C=20=EC=8B=9C?= =?UTF-8?q?=EB=AE=AC=EB=A0=88=EC=9D=B4=ED=84=B0=20=EA=B0=80=EC=83=81=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EB=AC=B4=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS 27 시뮬레이터는 배터리 정보가 없는 가상 컨트롤러("Gamepad")를 연결된 것으로 넘겨준다. 스크린샷 모드에서는 GameControllerManager 이벤트를 받지 않고 실행 인자 상태만 보여 준다. Co-Authored-By: Claude Opus 5.5 --- .../Sources/Controller/ControllerModel.swift | 8 ++++++-- .../Sources/Tester/TesterModel.swift | 19 +++++++++++-------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/Controllers/Controllers/Sources/Controller/ControllerModel.swift b/Controllers/Controllers/Sources/Controller/ControllerModel.swift index b682787..5fad813 100644 --- a/Controllers/Controllers/Sources/Controller/ControllerModel.swift +++ b/Controllers/Controllers/Sources/Controller/ControllerModel.swift @@ -35,8 +35,8 @@ final class ControllerModel: ObservableObject { // MARK: - Life Cycle public func updateControllerInfo() { #if DEBUG - // 스크린샷용 모의 상태를 실제 조회(시뮬레이터에서는 항상 없음)로 덮어쓰지 않는다. - if ScreenshotFixture.mockController != nil { return } + // 스크린샷 모드에서는 실행 인자 상태를 실제 조회로 덮어쓰지 않는다. + if ScreenshotFixture.isEnabled { return } #endif guard let info = manager.getControlelrInfo() else { WidgetSharedState.save(isConnected: false) @@ -69,6 +69,10 @@ extension ControllerModel { extension ControllerModel { private func addControllerObservers() { + #if DEBUG + // 스크린샷 모드에서는 시뮬레이터가 넘겨주는 가상 컨트롤러("Gamepad")를 무시하고 실행 인자 상태만 보여 준다. + if ScreenshotFixture.isEnabled { return } + #endif manager.addDelegate(self) // 컨트롤러가 연결된 채로 광고 이용권을 받으면 바로 Live Activity 를 띄운다. NotificationCenter.default.addObserver(forName: .proPassGranted, object: nil, queue: .main) { [weak self] _ in diff --git a/Controllers/Controllers/Sources/Tester/TesterModel.swift b/Controllers/Controllers/Sources/Tester/TesterModel.swift index a26468c..ae2f6c4 100644 --- a/Controllers/Controllers/Sources/Tester/TesterModel.swift +++ b/Controllers/Controllers/Sources/Tester/TesterModel.swift @@ -48,6 +48,17 @@ final class TesterModel: ObservableObject { private let manager = GameControllerManager.shared init() { + #if DEBUG + // 스크린샷 모드에서는 시뮬레이터의 가상 컨트롤러를 무시하고 실행 인자 상태만 보여 준다. + if ScreenshotFixture.isEnabled { + if let mock = ScreenshotFixture.mockController { + isConnected = true + vendorName = mock.vendorName + batteryLevel = mock.batteryLevel + } + return + } + #endif manager.addDelegate(self) manager.inputDelegate = self setupController() @@ -55,14 +66,6 @@ final class TesterModel: ObservableObject { if manager.getControlelrInfo() != nil { didConnectedController() } - #if DEBUG - // 스크린샷용 실행 인자가 있으면 모의 컨트롤러가 연결된 것처럼 보인다. - if let mock = ScreenshotFixture.mockController { - isConnected = true - vendorName = mock.vendorName - batteryLevel = mock.batteryLevel - } - #endif } func setupController() { From 32760a9c13f032367bbea0ad577b4d515287ee8f Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 27 Oct 2026 04:18:03 +0900 Subject: [PATCH 10/17] =?UTF-8?q?fix:=20=EC=9C=84=EC=A0=AF=20large=20?= =?UTF-8?q?=EB=B3=B5=EC=9B=90,=20=EB=B0=B0=ED=84=B0=EB=A6=AC=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=97=86=EC=9D=8C=20=ED=91=9C=EC=8B=9C,=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=88=20=ED=99=94=EB=A9=B4=20=EC=A7=A7=EC=9D=80=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 위젯은 supportedFamilies 기본값(small·medium·large·extraLarge)을 썼으므로 large를 다시 지원해 홈 화면의 기존 위젯이 사라지지 않게 한다. extraLarge(iPadOS 15+)는 large 레이아웃을 재사용한다. BATTERY_STATE가 unknown(-1)인 컨트롤러는 0%로 보이지 않고 "배터리 정보 없음"으로 표시한다(ko/en/ja). accessoryRectangular는 이름 접미사(Wireless Controller/Controller)를 떼 말줄임을 피한다. Co-Authored-By: Claude Sonnet 5.5 --- .../ControllersWidget/ControllersWidget.swift | 111 +++++++++++++++--- .../en.lproj/Localizable.strings | 1 + .../ja.lproj/Localizable.strings | 1 + .../ko.lproj/Localizable.strings | 1 + 4 files changed, 97 insertions(+), 17 deletions(-) diff --git a/Controllers/ControllersWidget/ControllersWidget.swift b/Controllers/ControllersWidget/ControllersWidget.swift index c3f0e5f..f5b7347 100644 --- a/Controllers/ControllersWidget/ControllersWidget.swift +++ b/Controllers/ControllersWidget/ControllersWidget.swift @@ -33,12 +33,14 @@ struct Provider: TimelineProvider { isConnected: defaults.bool(forKey: StringKey.CONTROLLER_CONNECTED), batteryLevel: defaults.float(forKey: StringKey.BATTERY_LEVEL), isCharging: defaults.integer(forKey: StringKey.BATTERY_STATE) == BatteryState.charging.rawValue, + hasBatteryInfo: defaults.integer(forKey: StringKey.BATTERY_STATE) != BatteryState.unknown.rawValue, vendorName: defaults.string(forKey: StringKey.VENDOR_NAME), lastUpdated: updated > 0 ? Date(timeIntervalSince1970: updated) : nil) if let info = GameControllerManager.shared.getBatteryInfo(), info.state != BatteryState.unknown.rawValue { entry.isConnected = true entry.batteryLevel = info.level entry.isCharging = info.state == BatteryState.charging.rawValue + entry.hasBatteryInfo = true entry.lastUpdated = entry.date } return entry @@ -50,6 +52,8 @@ struct ControllerEntry: TimelineEntry { var isConnected: Bool var batteryLevel: Float var isCharging: Bool + /// 배터리 상태가 unknown(-1)인 컨트롤러(가상 게임패드, 일부 비공식 컨트롤러)는 잔량을 믿을 수 없다. 0%라도 state 가 있으면 진짜 값이다. + var hasBatteryInfo = true var vendorName: String? var lastUpdated: Date? @@ -58,6 +62,9 @@ struct ControllerEntry: TimelineEntry { vendorName: "DualSense Wireless Controller", lastUpdated: Date()) } + /// 잔량을 그려도 되는 상태 + var showsLevel: Bool { isConnected && hasBatteryInfo } + var percentText: String { "\(Int(batteryLevel * 100))%" } @@ -67,6 +74,15 @@ struct ControllerEntry: TimelineEntry { return Text("widget.controller") } + /// 좁은 곳(잠금 화면 직사각형)용 이름. "DualSense Wireless Controller" → "DualSense" + var shortNameText: Text { + guard let vendorName else { return nameText } + let short = [" Wireless Controller", " Controller"].reduce(vendorName) { name, suffix in + name.hasSuffix(suffix) ? String(name.dropLast(suffix.count)) : name + } + return short.isEmpty ? nameText : Text(verbatim: short) + } + /// "5분 전 갱신" var updatedText: Text? { lastUpdated.map { Text("widget.updated \(Text($0, style: .relative))") } @@ -87,7 +103,7 @@ struct ControllersWidgetEntryView: View { var body: some View { if #available(iOS 17.0, *) { content.containerBackground(for: .widget) { Color(UIColor.systemBackground) } - } else if family == .systemSmall || family == .systemMedium { + } else if family.isSystem { content.padding() } else { content @@ -100,7 +116,12 @@ struct ControllersWidgetEntryView: View { SmallWidgetView(entry: entry) case .systemMedium: MediumWidgetView(entry: entry) + case .systemLarge: + LargeWidgetView(entry: entry) default: + if #available(iOS 15.0, *), family == .systemExtraLarge { + LargeWidgetView(entry: entry) + } else if #available(iOS 16.0, *) { AccessoryWidgetView(entry: entry) } @@ -108,24 +129,34 @@ struct ControllersWidgetEntryView: View { } } +private extension WidgetFamily { + var isSystem: Bool { + if #available(iOS 15.0, *), self == .systemExtraLarge { return true } + return self == .systemSmall || self == .systemMedium || self == .systemLarge + } +} + /// 잔량 링과 가운데 퍼센트. 미연결이면 흐린 컨트롤러 아이콘만 그린다. private struct BatteryRingView: View { let entry: ControllerEntry + var lineWidth: CGFloat = 8 + var iconFont: Font = .caption + var percentFont: Font = .title2.bold() var body: some View { ZStack { - ProgressBar(progress: entry.isConnected ? entry.batteryLevel : 0, lineWidth: 8, color: color) - if entry.isConnected { + ProgressBar(progress: entry.showsLevel ? entry.batteryLevel : 0, lineWidth: lineWidth, color: color) + if entry.showsLevel { VStack(spacing: 0) { Image(systemName: entry.isCharging ? "bolt.fill" : "gamecontroller.fill") - .font(.caption) + .font(iconFont) Text(verbatim: entry.percentText) - .font(.title2.bold()) + .font(percentFont) .lineLimit(1) .minimumScaleFactor(0.6) } } else { - Image(systemName: "gamecontroller") + Image(systemName: entry.isConnected ? "gamecontroller.fill" : "gamecontroller") .font(.title) .foregroundColor(.secondary) } @@ -134,7 +165,7 @@ private struct BatteryRingView: View { /// 충전 알림 기준값 이하면 빨간 링으로 보여 준다. private var color: Color { - guard entry.isConnected else { return .gray } + guard entry.showsLevel else { return .gray } return entry.batteryLevel * 100 <= Float(UserDefaults.shared.batteryNotificationThreshold) ? .red : .green } } @@ -146,10 +177,13 @@ private struct SmallWidgetView: View { VStack(spacing: 6) { BatteryRingView(entry: entry) .frame(width: 84, height: 84) - if entry.isConnected { + if entry.showsLevel { entry.updatedText? .font(.caption2) .foregroundColor(.secondary) + } else if entry.isConnected { + Text("widget.noBatteryInfo") + .font(.caption.bold()) } else { Text("widget.notConnected") .font(.caption.bold()) @@ -173,7 +207,7 @@ private struct MediumWidgetView: View { VStack(alignment: .leading, spacing: 4) { entry.nameText .font(.headline) - if entry.isConnected { + if entry.showsLevel { Text(verbatim: entry.percentText) .font(.largeTitle.bold()) Group { @@ -184,6 +218,9 @@ private struct MediumWidgetView: View { } } .font(.subheadline) + } else if entry.isConnected { + Text("widget.noBatteryInfo") + .font(.title3.bold()) } else { Text("widget.notConnected") .font(.title3.bold()) @@ -201,6 +238,42 @@ private struct MediumWidgetView: View { } } +/// large·extraLarge 공용. 큰 링 아래에 이름·상태·갱신 시각을 쌓는다. +private struct LargeWidgetView: View { + let entry: ControllerEntry + + var body: some View { + VStack(spacing: 12) { + entry.nameText + .font(.title3.bold()) + BatteryRingView(entry: entry, lineWidth: 16, iconFont: .title3, percentFont: .system(size: 44, weight: .bold)) + .frame(width: 190, height: 190) + Group { + if entry.showsLevel { + if entry.isCharging { + Label("widget.charging", systemImage: "bolt.fill") + } else { + Text("widget.notCharging") + } + } else if entry.isConnected { + Text("widget.noBatteryInfo") + } else { + Text("widget.notConnected") + entry.lastLevelText? + .foregroundColor(.secondary) + } + } + .font(.headline) + entry.updatedText? + .font(.footnote) + .foregroundColor(.secondary) + } + .lineLimit(1) + .minimumScaleFactor(0.8) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + @available(iOS 16.0, *) private struct AccessoryWidgetView: View { @Environment(\.widgetFamily) private var family @@ -218,7 +291,7 @@ private struct AccessoryWidgetView: View { } @ViewBuilder private var circular: some View { - if entry.isConnected { + if entry.showsLevel { Gauge(value: Double(entry.batteryLevel)) { Image(systemName: "gamecontroller.fill") } currentValueLabel: { @@ -245,9 +318,9 @@ private struct AccessoryWidgetView: View { private var rectangular: some View { VStack(alignment: .leading, spacing: 2) { - Label { entry.nameText } icon: { Image(systemName: "gamecontroller.fill") } + Label { entry.shortNameText } icon: { Image(systemName: "gamecontroller.fill") } .font(.headline) - if entry.isConnected { + if entry.showsLevel { HStack(spacing: 2) { Text(verbatim: entry.percentText) if entry.isCharging { @@ -256,6 +329,8 @@ private struct AccessoryWidgetView: View { } Gauge(value: Double(entry.batteryLevel)) { EmptyView() } .gaugeStyle(.accessoryLinearCapacity) + } else if entry.isConnected { + Text("widget.noBatteryInfo") } else { Text("widget.notConnected") entry.lastLevelText? @@ -267,7 +342,7 @@ private struct AccessoryWidgetView: View { } @ViewBuilder private var inline: some View { - if entry.isConnected { + if entry.showsLevel { Label { entry.isCharging ? Text(verbatim: entry.percentText) + Text(Image(systemName: "bolt.fill")) @@ -275,6 +350,8 @@ private struct AccessoryWidgetView: View { } icon: { Image(systemName: "gamecontroller.fill") } + } else if entry.isConnected { + Label("widget.noBatteryInfo", systemImage: "gamecontroller.fill") } else { Label("widget.notConnected", systemImage: "gamecontroller") } @@ -292,10 +369,10 @@ struct ControllersWidget: Widget { } private var supportedFamilies: [WidgetFamily] { - if #available(iOS 16.0, *) { - return [.systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular, .accessoryInline] - } - return [.systemSmall, .systemMedium] + var families: [WidgetFamily] = [.systemSmall, .systemMedium, .systemLarge] + if #available(iOS 15.0, *) { families.append(.systemExtraLarge) } + if #available(iOS 16.0, *) { families += [.accessoryCircular, .accessoryRectangular, .accessoryInline] } + return families } } diff --git a/Controllers/ControllersWidget/en.lproj/Localizable.strings b/Controllers/ControllersWidget/en.lproj/Localizable.strings index f309e3a..1c99183 100644 --- a/Controllers/ControllersWidget/en.lproj/Localizable.strings +++ b/Controllers/ControllersWidget/en.lproj/Localizable.strings @@ -7,3 +7,4 @@ "widget.notCharging" = "Not charging"; "widget.last %@" = "Last: %@"; "widget.updated %@" = "Updated %@ ago"; +"widget.noBatteryInfo" = "No battery info"; diff --git a/Controllers/ControllersWidget/ja.lproj/Localizable.strings b/Controllers/ControllersWidget/ja.lproj/Localizable.strings index 9c6f1a7..67e599a 100644 --- a/Controllers/ControllersWidget/ja.lproj/Localizable.strings +++ b/Controllers/ControllersWidget/ja.lproj/Localizable.strings @@ -7,3 +7,4 @@ "widget.notCharging" = "充電していません"; "widget.last %@" = "前回: %@"; "widget.updated %@" = "%@前に更新"; +"widget.noBatteryInfo" = "バッテリー情報なし"; diff --git a/Controllers/ControllersWidget/ko.lproj/Localizable.strings b/Controllers/ControllersWidget/ko.lproj/Localizable.strings index dd9489a..ab7aea4 100644 --- a/Controllers/ControllersWidget/ko.lproj/Localizable.strings +++ b/Controllers/ControllersWidget/ko.lproj/Localizable.strings @@ -7,3 +7,4 @@ "widget.notCharging" = "충전 중 아님"; "widget.last %@" = "마지막: %@"; "widget.updated %@" = "%@ 전 갱신"; +"widget.noBatteryInfo" = "배터리 정보 없음"; From e40994c8089054e88159e5e49447b68fec73c6cc Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 27 Oct 2026 04:18:29 +0900 Subject: [PATCH 11/17] =?UTF-8?q?fix:=20=EB=B0=B0=ED=84=B0=EB=A6=AC=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EC=A3=BC=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=EB=A5=BC=200%?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=B4=EC=97=AC=20=EC=A3=BC=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배터리 상태가 unknown이고 잔량이 0인 컨트롤러(iOS 27 시뮬레이터 가상 Gamepad, 일부 비공식 컨트롤러)가 연결 화면에 0% 링으로 그려져 배터리가 바닥난 것처럼 보였다. 이 경우 링 대신 정보 없음 안내와 기기 이름, 공식 지원(DualSense) 문구를 보여 준다. 0%여도 충전·방전 중이면 진짜 0%라 기존처럼 그린다. 스크린샷 픽스처는 -ACMockBattery -1로 같은 상태를 만든다. argument 도메인이 -1을 값이 아닌 키로 읽어서 이 값만 실행 인자에서 직접 꺼낸다. Co-Authored-By: Claude Opus 5.5 --- .../Sources/Controller/ControllerModel.swift | 4 ++ .../Sources/Controller/ControllerView.swift | 42 +++++++++++++------ .../Sources/Debug/ScreenshotFixture.swift | 12 +++++- .../en.lproj/Localizable.strings | 1 + .../ja.lproj/Localizable.strings | 1 + .../ko.lproj/Localizable.strings | 1 + 6 files changed, 46 insertions(+), 15 deletions(-) diff --git a/Controllers/Controllers/Sources/Controller/ControllerModel.swift b/Controllers/Controllers/Sources/Controller/ControllerModel.swift index c6df473..c38d675 100644 --- a/Controllers/Controllers/Sources/Controller/ControllerModel.swift +++ b/Controllers/Controllers/Sources/Controller/ControllerModel.swift @@ -18,6 +18,8 @@ final class ControllerModel: ObservableObject { @Published var batteryLevel: Float = 0.0 @Published var vendorName: String = "" @Published var isCharging: Bool = false + /// 상태 unknown·잔량 0이면 배터리 정보를 주지 않는 컨트롤러다. (시뮬레이터 가상 Gamepad, 일부 비공식 컨트롤러) + @Published var hasBatteryInfo: Bool = true private let manager = GameControllerManager.shared @@ -56,6 +58,7 @@ final class ControllerModel: ObservableObject { vendorName = info.vendorName batteryLevel = info.batteryLevel isCharging = info.batteryState == .charging + hasBatteryInfo = !(info.batteryState == .unknown && info.batteryLevel == 0) UserDefaults.shared.setValue(info.batteryLevel, forKey: StringKey.BATTERY_LEVEL) // 활성화된 Live Activity 가 있으면 배터리 정보를 갱신한다. @@ -153,6 +156,7 @@ extension ControllerModel { vendorName = mock.vendorName batteryLevel = mock.batteryLevel isCharging = mock.isCharging + hasBatteryInfo = mock.hasBatteryInfo } ScreenshotFixture.writeWidgetState() } diff --git a/Controllers/Controllers/Sources/Controller/ControllerView.swift b/Controllers/Controllers/Sources/Controller/ControllerView.swift index d51963c..e2bcc66 100644 --- a/Controllers/Controllers/Sources/Controller/ControllerView.swift +++ b/Controllers/Controllers/Sources/Controller/ControllerView.swift @@ -39,28 +39,44 @@ struct ControllerView: View { .frame(width: diameter * 0.45) .foregroundColor(.primary) - ProgressBarView( - progress: $model.batteryLevel, - isAnimating: true, - lineWidth: max(diameter * 0.03, 6) - ) + if model.hasBatteryInfo { + ProgressBarView( + progress: $model.batteryLevel, + isAnimating: true, + lineWidth: max(diameter * 0.03, 6) + ) + } } - .frame(width: diameter, height: diameter) + // 링이 없으면 아이콘 높이만큼만 차지한다. + .frame(width: diameter, height: model.hasBatteryInfo ? diameter : diameter * 0.5) - HStack(spacing: 6) { - Text("\(Int((model.batteryLevel) * 100)) %") - if model.isCharging { - Image(systemName: "bolt.fill") - .foregroundColor(.green) - .accessibilityLabel("Charging".localized) + if model.hasBatteryInfo { + HStack(spacing: 6) { + Text("\(Int((model.batteryLevel) * 100)) %") + if model.isCharging { + Image(systemName: "bolt.fill") + .foregroundColor(.green) + .accessibilityLabel("Charging".localized) + } } + .font(.largeTitle.bold()) + } else { + Text("No Battery Info".localized) + .font(.title3.bold()) + .multilineTextAlignment(.center) } - .font(.largeTitle.bold()) Text(model.vendorName) .font(.headline) .multilineTextAlignment(.center) + if !model.hasBatteryInfo { + Text("Connect Guide Support Note".localized) + .font(.footnote) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + Button(action: onRefresh) { Label("Refresh".localized, systemImage: "arrow.clockwise") .font(.headline) diff --git a/Controllers/Controllers/Sources/Debug/ScreenshotFixture.swift b/Controllers/Controllers/Sources/Debug/ScreenshotFixture.swift index 8b559be..fe6504f 100644 --- a/Controllers/Controllers/Sources/Debug/ScreenshotFixture.swift +++ b/Controllers/Controllers/Sources/Debug/ScreenshotFixture.swift @@ -24,9 +24,16 @@ enum ScreenshotFixture { } /// `-ACMockBattery` 를 주면 그 잔량으로 컨트롤러가 연결된 것처럼 보인다. 없으면 미연결 상태다. + /// 음수(-1)면 배터리 정보를 주지 않는 컨트롤러다. + /// argument 도메인은 `-1` 을 값이 아닌 키로 읽어서, 이 값만 실행 인자에서 직접 꺼낸다. static var mockController: MockController? { - guard isEnabled, arguments.object(forKey: "ACMockBattery") != nil else { return nil } - return MockController(batteryLevel: arguments.float(forKey: "ACMockBattery"), + let launchArguments = ProcessInfo.processInfo.arguments + guard isEnabled, + let index = launchArguments.firstIndex(of: "-ACMockBattery"), + index + 1 < launchArguments.count, + let level = Float(launchArguments[index + 1]) else { return nil } + return MockController(batteryLevel: max(level, 0), + hasBatteryInfo: level >= 0, isCharging: arguments.bool(forKey: "ACMockCharging"), vendorName: arguments.string(forKey: "ACMockVendor") ?? "DualSense Wireless Controller") } @@ -43,6 +50,7 @@ enum ScreenshotFixture { struct MockController { let batteryLevel: Float + let hasBatteryInfo: Bool let isCharging: Bool let vendorName: String } diff --git a/Controllers/Controllers/Supporting Files/en.lproj/Localizable.strings b/Controllers/Controllers/Supporting Files/en.lproj/Localizable.strings index 0f88efd..bbf8c43 100644 --- a/Controllers/Controllers/Supporting Files/en.lproj/Localizable.strings +++ b/Controllers/Controllers/Supporting Files/en.lproj/Localizable.strings @@ -59,4 +59,5 @@ "Connect Guide Step 3" = "Once connected, the battery level appears here automatically."; "Connect Guide Waiting" = "Waiting for connection…"; "Connect Guide Support Note" = "Officially supported: DualSense. Other controllers may not show battery info."; +"No Battery Info" = "This controller doesn't provide battery info"; "Charging" = "Charging"; diff --git a/Controllers/Controllers/Supporting Files/ja.lproj/Localizable.strings b/Controllers/Controllers/Supporting Files/ja.lproj/Localizable.strings index bb9477e..70a20c2 100644 --- a/Controllers/Controllers/Supporting Files/ja.lproj/Localizable.strings +++ b/Controllers/Controllers/Supporting Files/ja.lproj/Localizable.strings @@ -60,4 +60,5 @@ "Connect Guide Step 3" = "接続されると、ここにバッテリー残量が自動で表示されます。"; "Connect Guide Waiting" = "接続を待っています…"; "Connect Guide Support Note" = "公式サポート: DualSense。他のコントローラーではバッテリー情報が表示されない場合があります。"; +"No Battery Info" = "このコントローラーはバッテリー情報を提供していません"; "Charging" = "充電中"; diff --git a/Controllers/Controllers/Supporting Files/ko.lproj/Localizable.strings b/Controllers/Controllers/Supporting Files/ko.lproj/Localizable.strings index 98e44dd..2364ab4 100644 --- a/Controllers/Controllers/Supporting Files/ko.lproj/Localizable.strings +++ b/Controllers/Controllers/Supporting Files/ko.lproj/Localizable.strings @@ -67,4 +67,5 @@ "Connect Guide Step 3" = "연결되면 여기에 배터리가 자동으로 표시돼요."; "Connect Guide Waiting" = "연결을 기다리는 중…"; "Connect Guide Support Note" = "공식 지원: DualSense. 다른 컨트롤러는 배터리 정보가 표시되지 않을 수 있어요."; +"No Battery Info" = "이 컨트롤러는 배터리 정보를 제공하지 않아요"; "Charging" = "충전 중"; From 364e1f28fb9a29f2c1eb37d1f3b28b2a948bbb07 Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 27 Oct 2026 04:18:29 +0900 Subject: [PATCH 12/17] =?UTF-8?q?fix:=20=ED=8F=89=EA=B0=80=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EC=B0=A8=EB=A1=80=EC=9D=B8=20=EC=8B=A4=ED=96=89?= =?UTF-8?q?=EC=97=90=EC=84=9C=EB=8A=94=20Pro=20=ED=8C=9D=EC=97=85=EB=B3=B4?= =?UTF-8?q?=EB=8B=A4=20=ED=8F=89=EA=B0=80=20=EC=9A=94=EC=B2=AD=EC=9D=84=20?= =?UTF-8?q?=EB=A8=BC=EC=A0=80=20=ED=95=98=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 무료 사용자는 매 실행 첫 연결에서 Pro 팝업을 보는데, 평가 요청은 Pro 팝업을 띄운 실행이면 건너뛰어서 대부분의 사용자에게 실행되지 않았다. 평가 요청은 버전당 한 번이라, 누적 연결 3회 이상이고 이번 버전에 아직 요청하지 않은 실행(ControllerModel.isReviewRequestDue)에서는 ProPromotionManager가 Pro 팝업을 건너뛴다. 연결 횟수를 먼저 세서 두 곳이 같은 값을 본다. 시작 흐름(알림·광고 동의·ATT)이 끝나지 않았거나 광고·모달이 떠 있으면 바로 건너뛰지 않고 최대 30초 더 기다리며 다시 확인한다. 끝내 못 띄우면 횟수를 소모하지 않아 다음 연결에서 다시 시도한다. Co-Authored-By: Claude Opus 5.5 --- .../Sources/Controller/ControllerModel.swift | 53 ++++++++++++------- .../Sources/Models/ProPromotionManager.swift | 9 ++-- 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/Controllers/Controllers/Sources/Controller/ControllerModel.swift b/Controllers/Controllers/Sources/Controller/ControllerModel.swift index c38d675..71e1170 100644 --- a/Controllers/Controllers/Sources/Controller/ControllerModel.swift +++ b/Controllers/Controllers/Sources/Controller/ControllerModel.swift @@ -99,11 +99,12 @@ extension ControllerModel: GameControllerDelegate { isConnected = true updateControllerInfo() startLiveActivity() + // 횟수를 먼저 세야 Pro 팝업이 이번 실행에 평가 요청 차례인지 알 수 있다. + countConnectionAndRequestReview() // 유료 기능을 쓰지 않는 사용자에게 앱 실행 후 첫 연결 때 Pro 소개 팝업을 띄운다. Task { @MainActor in ProPromotionManager.shared.controllerDidConnect() } - countConnectionAndRequestReview() } func didDisConnectedController() { @@ -115,34 +116,50 @@ extension ControllerModel: GameControllerDelegate { // MARK: - Review Request extension ControllerModel { + /// 누적 연결 3회 이상이고 이번 버전에 아직 요청하지 않았으면 true. + /// 이 값이 true인 실행은 Pro 팝업보다 평가 요청을 먼저 한다. (ProPromotionManager도 이 값을 본다) + static var isReviewRequestDue: Bool { + let defaults = UserDefaults.standard + return defaults.integer(forKey: StringKey.CONTROLLER_CONNECT_COUNT) >= 3 + && defaults.string(forKey: StringKey.LAST_REVIEW_REQUEST_VERSION) != Bundle.main.versionString + } + /// 연결 횟수를 세고, 세 번째 연결부터 버전당 한 번 평가를 요청한다. private func countConnectionAndRequestReview() { let defaults = UserDefaults.standard let count = defaults.integer(forKey: StringKey.CONTROLLER_CONNECT_COUNT) + 1 defaults.set(count, forKey: StringKey.CONTROLLER_CONNECT_COUNT) - let version = Bundle.main.versionString - guard count >= 3, defaults.string(forKey: StringKey.LAST_REVIEW_REQUEST_VERSION) != version else { return } + guard Self.isReviewRequestDue else { return } - // 연결 직후 뜨는 Pro 팝업·시스템 팝업과 겹치지 않게 조금 기다린다. + // 연결 직후 2초 기다린 뒤, 시작 흐름(알림·광고 동의·ATT)·광고·다른 화면이 끝나기를 최대 30초 더 기다린다. + // 끝내 못 띄우면 횟수를 소모하지 않아 다음 연결에서 다시 시도한다. Task { @MainActor in try? await Task.sleep(nanoseconds: 2_000_000_000) - guard ProPromotionManager.shared.isLaunchFlowFinished, - !ProPromotionManager.shared.didPresentAlert, - !AppOpenAdManager.shared.isShowingAd, - !RewardedPassManager.shared.isShowingAd, - UIApplication.shared.applicationState == .active, - let topViewController = UIApplication.topViewController(), - topViewController.presentedViewController == nil, - !(topViewController is UIAlertController), - let windowScene = topViewController.view.window?.windowScene else { - NSLog("Review request skipped (connect count: \(count))") - return + for _ in 0..<60 { + if let windowScene = Self.reviewPresentableScene() { + let version = Bundle.main.versionString + defaults.set(version, forKey: StringKey.LAST_REVIEW_REQUEST_VERSION) + NSLog("Review requested (connect count: \(count), version: \(version))") + SKStoreReviewController.requestReview(in: windowScene) + return + } + try? await Task.sleep(nanoseconds: 500_000_000) } - defaults.set(version, forKey: StringKey.LAST_REVIEW_REQUEST_VERSION) - NSLog("Review requested (connect count: \(count), version: \(version))") - SKStoreReviewController.requestReview(in: windowScene) + NSLog("Review request skipped (connect count: \(count))") } } + + /// 앱이 활성 상태이고 시작 흐름·광고·모달이 없을 때만 평가를 띄울 화면을 돌려준다. + @MainActor + private static func reviewPresentableScene() -> UIWindowScene? { + guard ProPromotionManager.shared.isLaunchFlowFinished, + !AppOpenAdManager.shared.isShowingAd, + !RewardedPassManager.shared.isShowingAd, + UIApplication.shared.applicationState == .active, + let topViewController = UIApplication.topViewController(), + topViewController.presentingViewController == nil else { return nil } + return topViewController.view.window?.windowScene + } } #if DEBUG diff --git a/Controllers/Controllers/Sources/Models/ProPromotionManager.swift b/Controllers/Controllers/Sources/Models/ProPromotionManager.swift index bd32d61..18c0f86 100644 --- a/Controllers/Controllers/Sources/Models/ProPromotionManager.swift +++ b/Controllers/Controllers/Sources/Models/ProPromotionManager.swift @@ -22,13 +22,17 @@ final class ProPromotionManager { private var isPending = false /// 이번 실행에서 이미 처리했으면 true. 앱을 완전히 종료했다 켜면 다시 띄운다. private var isDone = false - /// 이번 실행에서 팝업을 실제로 띄웠으면 true. 평가 요청이 겹치지 않게 본다. - private(set) var didPresentAlert = false private init() {} func controllerDidConnect() { guard !isDone else { return } + // 평가 요청은 버전당 한 번이라, 그 차례인 실행에서는 Pro 팝업을 건너뛴다. + if ControllerModel.isReviewRequestDue { + NSLog("Pro promo skipped for review request") + finish() + return + } isPending = true presentIfNeeded() } @@ -58,7 +62,6 @@ final class ProPromotionManager { } private func presentAlert(from viewController: UIViewController) { - didPresentAlert = true let threshold = "\(UserDefaults.shared.batteryNotificationThreshold)%" let alert = UIAlertController(title: "Pro Promo Title".localized, message: "Pro Promo Message %@".localized(with: [threshold]), From 199eb8c756f201e790eafa0136316440e75bd896 Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 27 Oct 2026 04:36:03 +0900 Subject: [PATCH 13/17] =?UTF-8?q?feat:=20=EC=95=B1=C2=B7=EC=9C=84=EC=A0=AF?= =?UTF-8?q?=EC=97=90=20es,=20pt-BR,=20ru,=20tr,=20fr,=20de=206=EA=B0=9C=20?= =?UTF-8?q?=EC=96=B8=EC=96=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 상위 다운로드 국가(러시아·브라질·튀르키예·프랑스·독일·스페인어권) 사용자가 영어로 보던 문제를 해소한다. Localizable·InfoPlist(ATT 설명 포함)·위젯 문구를 번역하고 pbxproj variant group과 knownRegions에 등록했다. Co-Authored-By: Claude Sonnet 5.5 --- .../Controllers.xcodeproj/project.pbxproj | 42 ++++++++++++++ .../de.lproj/InfoPlist.strings | 7 +++ .../de.lproj/Localizable.strings | 57 +++++++++++++++++++ .../es.lproj/InfoPlist.strings | 7 +++ .../es.lproj/Localizable.strings | 57 +++++++++++++++++++ .../fr.lproj/InfoPlist.strings | 7 +++ .../fr.lproj/Localizable.strings | 57 +++++++++++++++++++ .../pt-BR.lproj/InfoPlist.strings | 7 +++ .../pt-BR.lproj/Localizable.strings | 57 +++++++++++++++++++ .../ru.lproj/InfoPlist.strings | 7 +++ .../ru.lproj/Localizable.strings | 57 +++++++++++++++++++ .../tr.lproj/InfoPlist.strings | 7 +++ .../tr.lproj/Localizable.strings | 57 +++++++++++++++++++ .../de.lproj/Localizable.strings | 14 +++++ .../es.lproj/Localizable.strings | 14 +++++ .../fr.lproj/Localizable.strings | 14 +++++ .../pt-BR.lproj/Localizable.strings | 14 +++++ .../ru.lproj/Localizable.strings | 14 +++++ .../tr.lproj/Localizable.strings | 14 +++++ 19 files changed, 510 insertions(+) create mode 100644 Controllers/Controllers/Supporting Files/de.lproj/InfoPlist.strings create mode 100644 Controllers/Controllers/Supporting Files/de.lproj/Localizable.strings create mode 100644 Controllers/Controllers/Supporting Files/es.lproj/InfoPlist.strings create mode 100644 Controllers/Controllers/Supporting Files/es.lproj/Localizable.strings create mode 100644 Controllers/Controllers/Supporting Files/fr.lproj/InfoPlist.strings create mode 100644 Controllers/Controllers/Supporting Files/fr.lproj/Localizable.strings create mode 100644 Controllers/Controllers/Supporting Files/pt-BR.lproj/InfoPlist.strings create mode 100644 Controllers/Controllers/Supporting Files/pt-BR.lproj/Localizable.strings create mode 100644 Controllers/Controllers/Supporting Files/ru.lproj/InfoPlist.strings create mode 100644 Controllers/Controllers/Supporting Files/ru.lproj/Localizable.strings create mode 100644 Controllers/Controllers/Supporting Files/tr.lproj/InfoPlist.strings create mode 100644 Controllers/Controllers/Supporting Files/tr.lproj/Localizable.strings create mode 100644 Controllers/ControllersWidget/de.lproj/Localizable.strings create mode 100644 Controllers/ControllersWidget/es.lproj/Localizable.strings create mode 100644 Controllers/ControllersWidget/fr.lproj/Localizable.strings create mode 100644 Controllers/ControllersWidget/pt-BR.lproj/Localizable.strings create mode 100644 Controllers/ControllersWidget/ru.lproj/Localizable.strings create mode 100644 Controllers/ControllersWidget/tr.lproj/Localizable.strings diff --git a/Controllers/Controllers.xcodeproj/project.pbxproj b/Controllers/Controllers.xcodeproj/project.pbxproj index ebd3cfb..1b51926 100644 --- a/Controllers/Controllers.xcodeproj/project.pbxproj +++ b/Controllers/Controllers.xcodeproj/project.pbxproj @@ -194,6 +194,24 @@ EF9362612A9E2D3E0009E8E8 /* String+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+.swift"; sourceTree = ""; }; EFC1A1312A83D6E00023754E /* ControllersTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ControllersTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EFC1A1332A83D6E00023754E /* ControllersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControllersTests.swift; sourceTree = ""; }; + ACDB0004000000000000C010 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + ACDB0004000000000000C011 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = pt-BR.lproj/Localizable.strings; sourceTree = ""; }; + ACDB0004000000000000C012 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + ACDB0004000000000000C013 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; + ACDB0004000000000000C014 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; + ACDB0004000000000000C015 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; + ACDB0004000000000000C020 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; + ACDB0004000000000000C021 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = pt-BR.lproj/InfoPlist.strings; sourceTree = ""; }; + ACDB0004000000000000C022 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; + ACDB0004000000000000C023 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; + ACDB0004000000000000C024 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; + ACDB0004000000000000C025 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; + ACDB0004000000000000C030 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + ACDB0004000000000000C031 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = pt-BR.lproj/Localizable.strings; sourceTree = ""; }; + ACDB0004000000000000C032 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + ACDB0004000000000000C033 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; + ACDB0004000000000000C034 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; + ACDB0004000000000000C035 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -568,6 +586,12 @@ Base, ja, ko, + es, + "pt-BR", + ru, + tr, + fr, + de, ); mainGroup = EF789B652A24D6DC006EC6D8; packageReferences = ( @@ -735,6 +759,12 @@ ACDB0003000000000000B005 /* en */, ACDB0003000000000000B006 /* ja */, ACDB0003000000000000B007 /* ko */, + ACDB0004000000000000C030 /* es */, + ACDB0004000000000000C031 /* pt-BR */, + ACDB0004000000000000C032 /* ru */, + ACDB0004000000000000C033 /* tr */, + ACDB0004000000000000C034 /* fr */, + ACDB0004000000000000C035 /* de */, ); name = Localizable.strings; sourceTree = ""; @@ -761,6 +791,12 @@ EF9362562A9E26120009E8E8 /* en */, EF93625D2A9E2B7A0009E8E8 /* ja */, EF93625F2A9E2B860009E8E8 /* ko */, + ACDB0004000000000000C020 /* es */, + ACDB0004000000000000C021 /* pt-BR */, + ACDB0004000000000000C022 /* ru */, + ACDB0004000000000000C023 /* tr */, + ACDB0004000000000000C024 /* fr */, + ACDB0004000000000000C025 /* de */, ); name = InfoPlist.strings; sourceTree = ""; @@ -771,6 +807,12 @@ EF93625B2A9E2B670009E8E8 /* en */, EF93625E2A9E2B7A0009E8E8 /* ja */, EF9362602A9E2B860009E8E8 /* ko */, + ACDB0004000000000000C010 /* es */, + ACDB0004000000000000C011 /* pt-BR */, + ACDB0004000000000000C012 /* ru */, + ACDB0004000000000000C013 /* tr */, + ACDB0004000000000000C014 /* fr */, + ACDB0004000000000000C015 /* de */, ); name = Localizable.strings; sourceTree = ""; diff --git a/Controllers/Controllers/Supporting Files/de.lproj/InfoPlist.strings b/Controllers/Controllers/Supporting Files/de.lproj/InfoPlist.strings new file mode 100644 index 0000000..b75d960 --- /dev/null +++ b/Controllers/Controllers/Supporting Files/de.lproj/InfoPlist.strings @@ -0,0 +1,7 @@ +/* + InfoPlist.strings + Controllers + +*/ +"CFBundleDisplayName" = "Ah Charge!"; +"NSUserTrackingUsageDescription" = "Wird verwendet, um dir relevantere Werbung zu zeigen. App und Werbeprämien funktionieren gleich, egal ob du es erlaubst oder nicht."; diff --git a/Controllers/Controllers/Supporting Files/de.lproj/Localizable.strings b/Controllers/Controllers/Supporting Files/de.lproj/Localizable.strings new file mode 100644 index 0000000..def868e --- /dev/null +++ b/Controllers/Controllers/Supporting Files/de.lproj/Localizable.strings @@ -0,0 +1,57 @@ +/* + Localizable.strings + Controllers + +*/ +"Controllers" = "Ah Charge !"; +"Controller" = "Controller"; +"Tester" = "Tester"; +"Setting" = "Einstellungen"; +"Not connected.." = "Nicht verbunden.."; +"Refresh" = "Aktualisieren"; +"Premium" = "Premium"; +"restorepurchase" = "Kauf wiederherstellen"; +"Info" = "Info"; +"Battery is low %@" = "Akku schwach: %@. Zeit zum Laden!"; +"Charge Alert Threshold" = "Schwelle für Ladehinweis"; +"Charge Alert Threshold Message" = "Erhalte einen Hinweis, wenn der Akku auf die gewählte Stufe oder darunter fällt."; +"Cancel" = "Abbrechen"; +"Ok!" = "Ok!"; +"Done" = "Fertig"; +"Purchase is completed" = "Kauf abgeschlossen"; +"/week" = "/Woche"; +"/month" = "/Monat"; +"/year" = "/Jahr"; +"No subscription to restore" = "Kein Abo zum Wiederherstellen vorhanden"; +"Watch Ad Pro Pass %@" = "Werbung ansehen und %@ Pro nutzen"; +"Pro Pass Active %@" = "Pro aktiv bis %@"; +"Pro Pass Granted %@" = "Du kannst Pro %@ lang nutzen."; +"Ad Not Completed" = "Sieh dir die ganze Werbung an, um den Pass zu erhalten."; +"Ad Not Available" = "Derzeit ist keine Werbung verfügbar. Bitte versuche es später erneut."; +"Ad Privacy Settings" = "Werbe-Datenschutz"; +"Pro Promo Title" = "Akku-Hinweise, während du spielst"; +"Pro Promo Message %@" = "Mit Pro erhältst du einen Hinweis, wenn der Akku deines Controllers auf %@ oder weniger fällt, auch in anderen Apps. Und ohne Werbung."; +"See Pro Plans" = "Pro-Pläne ansehen"; +"Not Now" = "Nicht jetzt"; +"Pro No Ads" = "Nutze die App ohne Werbung."; +"%@ connected" = "%@ verbunden"; +"Controller not connected" = "Controller nicht verbunden"; +"Upgrade to Pro" = "Auf Pro upgraden"; +"Already Subscribing !" = "Du hast bereits ein Abo!"; +"Current Battery is %@" = "Aktueller Akkustand: %@!"; +"Month Plan" = "Monatsplan"; +"Notifications can also be sent to the controller's battery information during game play" = "Erhalte Hinweise zum Akkustand deines Controllers, auch während du spielst."; +"privacy Policy" = "Datenschutzerklärung"; +"Start Premium" = "Premium starten"; +"termOfUse" = "Nutzungsbedingungen"; +"Week Plan" = "Wochenplan"; +"Year Plan" = "Jahresplan"; +"Connect Guide Title" = "Controller verbinden"; +"Connect Guide Subtitle" = "Einmal koppeln, und der Akkustand erscheint hier."; +"Connect Guide Step 1" = "Halte die PS-Taste und die Create-Taste gleichzeitig gedrückt, bis die Lichtleiste blinkt."; +"Connect Guide Step 2" = "Öffne Einstellungen > Bluetooth und wähle \"DualSense Wireless Controller\"."; +"Connect Guide Step 3" = "Sobald die Verbindung steht, erscheint der Akkustand hier automatisch."; +"Connect Guide Waiting" = "Warte auf Verbindung…"; +"Connect Guide Support Note" = "Offiziell unterstützt: DualSense. Andere Controller zeigen den Akku evtl. nicht an."; +"No Battery Info" = "Dieser Controller liefert keine Akkuinfos"; +"Charging" = "Wird geladen"; diff --git a/Controllers/Controllers/Supporting Files/es.lproj/InfoPlist.strings b/Controllers/Controllers/Supporting Files/es.lproj/InfoPlist.strings new file mode 100644 index 0000000..beb6770 --- /dev/null +++ b/Controllers/Controllers/Supporting Files/es.lproj/InfoPlist.strings @@ -0,0 +1,7 @@ +/* + InfoPlist.strings + Controllers + +*/ +"CFBundleDisplayName" = "Ah Charge!"; +"NSUserTrackingUsageDescription" = "Se usa para mostrarte anuncios más relevantes. La app y las recompensas de los anuncios funcionan igual si lo permites o no."; diff --git a/Controllers/Controllers/Supporting Files/es.lproj/Localizable.strings b/Controllers/Controllers/Supporting Files/es.lproj/Localizable.strings new file mode 100644 index 0000000..fd214ec --- /dev/null +++ b/Controllers/Controllers/Supporting Files/es.lproj/Localizable.strings @@ -0,0 +1,57 @@ +/* + Localizable.strings + Controllers + +*/ +"Controllers" = "Ah Charge !"; +"Controller" = "Mando"; +"Tester" = "Probador"; +"Setting" = "Ajustes"; +"Not connected.." = "Sin conexión.."; +"Refresh" = "Actualizar"; +"Premium" = "Premium"; +"restorepurchase" = "Restaurar compra"; +"Info" = "Info"; +"Battery is low %@" = "Batería baja: %@. ¡Hora de cargar!"; +"Charge Alert Threshold" = "Umbral de aviso de carga"; +"Charge Alert Threshold Message" = "Recibe un aviso cuando la batería baje al nivel elegido o por debajo."; +"Cancel" = "Cancelar"; +"Ok!" = "¡Ok!"; +"Done" = "Listo"; +"Purchase is completed" = "Compra completada"; +"/week" = "/semana"; +"/month" = "/mes"; +"/year" = "/año"; +"No subscription to restore" = "No hay ninguna suscripción que restaurar"; +"Watch Ad Pro Pass %@" = "Mira un anuncio y obtén Pro por %@"; +"Pro Pass Active %@" = "Pro activo hasta %@"; +"Pro Pass Granted %@" = "Puedes usar Pro durante %@."; +"Ad Not Completed" = "Mira el anuncio completo para obtener el pase."; +"Ad Not Available" = "Ahora no hay anuncios disponibles. Inténtalo de nuevo más tarde."; +"Ad Privacy Settings" = "Privacidad de anuncios"; +"Pro Promo Title" = "Avisos de batería mientras juegas"; +"Pro Promo Message %@" = "Con Pro, te avisaremos cuando la batería del mando baje al %@ o menos, incluso si estás en otras apps. Y sin anuncios."; +"See Pro Plans" = "Ver planes Pro"; +"Not Now" = "Ahora no"; +"Pro No Ads" = "Usa la app sin anuncios."; +"%@ connected" = "%@ conectado"; +"Controller not connected" = "Mando no conectado"; +"Upgrade to Pro" = "Mejorar a Pro"; +"Already Subscribing !" = "¡Ya estás suscrito!"; +"Current Battery is %@" = "¡La batería actual es %@!"; +"Month Plan" = "Plan mensual"; +"Notifications can also be sent to the controller's battery information during game play" = "Recibe avisos del nivel de batería de tu mando incluso mientras juegas."; +"privacy Policy" = "Política de privacidad"; +"Start Premium" = "Empezar Premium"; +"termOfUse" = "Términos de uso"; +"Week Plan" = "Plan semanal"; +"Year Plan" = "Plan anual"; +"Connect Guide Title" = "Conecta tu mando"; +"Connect Guide Subtitle" = "Vincúlalo una vez y aquí verás el nivel de batería."; +"Connect Guide Step 1" = "Mantén pulsados a la vez el botón PS y el botón Create hasta que la barra de luz parpadee."; +"Connect Guide Step 2" = "Abre Ajustes > Bluetooth y selecciona \"DualSense Wireless Controller\"."; +"Connect Guide Step 3" = "Al conectarse, el nivel de batería aparecerá aquí automáticamente."; +"Connect Guide Waiting" = "Esperando conexión…"; +"Connect Guide Support Note" = "Compatibilidad oficial: DualSense. Es posible que otros mandos no muestren la batería."; +"No Battery Info" = "Este mando no ofrece información de batería"; +"Charging" = "Cargando"; diff --git a/Controllers/Controllers/Supporting Files/fr.lproj/InfoPlist.strings b/Controllers/Controllers/Supporting Files/fr.lproj/InfoPlist.strings new file mode 100644 index 0000000..9572228 --- /dev/null +++ b/Controllers/Controllers/Supporting Files/fr.lproj/InfoPlist.strings @@ -0,0 +1,7 @@ +/* + InfoPlist.strings + Controllers + +*/ +"CFBundleDisplayName" = "Ah Charge!"; +"NSUserTrackingUsageDescription" = "Sert à vous montrer des publicités plus pertinentes. L'app et les récompenses publicitaires restent identiques, que vous autorisiez ou non."; diff --git a/Controllers/Controllers/Supporting Files/fr.lproj/Localizable.strings b/Controllers/Controllers/Supporting Files/fr.lproj/Localizable.strings new file mode 100644 index 0000000..026f190 --- /dev/null +++ b/Controllers/Controllers/Supporting Files/fr.lproj/Localizable.strings @@ -0,0 +1,57 @@ +/* + Localizable.strings + Controllers + +*/ +"Controllers" = "Ah Charge !"; +"Controller" = "Manette"; +"Tester" = "Testeur"; +"Setting" = "Réglages"; +"Not connected.." = "Non connectée.."; +"Refresh" = "Actualiser"; +"Premium" = "Premium"; +"restorepurchase" = "Restaurer l'achat"; +"Info" = "Infos"; +"Battery is low %@" = "Batterie faible : %@. Il est temps de recharger !"; +"Charge Alert Threshold" = "Seuil d'alerte de charge"; +"Charge Alert Threshold Message" = "Recevez une notification quand la batterie descend au niveau choisi ou en dessous."; +"Cancel" = "Annuler"; +"Ok!" = "Ok !"; +"Done" = "Terminé"; +"Purchase is completed" = "Achat effectué"; +"/week" = "/semaine"; +"/month" = "/mois"; +"/year" = "/an"; +"No subscription to restore" = "Aucun abonnement à restaurer"; +"Watch Ad Pro Pass %@" = "Regardez une pub pour avoir Pro pendant %@"; +"Pro Pass Active %@" = "Pro actif jusqu'à %@"; +"Pro Pass Granted %@" = "Vous pouvez utiliser Pro pendant %@."; +"Ad Not Completed" = "Regardez toute la pub pour obtenir le pass."; +"Ad Not Available" = "Aucune pub disponible pour le moment. Réessayez plus tard."; +"Ad Privacy Settings" = "Confidentialité des pubs"; +"Pro Promo Title" = "Alertes de batterie pendant que vous jouez"; +"Pro Promo Message %@" = "Avec Pro, vous êtes prévenu quand la batterie de la manette descend à %@ ou moins, même dans une autre app. Et sans pub."; +"See Pro Plans" = "Voir les offres Pro"; +"Not Now" = "Plus tard"; +"Pro No Ads" = "Utilisez l'app sans pub."; +"%@ connected" = "%@ connectée"; +"Controller not connected" = "Manette non connectée"; +"Upgrade to Pro" = "Passer à Pro"; +"Already Subscribing !" = "Vous êtes déjà abonné !"; +"Current Battery is %@" = "Batterie actuelle : %@ !"; +"Month Plan" = "Formule mensuelle"; +"Notifications can also be sent to the controller's battery information during game play" = "Soyez prévenu du niveau de batterie de votre manette même en jouant."; +"privacy Policy" = "Politique de confidentialité"; +"Start Premium" = "Démarrer Premium"; +"termOfUse" = "Conditions d'utilisation"; +"Week Plan" = "Formule hebdomadaire"; +"Year Plan" = "Formule annuelle"; +"Connect Guide Title" = "Connectez votre manette"; +"Connect Guide Subtitle" = "Associez-la une fois et le niveau de batterie s'affiche ici."; +"Connect Guide Step 1" = "Maintenez ensemble la touche PS et la touche Create jusqu'à ce que la barre lumineuse clignote."; +"Connect Guide Step 2" = "Allez dans Réglages > Bluetooth et sélectionnez \"DualSense Wireless Controller\"."; +"Connect Guide Step 3" = "Une fois connectée, le niveau de batterie s'affiche ici automatiquement."; +"Connect Guide Waiting" = "En attente de connexion…"; +"Connect Guide Support Note" = "Officiellement compatible : DualSense. Les autres manettes peuvent ne pas afficher la batterie."; +"No Battery Info" = "Cette manette ne fournit pas d'infos de batterie"; +"Charging" = "En charge"; diff --git a/Controllers/Controllers/Supporting Files/pt-BR.lproj/InfoPlist.strings b/Controllers/Controllers/Supporting Files/pt-BR.lproj/InfoPlist.strings new file mode 100644 index 0000000..e94abbb --- /dev/null +++ b/Controllers/Controllers/Supporting Files/pt-BR.lproj/InfoPlist.strings @@ -0,0 +1,7 @@ +/* + InfoPlist.strings + Controllers + +*/ +"CFBundleDisplayName" = "Ah Charge!"; +"NSUserTrackingUsageDescription" = "Usado para mostrar anúncios mais relevantes. O app e as recompensas dos anúncios funcionam da mesma forma, você permitindo ou não."; diff --git a/Controllers/Controllers/Supporting Files/pt-BR.lproj/Localizable.strings b/Controllers/Controllers/Supporting Files/pt-BR.lproj/Localizable.strings new file mode 100644 index 0000000..3d77892 --- /dev/null +++ b/Controllers/Controllers/Supporting Files/pt-BR.lproj/Localizable.strings @@ -0,0 +1,57 @@ +/* + Localizable.strings + Controllers + +*/ +"Controllers" = "Ah Charge !"; +"Controller" = "Controle"; +"Tester" = "Testador"; +"Setting" = "Ajustes"; +"Not connected.." = "Não conectado.."; +"Refresh" = "Atualizar"; +"Premium" = "Premium"; +"restorepurchase" = "Restaurar compra"; +"Info" = "Info"; +"Battery is low %@" = "Bateria baixa: %@. Hora de carregar!"; +"Charge Alert Threshold" = "Limite do aviso de carga"; +"Charge Alert Threshold Message" = "Receba um aviso quando a bateria chegar ao nível escolhido ou ficar abaixo dele."; +"Cancel" = "Cancelar"; +"Ok!" = "Ok!"; +"Done" = "Concluído"; +"Purchase is completed" = "Compra concluída"; +"/week" = "/semana"; +"/month" = "/mês"; +"/year" = "/ano"; +"No subscription to restore" = "Não há assinatura para restaurar"; +"Watch Ad Pro Pass %@" = "Assista a um anúncio e ganhe Pro por %@"; +"Pro Pass Active %@" = "Pro ativo até %@"; +"Pro Pass Granted %@" = "Você pode usar o Pro por %@."; +"Ad Not Completed" = "Assista ao anúncio inteiro para ganhar o passe."; +"Ad Not Available" = "Nenhum anúncio disponível agora. Tente novamente mais tarde."; +"Ad Privacy Settings" = "Privacidade de anúncios"; +"Pro Promo Title" = "Avisos de bateria enquanto você joga"; +"Pro Promo Message %@" = "Com o Pro, você recebe um aviso quando a bateria do controle chegar a %@ ou menos, mesmo usando outros apps. E sem anúncios."; +"See Pro Plans" = "Ver planos Pro"; +"Not Now" = "Agora não"; +"Pro No Ads" = "Use o app sem anúncios."; +"%@ connected" = "%@ conectado"; +"Controller not connected" = "Controle não conectado"; +"Upgrade to Pro" = "Assinar o Pro"; +"Already Subscribing !" = "Você já é assinante!"; +"Current Battery is %@" = "A bateria atual é %@!"; +"Month Plan" = "Plano mensal"; +"Notifications can also be sent to the controller's battery information during game play" = "Receba avisos sobre a bateria do seu controle mesmo enquanto joga."; +"privacy Policy" = "Política de Privacidade"; +"Start Premium" = "Começar o Premium"; +"termOfUse" = "Termos de Uso"; +"Week Plan" = "Plano semanal"; +"Year Plan" = "Plano anual"; +"Connect Guide Title" = "Conecte seu controle"; +"Connect Guide Subtitle" = "Pareie uma vez e o nível da bateria aparece aqui."; +"Connect Guide Step 1" = "Mantenha o botão PS e o botão Create pressionados juntos até a barra de luz piscar."; +"Connect Guide Step 2" = "Vá em Ajustes > Bluetooth e selecione \"DualSense Wireless Controller\"."; +"Connect Guide Step 3" = "Depois de conectado, o nível da bateria aparece aqui automaticamente."; +"Connect Guide Waiting" = "Aguardando conexão…"; +"Connect Guide Support Note" = "Suporte oficial: DualSense. Outros controles podem não mostrar a bateria."; +"No Battery Info" = "Este controle não fornece informações de bateria"; +"Charging" = "Carregando"; diff --git a/Controllers/Controllers/Supporting Files/ru.lproj/InfoPlist.strings b/Controllers/Controllers/Supporting Files/ru.lproj/InfoPlist.strings new file mode 100644 index 0000000..cafe3a9 --- /dev/null +++ b/Controllers/Controllers/Supporting Files/ru.lproj/InfoPlist.strings @@ -0,0 +1,7 @@ +/* + InfoPlist.strings + Controllers + +*/ +"CFBundleDisplayName" = "Ah Charge!"; +"NSUserTrackingUsageDescription" = "Используется, чтобы показывать более подходящую рекламу. Приложение и награды за рекламу работают одинаково, разрешите вы это или нет."; diff --git a/Controllers/Controllers/Supporting Files/ru.lproj/Localizable.strings b/Controllers/Controllers/Supporting Files/ru.lproj/Localizable.strings new file mode 100644 index 0000000..bbcf132 --- /dev/null +++ b/Controllers/Controllers/Supporting Files/ru.lproj/Localizable.strings @@ -0,0 +1,57 @@ +/* + Localizable.strings + Controllers + +*/ +"Controllers" = "Ah Charge !"; +"Controller" = "Геймпад"; +"Tester" = "Тест"; +"Setting" = "Настройки"; +"Not connected.." = "Не подключён.."; +"Refresh" = "Обновить"; +"Premium" = "Премиум"; +"restorepurchase" = "Восстановить покупку"; +"Info" = "Инфо"; +"Battery is low %@" = "Низкий заряд: %@. Пора зарядить!"; +"Charge Alert Threshold" = "Порог уведомления о зарядке"; +"Charge Alert Threshold Message" = "Уведомим, когда заряд упадёт до выбранного уровня или ниже."; +"Cancel" = "Отмена"; +"Ok!" = "Ок!"; +"Done" = "Готово"; +"Purchase is completed" = "Покупка завершена"; +"/week" = "/нед."; +"/month" = "/мес."; +"/year" = "/год"; +"No subscription to restore" = "Нет подписки для восстановления"; +"Watch Ad Pro Pass %@" = "Реклама — получить Pro на %@"; +"Pro Pass Active %@" = "Pro активен до %@"; +"Pro Pass Granted %@" = "Pro доступен на %@."; +"Ad Not Completed" = "Посмотрите рекламу до конца, чтобы получить доступ."; +"Ad Not Available" = "Сейчас нет доступной рекламы. Попробуйте позже."; +"Ad Privacy Settings" = "Конфиденциальность рекламы"; +"Pro Promo Title" = "Уведомления о заряде во время игры"; +"Pro Promo Message %@" = "С Pro вы получите уведомление, когда заряд геймпада упадёт до %@ или ниже, даже если вы в другом приложении. И без рекламы."; +"See Pro Plans" = "Тарифы Pro"; +"Not Now" = "Не сейчас"; +"Pro No Ads" = "Пользуйтесь приложением без рекламы."; +"%@ connected" = "%@ подключён"; +"Controller not connected" = "Геймпад не подключён"; +"Upgrade to Pro" = "Перейти на Pro"; +"Already Subscribing !" = "Вы уже подписаны!"; +"Current Battery is %@" = "Текущий заряд: %@!"; +"Month Plan" = "Месячный тариф"; +"Notifications can also be sent to the controller's battery information during game play" = "Получайте уведомления о заряде геймпада, даже пока играете."; +"privacy Policy" = "Политика конфиденциальности"; +"Start Premium" = "Начать Premium"; +"termOfUse" = "Условия использования"; +"Week Plan" = "Недельный тариф"; +"Year Plan" = "Годовой тариф"; +"Connect Guide Title" = "Подключите геймпад"; +"Connect Guide Subtitle" = "Подключите один раз — и здесь появится уровень заряда."; +"Connect Guide Step 1" = "Удерживайте кнопки PS и Create одновременно, пока не замигает световая панель."; +"Connect Guide Step 2" = "Откройте Настройки > Bluetooth и выберите \"DualSense Wireless Controller\"."; +"Connect Guide Step 3" = "После подключения уровень заряда появится здесь автоматически."; +"Connect Guide Waiting" = "Ожидание подключения…"; +"Connect Guide Support Note" = "Официально поддерживается DualSense. Другие геймпады могут не показывать заряд."; +"No Battery Info" = "Этот геймпад не передаёт данные о заряде"; +"Charging" = "Зарядка"; diff --git a/Controllers/Controllers/Supporting Files/tr.lproj/InfoPlist.strings b/Controllers/Controllers/Supporting Files/tr.lproj/InfoPlist.strings new file mode 100644 index 0000000..d03b54b --- /dev/null +++ b/Controllers/Controllers/Supporting Files/tr.lproj/InfoPlist.strings @@ -0,0 +1,7 @@ +/* + InfoPlist.strings + Controllers + +*/ +"CFBundleDisplayName" = "Ah Charge!"; +"NSUserTrackingUsageDescription" = "Sana daha alakalı reklamlar göstermek için kullanılır. İzin verip vermemen uygulamayı ve reklam ödüllerini değiştirmez."; diff --git a/Controllers/Controllers/Supporting Files/tr.lproj/Localizable.strings b/Controllers/Controllers/Supporting Files/tr.lproj/Localizable.strings new file mode 100644 index 0000000..af80b2d --- /dev/null +++ b/Controllers/Controllers/Supporting Files/tr.lproj/Localizable.strings @@ -0,0 +1,57 @@ +/* + Localizable.strings + Controllers + +*/ +"Controllers" = "Ah Charge !"; +"Controller" = "Kontrolcü"; +"Tester" = "Test"; +"Setting" = "Ayarlar"; +"Not connected.." = "Bağlı değil.."; +"Refresh" = "Yenile"; +"Premium" = "Premium"; +"restorepurchase" = "Satın Alımı Geri Yükle"; +"Info" = "Bilgi"; +"Battery is low %@" = "Pil az: %@. Şarj etme zamanı!"; +"Charge Alert Threshold" = "Şarj Uyarısı Eşiği"; +"Charge Alert Threshold Message" = "Pil seçilen seviyeye veya altına düştüğünde bildirim al."; +"Cancel" = "İptal"; +"Ok!" = "Tamam!"; +"Done" = "Bitti"; +"Purchase is completed" = "Satın alma tamamlandı"; +"/week" = "/hafta"; +"/month" = "/ay"; +"/year" = "/yıl"; +"No subscription to restore" = "Geri yüklenecek abonelik yok"; +"Watch Ad Pro Pass %@" = "Reklam izle, %@ Pro kazan"; +"Pro Pass Active %@" = "Pro şu tarihe kadar etkin: %@"; +"Pro Pass Granted %@" = "Pro'yu %@ boyunca kullanabilirsin."; +"Ad Not Completed" = "Geçişi kazanmak için reklamın tamamını izle."; +"Ad Not Available" = "Şu an gösterilecek reklam yok. Lütfen daha sonra tekrar dene."; +"Ad Privacy Settings" = "Reklam Gizliliği"; +"Pro Promo Title" = "Oynarken pil uyarıları al"; +"Pro Promo Message %@" = "Pro ile kontrolcünün pili %@ veya altına düştüğünde, başka uygulamalar kullanırken bile bildirim alırsın. Üstelik reklamsız."; +"See Pro Plans" = "Pro Planlarını Gör"; +"Not Now" = "Şimdi Değil"; +"Pro No Ads" = "Uygulamayı reklamsız kullan."; +"%@ connected" = "%@ bağlı"; +"Controller not connected" = "Kontrolcü bağlı değil"; +"Upgrade to Pro" = "Pro'ya Geç"; +"Already Subscribing !" = "Zaten abonesin!"; +"Current Battery is %@" = "Mevcut pil: %@!"; +"Month Plan" = "Aylık Plan"; +"Notifications can also be sent to the controller's battery information during game play" = "Oynarken bile kontrolcünün pil seviyesiyle ilgili bildirim al."; +"privacy Policy" = "Gizlilik Politikası"; +"Start Premium" = "Premium'u Başlat"; +"termOfUse" = "Kullanım Koşulları"; +"Week Plan" = "Haftalık Plan"; +"Year Plan" = "Yıllık Plan"; +"Connect Guide Title" = "Kontrolcünü bağla"; +"Connect Guide Subtitle" = "Bir kez eşleştir, pil seviyesi burada görünsün."; +"Connect Guide Step 1" = "Işık çubuğu yanıp sönene kadar PS düğmesine ve Create düğmesine birlikte basılı tut."; +"Connect Guide Step 2" = "Ayarlar > Bluetooth'a git ve \"DualSense Wireless Controller\" seç."; +"Connect Guide Step 3" = "Bağlanınca pil seviyesi burada otomatik olarak görünür."; +"Connect Guide Waiting" = "Bağlantı bekleniyor…"; +"Connect Guide Support Note" = "Resmi destek: DualSense. Diğer kontrolcüler pil bilgisi göstermeyebilir."; +"No Battery Info" = "Bu kontrolcü pil bilgisi sağlamıyor"; +"Charging" = "Şarj oluyor"; diff --git a/Controllers/ControllersWidget/de.lproj/Localizable.strings b/Controllers/ControllersWidget/de.lproj/Localizable.strings new file mode 100644 index 0000000..6461940 --- /dev/null +++ b/Controllers/ControllersWidget/de.lproj/Localizable.strings @@ -0,0 +1,14 @@ +/* + Localizable.strings + Controllers + +*/ +"widget.name" = "Controller-Akku"; +"widget.description" = "Verbindung und Akkustand deines Controllers auf einen Blick."; +"widget.controller" = "Controller"; +"widget.notConnected" = "Nicht verbunden"; +"widget.charging" = "Wird geladen"; +"widget.notCharging" = "Lädt nicht"; +"widget.last %@" = "Zuletzt: %@"; +"widget.updated %@" = "Vor %@ aktualisiert"; +"widget.noBatteryInfo" = "Keine Akkuinfo"; diff --git a/Controllers/ControllersWidget/es.lproj/Localizable.strings b/Controllers/ControllersWidget/es.lproj/Localizable.strings new file mode 100644 index 0000000..4914f36 --- /dev/null +++ b/Controllers/ControllersWidget/es.lproj/Localizable.strings @@ -0,0 +1,14 @@ +/* + Localizable.strings + Controllers + +*/ +"widget.name" = "Batería del mando"; +"widget.description" = "Consulta de un vistazo la conexión y la batería de tu mando."; +"widget.controller" = "Mando"; +"widget.notConnected" = "No conectado"; +"widget.charging" = "Cargando"; +"widget.notCharging" = "Sin cargar"; +"widget.last %@" = "Último: %@"; +"widget.updated %@" = "Actualizado hace %@"; +"widget.noBatteryInfo" = "Sin info de batería"; diff --git a/Controllers/ControllersWidget/fr.lproj/Localizable.strings b/Controllers/ControllersWidget/fr.lproj/Localizable.strings new file mode 100644 index 0000000..4c35ff8 --- /dev/null +++ b/Controllers/ControllersWidget/fr.lproj/Localizable.strings @@ -0,0 +1,14 @@ +/* + Localizable.strings + Controllers + +*/ +"widget.name" = "Batterie de la manette"; +"widget.description" = "Voyez d'un coup d'œil la connexion et la batterie de votre manette."; +"widget.controller" = "Manette"; +"widget.notConnected" = "Non connectée"; +"widget.charging" = "En charge"; +"widget.notCharging" = "Ne charge pas"; +"widget.last %@" = "Dernier : %@"; +"widget.updated %@" = "Actualisé il y a %@"; +"widget.noBatteryInfo" = "Pas d'infos de batterie"; diff --git a/Controllers/ControllersWidget/pt-BR.lproj/Localizable.strings b/Controllers/ControllersWidget/pt-BR.lproj/Localizable.strings new file mode 100644 index 0000000..291c417 --- /dev/null +++ b/Controllers/ControllersWidget/pt-BR.lproj/Localizable.strings @@ -0,0 +1,14 @@ +/* + Localizable.strings + Controllers + +*/ +"widget.name" = "Bateria do controle"; +"widget.description" = "Veja a conexão e a bateria do seu controle de relance."; +"widget.controller" = "Controle"; +"widget.notConnected" = "Não conectado"; +"widget.charging" = "Carregando"; +"widget.notCharging" = "Não está carregando"; +"widget.last %@" = "Último: %@"; +"widget.updated %@" = "Atualizado há %@"; +"widget.noBatteryInfo" = "Sem info de bateria"; diff --git a/Controllers/ControllersWidget/ru.lproj/Localizable.strings b/Controllers/ControllersWidget/ru.lproj/Localizable.strings new file mode 100644 index 0000000..448b8a1 --- /dev/null +++ b/Controllers/ControllersWidget/ru.lproj/Localizable.strings @@ -0,0 +1,14 @@ +/* + Localizable.strings + Controllers + +*/ +"widget.name" = "Заряд геймпада"; +"widget.description" = "Подключение и заряд геймпада — с первого взгляда."; +"widget.controller" = "Геймпад"; +"widget.notConnected" = "Не подключён"; +"widget.charging" = "Зарядка"; +"widget.notCharging" = "Не заряжается"; +"widget.last %@" = "Последний: %@"; +"widget.updated %@" = "Обновлено %@ назад"; +"widget.noBatteryInfo" = "Нет данных о заряде"; diff --git a/Controllers/ControllersWidget/tr.lproj/Localizable.strings b/Controllers/ControllersWidget/tr.lproj/Localizable.strings new file mode 100644 index 0000000..990f75d --- /dev/null +++ b/Controllers/ControllersWidget/tr.lproj/Localizable.strings @@ -0,0 +1,14 @@ +/* + Localizable.strings + Controllers + +*/ +"widget.name" = "Kontrolcü Pili"; +"widget.description" = "Kontrolcünün bağlantısını ve pilini tek bakışta gör."; +"widget.controller" = "Kontrolcü"; +"widget.notConnected" = "Bağlı değil"; +"widget.charging" = "Şarj oluyor"; +"widget.notCharging" = "Şarj olmuyor"; +"widget.last %@" = "Son: %@"; +"widget.updated %@" = "%@ önce güncellendi"; +"widget.noBatteryInfo" = "Pil bilgisi yok"; From fc84f5141859aa4c6d2379024f8ba324376b3b04 Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 27 Oct 2026 04:38:15 +0900 Subject: [PATCH 14/17] =?UTF-8?q?fix:=20iPad=20=EA=B5=AC=EB=A7=A4=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=BD=98=ED=85=90=EC=B8=A0=20=ED=8F=AD?= =?UTF-8?q?=EC=9D=84=20=EA=B0=80=EC=9A=B4=EB=8D=B0=20560pt=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readableContentGuide 는 iPad 세로에서 거의 전체 폭이 돼서 버튼이 화면 끝까지 늘어났다. 가운데 정렬된 레이아웃 가이드(최대 560pt, 좌우 여백 15pt)로 바꾼다. Co-Authored-By: Claude Opus 5.5 --- .../Setting/IAPOnboardingViewController.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Controllers/Controllers/Sources/Setting/IAPOnboardingViewController.swift b/Controllers/Controllers/Sources/Setting/IAPOnboardingViewController.swift index 2aefc07..d6bbfbc 100644 --- a/Controllers/Controllers/Sources/Setting/IAPOnboardingViewController.swift +++ b/Controllers/Controllers/Sources/Setting/IAPOnboardingViewController.swift @@ -285,7 +285,20 @@ extension IAPOnboardingViewController { } private func addConstraints() { - let readable = view.readableContentGuide + // iPad·가로 화면에서 콘텐츠가 너무 넓어지지 않게 가운데 최대 560pt 폭으로 제한한다. + // (readableContentGuide 는 iPad 세로에서 거의 전체 폭이 돼서 따로 둔다) + let readable = UILayoutGuide() + view.addLayoutGuide(readable) + let preferredWidth = readable.widthAnchor.constraint(equalTo: view.safeAreaLayoutGuide.widthAnchor, constant: -30) + preferredWidth.priority = .defaultHigh + NSLayoutConstraint.activate([ + readable.topAnchor.constraint(equalTo: view.topAnchor), + readable.bottomAnchor.constraint(equalTo: view.bottomAnchor), + readable.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor), + readable.widthAnchor.constraint(lessThanOrEqualToConstant: 560), + readable.widthAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.widthAnchor, constant: -30), + preferredWidth, + ]) NSLayoutConstraint.activate([ dimmedView.topAnchor.constraint(equalTo: view.topAnchor), dimmedView.trailingAnchor.constraint(equalTo: view.trailingAnchor), From 13d03cfc4f2ec3c384d4f5580096e91038942354 Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 27 Oct 2026 04:51:17 +0900 Subject: [PATCH 15/17] =?UTF-8?q?fix:=20=EA=B5=AC=EB=A7=A4=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=A0=9C=EB=AA=A9=EC=9D=B4=20=EB=8B=AB=EA=B8=B0=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=EA=B3=BC=20=EA=B2=B9=EC=B9=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EA=B2=8C=20=EC=96=91=EC=AA=BD=20=EC=97=AC=EB=B0=B1=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 제목을 큰 글자(largeTitle)로 바꾼 뒤 일본어처럼 긴 제목이 오른쪽 위 닫기 버튼과 겹쳤다. 양쪽을 같은 만큼 띄워 가운데를 유지하고, 길면 두 줄로 내려가게 한다. Co-Authored-By: Claude Opus 5.5 --- .../Sources/Setting/IAPOnboardingViewController.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Controllers/Controllers/Sources/Setting/IAPOnboardingViewController.swift b/Controllers/Controllers/Sources/Setting/IAPOnboardingViewController.swift index d6bbfbc..fe7b415 100644 --- a/Controllers/Controllers/Sources/Setting/IAPOnboardingViewController.swift +++ b/Controllers/Controllers/Sources/Setting/IAPOnboardingViewController.swift @@ -349,8 +349,9 @@ extension IAPOnboardingViewController { NSLayoutConstraint.activate([ titleLabel.topAnchor.constraint(equalTo: closeButton.centerYAnchor), - titleLabel.leadingAnchor.constraint(equalTo: readable.leadingAnchor), - titleLabel.trailingAnchor.constraint(equalTo: readable.trailingAnchor), + // 오른쪽 위 닫기 버튼과 겹치지 않게 양쪽을 같은 만큼 띄워 가운데를 유지한다. (긴 언어는 두 줄로 내려간다) + titleLabel.leadingAnchor.constraint(equalTo: readable.leadingAnchor, constant: 52), + titleLabel.trailingAnchor.constraint(equalTo: readable.trailingAnchor, constant: -52), infoLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 50), infoLabel.leadingAnchor.constraint(equalTo: readable.leadingAnchor), From 8fa77aa52992638d7809757f1d8f8cbf03688de5 Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 27 Oct 2026 05:01:15 +0900 Subject: [PATCH 16/17] =?UTF-8?q?docs:=20CLAUDE.md=EC=97=90=209=EA=B0=9C?= =?UTF-8?q?=20=EC=96=B8=EC=96=B4=C2=B7=EC=9C=84=EC=A0=AF=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=20=EC=83=81=ED=83=9C=C2=B7=EC=8A=A4=ED=81=AC=EB=A6=B0?= =?UTF-8?q?=EC=83=B7=20=ED=94=BD=EC=8A=A4=EC=B2=98=C2=B7=ED=8F=89=EA=B0=80?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이번 UI/UX 개편으로 지원 언어, 위젯이 읽는 App Group 키, DEBUG 실행 인자, 평가 요청 규칙이 바뀌어 문서가 실제 코드와 달라졌다. 하드코딩 한국어 문구가 남아 있다는 설명도 더 이상 맞지 않아 지운다. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d0bef38..f554b4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,7 @@ xcodebuild build -project Controllers/Controllers.xcodeproj -scheme Controllers - `ControllersTests` 타깃은 있지만 `Controllers` scheme의 TestAction에 등록되어 있지 않아 `xcodebuild test`가 실패합니다. - 백그라운드 알림 테스트 (LLDB, 앱 일시정지 상태에서): `e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.controller.battery"]` +- 스크린샷·화면 확인용 DEBUG 실행 인자 ([ScreenshotFixture.swift](Controllers/Controllers/Sources/Debug/ScreenshotFixture.swift)): `-ACScreenshot YES`(시작 팝업·광고 생략, 실제 컨트롤러 이벤트 무시) + `-ACMockBattery 0.42`(`-1`이면 배터리 정보 없는 컨트롤러) `-ACMockCharging YES` `-ACMockVendor "<이름>"` `-ACTab controller|tester|setting` `-ACPaywall YES`. 시뮬레이터에는 컨트롤러를 연결할 수 없어 연결 상태는 이것으로 만듭니다. iOS 27 시뮬레이터는 배터리 정보 없는 가상 "Gamepad"를 연결된 것으로 넘겨줍니다 ### CI @@ -129,7 +130,9 @@ xcodebuild build -project Controllers/Controllers.xcodeproj -scheme Controllers ### 위젯 ↔ 앱 데이터 공유 - App Group `UserDefaults`를 단방향(앱 → 위젯)으로 사용 -- 위젯이 읽는 키: `StringKey.CONTROLLER_CONNECTED`, `StringKey.BATTERY_LEVEL` +- 쓰기는 [WidgetSharedState](Controllers/Controllers/Sources/Models/WidgetSharedState.swift)`.save(...)` 한곳에서 합니다. 연결 여부·잔량·`BATTERY_STATE`·`VENDOR_NAME`·`LAST_UPDATED`를 쓰고, 상태가 바뀔 때만 타임라인을 다시 읽게 합니다 +- 위젯은 `batteryState`가 unknown(-1)이면 "배터리 정보 없음"으로, 미연결이면 마지막 값과 함께 "Not connected"로 표시합니다 +- 시뮬레이터에서 App Group을 공유하려면 로컬 서명으로 빌드해야 합니다(`CODE_SIGN_IDENTITY=- CODE_SIGNING_ALLOWED=YES`). `CODE_SIGNING_ALLOWED=NO` 빌드는 위젯이 앱 값을 못 읽습니다 - 위젯에서 Push를 보내던 코드는 제거됨 (커밋 `ba362d5`). 위젯 → 앱 통신을 다시 넣지 마세요 ### 백그라운드 알림 (구독자·광고 이용권 사용자 전용) @@ -160,14 +163,15 @@ xcodebuild build -project Controllers/Controllers.xcodeproj -scheme Controllers - **앱 오프닝 광고** ([AppOpenAdManager.swift](Controllers/Controllers/Sources/Models/AppOpenAdManager.swift)): 백그라운드에서 돌아올 때만(콜드 스타트·제어 센터 제외), 마지막 노출 후 `minimumInterval`(15분)이 지나야 표시. AdMob 정책상 앱을 열거나 돌아오는 시점에는 전면(interstitial) 광고를 쓰면 안 됩니다 - **Pro 팝업** ([ProPromotionManager.swift](Controllers/Controllers/Sources/Models/ProPromotionManager.swift)): 앱 실행 후 첫 컨트롤러 연결 때 1회. 광고 이용권 받기 / 구매 화면 / 나중에 - 앱이 사용자를 밖으로 보내는 코드(`UIApplication.shared.open` 등)를 추가하면 그 직전에 `AppOpenAdManager.shared.skipNextAd()`를 부르세요. 안 그러면 돌아오자마자 광고가 뜹니다 +- **평가 요청** ([ControllerModel.swift](Controllers/Controllers/Sources/Controller/ControllerModel.swift) `isReviewRequestDue`): 누적 연결 3회째부터 앱 버전당 한 번. 차례인 실행에서는 Pro 팝업을 건너뛰고 평가를 먼저 띄우며, 시작 흐름·광고·모달이 끝나기를 최대 30초 기다립니다 - DEBUG 빌드는 Google 테스트 광고 단위를 씀. 실제 광고 단위로 개발 중 광고를 누르지 마세요(AdMob 무효 트래픽) - AdMob 앱 ID `ca-app-pub-7469152743628808~7969925639`(Info.plist `GADApplicationIdentifier`), 보상형 `pro_pass_rewarded` `.../7469653173`, 앱 오프닝 `app_open_foreground` `.../3611967519` - 광고 SDK가 데이터를 수집하므로 App Store 개인정보 라벨(추적 포함)과 dy-solution.kr 개인정보처리방침을 같이 관리. 근거는 ARCHITECTURE.md 결정 #12, #13 ### 로컬라이제이션 -- ko / en / ja `Localizable.strings` + `InfoPlist.strings` -- 접근: `"key".localized`, `"key %@".localized(with: [arg])` ([String+.swift](Controllers/Controllers/Sources/Extensions/String+.swift)) -- 신규 문구는 세 언어 모두 추가. `TesterView`, 위젯 등 일부 SwiftUI 문구는 아직 한국어 하드코딩 상태 +- ko / en / ja / es / pt-BR / ru / tr / fr / de `Localizable.strings` + `InfoPlist.strings` (앱), 위젯은 `ControllersWidget/*.lproj/Localizable.strings`를 따로 가짐 +- 접근: `"key".localized`, `"key %@".localized(with: [arg])` ([String+.swift](Controllers/Controllers/Sources/Extensions/String+.swift)). 위젯은 `Text("key")`/`NSLocalizedString` +- 신규 문구는 9개 언어 모두 추가하고 키 집합을 ko와 맞추세요. 사용자에게 보이는 문구를 하드코딩하지 마세요 ### 컬러 - 컬러셋은 `ColorAsset.xcassets`에 정의하고 UIKit에서는 `UIColor.color(name:)` ([UIColor+.swift](Controllers/Controllers/Sources/Extensions/UIColor+.swift))로 접근. 현재 탭바 컬러 2개뿐 @@ -201,4 +205,4 @@ xcodebuild build -project Controllers/Controllers.xcodeproj -scheme Controllers --- -*기준: `develop` + PR #15 + `Feature/charge-alert-enhancements` 작업 트리 · 최종 갱신: 2026-10-04* +*기준: `Feature/develop-dev` (UI/UX 개편) · 최종 갱신: 2026-10-27* From e5895baf059ad3e094415b82a433e56001b1e7a8 Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 27 Oct 2026 18:00:04 +0900 Subject: [PATCH 17/17] =?UTF-8?q?chore:=201.2.0=20=EB=B2=84=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Controllers/Controllers.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Controllers/Controllers.xcodeproj/project.pbxproj b/Controllers/Controllers.xcodeproj/project.pbxproj index 1b51926..bb1946a 100644 --- a/Controllers/Controllers.xcodeproj/project.pbxproj +++ b/Controllers/Controllers.xcodeproj/project.pbxproj @@ -967,7 +967,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.1; + MARKETING_VERSION = 1.2.0; OTHER_LDFLAGS = "-ObjC"; PRODUCT_BUNDLE_IDENTIFIER = com.arex.achaCharge; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1000,7 +1000,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.1.1; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.arex.achaCharge.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1115,7 +1115,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.1; + MARKETING_VERSION = 1.2.0; OTHER_LDFLAGS = "-ObjC"; PRODUCT_BUNDLE_IDENTIFIER = com.arex.achaCharge; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1149,7 +1149,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.1.1; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.arex.achaCharge.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES;