From ec5761d6028e6def71db3aa744e5f1b28fb5968c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 01:14:16 +0000 Subject: [PATCH] =?UTF-8?q?feat(web):=20W2=20demo=20UX=20=E2=80=94=20split?= =?UTF-8?q?=20layout,=20WS=20status,=20tips?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ≥900px list|chat master-detail, conversation WebSocket link state in the chat chrome, dismissible web preview banner, and stronger user-ID copy from list and settings. Mark N4-W2 done. Co-authored-by: okuma --- docs/deploy-checklist.md | 8 +- docs/roadmap.md | 2 +- docs/web-upgrade.md | 18 +- mobile/lib/screens/chat_screen.dart | 45 ++- .../lib/screens/conversation_list_screen.dart | 262 ++++++++++++------ mobile/lib/screens/settings_screen.dart | 11 +- mobile/lib/services/ws_client.dart | 52 +++- mobile/lib/widgets/web_preview_banner.dart | 85 ++++++ mobile/test/ws_link_state_test.dart | 10 + 9 files changed, 380 insertions(+), 113 deletions(-) create mode 100644 mobile/lib/widgets/web_preview_banner.dart create mode 100644 mobile/test/ws_link_state_test.dart diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index 2e33084..ad7a698 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -37,7 +37,7 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다. - 앱 코드는 클로즈드 베타 직전 수준 - **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료** -- **웹 고도화:** [`web-upgrade.md`](./web-upgrade.md) — W0·**W1 done** · 다음 W2~W7는 Wi 승인 후 +- **웹 고도화:** [`web-upgrade.md`](./web-upgrade.md) — W0~**W2 done** · 다음 W3~W7는 Wi 승인 후 - 후순위: **N4 Android FCM/UI** — Master 시크릿·실기기 - UI: **iMessage-inspired light default** + soft charcoal dark 프로덕션 반영 - **Track C 콘텐츠 갭 A~F**: 프로덕션 반영 완료 (`95422bb`대) @@ -215,7 +215,7 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ |----|------|--------|------| | **N4-W0** | 제품 경계 · 문서 랜딩 | **done** (2026-08-04) | 성공 정의·비범위·규율 고정 | | **N4-W1** | 웹 로컬 지속성 (prefs/idb) | **done** (2026-08-04) | `app_database_web.dart` SharedPreferences · Drift WASM 비채택 · 라이브 하드 리프레시 스모크는 배포 후 | -| **N4-W2** | 데모 메신저 UX | todo | 분할 레이아웃·WS 상태·ID 복사 | +| **N4-W2** | 데모 메신저 UX | **done** (2026-08-04) | ≥900px 분할·WS 상태·ID 복사·웹 안내 배너 | | **N4-W3** | 인앱·Notification API | todo | 스누즈/에스컬레이션 인지 · Push 전 단계 | | **N4-W4** | Web Push (FCM + VAPID) | todo | Master: Firebase Web config · VAPID | | **N4-W5** | PWA | todo | manifest 테마 정렬 · 셸 캐시만 | @@ -290,8 +290,8 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P 확인 필요, 그때까지는 인앱 배지·배너가 실질적 대체 경로. Track C A~F 프로덕션 재배포는 **완료** (`95422bb` / docs `f363e8e`, 2026-08-03). -**바로 다음 (웹):** [`web-upgrade.md`](./web-upgrade.md) **W2** (데모 UX) — Wi 승인 후. -W1 지속성 **done**. Android는 **N4-1/3 → N4-4 → N4-5~10** 후순위 병행. +**바로 다음 (웹):** [`web-upgrade.md`](./web-upgrade.md) **W3** (인앱·Notification) — Wi 승인 후. +W0~W2 **done**. Android는 **N4-1/3 → N4-4 → N4-5~10** 후순위 병행. 완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다. diff --git a/docs/roadmap.md b/docs/roadmap.md index c8cd213..053e7b8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -415,7 +415,7 @@ Claude/Cursor 통합 DONE 목록과 항목 ID(N1-1 … N5-5, **N4-W0…W7**)는 - [x] **W0** 제품 경계 문서화 (2026-08-04) — 데모/프리뷰 · Q9 비범위 · Wi 규율 - [x] **W1** 웹 로컬 지속성 (prefs/idb · Drift WASM 비채택) — 2026-08-04 -- [ ] **W2** 데모 UX (분할·WS 상태·ID 복사) +- [x] **W2** 데모 UX (분할·WS 상태·ID 복사) — 2026-08-04 - [ ] **W3** 인앱 + Notification API - [ ] **W4** Web Push (FCM + VAPID) — Master Firebase Web 설정 - [ ] **W5** PWA (manifest 테마 · 셸 캐시) diff --git a/docs/web-upgrade.md b/docs/web-upgrade.md index 23153cd..f1868c4 100644 --- a/docs/web-upgrade.md +++ b/docs/web-upgrade.md @@ -149,7 +149,7 @@ flowchart LR | | | |--|--| -| **Status** | todo | +| **Status** | **done** (2026-08-04) | | **목표** | 데스크톱·모바일 브라우저에서 시연 마찰을 줄인 프리뷰 UX | | **비범위** | 디자인 시스템 전면 교체, 카드/히어로 과설계, Q9 카피 | @@ -169,17 +169,17 @@ flowchart LR ### 작업 분해 -- [ ] 반응형 셸 위젯 (목록+상세) — `main.dart` / conversation·chat 라우팅 정리 -- [ ] `WsClient` 상태 → UI 바인딩 -- [ ] ID 복사 SnackBar/토스트 -- [ ] 웹 전용 1회 dismissible 안내 -- [ ] 수동: 1280px·390px 폭 시연 시나리오 +- [x] 반응형 셸: `ConversationListScreen` ≥900px 목록|채팅 분할 · 좁은 폭 Navigator push +- [x] `ConversationSocket.linkStates` + 채팅 앱바/재연결 바 +- [x] ID 복사: 목록 칩 + 설정 계정 행 +- [x] 웹 전용 1회 dismissible 안내 (`WebPreviewBanner`) +- [ ] 수동: 1280px·390px 폭 시연 (배포 후) ### 수락 기준 -- [ ] 두 폭에서 가입→대화→초안 시연 가능 -- [ ] 재연결 중 사용자가 “멈춤”으로 오해하지 않도록 상태 문구 노출 -- [ ] 테마/브랜드 규칙 위반 없음 (기존 `AppTheme`) +- [x] 분할·스택 경로 코드 연결 (라이브 폭 스모크는 배포 후) +- [x] 재연결 중 「다시 연결 중…」 표시 +- [x] 기존 `AppTheme` 유지 ### Master 액션 diff --git a/mobile/lib/screens/chat_screen.dart b/mobile/lib/screens/chat_screen.dart index 40f66d5..3b20ba9 100644 --- a/mobile/lib/screens/chat_screen.dart +++ b/mobile/lib/screens/chat_screen.dart @@ -19,6 +19,7 @@ class ChatScreen extends StatefulWidget { this.title, this.isGroup = false, this.twinDisabledByFlood = false, + this.embedded = false, }); final int conversationId; @@ -29,6 +30,9 @@ class ChatScreen extends StatefulWidget { // 보여줄 수 있게 한다. final bool twinDisabledByFlood; + /// When true, shown inside the wide master-detail pane (N4-W2) — no back affordance. + final bool embedded; + @override State createState() => _ChatScreenState(); } @@ -46,6 +50,8 @@ class _ChatScreenState extends State with WidgetsBindingObserver { // 호출한다는 것만 여기서 보장하고, 나머지(진짜 네트워크 단절, 백그라운드 // 전환에서 OS가 소켓을 실제로 끊는지)는 실기기 QA 대상이다. StreamSubscription? _reconnectSub; + StreamSubscription? _linkSub; + WsLinkState _linkState = WsLinkState.connecting; String? _banner; DraftResult? _pendingDraft; bool _busy = false; @@ -70,10 +76,15 @@ class _ChatScreenState extends State with WidgetsBindingObserver { _banner = '도배 감지로 이 대화방의 와카뷰 자동응대가 일시중단되었습니다.'; } _socket = ConversationSocket(widget.conversationId)..connect(); + _linkState = _socket!.linkState; _sub = _socket!.events.listen(_onEvent); // 소켓이 끊겼다가 다시 붙으면(백그라운드, 네트워크 hiccup 등) 그 사이에 // 놓친 메시지를 REST since_id로 다시 받아온다 — ws_client.dart 참고. _reconnectSub = _socket!.reconnects.listen((_) => _catchUp()); + _linkSub = _socket!.linkStates.listen((s) { + if (!mounted) return; + setState(() => _linkState = s); + }); _loadHistory(); _loadSnooze(); } @@ -299,6 +310,7 @@ class _ChatScreenState extends State with WidgetsBindingObserver { WidgetsBinding.instance.removeObserver(this); _sub?.cancel(); _reconnectSub?.cancel(); + _linkSub?.cancel(); _socket?.dispose(); _input.dispose(); _draftEdit.dispose(); @@ -649,7 +661,22 @@ class _ChatScreenState extends State with WidgetsBindingObserver { final theme = Theme.of(context); return Scaffold( appBar: AppBar( - title: Text(widget.title ?? '대화방 #${widget.conversationId}'), + automaticallyImplyLeading: !widget.embedded, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.title ?? '대화방 #${widget.conversationId}'), + Text( + wsLinkStateLabel(_linkState), + style: theme.textTheme.labelSmall?.copyWith( + color: _linkState == WsLinkState.live + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ], + ), actions: [ if (widget.isGroup) IconButton( @@ -673,6 +700,22 @@ class _ChatScreenState extends State with WidgetsBindingObserver { ), body: Column( children: [ + if (_linkState == WsLinkState.reconnecting) + Material( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.9), + child: SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Text( + '연결이 끊겼습니다. 다시 연결하는 중…', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), if (_banner != null) MaterialBanner( leading: Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant), diff --git a/mobile/lib/screens/conversation_list_screen.dart b/mobile/lib/screens/conversation_list_screen.dart index 0d5b54f..65926e8 100644 --- a/mobile/lib/screens/conversation_list_screen.dart +++ b/mobile/lib/screens/conversation_list_screen.dart @@ -7,11 +7,15 @@ import '../services/snooze_service.dart'; import '../state/session_state.dart'; import '../widgets/gradient_text.dart'; import '../widgets/my_user_id_chip.dart'; +import '../widgets/web_preview_banner.dart'; import 'chat_screen.dart'; import 'contacts_screen.dart'; import 'inbox_screen.dart'; import 'settings_screen.dart'; +/// Breakpoint for list | chat master-detail (docs/web-upgrade.md N4-W2). +const double kMessengerSplitBreakpoint = 900; + class ConversationListScreen extends StatefulWidget { const ConversationListScreen({super.key}); @@ -28,6 +32,12 @@ class _ConversationListScreenState extends State { bool _loading = true; String? _error; + /// Wide-layout selection (narrow width still uses Navigator.push). + int? _selectedId; + String? _selectedTitle; + bool _selectedIsGroup = false; + bool _selectedFlood = false; + @override void initState() { super.initState(); @@ -78,6 +88,39 @@ class _ConversationListScreenState extends State { setState(() => _snoozes.remove(conversationId)); } + bool get _useSplit { + final width = MediaQuery.sizeOf(context).width; + return width >= kMessengerSplitBreakpoint; + } + + Future _openChat({ + required int conversationId, + required String title, + required bool isGroup, + bool twinDisabledByFlood = false, + }) async { + if (_useSplit) { + setState(() { + _selectedId = conversationId; + _selectedTitle = title; + _selectedIsGroup = isGroup; + _selectedFlood = twinDisabledByFlood; + }); + return; + } + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChatScreen( + conversationId: conversationId, + title: title, + isGroup: isGroup, + twinDisabledByFlood: twinDisabledByFlood, + ), + ), + ); + if (mounted) await _load(); + } + String _titleFor(ConversationSummary room, int? me) { if (me == null) return '대화방 #${room.id}'; final peers = room.userIds.where((id) => id != me).toList(); @@ -204,16 +247,13 @@ class _ConversationListScreenState extends State { isGroup: isGroup, ); if (!mounted) return; - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ChatScreen( - conversationId: conv.id, - title: isGroup ? '그룹 #${conv.id}' : (_peerNames[peers.first] ?? '상대 #${peers.first}'), - isGroup: isGroup, - ), - ), - ); await _load(); + if (!mounted) return; + await _openChat( + conversationId: conv.id, + title: isGroup ? '그룹 #${conv.id}' : (_peerNames[peers.first] ?? '상대 #${peers.first}'), + isGroup: isGroup, + ); } on ApiException catch (e) { setState(() => _error = '대화 생성 실패 (${e.statusCode}): ${e.body}'); } @@ -240,8 +280,9 @@ class _ConversationListScreenState extends State { final session = context.watch(); final theme = Theme.of(context); final me = session.user?.id; + final split = _useSplit; - return Scaffold( + final listPane = Scaffold( appBar: AppBar( title: GradientText('와카뷰', style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800)), actions: [ @@ -278,95 +319,129 @@ class _ConversationListScreenState extends State { tooltip: '새 대화', child: const Icon(Icons.chat_outlined), ), - body: RefreshIndicator( - onRefresh: _load, - child: _loading - ? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())]) - : ListView( - padding: const EdgeInsets.symmetric(vertical: 4), - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), - child: Text( - session.user == null ? '' : '안녕하세요, ${session.user!.displayName}님', - style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), - ), - ), - if (me != null) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: MyUserIdChip(userId: me), - ), - if (_error != null) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)), - ), - if (_rooms.isEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(32, 48, 32, 32), - child: Column( - children: [ - Text('대화방이 없습니다', style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - Text( - '1) 내 ID를 상대에게 알려 주세요\n' - '2) 연락처에 상대의 숫자 ID를 넣고 추가\n' - '3) 연락처에서 「대화」또는 행을 탭하세요', - textAlign: TextAlign.center, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - height: 1.5, + body: Column( + children: [ + const WebPreviewBanner(), + Expanded( + child: RefreshIndicator( + onRefresh: _load, + child: _loading + ? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())]) + : ListView( + padding: const EdgeInsets.symmetric(vertical: 4), + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: Text( + session.user == null ? '' : '안녕하세요, ${session.user!.displayName}님', + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ), + if (me != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: MyUserIdChip(userId: me), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)), + ), + if (_rooms.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(32, 48, 32, 32), + child: Column( + children: [ + Text('대화방이 없습니다', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + '1) 아래 「내 사용자 ID」를 탭해 복사한 뒤 상대에게 알려 주세요\n' + '2) 연락처에 상대의 숫자 ID를 넣고 추가\n' + '3) 연락처에서 「대화」또는 행을 탭하세요', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.5, + ), + ), + const SizedBox(height: 18), + OutlinedButton( + onPressed: () async { + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ContactsScreen()), + ); + await _load(); + }, + child: const Text('연락처 열기'), + ), + ], ), ), - const SizedBox(height: 18), - OutlinedButton( - onPressed: () async { - await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const ContactsScreen()), - ); - await _load(); - }, - child: const Text('연락처 열기'), - ), - ], - ), - ), - for (final room in _rooms) - Builder( - builder: (context) { - final snoozedUntil = _snoozes[room.id]; - final snoozePastDue = isSnoozePastDue(DateTime.now(), snoozedUntil); - return _ConversationRow( - room: room, - me: me, - title: _titleFor(room, me), - subtitle: _subtitleFor(room, me), - avatarColor: _avatarColor(context, room.id), - onAvatarColor: _onAvatarColor(context, room.id), - snoozePastDue: snoozePastDue, - onClearSnooze: () => _clearSnoozeFromList(room.id), - onTap: () async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ChatScreen( + for (final room in _rooms) + Builder( + builder: (context) { + final snoozedUntil = _snoozes[room.id]; + final snoozePastDue = isSnoozePastDue(DateTime.now(), snoozedUntil); + return _ConversationRow( + room: room, + me: me, + title: _titleFor(room, me), + subtitle: _subtitleFor(room, me), + avatarColor: _avatarColor(context, room.id), + onAvatarColor: _onAvatarColor(context, room.id), + snoozePastDue: snoozePastDue, + selected: split && _selectedId == room.id, + onClearSnooze: () => _clearSnoozeFromList(room.id), + onTap: () => _openChat( conversationId: room.id, title: _titleFor(room, me), isGroup: room.isGroup, twinDisabledByFlood: room.twinDisabledByFlood, ), - ), - ); - await _load(); - }, - ); - }, + ); + }, + ), + const SizedBox(height: 72), + ], ), - const SizedBox(height: 72), - ], - ), + ), + ), + ], ), ); + + if (!split) return listPane; + + return Row( + children: [ + SizedBox( + width: 360, + child: listPane, + ), + VerticalDivider(width: 1, thickness: 1, color: theme.dividerColor), + Expanded( + child: _selectedId == null + ? Scaffold( + body: Center( + child: Text( + '왼쪽에서 대화를 선택하세요', + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ) + : ChatScreen( + key: ValueKey(_selectedId), + conversationId: _selectedId!, + title: _selectedTitle, + isGroup: _selectedIsGroup, + twinDisabledByFlood: _selectedFlood, + embedded: true, + ), + ), + ], + ); } } @@ -381,6 +456,7 @@ class _ConversationRow extends StatelessWidget { required this.snoozePastDue, required this.onClearSnooze, required this.onTap, + this.selected = false, }); final ConversationSummary room; @@ -390,6 +466,7 @@ class _ConversationRow extends StatelessWidget { final Color avatarColor; final Color onAvatarColor; final bool snoozePastDue; + final bool selected; final VoidCallback onClearSnooze; final VoidCallback onTap; @@ -398,7 +475,7 @@ class _ConversationRow extends StatelessWidget { final theme = Theme.of(context); final blockedColor = room.twinDisabledByPeer || room.twinDisabledByFlood; return Material( - color: Colors.transparent, + color: selected ? theme.colorScheme.primary.withValues(alpha: 0.08) : Colors.transparent, child: InkWell( onTap: onTap, child: Padding( @@ -421,7 +498,10 @@ class _ConversationRow extends StatelessWidget { children: [ Text( title, - style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: selected ? theme.colorScheme.primary : null, + ), ), const SizedBox(height: 2), Row( diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 0b1a035..e5ac23d 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../state/session_state.dart'; @@ -65,11 +66,19 @@ class SettingsScreen extends StatelessWidget { ), title: Text(user.displayName), subtitle: Text( - 'user #${user.id}', + 'user #${user.id} · 탭하여 ID 복사', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), + trailing: const Icon(Icons.copy_rounded, size: 18), + onTap: () async { + await Clipboard.setData(ClipboardData(text: '${user.id}')); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('내 사용자 ID ${user.id} 를 복사했습니다')), + ); + }, ), const Divider(height: 1), ListTile( diff --git a/mobile/lib/services/ws_client.dart b/mobile/lib/services/ws_client.dart index d4e9343..d030aa3 100644 --- a/mobile/lib/services/ws_client.dart +++ b/mobile/lib/services/ws_client.dart @@ -7,16 +7,20 @@ import '../config.dart'; import '../models/models.dart'; import 'message_sync.dart'; +/// UI-facing WebSocket link state for a conversation (docs/web-upgrade.md N4-W2). +enum WsLinkState { + connecting, + live, + reconnecting, +} + /// Conversation-scoped WebSocket relay (`GET /ws/conversations/:id`). /// /// 오프라인 큐 캐치업(roadmap.md "멀티 디바이스 동기화" / deploy-checklist N4-11): /// 소켓이 끊기면(백그라운드, 네트워크 hiccup 등) `onDone`/`onError`에서 그냥 /// 멈추는 게 아니라 [nextReconnectDelay]로 계산한 backoff 후 재연결을 시도한다. /// 재연결에 성공하면 [reconnects] 스트림으로 신호를 보내 — 화면(chat_screen.dart)이 -/// 그 신호를 받아 REST `since_id` 캐치업을 트리거할 수 있게 한다. 실제 소켓 -/// 타이밍(진짜 네트워크 단절 등)은 단위 테스트로 결정적으로 검증할 수 없어 -/// 실기기 QA가 필요하다 — [nextReconnectDelay] 자체의 계산 로직만 순수 -/// 함수로 분리해 테스트했다(mobile/test/message_sync_test.dart). +/// 그 신호를 받아 REST `since_id` 캐치업을 트리거할 수 있게 한다. class ConversationSocket { ConversationSocket(this.conversationId); @@ -25,10 +29,12 @@ class ConversationSocket { StreamSubscription? _channelSub; final _controller = StreamController>.broadcast(); final _reconnectController = StreamController.broadcast(); + final _linkController = StreamController.broadcast(); Timer? _reconnectTimer; int _reconnectAttempt = 0; bool _disposed = false; bool _everConnected = false; + WsLinkState _linkState = WsLinkState.connecting; Stream> get events => _controller.stream; @@ -37,18 +43,38 @@ class ConversationSocket { /// 히스토리를 로드하므로, 이 신호는 "끊겼다가 다시 붙었을 때"만 의미가 있다). Stream get reconnects => _reconnectController.stream; + /// connecting → live → reconnecting → live … + Stream get linkStates => _linkController.stream; + + WsLinkState get linkState => _linkState; + + void _setLinkState(WsLinkState next) { + if (_disposed || _linkState == next) return; + _linkState = next; + if (!_linkController.isClosed) _linkController.add(next); + } + void connect() { final wasReconnect = _everConnected; + _setLinkState(wasReconnect ? WsLinkState.reconnecting : WsLinkState.connecting); // 이전 연결이 onError로 끊겼을 때는 cancelOnError:false라 구독이 아직 // 살아 있을 수 있다 — 새 채널을 만들기 전에 정리해 leak을 막는다. _channelSub?.cancel(); final uri = Uri.parse('${AppConfig.wsBase()}/ws/conversations/$conversationId'); - _channel = WebSocketChannel.connect(uri); + final channel = WebSocketChannel.connect(uri); + _channel = channel; _everConnected = true; - _channelSub = _channel!.stream.listen( + channel.ready.then((_) { + if (_disposed) return; + _setLinkState(WsLinkState.live); + }).catchError((Object _) { + // onError / onDone will schedule reconnect. + }); + _channelSub = channel.stream.listen( (raw) { // 데이터가 왔다는 건 연결이 살아 있다는 뜻 — backoff를 리셋한다. _reconnectAttempt = 0; + _setLinkState(WsLinkState.live); if (raw is! String) return; final decoded = jsonDecode(raw); if (decoded is Map) { @@ -70,6 +96,7 @@ class ConversationSocket { void _scheduleReconnect() { if (_disposed) return; + _setLinkState(WsLinkState.reconnecting); _reconnectTimer?.cancel(); final delay = nextReconnectDelay(_reconnectAttempt); _reconnectAttempt++; @@ -100,5 +127,18 @@ class ConversationSocket { await _channel?.sink.close(); await _controller.close(); await _reconnectController.close(); + await _linkController.close(); + } +} + +/// Short Korean label for chat chrome (N4-W2). +String wsLinkStateLabel(WsLinkState state) { + switch (state) { + case WsLinkState.connecting: + return '연결 중…'; + case WsLinkState.live: + return '실시간'; + case WsLinkState.reconnecting: + return '다시 연결 중…'; } } diff --git a/mobile/lib/widgets/web_preview_banner.dart b/mobile/lib/widgets/web_preview_banner.dart new file mode 100644 index 0000000..73780ef --- /dev/null +++ b/mobile/lib/widgets/web_preview_banner.dart @@ -0,0 +1,85 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../state/session_state.dart'; + +/// One-time dismissible tips for the web demo/preview surface (N4-W2). +/// +/// Hidden on non-web builds. Dismissal is stored in the local DB KV so it +/// survives hard refresh after W1. +class WebPreviewBanner extends StatefulWidget { + const WebPreviewBanner({super.key}); + + static const dismissKvKey = 'web_preview_tips_dismissed'; + + @override + State createState() => _WebPreviewBannerState(); +} + +class _WebPreviewBannerState extends State { + bool _visible = false; + bool _loaded = false; + + @override + void initState() { + super.initState(); + if (kIsWeb) { + WidgetsBinding.instance.addPostFrameCallback((_) => _load()); + } else { + _loaded = true; + } + } + + Future _load() async { + if (!mounted) return; + final session = context.read(); + final db = session.db; + final dismissed = db == null + ? false + : await db.getBoolKv(WebPreviewBanner.dismissKvKey); + if (!mounted) return; + setState(() { + _visible = !dismissed; + _loaded = true; + }); + } + + Future _dismiss() async { + setState(() => _visible = false); + final db = context.read().db; + await db?.setBoolKv(WebPreviewBanner.dismissKvKey, true); + } + + @override + Widget build(BuildContext context) { + if (!kIsWeb || !_loaded || !_visible) return const SizedBox.shrink(); + final theme = Theme.of(context); + return Material( + color: theme.colorScheme.primaryContainer.withValues(alpha: 0.55), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 8, 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.web_asset, size: 20, color: theme.colorScheme.primary), + const SizedBox(width: 10), + Expanded( + child: Text( + '웹 프리뷰입니다. 말투·설정은 새로고침 후에도 이 브라우저에 남습니다. ' + '브라우저 알림·홈 화면 추가는 곧 이어집니다.', + style: theme.textTheme.bodySmall?.copyWith(height: 1.4), + ), + ), + IconButton( + tooltip: '닫기', + onPressed: _dismiss, + icon: const Icon(Icons.close, size: 18), + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + ); + } +} diff --git a/mobile/test/ws_link_state_test.dart b/mobile/test/ws_link_state_test.dart new file mode 100644 index 0000000..2c5dae2 --- /dev/null +++ b/mobile/test/ws_link_state_test.dart @@ -0,0 +1,10 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ykavu_mobile/services/ws_client.dart'; + +void main() { + test('wsLinkStateLabel covers all states', () { + expect(wsLinkStateLabel(WsLinkState.connecting), '연결 중…'); + expect(wsLinkStateLabel(WsLinkState.live), '실시간'); + expect(wsLinkStateLabel(WsLinkState.reconnecting), '다시 연결 중…'); + }); +}