Merge claude/project-planning-approach-ukdz31: Flutter UI polish

This commit is contained in:
Claude 2026-07-30 07:18:50 +00:00
commit 6eceef44f8
No known key found for this signature in database
12 changed files with 891 additions and 334 deletions

View File

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

View File

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

View File

@ -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),

View File

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

View File

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

View File

@ -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(
[
@ -40,23 +86,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: '전체 채팅 백업 업로드, 온디바이스 말투 학습 원문 코퍼스, '
'관계 메모의 자동 클라우드 분석',
),
],
),

View File

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

View File

@ -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('저장하고 시작'),

View File

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

View File

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

View File

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

View File

@ -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;
}