아틀리에 1.1.0+19 업데이트

노영택·2026년 9월 18일

아틀리에316

목록 보기
22/28

2026-09-15 위젯 잠금화면 정리, 즐겨찾기→좋아요 전환, 알림 개선, 업로드 나가기 확인

어제 iOS 잠금화면 위젯을 처음 넣었는데, 오늘은 그 뒷정리부터 시작해서 하루 종일 작은 요청들을 연달아 처리했다. "안드로이드도 잠금화면 되게 해줘"로 시작해서 "근데 4스타일이 다 뜨는 건 이상하다"로 이어졌고, 그 김에 밀려있던 UX 요청들(즐겨찾기를 좋아요로, 업로드 나가기 확인, 관리자 알림 발송)까지 한 번에 처리했다. 마지막엔 잠금화면 위젯에서 긴 말씀이 이상하게 잘리는 문제의 진짜 원인을 찾다가, 처음엔 성경 DB를 의심했는데 실제로는 작가가 업로드 에디터에서 일부러 넣은 줄바꿈이었다는 걸 사용자가 직접 되물어봐서 바로잡았다.

1. 위젯: 안드로이드 잠금화면 지원 + 스타일 하나로 정리 + 긴 말씀 줄바꿈 버그

안드로이드도 잠금화면 위젯이 된다는 걸 다시 확인했다

어제 작업 일지에 "안드로이드는 잠금화면 위젯 자체가 없다(Android 5.0 이후 keyguard 위젯이 통째로 빠짐)"고 코드 주석까지 남겨뒀었는데, 이게 틀렸다. backend-design.md엔 더 정확하게 "Android 16 QPR1에서 부활했지만 보급률이 낮아 범위에서 제외"라고 적혀 있었다 — 같은 조사에서 나온 두 결론이 서로 달랐던 거다. 다시 확인해보니 widgetCategory="keyguard"는 API 17부터 있던 상수라 새 SDK 없이 XML 플래그만 켜면 됐다.

<!-- popular_verse_widget_*_info.xml -->
android:widgetCategory="home_screen|keyguard"

코드 변경은 필요 없었다 — 크기별 레이아웃 선택(updateOne)이 원래도 위젯 호스트가 넘기는 min width/height를 그대로 따라가는 구조라, 잠금화면 호스트가 더 좁은 폭을 요청해도 자연히 소형 레이아웃으로 떨어진다.

4스타일이 잠금화면에 중복으로 뜬다

바로 다음에 "안드로이드도 위젯 스타일 1개로 축소해달라"는 요청이 왔다. iOS .accessoryRectangular는 시스템이 색을 강제로 단색화해서 4스타일이 진짜로 똑같이 보이는데, 안드로이드는 그런 강제가 없다. 그래도 잠금화면 위젯 추가 화면에 똑같은 위젯이 4번 뜨는 건 마찬가지로 잡음이라 iOS와 같은 기준으로 정리했다 — "종이" 하나만 home_screen|keyguard, 나머지(코랄·밤·작품위)는 home_screen만.

// PopularVerseWidget.swift
private let allFamilies: [WidgetFamily] = [.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular]
private let homeOnlyFamilies: [WidgetFamily] = [.systemSmall, .systemMedium, .systemLarge]

iOS도 같은 날 먼저 이 요청을 받아서 "종이"만 allFamilies(잠금화면 포함), 나머지 3개는 homeOnlyFamilies로 나눴다.

긴 말씀이 잠금화면에서 이상하게 잘린다

"잠금화면 위젯의 말씀이 긴 것들은 어쩔 수 없지만 최대한 다 나오게 해줘. 쓸데없는 줄바꿈도 있어"라는 요청을 받고 처음엔 iOS lockContent의 lineLimit/minimumScaleFactor 문제로만 생각했다. 그런데 실제 데이터를 까보니 훨씬 근본적인 문제가 있었다 — DB에 진짜 줄바꿈 문자(\n)가 박혀 있었다.

"시편 148:4" → "하늘의 하늘도 찬양하며\n 하늘 위에 있는 물들도 찬양할지어다"

처음엔 이게 bible_verses(성경 원문 DB) 자체의 문제인 줄 알고 그렇게 코드 주석까지 남겼는데, 사용자가 "우리 성경 DB 자체에 \n이 있다는 거야?"라고 되물어서 직접 두 테이블을 대조해봤다.

테이블같은 절(시편 148:4) 값
bible_verses(원문 DB)"하늘의 하늘도 찬양하며 하늘 위에 있는 물들도 찬양할지어다" (깨끗함)
products.verse_text(그 작품이 저장한 값)"하늘의 하늘도 찬양하며\n 하늘 위에 있는 물들도 찬양할지어다"

성경 DB는 깨끗했다. 줄바꿈은 그 작품에만 있었다. 원인은 업로드 에디터의 기능이었다 — 캔버스에서 이미 선택된 말씀 블록을 한 번 더 탭하면 "줄바꿈 편집 모드"로 들어가는데, 여기서 Enter를 치면 그 줄바꿈이 블록 텍스트 자체에 들어간다. 작가가 작품 이미지 위에서 보기 좋게 줄을 나누려고 일부러 넣은 서식이었고, products.verse_text는 그 블록 텍스트를 그대로 저장한 값(blocks.first.text)이라 이 서식까지 같이 딸려 나온 거다. 캔버스에선 의도된 디자인인데, 위젯(한 문단으로 흘러야 하는 좁은 자리)에선 "쓸데없는" 줄바꿈으로 보인 것.

bible_verses로 다시 매칭해서 가져오는 방법도 생각해봤지만, verse_ref(예: "고전 13:4-8" 같은 범위) 파싱과 언어별 데이터 공백 같은 훨씬 복잡한 문제를 다시 끌어오는 거라 그러지 않았다. 대신 위젯에 보내기 직전에만 공백으로 펴는 쪽을 택했다 — 원본 데이터도, 캔버스 이미지 서식도 안 건드린다.

// home_widget_service.dart
String _flattenText(String text) => text.replaceAll(RegExp(r'\s+'), ' ').trim();

syncPopularVerses/syncPopularArtwork 둘 다 위젯 JSON을 쓰기 직전에 이걸 거치게 했다 — 같은 JSON을 홈 화면·잠금화면이 같이 읽으므로 이 수정은 양쪽 다 적용된다. 그 위에 iOS lockContent는 그래도 정말 긴 말씀을 위한 여유분을 더 줬다: lineLimit 3→5, 최소 폰트 8pt→6.5pt, spacing 3→1. 이건 잠금화면(.accessoryRectangular) 전용이라 홈 화면 레이아웃엔 영향 없다.

2. 즐겨찾기 → 좋아요 전환

"즐겨찾기 → 좋아요로 바꿔달라"는 요청 하나가 여러 갈래로 이어졌다.

문구·아이콘 교체

15개 언어 ARB에서 favorites/favTitle/emptyFav 세 키를 전부 "좋아요" 계열로 바꿨다(예: 일본어는 인스타그램식 "いいね", 중국어는 "喜欢", 스페인어는 "Me gusta"). 홈 피드 카드의 Icons.bookmark도 Icons.favorite(하트)로 바꿨다.

상세 화면에 좋아요 버튼이 없었다

홈 피드에만 즐겨찾기 토글이 있고 상세 화면엔 아예 없었다. library.favorites를 그대로 공유하는 하트 토글 버튼을 히어로 이미지 우상단에 추가해서, 피드에서 눌러도 상세에서 눌러도 항상 같은 상태가 보이게 했다.

좋아요 아이콘에 숫자를 넣어달라 — DB에 숨어있던 죽은 컬럼

숫자를 넣으려고 Product.likes(→ products.likes_count)를 봤는데, 이 컬럼이 한 번도 실제로 집계된 적이 없었다. sold_count엔 구매 시 자동 증가 트리거가 있는데 likes_count엔 즐겨찾기 추가/삭제 트리거가 없어서 항상 0으로 고정돼 있었다(2026.09.01에 이미 발견됐던 문제 — "인기순 정렬에 의미가 없다"는 이유로 그때는 sold_count로 대체하고 넘어갔던 것). 새 마이그레이션으로 트리거를 만들고 기존 즐겨찾기도 백필했다.

create function public.bump_likes_count () returns trigger as $$
begin
  if TG_OP = 'INSERT' then
    update public.products set likes_count = likes_count + 1 where id = new.product_id;
  elsif TG_OP = 'DELETE' then
    update public.products set likes_count = greatest(likes_count - 1, 0) where id = old.product_id;
  end if;
  return null;
end;
$$ language plpgsql security definer;

create trigger on_favorite_bump_likes
after insert or delete on public.favorites for each row
execute function public.bump_likes_count ();

배포 후 실제로 확인해보니 기존에 좋아요가 있던 작품들이 likes_count: 1로 정상적으로 채워졌다.

홈 피드·상세 둘 다 다운로드 수 알약과 같은 모양(아이콘+숫자, 알약형)으로 통일했다.

"낙관적 업데이트로 바꿔줘"

처음엔 숫자를 서버 값 그대로만 보여줘서, 하트를 눌러도 숫자는 다음 새로고침 전까지 안 움직였다. 다운로드 수가 원래 그런 식이라 그대로 맞췄던 건데, 사용자가 바로 "아니야, 낙관적 업데이트로 바꿔"라고 정정해서 LibraryViewModel에 likeDelta 맵을 추가했다.

void toggleFavorite(String id) {
  final on = !favorites.contains(id);
  on ? favorites.add(id) : favorites.remove(id);
  likeDelta[id] = (likeDelta[id] ?? 0) + (on ? 1 : -1);
  notifyListeners();
  unawaited(_library.setFavorite(id, on).catchError((e) {
    on ? favorites.remove(id) : favorites.add(id);
    likeDelta[id] = (likeDelta[id] ?? 0) - (on ? 1 : -1);
    notifyListeners();
  }));
}

화면은 product.likes + (likeDelta[id] ?? 0)로 더해서 보여준다 — favorites 토글이 이미 쓰던 것과 똑같은 낙관적-반영-후-실패시-롤백 패턴을 그대로 복사한 거라 새로 고민할 건 없었다. 재로그인/로그아웃 시점엔 델타를 비운다 — 그때부턴 새로 읽어온 서버 값이 진실이라 델타가 남아있으면 이중 반영된다.

3. 알림 개선 — 공지사항 탭 이동 + 관리자 "알림 방송" 신설

공지사항 FCM을 탭하면 엉뚱한 곳으로 갔다

_handlePushTap이 product_id/seller_id만 보고 나머지는 전부 /me(마이페이지)로 보내고 있었다 — 공지사항 타입도 예외가 아니었다. 상세 화면이 따로 없고 "마이 > 공지사항"(notices 테이블)이 전체 목록의 단일 소스라, 그쪽으로 보내도록 세 곳(콜드/웜 스타트 푸시 탭, 포그라운드 로컬 알림 탭, 알림함 목록 안 탭)을 다 고쳤다. send-push가 이미 type을 FCM data payload에 넣어주고 있어서 서버 쪽은 안 건드렸다.

"공지 보내기는 공지사항에 남잖아, 단순 FCM만 보내는 기능을 넣어달라는 거야"

여기서 요청을 처음엔 잘못 이해했다 — "공지사항 FCM 탭 이동"을 고쳤다고 보고했더니, 사용자가 진짜 원하는 건 그게 아니라 관리자 메뉴에 새로운 발송 기능이었다. 기존 "공지 보내기"(admin_broadcast_announcement)는 notices 테이블에 원본을 남기고 "마이 > 공지사항"에 영구히 남는데, 그거 말고 그냥 푸시만 쏘고 끝나는 가벼운 기능이 필요하다는 거였다. "관리자 메뉴에 알림 방송 탭을 넣어주면 돼"로 요청이 구체화된 뒤에야 제대로 만들었다.

  • 새 RPC admin_send_push — notifications엔 type='admin_push'로 insert하지만 notices엔 아무것도 안 남긴다.
  • send-push Edge Function에 admin_push 타입 처리 추가(payload 모양이 announcement와 같아서 로직은 거의 그대로 재사용).
  • 마이 > 관리자 > "알림 방송" 탭 신설 — "공지 보내기"와 같은 스타일의 다이얼로그.
insert into public.notifications (user_id, type, payload)
select id, 'admin_push', jsonb_build_object('title', p_title, 'body', p_body)
from public.users;

배포 후 익명 호출로 is_admin() 체크가 제대로 막는지 확인했다 — 실제 발송은 전체 유저에게 푸시가 나가버려서 테스트로 쏴볼 수가 없어, 권한 거부까지만 확인했다.

curl ... /rpc/admin_send_push → {"code":"P0001","message":"not allowed"}

4. 업로드 나가기 확인 팝업

"작품 만드는 중에 임시 저장이 무조건 되는데, 팝업으로 물어보게 해달라"는 요청. 예전엔 dispose()가 무조건 임시저장을 했었다 — 나가는 방법(뒤로가기 버튼, 하드웨어/제스처 뒤로가기)이 여러 경로라 한 곳만 고쳐선 안 됐다. PopScope(canPop: false)로 화면 전체를 감싸서 모든 pop 시도를 한 곳(_handleBackAttempt)으로 모았다.

return PopScope(
  canPop: false,
  onPopInvokedWithResult: (didPop, _) {
    if (!didPop) _handleBackAttempt();
  },
  child: GestureDetector(...),
);

내용이 하나도 없으면(빈 캔버스) 물어보지 않고 바로 나가고, 뭔가 있으면 취소/저장 안 함/임시 저장 세 가지를 묻는다. "저장 안 함"을 고르면 _discardDraft 플래그가 서고, dispose()가 평소처럼 저장하는 대신 캐시를 지운다. 게시 성공 후의 이동(context.go('/'))은 pop이 아니라 스택 교체라 이 가로채기에 안 걸린다는 것도 확인했다 — 안 그러면 게시 완료 직후에도 확인 팝업이 떴을 것.

오늘 한 것 정리

  • 안드로이드 잠금화면 위젯 지원 추가(widgetCategory="home_screen|keyguard", 코드 변경 없음)
  • iOS·안드로이드 둘 다 잠금화면 위젯 스타일을 "종이" 하나로 정리(4개 중복 노출 제거)
  • 잠금화면 위젯 긴 말씀 줄바꿈 버그 — 원인은 성경 DB가 아니라 작가가 업로드 에디터에서 넣은 캔버스용 줄바꿈이 products.verse_text에 그대로 저장된 것. HomeWidgetService._flattenText로 위젯 전송 직전에 공백으로 정리(홈+잠금 둘 다 적용), iOS lockContent는 lineLimit·최소폰트도 추가로 확보(잠금화면만)
  • 즐겨찾기 → 좋아요: 15개 언어 문구·아이콘 교체, 상세 화면에 좋아요 버튼 신설
  • products.likes_count가 트리거 없이 항상 0이었던 걸 발견 — bump_likes_count 트리거 신설 + 기존 데이터 백필, 좋아요 숫자를 알약형으로 표시
  • 좋아요 숫자 낙관적 업데이트(LibraryViewModel.likeDelta) — favorites 토글과 같은 패턴
  • 공지사항 FCM/로컬 알림 탭 → "마이 > 공지사항"으로 이동(3개 진입 경로 전부)
  • 관리자 메뉴에 "알림 방송" 신설 — notices에 안 남기고 FCM만 발송하는 새 RPC(admin_send_push) + Edge Function 처리 추가
  • 업로드 화면 나가기 확인 팝업(PopScope + 취소/저장 안 함/임시 저장) — dispose()가 무조건 저장하던 걸 사용자 선택에 맡기게 변경

남은 것

출시 전 필수

  • 없음

그 외

  • 안드로이드 잠금화면 위젯은 Android 16 QPR1 실사용자 보급률이 아직 낮아 실기기 확인이 뒤로 밀려 있음 — Pixel 계열 기기로 잠금화면 커스터마이즈 > 위젯 추가에서 "종이" 하나만 뜨는지 확인 필요
  • iOS 잠금화면 위젯 갤러리(4→1개로 줄어든 것), 알림 방송 실제 발송, 업로드 나가기 팝업 3가지 전부 실기기 확인 대기
  • 잠금화면 위젯 줄바꿈 수정은 앞으로 업로드되는 작품/기존 작품 모두에 적용되지만, 원본 products.verse_text의 줄바꿈 자체는 그대로 남아있음 — 캔버스 이미지 표시에는 영향 없음

2026-09-16 잠금화면 추천 오버레이 권한 버그, 레이아웃 다듬기, 브랜드 버튼, 딥링크 라우팅 버그

캐시워크·캐시슬라이드처럼 잠금화면 위에 인기 작품을 띄우는 기능을 "시작하자"는 말로 오늘을 열었는데, 알고 보니 이미 지난 세션에 다 구현이 끝나 커밋까지 돼 있었다. 그래서 오늘은 새로 만드는 게 아니라 실기기에서 켜보면서 하나씩 터지는 문제를 잡는 하루였다 — 권한 문제로 아예 안 뜨던 것부터, 뜬 뒤엔 레이아웃을 대여섯 번 갈아엎은 것, 마지막엔 버튼을 눌러도 상세 화면 대신 홈으로만 떨어지는 버그의 진짜 원인을 찾는 데까지 이어졌다. 특히 마지막 버그는 안드로이드 인텐트 문제인 줄 알고 세 번을 잘못 짚었다가, 로그 두 줄을 직접 심어보고 나서야 Flutter 엔진 자체의 숨은 동작이 원인이라는 걸 알았다.

1. "이미 있는 기능인데요?" — 시작하기 전에 먼저 확인한 것

Serena로 코드베이스를 먼저 훑어보니 LockOverlayActivity.kt·LockSuggestService.kt·마이페이지 토글까지 커밋 543610d에 이미 다 들어가 있었다. 사용자에게 "이거 이미 구현돼 있는데 지금 뭘 시작하려는 거냐"고 되물었더니 "실기기 검증 안내만"이라는 답이 왔다 — 여기서부턴 새 기능 개발이 아니라 버그 픽스 세션이 됐다.

2. 화면을 켜도 오버레이가 안 뜬다 — Background Activity Launch 제한

증상

스위치를 켜면 알림("잠금화면 추천이 켜져 있어요")은 정상적으로 뜨는데, 화면을 껐다 켜도 인기 작품 오버레이는 전혀 안 보였다.

원인

Android 10(API 29)부터 백그라운드 상태의 앱이 startActivity()로 새 화면을 띄우는 건 기본 차단된다(Background Activity Launch 제한). 포그라운드 서비스가 떠 있다는 것만으로는 예외가 안 된다 — 그래서 서비스 자신이 만드는 알림은 정상 표시됐지만, 서비스 안 리시버가 부르는 startActivity()는 시스템이 조용히 무시하고 있었다.

공식 문서(developer.android.com/guide/components/activities/background-starts)에 나온 예외 조건 중 하나가 "SYSTEM_ALERT_WINDOW(다른 앱 위에 표시) 권한 보유" 였다.

해결

// MainActivity.kt
"hasOverlayPermission" -> result.success(Settings.canDrawOverlays(this))
"requestOverlayPermission" -> startActivity(
    Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:$packageName"))
        .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)

마이페이지 스위치를 켤 때 이 권한부터 확인하고, 없으면 설정 화면으로 보낸 뒤 서비스는 켜지 않게 했다(권한 없이 켜봐야 오버레이가 안 뜨는 헛켬이라). 매니페스트에 SYSTEM_ALERT_WINDOW 권한도 선언했다 — 설치 시 자동 승인 안 되는 특수 접근 권한이라 사용자가 직접 허용해야 한다.


권한을 허용하고 나니 오버레이가 바로 떴다.

3. 오버레이 레이아웃 — 대여섯 번 갈아엎은 하루

기능이 뜨기 시작하자 요청이 빠르게 이어졌다. 순서대로 겪은 것만 적는다.

순서요청결과
1말씀도 같이 보여줘하단 그라데이션 스크림 위에 텍스트 직접 그림
2버튼 문구·탭 영역 가운데 정렬버튼 내부 gravity만 손봄(위치는 그대로 하단)
3실제 작품과 해당 말씀으로baked(말씀 이미 구워진) 렌더링을 따로 캐싱해서 사용
4아니다, 배경만 쓰고 말씀은 직접 그려줘(baked 되돌리기)baked 캐싱 로직 전부 삭제, 원래 방식으로 복귀
5말씀·버튼이 합쳐져 보인다, 버튼은 원래 따로였다말씀 패널과 버튼을 별개 배경을 가진 요소로 분리
6말씀을 버튼처럼(둥근 카드) 만들지 마라카드 배경 제거, 텍스트에 그림자만 남김
7가로폭이 좁아서 말씀이 잘린다WRAP_CONTENT 부모 + MATCH_PARENT 자식 조합이 원인
8버튼은 하단에(내비게이션 바 참고), 반투명, 패딩 축소브랜드 톤 색상 + 알파, 위치 분리

이 중 3번(baked)과 7번(폭 잘림)이 각각 한 번씩 되짚어볼 만하다.

3-1. baked 이미지를 썼다가 되돌린 이유

"실제 작품과 해당 말씀으로 바꿔달라"는 요청을 받고 처음엔 직접 그린 텍스트가 그 작품의 실제 디자인(폰트·배치·색)과 다르게 보인다는 뜻으로 해석해서, 말씀이 이미 구워진 렌더링(verse-preview.jpg)을 새로 캐싱해서 쓰도록 바꿨다. PopularProductImage에 previewUrl 필드를 추가하고, home_widget_service.dart가 이미지를 두 벌(배경만/말씀 baked) 다운로드하게 했다.

그런데 바로 다음 메시지에서 "아니다, 작품 이미지만 쓰고 원래대로 말씀은 구운 것 말고 해당 말씀 가져오자"는 정정이 왔다 — 원래 방식(배경만 이미지 + 직접 그린 텍스트)이 맞다는 거였다. baked 관련 코드를 전부 되돌렸다: PopularProductImage.previewUrl 삭제, 리포지토리의 verse-preview.jpg 추가 조회 삭제, WidgetEntry.bakedImagePath/pickRandomEntryWithBakedImage 삭제. 요청을 한 번에 정확히 못 좁히고 왕복한 케이스였다.

3-2. 가로폭이 좁아서 말씀이 잘리는 버그

말씀 패널을 화면 중앙에 띄우면서 outer(WRAP_CONTENT) 안에 말씀 텍스트(LinearLayout 기본값으로 MATCH_PARENT)를 넣었더니, 실제 줄바꿈 폭이 예상보다 훨씬 좁게 계산돼 maxLines = 6에 걸려 뒷부분이 잘렸다.

// 문제였던 조합
FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT) // outer
// → 안에 MATCH_PARENT 자식을 넣어도 실제 폭 계산이 애매해짐

