Flutter UI 디자인 다듬기 — 테마 시스템 도입 + 화면별 시각 개선
일관된 디자인 토큰(mobile/lib/theme/app_theme.dart)을 새로 만들고 전체 화면에 적용: - 라이트/다크 ColorScheme을 명시적으로 정의(기존엔 라이트만 있어서 다크 모드 기기에서 깨져 보였음), AppBar/Card/입력창/버튼/칩/배너 등 컴포넌트 테마를 통일. "분신" 뱃지 전용 twin 강조색(amber) 분리 - 화면마다 제각각이던 TextField 테두리를 전부 테마 하나로 통일 (chat_screen의 L1 편집창에 하드코딩돼 있던 Colors.white70 배경도 제거 — 다크 모드에서 흰 배경에 흰 글씨로 안 보이던 실제 버그였음) 화면별 개선: - signup: 로고 마크 + 안내 문구 추가, 아이콘 붙인 입력창 - 대화 목록: 앱바 아이콘 4개를 겹쳐놨던 걸 정리(더보기 메뉴로 이동), 색상 아바타, 빈 상태 안내, 확장형 FAB - message_bubble: 좌우 비대칭 말풍선 모양(꼬리 느낌), 타임스탬프 추가, 분신 뱃지를 아이콘+칩 형태로 - chat: 거부권 버튼에 확인 다이얼로그 추가(실수로 눌러도 되돌릴 수 없는 동작이라 안전장치), L1/에스컬레이션 패널 카드화 - contacts/inbox/sessions/autonomy_settings/data_flow/onboarding: 아바타, 섹션 카드, 빈 상태, 화이트리스트 칩 UI 등 톤 통일 주의: 이 작업 환경에는 Flutter SDK가 없어 flutter analyze/run/test로 직접 검증하지 못했음. 아이콘 이름 등은 기존 코드에 이미 쓰인 것 위주로 맞췄지만, 로컬에서 flutter analyze 먼저 돌려보는 걸 권장.
This commit is contained in:
parent
86f65d27ce
commit
5ce189a76a
|
|
@ -5,6 +5,7 @@ import 'screens/conversation_list_screen.dart';
|
|||
import 'screens/onboarding_tone_screen.dart';
|
||||
import 'screens/signup_screen.dart';
|
||||
import 'state/session_state.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
|
@ -24,13 +25,10 @@ class BunsinApp extends StatelessWidget {
|
|||
value: session,
|
||||
child: MaterialApp(
|
||||
title: '분신',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF1F6F5B),
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.dark(),
|
||||
themeMode: ThemeMode.system,
|
||||
home: Consumer<SessionState>(
|
||||
builder: (context, s, _) {
|
||||
if (s.user == null) return const SignupScreen();
|
||||
|
|
|
|||
|
|
@ -49,36 +49,50 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
static const _levelDescriptions = {
|
||||
AutonomyLevel.L0: '분신이 초안만 만들고, 발송은 항상 직접 합니다.',
|
||||
AutonomyLevel.L1: '분신이 초안을 만들면 검토·수정 후 승인해야 보내집니다.',
|
||||
AutonomyLevel.L2: '아래 화이트리스트 주제는 승인 없이 자동으로 보내집니다.',
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = context.watch<SessionState>();
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('자율성 설정')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
||||
children: [
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.privacy_tip_outlined),
|
||||
title: const Text('데이터 흐름'),
|
||||
subtitle: const Text('무엇이 기기에 남고 서버로 가는지'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DataFlowScreen()));
|
||||
},
|
||||
),
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.devices),
|
||||
title: const Text('로그인 세션'),
|
||||
subtitle: const Text('멀티 디바이스 세션 목록'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SessionsScreen()));
|
||||
},
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Text('전역 레벨', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('전역 자율성 레벨', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
SegmentedButton<AutonomyLevel>(
|
||||
segments: const [
|
||||
ButtonSegment(value: AutonomyLevel.L0, label: Text('L0'), tooltip: '초안만'),
|
||||
|
|
@ -88,23 +102,37 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
|
|||
selected: {session.autonomyLevel},
|
||||
onSelectionChanged: (s) => session.setAutonomy(s.first),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'기본값은 L0입니다. L2는 아래 화이트리스트 주제에만 자동 발송됩니다.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Text('L2 화이트리스트 주제', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 18, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_levelDescriptions[session.autonomyLevel] ?? '',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Text('L2 화이트리스트 주제', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _keyword,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '주제 키워드',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
decoration: const InputDecoration(labelText: '주제 키워드'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
|
@ -128,24 +156,37 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
|
|||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
if (_loading)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_rules.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text(
|
||||
'아직 화이트리스트 주제가 없습니다.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
)
|
||||
else
|
||||
..._rules.map(
|
||||
(r) => ListTile(
|
||||
title: Text(r.topicKeyword),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: () async {
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final r in _rules)
|
||||
InputChip(
|
||||
label: Text(r.topicKeyword),
|
||||
onDeleted: () async {
|
||||
if (session.user == null) return;
|
||||
await session.api.deleteWhitelist(session.user!.id, r.id);
|
||||
setState(() => _rules = _rules.where((x) => x.id != r.id).toList());
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -205,6 +205,18 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
}
|
||||
|
||||
Future<void> _veto() async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('거부권을 쓸까요?'),
|
||||
content: const Text('이 대화방에서 분신 자동응대가 즉시 중단됩니다. 이후에는 다시 켤 수 없습니다.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
|
||||
FilledButton.tonal(onPressed: () => Navigator.pop(ctx, true), child: const Text('거부권 사용')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true || !mounted) return;
|
||||
final session = context.read<SessionState>();
|
||||
try {
|
||||
await session.api.vetoConversation(widget.conversationId);
|
||||
|
|
@ -229,32 +241,48 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
if (draft == null) return const SizedBox.shrink();
|
||||
|
||||
if (draft.isEscalate) {
|
||||
return Material(
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('직접 확인 필요', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700)),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, size: 18, color: theme.colorScheme.onErrorContainer),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'직접 확인 필요',
|
||||
style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onErrorContainer),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
draft.text.isEmpty ? '민감·확정성 내용으로 분신 발송이 보류되었습니다.' : draft.text,
|
||||
style: TextStyle(color: theme.colorScheme.onErrorContainer),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(draft.text.isEmpty ? '민감·확정성 내용으로 분신 발송이 보류되었습니다.' : draft.text),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(onPressed: _rejectDraft, child: const Text('닫기')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Material(
|
||||
elevation: 2,
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.secondaryContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
|
|
@ -264,22 +292,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'L1 승인 — 수정 후 보내기',
|
||||
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
|
||||
style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onSecondaryContainer),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _draftEdit,
|
||||
minLines: 2,
|
||||
maxLines: 5,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
filled: true,
|
||||
fillColor: Colors.white70,
|
||||
style: TextStyle(color: theme.colorScheme.onSurface),
|
||||
decoration: InputDecoration(fillColor: theme.colorScheme.surface),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(onPressed: _busy ? null : _rejectDraft, child: const Text('버리기')),
|
||||
|
|
@ -297,7 +322,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -306,17 +330,23 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
final session = context.watch<SessionState>();
|
||||
final me = session.user?.id;
|
||||
|
||||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.title ?? '대화방 #${widget.conversationId}'),
|
||||
actions: [
|
||||
TextButton(onPressed: _busy ? null : _veto, child: const Text('거부권')),
|
||||
IconButton(
|
||||
tooltip: '거부권 (분신 자동응대 중단)',
|
||||
onPressed: _busy ? null : _veto,
|
||||
icon: const Icon(Icons.block),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (_banner != null)
|
||||
MaterialBanner(
|
||||
leading: Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant),
|
||||
content: Text(_banner!),
|
||||
actions: [
|
||||
TextButton(onPressed: () => setState(() => _banner = null), child: const Text('닫기')),
|
||||
|
|
@ -325,6 +355,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
Expanded(
|
||||
child: _loadingHistory
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _messages.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'아직 메시지가 없습니다. 첫 메시지를 보내 보세요.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
controller: _scroll,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
|
|
@ -344,16 +381,14 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _input,
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '메시지',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
decoration: const InputDecoration(hintText: '메시지'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
children: [
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(labelText: '표시 이름', border: OutlineInputBorder()),
|
||||
decoration: const InputDecoration(labelText: '표시 이름'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
|
|
@ -63,13 +63,12 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
decoration: const InputDecoration(
|
||||
labelText: '상대 사용자 ID (선택)',
|
||||
helperText: '대화 시작에 필요',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: noteCtrl,
|
||||
decoration: const InputDecoration(labelText: '관계 메모 (선택)', border: OutlineInputBorder()),
|
||||
decoration: const InputDecoration(labelText: '관계 메모 (선택)'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -118,54 +117,102 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _delete(Contact c) async {
|
||||
final session = context.read<SessionState>();
|
||||
if (session.user == null) return;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('연락처 삭제'),
|
||||
content: Text('${c.displayName}을(를) 삭제할까요?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
|
||||
FilledButton.tonal(onPressed: () => Navigator.pop(ctx, true), child: const Text('삭제')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
await session.api.deleteContact(session.user!.id, c.id);
|
||||
if (!mounted) return;
|
||||
setState(() => _contacts = _contacts.where((x) => x.id != c.id).toList());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('연락처')),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _showAddDialog,
|
||||
tooltip: '연락처 추가',
|
||||
child: const Icon(Icons.person_add_alt_1),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: _loading
|
||||
? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
|
||||
? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())])
|
||||
: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
children: [
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
||||
),
|
||||
if (_contacts.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text('연락처가 없습니다. + 버튼으로 추가하세요.'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 64, horizontal: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.person_add_outlined, size: 40, color: theme.colorScheme.outline),
|
||||
const SizedBox(height: 12),
|
||||
Text('연락처가 없습니다', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'오른쪽 아래 버튼으로 첫 연락처를 추가해 보세요.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final c in _contacts)
|
||||
ListTile(
|
||||
leading: const CircleAvatar(child: Icon(Icons.person_outline)),
|
||||
title: Text(c.displayName),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: theme.colorScheme.secondaryContainer,
|
||||
child: Text(
|
||||
c.displayName.isEmpty ? '?' : c.displayName.substring(0, 1),
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(c.displayName, style: theme.textTheme.titleSmall),
|
||||
subtitle: Text(
|
||||
[
|
||||
if (c.contactUserId != null) '사용자 #${c.contactUserId}',
|
||||
if (c.relationshipNote.isNotEmpty) c.relationshipNote,
|
||||
].join(' · '),
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
trailing: c.contactUserId == null
|
||||
? null
|
||||
: TextButton(onPressed: () => _startChat(c), child: const Text('대화')),
|
||||
onLongPress: () async {
|
||||
final session = context.read<SessionState>();
|
||||
if (session.user == null) return;
|
||||
await session.api.deleteContact(session.user!.id, c.id);
|
||||
setState(() => _contacts = _contacts.where((x) => x.id != c.id).toList());
|
||||
},
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (c.contactUserId != null)
|
||||
TextButton(onPressed: () => _startChat(c), child: const Text('대화')),
|
||||
IconButton(
|
||||
tooltip: '삭제',
|
||||
icon: const Icon(Icons.delete_outline, size: 20),
|
||||
onPressed: () => _delete(c),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('길게 누르면 삭제됩니다.', textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 72),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
|||
decoration: const InputDecoration(
|
||||
labelText: '상대 사용자 ID',
|
||||
helperText: '연락처에 등록된 상대면 연락처 화면에서 시작하는 편이 낫습니다.',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
|
|
@ -84,14 +83,27 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
Color _avatarColor(BuildContext context, int seed) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final palette = [scheme.primaryContainer, scheme.tertiaryContainer, scheme.secondaryContainer];
|
||||
return palette[seed % palette.length];
|
||||
}
|
||||
|
||||
Color _onAvatarColor(BuildContext context, int seed) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final palette = [scheme.onPrimaryContainer, scheme.onTertiaryContainer, scheme.onSecondaryContainer];
|
||||
return palette[seed % palette.length];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = context.watch<SessionState>();
|
||||
final theme = Theme.of(context);
|
||||
final me = session.user?.id;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('분신 · ${session.user?.displayName ?? ''}'),
|
||||
title: const Text('분신'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: '사후 알림',
|
||||
|
|
@ -108,56 +120,106 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
|||
},
|
||||
icon: const Icon(Icons.contacts_outlined),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '말투 샘플',
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const OnboardingToneScreen()));
|
||||
},
|
||||
icon: const Icon(Icons.record_voice_over_outlined),
|
||||
PopupMenuButton<VoidCallback>(
|
||||
tooltip: '더보기',
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onSelected: (action) => action(),
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: () => Navigator.of(context)
|
||||
.push(MaterialPageRoute(builder: (_) => const OnboardingToneScreen())),
|
||||
child: const ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(Icons.record_voice_over_outlined),
|
||||
title: Text('말투 샘플'),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: () => Navigator.of(context)
|
||||
.push(MaterialPageRoute(builder: (_) => const AutonomySettingsScreen())),
|
||||
child: const ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(Icons.tune),
|
||||
title: Text('자율성 설정'),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '자율성 설정',
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const AutonomySettingsScreen()),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.tune),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _createConversation,
|
||||
child: const Icon(Icons.chat),
|
||||
icon: const Icon(Icons.chat),
|
||||
label: const Text('새 대화'),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: _loading
|
||||
? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
|
||||
? 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, 12),
|
||||
child: Text(
|
||||
session.user == null ? '' : '안녕하세요, ${session.user!.displayName}님',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
||||
),
|
||||
if (_rooms.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text('대화방이 없습니다. + 버튼이나 연락처에서 대화를 시작하세요.'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 64, horizontal: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.chat_bubble_outline, size: 40, color: theme.colorScheme.outline),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'대화방이 없습니다',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'연락처나 "새 대화" 버튼으로 첫 대화를 시작해 보세요.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final room in _rooms)
|
||||
ListTile(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
child: Icon(room.isGroup ? Icons.groups_outlined : Icons.chat_bubble_outline),
|
||||
backgroundColor: _avatarColor(context, room.id),
|
||||
child: Icon(
|
||||
room.isGroup ? Icons.groups_outlined : Icons.person_outline,
|
||||
color: _onAvatarColor(context, room.id),
|
||||
),
|
||||
title: Text(me == null ? '대화방 #${room.id}' : room.titleFor(me)),
|
||||
subtitle: Text(
|
||||
[
|
||||
'ID ${room.id}',
|
||||
if (room.twinDisabledByPeer) '상대가 분신 거부',
|
||||
].join(' · '),
|
||||
),
|
||||
title: Text(
|
||||
me == null ? '대화방 #${room.id}' : room.titleFor(me),
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
subtitle: room.twinDisabledByPeer
|
||||
? Row(
|
||||
children: [
|
||||
Icon(Icons.block, size: 13, color: theme.colorScheme.error),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'상대가 분신을 거부함',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Text('대화방 ID ${room.id}', style: theme.textTheme.bodySmall),
|
||||
trailing: const Icon(Icons.chevron_right, size: 20),
|
||||
onTap: () async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
|
|
@ -170,6 +232,8 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
|||
await _load();
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 72),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,50 @@ import '../state/session_state.dart';
|
|||
class DataFlowScreen extends StatelessWidget {
|
||||
const DataFlowScreen({super.key});
|
||||
|
||||
Widget _section(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required Color tint,
|
||||
required String title,
|
||||
required String body,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(color: tint.withOpacity(0.15), shape: BoxShape.circle),
|
||||
child: Icon(icon, size: 18, color: tint),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
body,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = context.watch<SessionState>();
|
||||
|
|
@ -16,18 +60,20 @@ class DataFlowScreen extends StatelessWidget {
|
|||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('데이터 흐름')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text('기기 안에만 둡니다', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. '
|
||||
_section(
|
||||
context,
|
||||
icon: Icons.lock_outline,
|
||||
tint: Colors.green.shade600,
|
||||
title: '기기 안에만 둡니다',
|
||||
body: '말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. '
|
||||
'원문 대화 전체를 서버로 올리지 않는 것이 기본 원칙입니다.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.record_voice_over_outlined, color: theme.colorScheme.primary),
|
||||
title: Text('말투 샘플 ${samples.length}개'),
|
||||
subtitle: Text(
|
||||
[
|
||||
|
|
@ -38,23 +84,23 @@ class DataFlowScreen extends StatelessWidget {
|
|||
isThreeLine: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('서버로 보낼 수 있는 것', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트).\n'
|
||||
'채팅 릴레이: 보낸 메시지 본문.\n'
|
||||
'계정: 표시 이름·초대 코드·세션 토큰.\n'
|
||||
'푸시(준비 중): 디바이스 토큰만 등록, 실제 전송은 FCM 프로젝트 연결 후.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
_section(
|
||||
context,
|
||||
icon: Icons.cloud_upload_outlined,
|
||||
tint: Colors.amber.shade800,
|
||||
title: '서버로 보낼 수 있는 것',
|
||||
body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n'
|
||||
'채팅 릴레이: 보낸 메시지 본문\n'
|
||||
'계정: 표시 이름·초대 코드·세션 토큰\n'
|
||||
'푸시(준비 중): 디바이스 토큰만 등록, 실제 전송은 FCM 프로젝트 연결 후',
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('보내지 않는 것', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'전체 채팅 백업 업로드, 온디바이스 말투 학습 원문 코퍼스, '
|
||||
'관계 메모의 자동 클라우드 분석.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
_section(
|
||||
context,
|
||||
icon: Icons.block,
|
||||
tint: theme.colorScheme.error,
|
||||
title: '보내지 않는 것',
|
||||
body: '전체 채팅 백업 업로드, 온디바이스 말투 학습 원문 코퍼스, '
|
||||
'관계 메모의 자동 클라우드 분석',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -51,11 +51,12 @@ class _InboxScreenState extends State<InboxScreen> {
|
|||
body: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: _loading
|
||||
? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
|
||||
? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())])
|
||||
: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
padding: const EdgeInsets.fromLTRB(4, 0, 4, 12),
|
||||
child: Text(
|
||||
'분신이 보류·차단한 내용과 에스컬레이션 기록입니다. '
|
||||
'이미 보낸 분신 메시지는 해당 대화방에서 되돌릴 수 있습니다.',
|
||||
|
|
@ -64,29 +65,26 @@ class _InboxScreenState extends State<InboxScreen> {
|
|||
),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
||||
),
|
||||
if (_logs.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text('새 알림이 없습니다.'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 64, horizontal: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.notifications_none, size: 40, color: theme.colorScheme.outline),
|
||||
const SizedBox(height: 12),
|
||||
Text('새 알림이 없습니다', style: theme.textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final log in _logs)
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
log.resolved ? Icons.check_circle_outline : Icons.warning_amber_rounded,
|
||||
color: log.resolved ? theme.colorScheme.primary : theme.colorScheme.error,
|
||||
),
|
||||
title: Text(log.reason.isEmpty ? '에스컬레이션' : log.reason),
|
||||
subtitle: Text(
|
||||
[
|
||||
'대화방 #${log.conversationId}',
|
||||
if (log.messageSnippet.isNotEmpty) log.messageSnippet,
|
||||
log.createdAt.toLocal().toString().split('.').first,
|
||||
].join('\n'),
|
||||
),
|
||||
isThreeLine: true,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Card(
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
|
|
@ -94,6 +92,62 @@ class _InboxScreenState extends State<InboxScreen> {
|
|||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: log.resolved
|
||||
? theme.colorScheme.primaryContainer
|
||||
: theme.colorScheme.errorContainer,
|
||||
child: Icon(
|
||||
log.resolved ? Icons.check_circle_outline : Icons.warning_amber_rounded,
|
||||
size: 18,
|
||||
color: log.resolved
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
log.reason.isEmpty ? '에스컬레이션' : log.reason,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'대화방 #${log.conversationId}',
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
if (log.messageSnippet.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
log.messageSnippet,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
log.createdAt.toLocal().toString().split('.').first,
|
||||
style: theme.textTheme.labelSmall
|
||||
?.copyWith(color: theme.colorScheme.outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('말투 샘플'),
|
||||
|
|
@ -54,20 +55,28 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
|
|||
onPressed: () => context.read<SessionState>().skipToneOnboarding(),
|
||||
child: const Text('나중에'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
Text('분신이 따라 쓸 말투', style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700)),
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(color: scheme.primaryContainer, borderRadius: BorderRadius.circular(16)),
|
||||
child: Icon(Icons.record_voice_over_outlined, size: 28, color: scheme.onPrimaryContainer),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('분신이 따라 쓸 말투', style: theme.textTheme.headlineSmall),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다. '
|
||||
'최종 문구·수집 방식은 사람 PoC 이후에 다듬습니다.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const SizedBox(height: 28),
|
||||
for (var i = 0; i < _samples.length; i++) ...[
|
||||
TextField(
|
||||
controller: _samples[i],
|
||||
|
|
@ -75,12 +84,26 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
|
|||
decoration: InputDecoration(
|
||||
labelText: '샘플 ${i + 1}',
|
||||
hintText: i == 0 ? '예: ㅇㅇ 알겠음' : null,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: Padding(
|
||||
padding: const EdgeInsets.only(left: 4, right: 4, top: 4),
|
||||
child: CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundColor: scheme.secondaryContainer,
|
||||
child: Text(
|
||||
'${i + 1}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: scheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: () => _save(markDone: true),
|
||||
child: const Text('저장하고 시작'),
|
||||
|
|
|
|||
|
|
@ -72,34 +72,55 @@ class _SessionsScreenState extends State<SessionsScreen> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('로그인 세션')),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: _loading
|
||||
? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
|
||||
? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())])
|
||||
: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('이 계정에 연결된 활성 세션입니다. 다른 기기를 종료하면 해당 토큰이 즉시 무효화됩니다.'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Text(
|
||||
'이 계정에 연결된 활성 세션입니다. 다른 기기를 종료하면 해당 토큰이 즉시 무효화됩니다.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
||||
),
|
||||
for (final s in _sessions)
|
||||
ListTile(
|
||||
leading: Icon(s['is_current'] == true ? Icons.smartphone : Icons.devices_other),
|
||||
title: Text(s['is_current'] == true ? '이 기기 (현재)' : '세션 #${s['id']}'),
|
||||
subtitle: Text('만료: ${s['expires_at'] ?? ''}'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: s['is_current'] == true
|
||||
? theme.colorScheme.primaryContainer
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
s['is_current'] == true ? Icons.smartphone : Icons.devices_other,
|
||||
color: s['is_current'] == true
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
s['is_current'] == true ? '이 기기 (현재)' : '세션 #${s['id']}',
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
subtitle: Text('만료: ${s['expires_at'] ?? ''}', style: theme.textTheme.bodySmall),
|
||||
trailing: IconButton(
|
||||
tooltip: '세션 종료',
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: () => _revoke(s),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -24,28 +24,47 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = context.watch<SessionState>();
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 48),
|
||||
Text('분신', style: Theme.of(context).textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w800)),
|
||||
const Spacer(flex: 3),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
child: Icon(Icons.auto_awesome, size: 34, color: scheme.onPrimaryContainer),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'분신',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'초대 코드로 클로즈드 베타에 참여합니다.',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
'나를 대신해 답하는, 나만의 분신',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(color: scheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const Spacer(flex: 3),
|
||||
Text('초대 코드로 클로즈드 베타에 참여합니다', style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _invite,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '초대 코드',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.vpn_key),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
|
|
@ -54,15 +73,15 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||
controller: _name,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '표시 이름',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
if (session.error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(session.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
Text(session.error!, style: TextStyle(color: scheme.error)),
|
||||
],
|
||||
const Spacer(),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(
|
||||
onPressed: session.loading
|
||||
? null
|
||||
|
|
@ -71,6 +90,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('시작하기'),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Central design tokens for 분신 (Bunsin). One seed color drives the whole
|
||||
/// Material 3 palette; `twinAccent` is a separate warm accent used only for
|
||||
/// the AI-authored badge/border so a twin-written bubble never reads as
|
||||
/// something the human actually typed (PRD §3.1 분신 뱃지).
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
|
||||
static const _seed = Color(0xFF1F6F5B);
|
||||
|
||||
static Color twinAccent(Brightness brightness) =>
|
||||
brightness == Brightness.dark ? Colors.amber.shade300 : Colors.amber.shade800;
|
||||
|
||||
static ThemeData light() => _build(Brightness.light);
|
||||
static ThemeData dark() => _build(Brightness.dark);
|
||||
|
||||
static ThemeData _build(Brightness brightness) {
|
||||
final scheme = ColorScheme.fromSeed(seedColor: _seed, brightness: brightness);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: brightness,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: scheme.surface,
|
||||
visualDensity: VisualDensity.comfortable,
|
||||
textTheme: _textTheme(),
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: scheme.surface,
|
||||
foregroundColor: scheme.onSurface,
|
||||
surfaceTintColor: scheme.surfaceTint,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 1,
|
||||
centerTitle: false,
|
||||
titleTextStyle: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: scheme.onSurface,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 0,
|
||||
color: scheme.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
listTileTheme: ListTileThemeData(
|
||||
iconColor: scheme.onSurfaceVariant,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
),
|
||||
dividerTheme: DividerThemeData(color: scheme.outlineVariant, space: 32, thickness: 1),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: scheme.surfaceContainerHighest,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: scheme.primary, width: 1.6),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: scheme.error, width: 1.2),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: scheme.error, width: 1.6),
|
||||
),
|
||||
hintStyle: TextStyle(color: scheme.onSurfaceVariant.withOpacity(0.7)),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
),
|
||||
iconButtonTheme: IconButtonThemeData(
|
||||
style: IconButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
segmentedButtonTheme: SegmentedButtonThemeData(
|
||||
style: SegmentedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
),
|
||||
),
|
||||
chipTheme: ChipThemeData(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
side: BorderSide.none,
|
||||
backgroundColor: scheme.surfaceContainerHighest,
|
||||
labelStyle: TextStyle(color: scheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
),
|
||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||
backgroundColor: scheme.primary,
|
||||
foregroundColor: scheme.onPrimary,
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
|
||||
),
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
bannerTheme: MaterialBannerThemeData(
|
||||
backgroundColor: scheme.surfaceContainerHigh,
|
||||
padding: const EdgeInsets.all(16),
|
||||
),
|
||||
dialogTheme: DialogThemeData(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
),
|
||||
progressIndicatorTheme: ProgressIndicatorThemeData(color: scheme.primary),
|
||||
);
|
||||
}
|
||||
|
||||
static TextTheme _textTheme() {
|
||||
return const TextTheme(
|
||||
displaySmall: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.5),
|
||||
headlineSmall: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.3),
|
||||
titleLarge: TextStyle(fontWeight: FontWeight.w700),
|
||||
titleMedium: TextStyle(fontWeight: FontWeight.w700),
|
||||
titleSmall: TextStyle(fontWeight: FontWeight.w700),
|
||||
bodyLarge: TextStyle(height: 1.4),
|
||||
bodyMedium: TextStyle(height: 1.4),
|
||||
labelLarge: TextStyle(fontWeight: FontWeight.w700),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/models.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Twin messages use a dashed border + badge (PRD §3.1 분신 뱃지).
|
||||
/// Twin messages get a dashed border + badge (PRD §3.1 분신 뱃지) so an
|
||||
/// auto-sent bubble never reads as something the human actually typed.
|
||||
class MessageBubble extends StatelessWidget {
|
||||
const MessageBubble({
|
||||
super.key,
|
||||
|
|
@ -15,90 +17,146 @@ class MessageBubble extends StatelessWidget {
|
|||
final bool isMine;
|
||||
final VoidCallback? onRetract;
|
||||
|
||||
static String _time(DateTime dt) {
|
||||
final local = dt.toLocal();
|
||||
final h = local.hour.toString().padLeft(2, '0');
|
||||
final m = local.minute.toString().padLeft(2, '0');
|
||||
return '$h:$m';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final twin = message.isTwin;
|
||||
final bg = isMine
|
||||
? theme.colorScheme.primaryContainer
|
||||
: theme.colorScheme.surfaceContainerHighest;
|
||||
final retracted = message.retracted;
|
||||
final accent = AppTheme.twinAccent(theme.brightness);
|
||||
|
||||
final Color bg;
|
||||
final Color fg;
|
||||
if (retracted) {
|
||||
bg = scheme.surfaceContainerHigh;
|
||||
fg = scheme.onSurfaceVariant;
|
||||
} else if (isMine) {
|
||||
bg = scheme.primaryContainer;
|
||||
fg = scheme.onPrimaryContainer;
|
||||
} else {
|
||||
bg = scheme.surfaceContainerHighest;
|
||||
fg = scheme.onSurface;
|
||||
}
|
||||
|
||||
final radius = BorderRadius.only(
|
||||
topLeft: const Radius.circular(18),
|
||||
topRight: const Radius.circular(18),
|
||||
bottomLeft: Radius.circular(isMine ? 18 : 4),
|
||||
bottomRight: Radius.circular(isMine ? 4 : 18),
|
||||
);
|
||||
|
||||
final bubble = Container(
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.78),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: twin
|
||||
? Border.all(color: theme.colorScheme.tertiary, width: 1.5, strokeAlign: BorderSide.strokeAlignOutside)
|
||||
: null,
|
||||
),
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.76),
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 8),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: radius),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (twin)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.auto_awesome, size: 13, color: accent),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'분신',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.tertiary,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: accent,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (retracted)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.replay, size: 14, color: fg),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
message.retracted ? '(되돌린 메시지)' : message.text,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontStyle: message.retracted ? FontStyle.italic : FontStyle.normal,
|
||||
color: message.retracted ? theme.disabledColor : null,
|
||||
'되돌린 메시지',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: fg, fontStyle: FontStyle.italic),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Text(
|
||||
message.text,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: fg, height: 1.35),
|
||||
),
|
||||
if (twin && isMine && !message.retracted && onRetract != null)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: onRetract,
|
||||
child: const Text('되돌리기'),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_time(message.createdAt),
|
||||
style: theme.textTheme.labelSmall?.copyWith(color: fg.withOpacity(0.55), fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// Dashed look for twin: overlay a custom painter border when twin.
|
||||
if (!twin) {
|
||||
return Align(
|
||||
alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
|
||||
final content = Column(
|
||||
crossAxisAlignment: isMine ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
twin
|
||||
? CustomPaint(
|
||||
painter: _DashedRRectPainter(color: accent, radius: radius),
|
||||
child: bubble,
|
||||
)
|
||||
: bubble,
|
||||
if (twin && isMine && !retracted && onRetract != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2, right: 4, left: 4),
|
||||
child: TextButton.icon(
|
||||
onPressed: onRetract,
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
foregroundColor: scheme.onSurfaceVariant,
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
),
|
||||
icon: const Icon(Icons.undo, size: 14),
|
||||
label: const Text('되돌리기'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Align(
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
|
||||
child: Align(
|
||||
alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: CustomPaint(
|
||||
painter: _DashedRRectPainter(color: theme.colorScheme.tertiary),
|
||||
child: bubble,
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DashedRRectPainter extends CustomPainter {
|
||||
_DashedRRectPainter({required this.color});
|
||||
_DashedRRectPainter({required this.color, required this.radius});
|
||||
|
||||
final Color color;
|
||||
final BorderRadius radius;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.5;
|
||||
final rrect = RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(1, 1, size.width - 2, size.height - 2),
|
||||
const Radius.circular(14),
|
||||
);
|
||||
..strokeWidth = 1.4;
|
||||
final rrect = radius.toRRect(Rect.fromLTWH(0.7, 0.7, size.width - 1.4, size.height - 1.4));
|
||||
final path = Path()..addRRect(rrect);
|
||||
final dashed = _dashPath(path, dashLength: 5, gapLength: 4);
|
||||
canvas.drawPath(dashed, paint);
|
||||
|
|
@ -119,5 +177,6 @@ class _DashedRRectPainter extends CustomPainter {
|
|||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _DashedRRectPainter oldDelegate) => oldDelegate.color != color;
|
||||
bool shouldRepaint(covariant _DashedRRectPainter oldDelegate) =>
|
||||
oldDelegate.color != color || oldDelegate.radius != radius;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue