feat(web): W2 demo UX — split layout, WS status, tips
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 <o0kuma@users.noreply.github.com>
This commit is contained in:
parent
866f36e319
commit
ec5761d602
|
|
@ -37,7 +37,7 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
|
||||||
|
|
||||||
- 앱 코드는 클로즈드 베타 직전 수준
|
- 앱 코드는 클로즈드 베타 직전 수준
|
||||||
- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료**
|
- **`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 시크릿·실기기
|
- 후순위: **N4 Android FCM/UI** — Master 시크릿·실기기
|
||||||
- UI: **iMessage-inspired light default** + soft charcoal dark 프로덕션 반영
|
- UI: **iMessage-inspired light default** + soft charcoal dark 프로덕션 반영
|
||||||
- **Track C 콘텐츠 갭 A~F**: 프로덕션 반영 완료 (`95422bb`대)
|
- **Track C 콘텐츠 갭 A~F**: 프로덕션 반영 완료 (`95422bb`대)
|
||||||
|
|
@ -215,7 +215,7 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/
|
||||||
|----|------|--------|------|
|
|----|------|--------|------|
|
||||||
| **N4-W0** | 제품 경계 · 문서 랜딩 | **done** (2026-08-04) | 성공 정의·비범위·규율 고정 |
|
| **N4-W0** | 제품 경계 · 문서 랜딩 | **done** (2026-08-04) | 성공 정의·비범위·규율 고정 |
|
||||||
| **N4-W1** | 웹 로컬 지속성 (prefs/idb) | **done** (2026-08-04) | `app_database_web.dart` SharedPreferences · Drift WASM 비채택 · 라이브 하드 리프레시 스모크는 배포 후 |
|
| **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-W3** | 인앱·Notification API | todo | 스누즈/에스컬레이션 인지 · Push 전 단계 |
|
||||||
| **N4-W4** | Web Push (FCM + VAPID) | todo | Master: Firebase Web config · VAPID |
|
| **N4-W4** | Web Push (FCM + VAPID) | todo | Master: Firebase Web config · VAPID |
|
||||||
| **N4-W5** | PWA | todo | manifest 테마 정렬 · 셸 캐시만 |
|
| **N4-W5** | PWA | todo | manifest 테마 정렬 · 셸 캐시만 |
|
||||||
|
|
@ -290,8 +290,8 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P
|
||||||
확인 필요, 그때까지는 인앱 배지·배너가 실질적 대체 경로. Track C A~F 프로덕션
|
확인 필요, 그때까지는 인앱 배지·배너가 실질적 대체 경로. Track C A~F 프로덕션
|
||||||
재배포는 **완료** (`95422bb` / docs `f363e8e`, 2026-08-03).
|
재배포는 **완료** (`95422bb` / docs `f363e8e`, 2026-08-03).
|
||||||
|
|
||||||
**바로 다음 (웹):** [`web-upgrade.md`](./web-upgrade.md) **W2** (데모 UX) — Wi 승인 후.
|
**바로 다음 (웹):** [`web-upgrade.md`](./web-upgrade.md) **W3** (인앱·Notification) — Wi 승인 후.
|
||||||
W1 지속성 **done**. Android는 **N4-1/3 → N4-4 → N4-5~10** 후순위 병행.
|
W0~W2 **done**. Android는 **N4-1/3 → N4-4 → N4-5~10** 후순위 병행.
|
||||||
|
|
||||||
|
|
||||||
완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.
|
완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.
|
||||||
|
|
|
||||||
|
|
@ -415,7 +415,7 @@ Claude/Cursor 통합 DONE 목록과 항목 ID(N1-1 … N5-5, **N4-W0…W7**)는
|
||||||
|
|
||||||
- [x] **W0** 제품 경계 문서화 (2026-08-04) — 데모/프리뷰 · Q9 비범위 · Wi 규율
|
- [x] **W0** 제품 경계 문서화 (2026-08-04) — 데모/프리뷰 · Q9 비범위 · Wi 규율
|
||||||
- [x] **W1** 웹 로컬 지속성 (prefs/idb · Drift WASM 비채택) — 2026-08-04
|
- [x] **W1** 웹 로컬 지속성 (prefs/idb · Drift WASM 비채택) — 2026-08-04
|
||||||
- [ ] **W2** 데모 UX (분할·WS 상태·ID 복사)
|
- [x] **W2** 데모 UX (분할·WS 상태·ID 복사) — 2026-08-04
|
||||||
- [ ] **W3** 인앱 + Notification API
|
- [ ] **W3** 인앱 + Notification API
|
||||||
- [ ] **W4** Web Push (FCM + VAPID) — Master Firebase Web 설정
|
- [ ] **W4** Web Push (FCM + VAPID) — Master Firebase Web 설정
|
||||||
- [ ] **W5** PWA (manifest 테마 · 셸 캐시)
|
- [ ] **W5** PWA (manifest 테마 · 셸 캐시)
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,7 @@ flowchart LR
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|--|--|
|
|--|--|
|
||||||
| **Status** | todo |
|
| **Status** | **done** (2026-08-04) |
|
||||||
| **목표** | 데스크톱·모바일 브라우저에서 시연 마찰을 줄인 프리뷰 UX |
|
| **목표** | 데스크톱·모바일 브라우저에서 시연 마찰을 줄인 프리뷰 UX |
|
||||||
| **비범위** | 디자인 시스템 전면 교체, 카드/히어로 과설계, Q9 카피 |
|
| **비범위** | 디자인 시스템 전면 교체, 카드/히어로 과설계, Q9 카피 |
|
||||||
|
|
||||||
|
|
@ -169,17 +169,17 @@ flowchart LR
|
||||||
|
|
||||||
### 작업 분해
|
### 작업 분해
|
||||||
|
|
||||||
- [ ] 반응형 셸 위젯 (목록+상세) — `main.dart` / conversation·chat 라우팅 정리
|
- [x] 반응형 셸: `ConversationListScreen` ≥900px 목록|채팅 분할 · 좁은 폭 Navigator push
|
||||||
- [ ] `WsClient` 상태 → UI 바인딩
|
- [x] `ConversationSocket.linkStates` + 채팅 앱바/재연결 바
|
||||||
- [ ] ID 복사 SnackBar/토스트
|
- [x] ID 복사: 목록 칩 + 설정 계정 행
|
||||||
- [ ] 웹 전용 1회 dismissible 안내
|
- [x] 웹 전용 1회 dismissible 안내 (`WebPreviewBanner`)
|
||||||
- [ ] 수동: 1280px·390px 폭 시연 시나리오
|
- [ ] 수동: 1280px·390px 폭 시연 (배포 후)
|
||||||
|
|
||||||
### 수락 기준
|
### 수락 기준
|
||||||
|
|
||||||
- [ ] 두 폭에서 가입→대화→초안 시연 가능
|
- [x] 분할·스택 경로 코드 연결 (라이브 폭 스모크는 배포 후)
|
||||||
- [ ] 재연결 중 사용자가 “멈춤”으로 오해하지 않도록 상태 문구 노출
|
- [x] 재연결 중 「다시 연결 중…」 표시
|
||||||
- [ ] 테마/브랜드 규칙 위반 없음 (기존 `AppTheme`)
|
- [x] 기존 `AppTheme` 유지
|
||||||
|
|
||||||
### Master 액션
|
### Master 액션
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ class ChatScreen extends StatefulWidget {
|
||||||
this.title,
|
this.title,
|
||||||
this.isGroup = false,
|
this.isGroup = false,
|
||||||
this.twinDisabledByFlood = false,
|
this.twinDisabledByFlood = false,
|
||||||
|
this.embedded = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int conversationId;
|
final int conversationId;
|
||||||
|
|
@ -29,6 +30,9 @@ class ChatScreen extends StatefulWidget {
|
||||||
// 보여줄 수 있게 한다.
|
// 보여줄 수 있게 한다.
|
||||||
final bool twinDisabledByFlood;
|
final bool twinDisabledByFlood;
|
||||||
|
|
||||||
|
/// When true, shown inside the wide master-detail pane (N4-W2) — no back affordance.
|
||||||
|
final bool embedded;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChatScreen> createState() => _ChatScreenState();
|
State<ChatScreen> createState() => _ChatScreenState();
|
||||||
}
|
}
|
||||||
|
|
@ -46,6 +50,8 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
// 호출한다는 것만 여기서 보장하고, 나머지(진짜 네트워크 단절, 백그라운드
|
// 호출한다는 것만 여기서 보장하고, 나머지(진짜 네트워크 단절, 백그라운드
|
||||||
// 전환에서 OS가 소켓을 실제로 끊는지)는 실기기 QA 대상이다.
|
// 전환에서 OS가 소켓을 실제로 끊는지)는 실기기 QA 대상이다.
|
||||||
StreamSubscription? _reconnectSub;
|
StreamSubscription? _reconnectSub;
|
||||||
|
StreamSubscription? _linkSub;
|
||||||
|
WsLinkState _linkState = WsLinkState.connecting;
|
||||||
String? _banner;
|
String? _banner;
|
||||||
DraftResult? _pendingDraft;
|
DraftResult? _pendingDraft;
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
|
|
@ -70,10 +76,15 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
_banner = '도배 감지로 이 대화방의 와카뷰 자동응대가 일시중단되었습니다.';
|
_banner = '도배 감지로 이 대화방의 와카뷰 자동응대가 일시중단되었습니다.';
|
||||||
}
|
}
|
||||||
_socket = ConversationSocket(widget.conversationId)..connect();
|
_socket = ConversationSocket(widget.conversationId)..connect();
|
||||||
|
_linkState = _socket!.linkState;
|
||||||
_sub = _socket!.events.listen(_onEvent);
|
_sub = _socket!.events.listen(_onEvent);
|
||||||
// 소켓이 끊겼다가 다시 붙으면(백그라운드, 네트워크 hiccup 등) 그 사이에
|
// 소켓이 끊겼다가 다시 붙으면(백그라운드, 네트워크 hiccup 등) 그 사이에
|
||||||
// 놓친 메시지를 REST since_id로 다시 받아온다 — ws_client.dart 참고.
|
// 놓친 메시지를 REST since_id로 다시 받아온다 — ws_client.dart 참고.
|
||||||
_reconnectSub = _socket!.reconnects.listen((_) => _catchUp());
|
_reconnectSub = _socket!.reconnects.listen((_) => _catchUp());
|
||||||
|
_linkSub = _socket!.linkStates.listen((s) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _linkState = s);
|
||||||
|
});
|
||||||
_loadHistory();
|
_loadHistory();
|
||||||
_loadSnooze();
|
_loadSnooze();
|
||||||
}
|
}
|
||||||
|
|
@ -299,6 +310,7 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_sub?.cancel();
|
_sub?.cancel();
|
||||||
_reconnectSub?.cancel();
|
_reconnectSub?.cancel();
|
||||||
|
_linkSub?.cancel();
|
||||||
_socket?.dispose();
|
_socket?.dispose();
|
||||||
_input.dispose();
|
_input.dispose();
|
||||||
_draftEdit.dispose();
|
_draftEdit.dispose();
|
||||||
|
|
@ -649,7 +661,22 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
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: [
|
actions: [
|
||||||
if (widget.isGroup)
|
if (widget.isGroup)
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|
@ -673,6 +700,22 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
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)
|
if (_banner != null)
|
||||||
MaterialBanner(
|
MaterialBanner(
|
||||||
leading: Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant),
|
leading: Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,15 @@ import '../services/snooze_service.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import '../widgets/gradient_text.dart';
|
import '../widgets/gradient_text.dart';
|
||||||
import '../widgets/my_user_id_chip.dart';
|
import '../widgets/my_user_id_chip.dart';
|
||||||
|
import '../widgets/web_preview_banner.dart';
|
||||||
import 'chat_screen.dart';
|
import 'chat_screen.dart';
|
||||||
import 'contacts_screen.dart';
|
import 'contacts_screen.dart';
|
||||||
import 'inbox_screen.dart';
|
import 'inbox_screen.dart';
|
||||||
import 'settings_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 {
|
class ConversationListScreen extends StatefulWidget {
|
||||||
const ConversationListScreen({super.key});
|
const ConversationListScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -28,6 +32,12 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
|
/// Wide-layout selection (narrow width still uses Navigator.push).
|
||||||
|
int? _selectedId;
|
||||||
|
String? _selectedTitle;
|
||||||
|
bool _selectedIsGroup = false;
|
||||||
|
bool _selectedFlood = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -78,6 +88,39 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
setState(() => _snoozes.remove(conversationId));
|
setState(() => _snoozes.remove(conversationId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get _useSplit {
|
||||||
|
final width = MediaQuery.sizeOf(context).width;
|
||||||
|
return width >= kMessengerSplitBreakpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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) {
|
String _titleFor(ConversationSummary room, int? me) {
|
||||||
if (me == null) return '대화방 #${room.id}';
|
if (me == null) return '대화방 #${room.id}';
|
||||||
final peers = room.userIds.where((id) => id != me).toList();
|
final peers = room.userIds.where((id) => id != me).toList();
|
||||||
|
|
@ -204,16 +247,13 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
isGroup: isGroup,
|
isGroup: isGroup,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await Navigator.of(context).push(
|
await _load();
|
||||||
MaterialPageRoute(
|
if (!mounted) return;
|
||||||
builder: (_) => ChatScreen(
|
await _openChat(
|
||||||
conversationId: conv.id,
|
conversationId: conv.id,
|
||||||
title: isGroup ? '그룹 #${conv.id}' : (_peerNames[peers.first] ?? '상대 #${peers.first}'),
|
title: isGroup ? '그룹 #${conv.id}' : (_peerNames[peers.first] ?? '상대 #${peers.first}'),
|
||||||
isGroup: isGroup,
|
isGroup: isGroup,
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
await _load();
|
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
setState(() => _error = '대화 생성 실패 (${e.statusCode}): ${e.body}');
|
setState(() => _error = '대화 생성 실패 (${e.statusCode}): ${e.body}');
|
||||||
}
|
}
|
||||||
|
|
@ -240,8 +280,9 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
final session = context.watch<SessionState>();
|
final session = context.watch<SessionState>();
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final me = session.user?.id;
|
final me = session.user?.id;
|
||||||
|
final split = _useSplit;
|
||||||
|
|
||||||
return Scaffold(
|
final listPane = Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: GradientText('와카뷰', style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800)),
|
title: GradientText('와카뷰', style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800)),
|
||||||
actions: [
|
actions: [
|
||||||
|
|
@ -278,7 +319,11 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
tooltip: '새 대화',
|
tooltip: '새 대화',
|
||||||
child: const Icon(Icons.chat_outlined),
|
child: const Icon(Icons.chat_outlined),
|
||||||
),
|
),
|
||||||
body: RefreshIndicator(
|
body: Column(
|
||||||
|
children: [
|
||||||
|
const WebPreviewBanner(),
|
||||||
|
Expanded(
|
||||||
|
child: RefreshIndicator(
|
||||||
onRefresh: _load,
|
onRefresh: _load,
|
||||||
child: _loading
|
child: _loading
|
||||||
? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())])
|
? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())])
|
||||||
|
|
@ -310,7 +355,7 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
Text('대화방이 없습니다', style: theme.textTheme.titleMedium),
|
Text('대화방이 없습니다', style: theme.textTheme.titleMedium),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'1) 내 ID를 상대에게 알려 주세요\n'
|
'1) 아래 「내 사용자 ID」를 탭해 복사한 뒤 상대에게 알려 주세요\n'
|
||||||
'2) 연락처에 상대의 숫자 ID를 넣고 추가\n'
|
'2) 연락처에 상대의 숫자 ID를 넣고 추가\n'
|
||||||
'3) 연락처에서 「대화」또는 행을 탭하세요',
|
'3) 연락처에서 「대화」또는 행을 탭하세요',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
|
|
@ -345,20 +390,14 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
avatarColor: _avatarColor(context, room.id),
|
avatarColor: _avatarColor(context, room.id),
|
||||||
onAvatarColor: _onAvatarColor(context, room.id),
|
onAvatarColor: _onAvatarColor(context, room.id),
|
||||||
snoozePastDue: snoozePastDue,
|
snoozePastDue: snoozePastDue,
|
||||||
|
selected: split && _selectedId == room.id,
|
||||||
onClearSnooze: () => _clearSnoozeFromList(room.id),
|
onClearSnooze: () => _clearSnoozeFromList(room.id),
|
||||||
onTap: () async {
|
onTap: () => _openChat(
|
||||||
await Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => ChatScreen(
|
|
||||||
conversationId: room.id,
|
conversationId: room.id,
|
||||||
title: _titleFor(room, me),
|
title: _titleFor(room, me),
|
||||||
isGroup: room.isGroup,
|
isGroup: room.isGroup,
|
||||||
twinDisabledByFlood: room.twinDisabledByFlood,
|
twinDisabledByFlood: room.twinDisabledByFlood,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
|
||||||
await _load();
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -366,6 +405,42 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
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.snoozePastDue,
|
||||||
required this.onClearSnooze,
|
required this.onClearSnooze,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
|
this.selected = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final ConversationSummary room;
|
final ConversationSummary room;
|
||||||
|
|
@ -390,6 +466,7 @@ class _ConversationRow extends StatelessWidget {
|
||||||
final Color avatarColor;
|
final Color avatarColor;
|
||||||
final Color onAvatarColor;
|
final Color onAvatarColor;
|
||||||
final bool snoozePastDue;
|
final bool snoozePastDue;
|
||||||
|
final bool selected;
|
||||||
final VoidCallback onClearSnooze;
|
final VoidCallback onClearSnooze;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
|
@ -398,7 +475,7 @@ class _ConversationRow extends StatelessWidget {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final blockedColor = room.twinDisabledByPeer || room.twinDisabledByFlood;
|
final blockedColor = room.twinDisabledByPeer || room.twinDisabledByFlood;
|
||||||
return Material(
|
return Material(
|
||||||
color: Colors.transparent,
|
color: selected ? theme.colorScheme.primary.withValues(alpha: 0.08) : Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|
@ -421,7 +498,10 @@ class _ConversationRow extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
title,
|
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),
|
const SizedBox(height: 2),
|
||||||
Row(
|
Row(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
|
|
@ -65,11 +66,19 @@ class SettingsScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
title: Text(user.displayName),
|
title: Text(user.displayName),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'user #${user.id}',
|
'user #${user.id} · 탭하여 ID 복사',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
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),
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,20 @@ import '../config.dart';
|
||||||
import '../models/models.dart';
|
import '../models/models.dart';
|
||||||
import 'message_sync.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`).
|
/// Conversation-scoped WebSocket relay (`GET /ws/conversations/:id`).
|
||||||
///
|
///
|
||||||
/// 오프라인 큐 캐치업(roadmap.md "멀티 디바이스 동기화" / deploy-checklist N4-11):
|
/// 오프라인 큐 캐치업(roadmap.md "멀티 디바이스 동기화" / deploy-checklist N4-11):
|
||||||
/// 소켓이 끊기면(백그라운드, 네트워크 hiccup 등) `onDone`/`onError`에서 그냥
|
/// 소켓이 끊기면(백그라운드, 네트워크 hiccup 등) `onDone`/`onError`에서 그냥
|
||||||
/// 멈추는 게 아니라 [nextReconnectDelay]로 계산한 backoff 후 재연결을 시도한다.
|
/// 멈추는 게 아니라 [nextReconnectDelay]로 계산한 backoff 후 재연결을 시도한다.
|
||||||
/// 재연결에 성공하면 [reconnects] 스트림으로 신호를 보내 — 화면(chat_screen.dart)이
|
/// 재연결에 성공하면 [reconnects] 스트림으로 신호를 보내 — 화면(chat_screen.dart)이
|
||||||
/// 그 신호를 받아 REST `since_id` 캐치업을 트리거할 수 있게 한다. 실제 소켓
|
/// 그 신호를 받아 REST `since_id` 캐치업을 트리거할 수 있게 한다.
|
||||||
/// 타이밍(진짜 네트워크 단절 등)은 단위 테스트로 결정적으로 검증할 수 없어
|
|
||||||
/// 실기기 QA가 필요하다 — [nextReconnectDelay] 자체의 계산 로직만 순수
|
|
||||||
/// 함수로 분리해 테스트했다(mobile/test/message_sync_test.dart).
|
|
||||||
class ConversationSocket {
|
class ConversationSocket {
|
||||||
ConversationSocket(this.conversationId);
|
ConversationSocket(this.conversationId);
|
||||||
|
|
||||||
|
|
@ -25,10 +29,12 @@ class ConversationSocket {
|
||||||
StreamSubscription? _channelSub;
|
StreamSubscription? _channelSub;
|
||||||
final _controller = StreamController<Map<String, dynamic>>.broadcast();
|
final _controller = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
final _reconnectController = StreamController<void>.broadcast();
|
final _reconnectController = StreamController<void>.broadcast();
|
||||||
|
final _linkController = StreamController<WsLinkState>.broadcast();
|
||||||
Timer? _reconnectTimer;
|
Timer? _reconnectTimer;
|
||||||
int _reconnectAttempt = 0;
|
int _reconnectAttempt = 0;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
bool _everConnected = false;
|
bool _everConnected = false;
|
||||||
|
WsLinkState _linkState = WsLinkState.connecting;
|
||||||
|
|
||||||
Stream<Map<String, dynamic>> get events => _controller.stream;
|
Stream<Map<String, dynamic>> get events => _controller.stream;
|
||||||
|
|
||||||
|
|
@ -37,18 +43,38 @@ class ConversationSocket {
|
||||||
/// 히스토리를 로드하므로, 이 신호는 "끊겼다가 다시 붙었을 때"만 의미가 있다).
|
/// 히스토리를 로드하므로, 이 신호는 "끊겼다가 다시 붙었을 때"만 의미가 있다).
|
||||||
Stream<void> get reconnects => _reconnectController.stream;
|
Stream<void> get reconnects => _reconnectController.stream;
|
||||||
|
|
||||||
|
/// connecting → live → reconnecting → live …
|
||||||
|
Stream<WsLinkState> 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() {
|
void connect() {
|
||||||
final wasReconnect = _everConnected;
|
final wasReconnect = _everConnected;
|
||||||
|
_setLinkState(wasReconnect ? WsLinkState.reconnecting : WsLinkState.connecting);
|
||||||
// 이전 연결이 onError로 끊겼을 때는 cancelOnError:false라 구독이 아직
|
// 이전 연결이 onError로 끊겼을 때는 cancelOnError:false라 구독이 아직
|
||||||
// 살아 있을 수 있다 — 새 채널을 만들기 전에 정리해 leak을 막는다.
|
// 살아 있을 수 있다 — 새 채널을 만들기 전에 정리해 leak을 막는다.
|
||||||
_channelSub?.cancel();
|
_channelSub?.cancel();
|
||||||
final uri = Uri.parse('${AppConfig.wsBase()}/ws/conversations/$conversationId');
|
final uri = Uri.parse('${AppConfig.wsBase()}/ws/conversations/$conversationId');
|
||||||
_channel = WebSocketChannel.connect(uri);
|
final channel = WebSocketChannel.connect(uri);
|
||||||
|
_channel = channel;
|
||||||
_everConnected = true;
|
_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) {
|
(raw) {
|
||||||
// 데이터가 왔다는 건 연결이 살아 있다는 뜻 — backoff를 리셋한다.
|
// 데이터가 왔다는 건 연결이 살아 있다는 뜻 — backoff를 리셋한다.
|
||||||
_reconnectAttempt = 0;
|
_reconnectAttempt = 0;
|
||||||
|
_setLinkState(WsLinkState.live);
|
||||||
if (raw is! String) return;
|
if (raw is! String) return;
|
||||||
final decoded = jsonDecode(raw);
|
final decoded = jsonDecode(raw);
|
||||||
if (decoded is Map<String, dynamic>) {
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
|
@ -70,6 +96,7 @@ class ConversationSocket {
|
||||||
|
|
||||||
void _scheduleReconnect() {
|
void _scheduleReconnect() {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
|
_setLinkState(WsLinkState.reconnecting);
|
||||||
_reconnectTimer?.cancel();
|
_reconnectTimer?.cancel();
|
||||||
final delay = nextReconnectDelay(_reconnectAttempt);
|
final delay = nextReconnectDelay(_reconnectAttempt);
|
||||||
_reconnectAttempt++;
|
_reconnectAttempt++;
|
||||||
|
|
@ -100,5 +127,18 @@ class ConversationSocket {
|
||||||
await _channel?.sink.close();
|
await _channel?.sink.close();
|
||||||
await _controller.close();
|
await _controller.close();
|
||||||
await _reconnectController.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 '다시 연결 중…';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<WebPreviewBanner> createState() => _WebPreviewBannerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WebPreviewBannerState extends State<WebPreviewBanner> {
|
||||||
|
bool _visible = false;
|
||||||
|
bool _loaded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (kIsWeb) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
||||||
|
} else {
|
||||||
|
_loaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
final session = context.read<SessionState>();
|
||||||
|
final db = session.db;
|
||||||
|
final dismissed = db == null
|
||||||
|
? false
|
||||||
|
: await db.getBoolKv(WebPreviewBanner.dismissKvKey);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_visible = !dismissed;
|
||||||
|
_loaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _dismiss() async {
|
||||||
|
setState(() => _visible = false);
|
||||||
|
final db = context.read<SessionState>().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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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), '다시 연결 중…');
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue