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:
Cursor Agent 2026-08-04 01:14:16 +00:00
parent 866f36e319
commit ec5761d602
No known key found for this signature in database
9 changed files with 380 additions and 113 deletions

View File

@ -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의 대응 `[~]`/`[ ]`도 같이 갱신한다.

View File

@ -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 테마 · 셸 캐시)

View File

@ -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 액션

View File

@ -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<ChatScreen> createState() => _ChatScreenState();
}
@ -46,6 +50,8 @@ class _ChatScreenState extends State<ChatScreen> 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<ChatScreen> 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<ChatScreen> 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<ChatScreen> 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<ChatScreen> 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),

View File

@ -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<ConversationListScreen> {
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<ConversationListScreen> {
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) {
if (me == null) return '대화방 #${room.id}';
final peers = room.userIds.where((id) => id != me).toList();
@ -204,16 +247,13 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
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<ConversationListScreen> {
final session = context.watch<SessionState>();
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<ConversationListScreen> {
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(

View File

@ -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(

View File

@ -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<Map<String, dynamic>>.broadcast();
final _reconnectController = StreamController<void>.broadcast();
final _linkController = StreamController<WsLinkState>.broadcast();
Timer? _reconnectTimer;
int _reconnectAttempt = 0;
bool _disposed = false;
bool _everConnected = false;
WsLinkState _linkState = WsLinkState.connecting;
Stream<Map<String, dynamic>> get events => _controller.stream;
@ -37,18 +43,38 @@ class ConversationSocket {
/// , "끊겼다가 다시 붙었을 때" ).
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() {
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<String, dynamic>) {
@ -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 '다시 연결 중…';
}
}

View File

@ -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,
),
],
),
),
);
}
}

View File

@ -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), '다시 연결 중…');
});
}