WRAP_CONTENT 부모 안에 MATCH_PARENT 자식을 두는 조합은 Android 레이아웃에서 폭 계산이 불안정하다. 처음 이 기능을 만들 때 썼던 방식 — 패널 자체를 MATCH_PARENT로 두고 좌우 마진(24dp)으로만 폭을 좁히는 방식 — 으로 되돌리니 바로 해결됐다.

4. 버튼을 눌러도 상세로 안 가고 홈으로만 간다

레이아웃이 다 정리된 뒤에도 가장 오래 걸린 건 이 버그였다. 세 번을 잘못 짚었다.

시도 1: PendingIntent를 위젯과 똑같이 맞춤

홈 화면 위젯 탭은 이미 정상 작동하니, 그거랑 완전히 같은 방식(HomeWidgetLaunchIntent.getActivity(...).send())으로 통일하면 될 거라 생각했다. 재현됐다.

시도 2: 태스크 어피니티 이론

위젯의 PendingIntent는 런처(다른 프로세스)가 보내는데 반해 여기는 우리 앱 자신이 같은 PendingIntent를 보낸다는 차이에 주목해서, FLAG_ACTIVITY_NEW_TASK를 명시한 startActivity()로 바꿨다. 이것도 재현됐다.

시도 3: 로그를 심어서 실측

여기서부터는 추측을 멈추고 로그로 확인하기로 했다. 안드로이드 쪽(Log.i)과 Dart _handleWidgetTap 쪽에 로그를 하나씩 심었다.

android: lock overlay openProduct: productId=495ce4ab-... uri=atelier316://product?id=495ce4ab-...
flutter:  [widget-tap] _handleWidgetTap uri=atelier316://product?id=495ce4ab-... routerReady=true

둘 다 정상이었다 — URI도 맞고, _cached.push('/product/$id')까지 확실히 호출됐다. 그런데도 여전히 홈으로 떨어졌다. 마지막으로 GoRouter의 redirect 콜백 자체에 로그를 심었다.

[redirect] matchedLocation=/product/ec2de41f-... loggedIn=true banned=false profileComplete=true
[redirect] → null
[redirect] matchedLocation=/ loggedIn=true banned=false profileComplete=true   ← 4ms 뒤
[redirect] → null

/product/...로 정상 push된 지 딱 4ms 만에, 완전히 별개의 이벤트가 위치를 /로 바꿔치기하고 있었다. redirect는 위치를 바꾸는 함수가 아니라 "현재 위치를 보고 리다이렉트할지" 판단만 하는 함수라, 두 번째 로그(matchedLocation=/)가 찍혔다는 건 그 시점에 이미 뭔가가 라우터 위치 자체를 /로 바꿔놓았다는 뜻이었다.

진짜 원인 — Flutter 자체의 자동 딥링킹

매니페스트에 로그인 콜백용 atelier316:// intent-filter(android:host="login-callback")가 있는데, 이게 있으면 Flutter 엔진이 딥링킹을 기본으로 활성화한다. 그러면 intent.data가 설정된 인텐트가 들어올 때마다:

  1. home_widget 플러그인이 수동으로 파싱해 /product/:id로 push — 정상 동작
  2. 동시에 GoRouter 내장 플랫폼 라우트 리스너도 같은 원문 URI를 자기 라우트 테이블과 매칭 시도 — 이건 우리가 요청한 적 없는 동작

atelier316://product?id=xxx를 Uri로 파싱하면 host가 "product"를 먹어버려서 path는 빈 문자열이 된다. 이건 어떤 GoRoute(/product/:id 같은 path 기반 경로)와도 매칭이 안 되고, GoRouter는 매칭 실패 시 홈(/)으로 떨어진다 — 방금 성공한 push를 4ms 뒤에 덮어쓰는 정체가 이거였다.

두 플러그인(home_widget, GoRouter 내장 딥링킹)이 같은 인텐트를 각자 다른 방식으로 처리하려다 충돌한 것 — 위젯 탭도 똑같이 intent.data를 쓰므로 잠재적으로 같은 문제를 안고 있었을 가능성이 높다.

해결

<!-- AndroidManifest.xml, MainActivity의 <activity> 안 -->
<meta-data
    android:name="flutter_deeplinking_enabled"
    android:value="false"/>

우리는 딥링크를 login-callback(app_links/supabase, 별도 채널)과 home_widget(별도 채널) 두 플러그인이 이미 수동으로 처리하고 있어서, GoRouter의 내장 플랫폼 라우트 처리는 꺼도 안전하다. 한 줄 추가로 해결됐다.

디버깅용으로 심었던 로그([redirect], [widget-tap])는 원인을 확인한 뒤 정리했다. 안드로이드 쪽 Log.i 한 줄은 가볍고 유용해서 남겨뒀다.

5. 버튼 브랜드 톤 디자인

기본 Button을 그대로 쓰다가 "우리 톤으로 바꿔달라"는 요청을 받고 Flutter theme.dart의 Tokens.primary/Cta 위젯과 값을 맞췄다.

val isNight = resources.configuration.uiMode and
    Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
val brandPrimary = if (isNight) 0xFFC26C50.toInt() else 0xFFA9583E.toInt()
background = GradientDrawable().apply {
    setColor((0xCC shl 24) or (brandPrimary and 0x00FFFFFF)) // 반투명
    cornerRadius = 14 * dp
}

라이트 #A9583E/다크 #C26C50(기기 다크모드 설정 따라 자동 선택), 모서리 14dp, 높이 52dp — Flutter Cta 위젯 스펙 그대로다. 여기에 반투명(알파 0xCC)과 좌우 패딩 축소(28dp→16dp), 위치를 화면 하단(내비게이션 바 높이를 참고한 48dp 여백)으로 옮기는 요청까지 반영했다.

screenshots/03-button-final-closeup.png

오늘 한 것 정리

  • 잠금화면 추천이 실기기에서 안 뜨던 원인(Background Activity Launch 제한) 확인 — SYSTEM_ALERT_WINDOW 권한 요청 흐름 추가(hasOverlayPermission/requestOverlayPermission)
  • 오버레이 레이아웃 8라운드 조정 — 말씀 텍스트 표시, baked 이미지 시도 후 원복, 말씀/버튼 위치 분리, 가로폭 잘림 버그(WRAP_CONTENT+MATCH_PARENT 조합 문제) 수정
  • 버튼 눌러도 상세로 안 가던 버그의 진짜 원인 규명 — flutter_deeplinking_enabled가 켜져 있어서 GoRouter 내장 플랫폼 라우트 리스너가 커스텀 스킴 URI를 오해석해 push 직후 홈으로 덮어쓰던 것. 매니페스트 메타데이터 한 줄로 해결
  • 버튼을 브랜드 톤(Flutter Tokens.primary/Cta와 동일 색상·크기)으로, 반투명·하단 배치·패딩 축소까지 반영

남은 것

출시 전 필수

  • 없음(이번 세션 범위에선 없음)

그 외

  • LockSuggestService.kt의 ponytail 코멘트대로, Play Console "권한 및 API 선언"에 이 특수 목적 포그라운드 서비스 설명 문구를 아직 안 채움 — 실제 스토어 제출 직전에 채울 것
  • 홈 화면 위젯(작품위 스타일) 탭도 flutter_deeplinking_enabled 수정의 영향을 받는지 실기기로 같이 확인 필요(같은 방식으로 intent.data를 쓰므로 잠재적으로 같은 버그가 있었을 가능성)
  • 기기 재부팅 후 서비스가 스스로 안 살아나는 것(boot-completed 리시버 없음)이 의도한 동작인지 재확인 필요
profile
https://github.com/NohYeongtaek

0개의 댓글