Compare commits

...

2 Commits

Author SHA1 Message Date
Cursor Agent ca4aeb059d
feat(ui): Quiet Ink redesign — teal accent, light/dark
Replace aurora-glass purple with a calm messenger system: off-white/
charcoal canvas, Plus Jakarta Sans, solid teal CTAs, and quieter
panels across signup, chats, contacts, inbox, and settings.

Co-authored-by: okuma <o0kuma@users.noreply.github.com>
2026-07-31 09:01:57 +00:00
Claude f470dd473c
docs: log the aurora-glass UI redesign in deploy-checklist.md
Keeps deploy-checklist.md's DONE list in sync with the actual UI redesign
work per AGENTS.md's "Phase 1 앱 빌드 작업 규칙". Also flags that this
pushed to GitHub main but has not been redeployed to the live
msn.iykyka.com production host, since this session has no access to that
host (deploy runs via scripts/server-up.sh on the iykyka box itself).
2026-07-31 09:01:12 +00:00
12 changed files with 316 additions and 304 deletions

View File

@ -17,6 +17,13 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
- [x] 스택 확정: Flutter + Go core + Python AI - [x] 스택 확정: Flutter + Go core + Python AI
- [x] `core-backend/` · `ai-service/` · 하드게이트·거부권·초대·되돌리기·L0~L2 QA - [x] `core-backend/` · `ai-service/` · 하드게이트·거부권·초대·되돌리기·L0~L2 QA
- [x] Flutter UI 테마 폴리시 (`app_theme` + 화면별 시각 개선) - [x] Flutter UI 테마 폴리시 (`app_theme` + 화면별 시각 개선)
- [x] UI 2차 리디자인 — "아우로라 글래스"로 강화: 채도 높인 그라디언트+블러 글로우 orb, 고대비
글래스 카드/그림자, `BrandMark`/`GradientText`/`PrimaryGradientButton` 신규 위젯으로 스플래시·
가입·온보딩·대화목록 재적용. 실제 Flutter SDK 설치해 로컬 빌드+스크린샷(가입→온보딩→대화목록→
채팅)으로 검증, 부수적으로 onboarding 헤더 뱃지가 `ListView` 직계 자식이라 전체폭으로 늘어나던
기존 레이아웃 버그도 발견해 수정 (`Align`로 감쌈). **주의: GitHub `main`에는 반영됐지만
`msn.iykyka.com` 프로덕션 재배포는 안 됨** — 이 작업 세션은 iykyka 호스트 접근 권한이 없어
`scripts/server-up.sh` 재기동은 Master가 해당 호스트에서 직접 해야 함
### DONE — Cursor 후속 ### DONE — Cursor 후속
@ -30,8 +37,8 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
- 앱 코드는 클로즈드 베타 직전 수준 - 앱 코드는 클로즈드 베타 직전 수준
- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료** - **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료**
- 진행 중: **N4 FCM 코드 경로** → Master 시크릿 대기 → Android UI QA - UI: **Quiet Ink** (틸 액센트 · 라이트/다크) 전면 적용 중 — FCM 실기기는 잠시 후순위
- 실 FCM 전송·Android 실기기 탭 · 사람 PoC 실행은 남음 - 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음
### NEXT 순서 ### NEXT 순서

View File

@ -258,7 +258,7 @@ class _ChatScreenState extends State<ChatScreen> {
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8), margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.errorContainer.withOpacity(0.85), color: theme.colorScheme.errorContainer.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppTheme.glassBorder(theme.brightness)), border: Border.all(color: AppTheme.glassBorder(theme.brightness)),
), ),
@ -302,7 +302,7 @@ class _ChatScreenState extends State<ChatScreen> {
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8), margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer.withOpacity(0.85), color: theme.colorScheme.secondaryContainer.withValues(alpha: 0.95),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppTheme.glassBorder(theme.brightness)), border: Border.all(color: AppTheme.glassBorder(theme.brightness)),
), ),

View File

@ -155,14 +155,18 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
} }
Color _avatarColor(BuildContext context, int seed) { Color _avatarColor(BuildContext context, int seed) {
final scheme = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark;
final palette = [scheme.primaryContainer, scheme.tertiaryContainer, scheme.secondaryContainer]; final palette = isDark
? const [Color(0xFF1F3A37), Color(0xFF2A3140), Color(0xFF3A3224)]
: const [Color(0xFFD9F3F0), Color(0xFFE8E6E1), Color(0xFFFFE8C8)];
return palette[seed % palette.length]; return palette[seed % palette.length];
} }
Color _onAvatarColor(BuildContext context, int seed) { Color _onAvatarColor(BuildContext context, int seed) {
final scheme = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark;
final palette = [scheme.onPrimaryContainer, scheme.onTertiaryContainer, scheme.onSecondaryContainer]; final palette = isDark
? const [Color(0xFF2DD4BF), Color(0xFFF2F1EF), Color(0xFFFBBF24)]
: const [Color(0xFF0F766E), Color(0xFF1C1B1A), Color(0xFFB45309)];
return palette[seed % palette.length]; return palette[seed % palette.length];
} }
@ -251,26 +255,19 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
), ),
if (_rooms.isEmpty) if (_rooms.isEmpty)
Padding( Padding(
padding: const EdgeInsets.fromLTRB(32, 48, 32, 32), padding: const EdgeInsets.fromLTRB(32, 56, 32, 32),
child: Column( child: Column(
children: [ children: [
Container( Container(
width: 72, width: 68,
height: 72, height: 68,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
gradient: AppTheme.brandGradient, color: theme.colorScheme.primaryContainer,
boxShadow: [
BoxShadow(
color: const Color(0xFF6D5BD0).withValues(alpha: 0.3),
blurRadius: 24,
offset: const Offset(0, 10),
),
],
), ),
child: const Icon(Icons.chat_bubble_outline, size: 30, color: Colors.white), child: Icon(Icons.chat_bubble_outline, size: 28, color: theme.colorScheme.primary),
), ),
const SizedBox(height: 16), const SizedBox(height: 18),
Text('대화방이 없습니다', style: theme.textTheme.titleMedium), Text('대화방이 없습니다', style: theme.textTheme.titleMedium),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
@ -280,10 +277,10 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
height: 1.45, height: 1.5,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 18),
FilledButton.tonalIcon( FilledButton.tonalIcon(
onPressed: () async { onPressed: () async {
await Navigator.of(context).push( await Navigator.of(context).push(
@ -299,49 +296,73 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
), ),
for (final room in _rooms) for (final room in _rooms)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
child: ListTile( child: Material(
leading: CircleAvatar( color: AppTheme.glassFill(theme.brightness),
backgroundColor: _avatarColor(context, room.id), borderRadius: BorderRadius.circular(16),
child: Icon( child: InkWell(
room.isGroup ? Icons.groups_outlined : Icons.person_outline, borderRadius: BorderRadius.circular(16),
color: _onAvatarColor(context, room.id), onTap: () async {
), await Navigator.of(context).push(
), MaterialPageRoute(
title: Text( builder: (_) => ChatScreen(
_titleFor(room, me), conversationId: room.id,
style: theme.textTheme.titleSmall, title: _titleFor(room, me),
),
subtitle: Row(
children: [
if (room.twinDisabledByPeer) ...[
Icon(Icons.block, size: 13, color: theme.colorScheme.error),
const SizedBox(width: 4),
],
Expanded(
child: Text(
_subtitleFor(room, me),
style: theme.textTheme.bodySmall?.copyWith(
color: room.twinDisabledByPeer
? theme.colorScheme.error
: theme.colorScheme.onSurfaceVariant,
), ),
), ),
);
await _load();
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
child: Row(
children: [
CircleAvatar(
radius: 24,
backgroundColor: _avatarColor(context, room.id),
child: Icon(
room.isGroup ? Icons.groups_outlined : Icons.person_outline,
color: _onAvatarColor(context, room.id),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_titleFor(room, me),
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 3),
Row(
children: [
if (room.twinDisabledByPeer) ...[
Icon(Icons.block, size: 13, color: theme.colorScheme.error),
const SizedBox(width: 4),
],
Expanded(
child: Text(
_subtitleFor(room, me),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: room.twinDisabledByPeer
? theme.colorScheme.error
: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
],
),
),
Icon(Icons.chevron_right, size: 20, color: theme.colorScheme.onSurfaceVariant),
],
), ),
], ),
), ),
trailing: const Icon(Icons.chevron_right, size: 20),
onTap: () async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ChatScreen(
conversationId: room.id,
title: _titleFor(room, me),
),
),
);
await _load();
},
), ),
), ),
const SizedBox(height: 72), const SizedBox(height: 72),

View File

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../theme/app_theme.dart';
import '../widgets/primary_gradient_button.dart'; import '../widgets/primary_gradient_button.dart';
/// Phase 1 onboarding skeleton: capture a few style samples locally. /// Phase 1 onboarding skeleton: capture a few style samples locally.
@ -70,13 +69,10 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
width: 56, width: 56,
height: 56, height: 56,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: AppTheme.brandGradient, color: scheme.primary,
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(color: const Color(0xFF6D5BD0).withValues(alpha: 0.3), blurRadius: 18, offset: const Offset(0, 8)),
],
), ),
child: const Icon(Icons.record_voice_over_outlined, size: 28, color: Colors.white), child: Icon(Icons.record_voice_over_outlined, size: 28, color: scheme.onPrimary),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),

View File

@ -143,9 +143,6 @@ class _DemoTestPanel extends StatelessWidget {
color: AppTheme.glassFill(brightness), color: AppTheme.glassFill(brightness),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppTheme.glassBorder(brightness)), border: Border.all(color: AppTheme.glassBorder(brightness)),
boxShadow: [
BoxShadow(color: AppTheme.glassShadow(brightness), blurRadius: 20, offset: const Offset(0, 8)),
],
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -154,7 +151,7 @@ class _DemoTestPanel extends StatelessWidget {
'테스트용 (누구나)', '테스트용 (누구나)',
style: theme.textTheme.labelLarge?.copyWith( style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
letterSpacing: 0.4, letterSpacing: 0.2,
), ),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),

View File

@ -1,95 +1,133 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// Central design tokens for (Ykavu) "aurora glass" direction. /// Quiet Ink calm messenger direction for .
/// `backgroundGradient` + the glow orbs in [GradientBackdrop] paint behind /// Light: warm off-white canvas, charcoal ink, single deep-teal accent.
/// every screen (wired into `MaterialApp.builder`); cards/inputs/app bars /// Dark: charcoal surfaces, same teal (brighter), twin stays warm amber.
/// are semi-transparent "glass" surfaces with a soft colored shadow so they /// Avoids aurora/purple glass so chat reads as a daily messenger, not a demo.
/// visibly lift off that background. `twinAccent` stays a separate warm
/// accent so a twin-written bubble never reads as something the human
/// actually typed (PRD §3.1 ).
class AppTheme { class AppTheme {
AppTheme._(); AppTheme._();
static const _seed = Color(0xFF6D5BD0); // Light
static const canvasLight = Color(0xFFF7F6F3);
static const surfaceLight = Color(0xFFFFFFFF);
static const inkLight = Color(0xFF1C1B1A);
static const mutedLight = Color(0xFF6B6864);
static const lineLight = Color(0xFFE8E6E1);
static const teal = Color(0xFF0F766E);
static const tealSoft = Color(0xFFD9F3F0);
static const twinAmberLight = Color(0xFFB45309);
/// Brand gradient used for the wordmark, brand mark and primary CTAs. // Dark
static const canvasDark = Color(0xFF0E0F12);
static const surfaceDark = Color(0xFF1A1C22);
static const inkDark = Color(0xFFF2F1EF);
static const mutedDark = Color(0xFF9B9893);
static const lineDark = Color(0xFF2A2D36);
static const tealBright = Color(0xFF2DD4BF);
static const tealSoftDark = Color(0xFF143D3A);
static const twinAmberDark = Color(0xFFFBBF24);
/// Kept for call sites that still reference a "brand" fill solid teal, not a rainbow.
static const brandGradient = LinearGradient( static const brandGradient = LinearGradient(
begin: Alignment.centerLeft, begin: Alignment.centerLeft,
end: Alignment.centerRight, end: Alignment.centerRight,
colors: [Color(0xFF6D5BD0), Color(0xFFA855C9), Color(0xFFE0609A)], colors: [teal, Color(0xFF0D9488)],
); );
static Color twinAccent(Brightness brightness) => static Color twinAccent(Brightness brightness) =>
brightness == Brightness.dark ? Colors.amber.shade300 : Colors.amber.shade800; brightness == Brightness.dark ? twinAmberDark : twinAmberLight;
/// Diagonal aurora gradient painted behind every screen. Light: violet /// Soft near-flat wash (not aurora). Used by [GradientBackdrop].
/// sky pink, more saturated than a "safe" pastel so it reads as
/// intentional branding rather than a default Material backdrop. Dark:
/// deep indigo navy plum. Paired with the blurred glow orbs in
/// [GradientBackdrop].
static LinearGradient backgroundGradient(Brightness brightness) { static LinearGradient backgroundGradient(Brightness brightness) {
if (brightness == Brightness.dark) { if (brightness == Brightness.dark) {
return const LinearGradient( return const LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topCenter,
end: Alignment.bottomRight, end: Alignment.bottomCenter,
colors: [Color(0xFF201A3D), Color(0xFF16213E), Color(0xFF351C3C)], colors: [Color(0xFF12141A), canvasDark],
); );
} }
return const LinearGradient( return const LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topCenter,
end: Alignment.bottomRight, end: Alignment.bottomCenter,
colors: [Color(0xFFDCCBFF), Color(0xFFC3E4FF), Color(0xFFFFC9E8)], colors: [Color(0xFFF3F2EE), canvasLight],
); );
} }
/// Soft blurred accent blobs layered over [backgroundGradient] by static Color glowPrimary(Brightness brightness) =>
/// [GradientBackdrop] for depth ("aurora glow"). (brightness == Brightness.dark ? tealBright : teal).withValues(alpha: 0.06);
static Color glowPrimary(Brightness brightness) => brightness == Brightness.dark
? const Color(0xFF8B7CF6).withValues(alpha: 0.35)
: const Color(0xFFB79CFF).withValues(alpha: 0.55);
static Color glowSecondary(Brightness brightness) => brightness == Brightness.dark static Color glowSecondary(Brightness brightness) => Colors.transparent;
? const Color(0xFFE879C4).withValues(alpha: 0.25)
: const Color(0xFFFFA9DC).withValues(alpha: 0.50);
/// "Glass" fill for cards/panels semi-transparent so the gradient /// Panel fill (replaces glassmorphism).
/// behind still reads through, with a bright border for the classic static Color glassFill(Brightness brightness) =>
/// glassmorphism edge highlight and a tinted shadow so the surface brightness == Brightness.dark ? surfaceDark : surfaceLight;
/// visibly lifts off the backdrop instead of blending into it.
static Color glassFill(Brightness brightness) => brightness == Brightness.dark
? Colors.white.withValues(alpha: 0.08)
: Colors.white.withValues(alpha: 0.72);
static Color glassBorder(Brightness brightness) => brightness == Brightness.dark static Color glassBorder(Brightness brightness) =>
? Colors.white.withValues(alpha: 0.16) brightness == Brightness.dark ? lineDark : lineLight;
: Colors.white.withValues(alpha: 0.85);
static Color glassShadow(Brightness brightness) => brightness == Brightness.dark static Color glassShadow(Brightness brightness) =>
? Colors.black.withValues(alpha: 0.45) brightness == Brightness.dark ? Colors.black.withValues(alpha: 0.35) : const Color(0xFF1C1B1A).withValues(alpha: 0.06);
: const Color(0xFF6D5BD0).withValues(alpha: 0.18);
static Color mineBubble(Brightness brightness) =>
brightness == Brightness.dark ? tealSoftDark : teal;
static Color mineBubbleFg(Brightness brightness) =>
brightness == Brightness.dark ? inkDark : Colors.white;
static Color peerBubble(Brightness brightness) =>
brightness == Brightness.dark ? const Color(0xFF24272F) : const Color(0xFFEFEEEA);
static ThemeData light() => _build(Brightness.light); static ThemeData light() => _build(Brightness.light);
static ThemeData dark() => _build(Brightness.dark); static ThemeData dark() => _build(Brightness.dark);
static ThemeData _build(Brightness brightness) { static ThemeData _build(Brightness brightness) {
final scheme = ColorScheme.fromSeed(seedColor: _seed, brightness: brightness); final isDark = brightness == Brightness.dark;
final glass = glassFill(brightness); final primary = isDark ? tealBright : teal;
final glassEdge = glassBorder(brightness); final onPrimary = isDark ? canvasDark : Colors.white;
final glassShadowColor = glassShadow(brightness); final scheme = ColorScheme(
final glassShape = RoundedRectangleBorder( brightness: brightness,
borderRadius: BorderRadius.circular(18), primary: primary,
side: BorderSide(color: glassEdge, width: 1), onPrimary: onPrimary,
primaryContainer: isDark ? tealSoftDark : tealSoft,
onPrimaryContainer: isDark ? tealBright : const Color(0xFF0A4F4A),
secondary: isDark ? twinAmberDark : twinAmberLight,
onSecondary: isDark ? canvasDark : Colors.white,
secondaryContainer: isDark ? const Color(0xFF3D2E14) : const Color(0xFFFFE8C8),
onSecondaryContainer: isDark ? twinAmberDark : twinAmberLight,
tertiary: primary,
onTertiary: onPrimary,
error: isDark ? const Color(0xFFF87171) : const Color(0xFFB91C1C),
onError: Colors.white,
surface: isDark ? surfaceDark : surfaceLight,
onSurface: isDark ? inkDark : inkLight,
onSurfaceVariant: isDark ? mutedDark : mutedLight,
outline: isDark ? lineDark : lineLight,
outlineVariant: isDark ? lineDark : lineLight,
surfaceContainerHighest: isDark ? const Color(0xFF24272F) : const Color(0xFFEFEEEA),
surfaceContainerHigh: isDark ? const Color(0xFF20232A) : const Color(0xFFF3F2EE),
surfaceContainer: isDark ? surfaceDark : surfaceLight,
);
final baseText = GoogleFonts.plusJakartaSansTextTheme();
final textTheme = _textTheme(baseText, scheme.onSurface);
final panel = glassFill(brightness);
final edge = glassBorder(brightness);
final shadow = glassShadow(brightness);
final shape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: edge),
); );
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: brightness, brightness: brightness,
colorScheme: scheme, colorScheme: scheme,
// Transparent so the gradient painted by MaterialApp.builder shows
// through behind every Scaffold.
scaffoldBackgroundColor: Colors.transparent, scaffoldBackgroundColor: Colors.transparent,
visualDensity: VisualDensity.comfortable, visualDensity: VisualDensity.standard,
textTheme: _textTheme(), textTheme: textTheme,
primaryTextTheme: textTheme,
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
foregroundColor: scheme.onSurface, foregroundColor: scheme.onSurface,
@ -97,127 +135,128 @@ class AppTheme {
elevation: 0, elevation: 0,
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
centerTitle: false, centerTitle: false,
titleTextStyle: TextStyle( titleTextStyle: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700, letterSpacing: -0.3),
fontSize: 20,
fontWeight: FontWeight.w800,
color: scheme.onSurface,
letterSpacing: -0.2,
),
), ),
cardTheme: CardThemeData( cardTheme: CardThemeData(
elevation: 8, elevation: 0,
shadowColor: glassShadowColor, shadowColor: shadow,
color: glass, color: panel,
shape: glassShape, shape: shape,
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
), ),
listTileTheme: ListTileThemeData( listTileTheme: ListTileThemeData(
iconColor: scheme.onSurfaceVariant, iconColor: scheme.onSurfaceVariant,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
minVerticalPadding: 10,
), ),
dividerTheme: DividerThemeData(color: scheme.outlineVariant, space: 32, thickness: 1), dividerTheme: DividerThemeData(color: edge, space: 1, thickness: 1),
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: glass, fillColor: isDark ? const Color(0xFF14161C) : const Color(0xFFF0EFEC),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: glassEdge, width: 1), borderSide: BorderSide(color: edge),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: glassEdge, width: 1), borderSide: BorderSide(color: edge),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: scheme.primary, width: 1.6), borderSide: BorderSide(color: primary, width: 1.5),
), ),
errorBorder: OutlineInputBorder( errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: scheme.error, width: 1.2), borderSide: BorderSide(color: scheme.error),
), ),
focusedErrorBorder: OutlineInputBorder( focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: scheme.error, width: 1.6), borderSide: BorderSide(color: scheme.error, width: 1.5),
), ),
hintStyle: TextStyle(color: scheme.onSurfaceVariant.withValues(alpha: 0.7)), hintStyle: TextStyle(color: scheme.onSurfaceVariant.withValues(alpha: 0.75)),
), ),
filledButtonTheme: FilledButtonThemeData( filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: primary,
foregroundColor: onPrimary,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
textStyle: const TextStyle(fontWeight: FontWeight.w700), textStyle: const TextStyle(fontWeight: FontWeight.w700),
elevation: 0,
), ),
), ),
outlinedButtonTheme: OutlinedButtonThemeData( outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: scheme.onSurface,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
side: BorderSide(color: edge),
), ),
), ),
textButtonTheme: TextButtonThemeData( textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: primary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
), ),
), ),
iconButtonTheme: IconButtonThemeData( iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom( style: IconButton.styleFrom(
foregroundColor: scheme.onSurfaceVariant,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
), ),
), ),
segmentedButtonTheme: SegmentedButtonThemeData(
style: SegmentedButton.styleFrom(
backgroundColor: glass,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
),
chipTheme: ChipThemeData( chipTheme: ChipThemeData(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
borderRadius: BorderRadius.circular(10), side: BorderSide(color: edge),
side: BorderSide(color: glassEdge, width: 1), backgroundColor: panel,
),
side: BorderSide.none,
backgroundColor: glass,
labelStyle: TextStyle(color: scheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12), labelStyle: TextStyle(color: scheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
), ),
floatingActionButtonTheme: FloatingActionButtonThemeData( floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: scheme.primary, backgroundColor: primary,
foregroundColor: scheme.onPrimary, foregroundColor: onPrimary,
elevation: 4, elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
), ),
snackBarTheme: SnackBarThemeData( snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
backgroundColor: isDark ? surfaceDark : inkLight,
contentTextStyle: TextStyle(color: isDark ? inkDark : Colors.white),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
), ),
bannerTheme: MaterialBannerThemeData(
backgroundColor: glass,
padding: const EdgeInsets.all(16),
),
dialogTheme: DialogThemeData( dialogTheme: DialogThemeData(
backgroundColor: brightness == Brightness.dark backgroundColor: panel,
? const Color(0xFF211D3D)
: Colors.white.withValues(alpha: 0.95),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
), ),
progressIndicatorTheme: ProgressIndicatorThemeData(color: scheme.primary), progressIndicatorTheme: ProgressIndicatorThemeData(color: primary),
bannerTheme: MaterialBannerThemeData(backgroundColor: panel, padding: const EdgeInsets.all(16)),
); );
} }
static TextTheme _textTheme() { static TextTheme _textTheme(TextTheme base, Color ink) {
return const TextTheme( TextStyle s(TextStyle? t, {FontWeight? w, double? size, double? ls, double? h}) =>
displayLarge: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -1.2), (t ?? const TextStyle()).copyWith(
displaySmall: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.5), color: ink,
headlineSmall: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.3), fontWeight: w,
titleLarge: TextStyle(fontWeight: FontWeight.w700), fontSize: size,
titleMedium: TextStyle(fontWeight: FontWeight.w700), letterSpacing: ls,
titleSmall: TextStyle(fontWeight: FontWeight.w700), height: h,
bodyLarge: TextStyle(height: 1.4), );
bodyMedium: TextStyle(height: 1.4),
labelLarge: TextStyle(fontWeight: FontWeight.w700), return base.copyWith(
displayLarge: s(base.displayLarge, w: FontWeight.w700, size: 40, ls: -1.2),
displaySmall: s(base.displaySmall, w: FontWeight.w700, size: 32, ls: -0.8),
headlineSmall: s(base.headlineSmall, w: FontWeight.w700, size: 22, ls: -0.3),
titleLarge: s(base.titleLarge, w: FontWeight.w700, size: 18, ls: -0.2),
titleMedium: s(base.titleMedium, w: FontWeight.w600, size: 16),
titleSmall: s(base.titleSmall, w: FontWeight.w600, size: 14),
bodyLarge: s(base.bodyLarge, w: FontWeight.w400, size: 16, h: 1.45),
bodyMedium: s(base.bodyMedium, w: FontWeight.w400, size: 14, h: 1.45),
bodySmall: s(base.bodySmall, w: FontWeight.w400, size: 12, h: 1.4),
labelLarge: s(base.labelLarge, w: FontWeight.w600, size: 14),
labelSmall: s(base.labelSmall, w: FontWeight.w600, size: 11, ls: 0.1),
); );
} }
} }

View File

@ -2,10 +2,7 @@ import 'package:flutter/material.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
/// The app icon mark: a gradient-filled rounded badge with a bold /// Quiet Ink brand mark solid teal tile with a "Y" monogram.
/// "Y" monogram and a soft brand-tinted shadow, used wherever the splash /
/// signup / onboarding screens need a small brand anchor instead of a
/// generic Material icon.
class BrandMark extends StatelessWidget { class BrandMark extends StatelessWidget {
const BrandMark({super.key, this.size = 64}); const BrandMark({super.key, this.size = 64});
@ -13,18 +10,21 @@ class BrandMark extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final radius = size * 0.32; final isDark = Theme.of(context).brightness == Brightness.dark;
final fill = isDark ? AppTheme.tealBright : AppTheme.teal;
final fg = isDark ? AppTheme.canvasDark : Colors.white;
final radius = size * 0.28;
return Container( return Container(
width: size, width: size,
height: size, height: size,
decoration: BoxDecoration( decoration: BoxDecoration(
color: fill,
borderRadius: BorderRadius.circular(radius), borderRadius: BorderRadius.circular(radius),
gradient: AppTheme.brandGradient,
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: const Color(0xFF6D5BD0).withValues(alpha: 0.38), color: AppTheme.teal.withValues(alpha: isDark ? 0.25 : 0.18),
blurRadius: size * 0.35, blurRadius: size * 0.22,
offset: Offset(0, size * 0.12), offset: Offset(0, size * 0.08),
), ),
], ],
), ),
@ -32,10 +32,11 @@ class BrandMark extends StatelessWidget {
child: Text( child: Text(
'Y', 'Y',
style: TextStyle( style: TextStyle(
color: Colors.white, color: fg,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w800,
fontSize: size * 0.46, fontSize: size * 0.44,
height: 1, height: 1,
letterSpacing: -0.5,
), ),
), ),
); );

View File

@ -1,14 +1,8 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
/// Paints the app-wide aurora gradient + soft blurred glow orbs behind /// Quiet canvas behind every screen near-flat wash, no aurora orbs.
/// [child]. Wired into `MaterialApp.builder` so every screen gets it
/// automatically without each Scaffold needing its own background
/// screens just need a transparent `scaffoldBackgroundColor` (set globally
/// in [AppTheme]).
class GradientBackdrop extends StatelessWidget { class GradientBackdrop extends StatelessWidget {
const GradientBackdrop({super.key, required this.child}); const GradientBackdrop({super.key, required this.child});
@ -17,43 +11,9 @@ class GradientBackdrop extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final brightness = Theme.of(context).brightness; final brightness = Theme.of(context).brightness;
return Stack( return DecoratedBox(
fit: StackFit.expand, decoration: BoxDecoration(gradient: AppTheme.backgroundGradient(brightness)),
children: [ child: child,
DecoratedBox(decoration: BoxDecoration(gradient: AppTheme.backgroundGradient(brightness))),
Positioned(
top: -90,
right: -70,
child: _Glow(color: AppTheme.glowPrimary(brightness), size: 260),
),
Positioned(
bottom: -110,
left: -90,
child: _Glow(color: AppTheme.glowSecondary(brightness), size: 320),
),
if (child != null) child!,
],
);
}
}
class _Glow extends StatelessWidget {
const _Glow({required this.color, required this.size});
final Color color;
final double size;
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 60, sigmaY: 60),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
),
),
); );
} }
} }

View File

@ -2,9 +2,7 @@ import 'package:flutter/material.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
/// Renders [text] filled with [AppTheme.brandGradient] instead of a flat /// Brand wordmark ink (not rainbow gradient) so Quiet Ink stays calm.
/// color used for the "와카뷰" wordmark so the brand reads as designed
/// rather than default black-on-gradient body text.
class GradientText extends StatelessWidget { class GradientText extends StatelessWidget {
const GradientText(this.text, {super.key, this.style, this.textAlign}); const GradientText(this.text, {super.key, this.style, this.textAlign});
@ -14,10 +12,11 @@ class GradientText extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ShaderMask( final ink = Theme.of(context).brightness == Brightness.dark ? AppTheme.inkDark : AppTheme.inkLight;
blendMode: BlendMode.srcIn, return Text(
shaderCallback: (bounds) => AppTheme.brandGradient.createShader(Rect.fromLTWH(0, 0, bounds.width, bounds.height)), text,
child: Text(text, style: style, textAlign: textAlign), textAlign: textAlign,
style: (style ?? const TextStyle()).copyWith(color: ink, fontWeight: FontWeight.w800),
); );
} }
} }

View File

@ -3,8 +3,7 @@ import 'package:flutter/material.dart';
import '../models/models.dart'; import '../models/models.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
/// Twin messages get a dashed border + badge (PRD §3.1 ) so an /// Twin messages get a dashed border + badge (PRD §3.1 ).
/// auto-sent bubble never reads as something the human actually typed.
class MessageBubble extends StatelessWidget { class MessageBubble extends StatelessWidget {
const MessageBubble({ const MessageBubble({
super.key, super.key,
@ -31,6 +30,7 @@ class MessageBubble extends StatelessWidget {
final twin = message.isTwin; final twin = message.isTwin;
final retracted = message.retracted; final retracted = message.retracted;
final accent = AppTheme.twinAccent(theme.brightness); final accent = AppTheme.twinAccent(theme.brightness);
final brightness = theme.brightness;
final Color bg; final Color bg;
final Color fg; final Color fg;
@ -38,18 +38,18 @@ class MessageBubble extends StatelessWidget {
bg = scheme.surfaceContainerHigh; bg = scheme.surfaceContainerHigh;
fg = scheme.onSurfaceVariant; fg = scheme.onSurfaceVariant;
} else if (isMine) { } else if (isMine) {
bg = scheme.primaryContainer; bg = AppTheme.mineBubble(brightness);
fg = scheme.onPrimaryContainer; fg = AppTheme.mineBubbleFg(brightness);
} else { } else {
bg = scheme.surfaceContainerHighest; bg = AppTheme.peerBubble(brightness);
fg = scheme.onSurface; fg = scheme.onSurface;
} }
final radius = BorderRadius.only( final radius = BorderRadius.only(
topLeft: const Radius.circular(18), topLeft: const Radius.circular(18),
topRight: const Radius.circular(18), topRight: const Radius.circular(18),
bottomLeft: Radius.circular(isMine ? 18 : 4), bottomLeft: Radius.circular(isMine ? 18 : 5),
bottomRight: Radius.circular(isMine ? 4 : 18), bottomRight: Radius.circular(isMine ? 5 : 18),
); );
final bubble = Container( final bubble = Container(
@ -94,12 +94,12 @@ class MessageBubble extends StatelessWidget {
else else
Text( Text(
message.text, message.text,
style: theme.textTheme.bodyMedium?.copyWith(color: fg, height: 1.35), style: theme.textTheme.bodyMedium?.copyWith(color: fg, height: 1.4),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
_time(message.createdAt), _time(message.createdAt),
style: theme.textTheme.labelSmall?.copyWith(color: fg.withOpacity(0.55), fontSize: 10), style: theme.textTheme.labelSmall?.copyWith(color: fg.withValues(alpha: 0.55), fontSize: 10),
), ),
], ],
), ),
@ -135,7 +135,7 @@ class MessageBubble extends StatelessWidget {
); );
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), padding: const EdgeInsets.symmetric(vertical: 3, horizontal: 12),
child: Align( child: Align(
alignment: isMine ? Alignment.centerRight : Alignment.centerLeft, alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
child: content, child: content,
@ -155,11 +155,10 @@ class _DashedRRectPainter extends CustomPainter {
final paint = Paint() final paint = Paint()
..color = color ..color = color
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 1.4; ..strokeWidth = 1.2;
final rrect = radius.toRRect(Rect.fromLTWH(0.7, 0.7, size.width - 1.4, size.height - 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 path = Path()..addRRect(rrect);
final dashed = _dashPath(path, dashLength: 5, gapLength: 4); canvas.drawPath(_dashPath(path, dashLength: 5, gapLength: 4), paint);
canvas.drawPath(dashed, paint);
} }
Path _dashPath(Path source, {required double dashLength, required double gapLength}) { Path _dashPath(Path source, {required double dashLength, required double gapLength}) {

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../theme/app_theme.dart';
/// Shows the signed-in numeric user id with one-tap copy. /// Shows the signed-in numeric user id with one-tap copy.
class MyUserIdChip extends StatelessWidget { class MyUserIdChip extends StatelessWidget {
const MyUserIdChip({super.key, required this.userId, this.compact = false}); const MyUserIdChip({super.key, required this.userId, this.compact = false});
@ -29,18 +31,23 @@ class MyUserIdChip extends StatelessWidget {
icon: const Icon(Icons.badge_outlined), icon: const Icon(Icons.badge_outlined),
); );
} }
final brightness = theme.brightness;
return Material( return Material(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7), color: AppTheme.glassFill(brightness),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(14),
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(14),
onTap: () => _copy(context), onTap: () => _copy(context),
child: Padding( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppTheme.glassBorder(brightness)),
),
child: Row( child: Row(
children: [ children: [
Icon(Icons.badge_outlined, size: 18, color: theme.colorScheme.primary), Icon(Icons.badge_outlined, size: 18, color: theme.colorScheme.primary),
const SizedBox(width: 8), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -55,16 +62,17 @@ class MyUserIdChip extends StatelessWidget {
'$userId', '$userId',
style: theme.textTheme.titleMedium?.copyWith( style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.5, letterSpacing: 0.4,
), ),
), ),
], ],
), ),
), ),
Text( Text(
'탭하여 복사', '복사',
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
fontWeight: FontWeight.w700,
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),

View File

@ -2,10 +2,7 @@ import 'package:flutter/material.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
/// Primary call-to-action button filled with [AppTheme.brandGradient] and a /// Primary CTA solid teal (API name kept for existing call sites).
/// soft brand-tinted shadow, so the one action per screen that matters most
/// ( / ) visibly outranks the flat glass buttons
/// around it.
class PrimaryGradientButton extends StatelessWidget { class PrimaryGradientButton extends StatelessWidget {
const PrimaryGradientButton({ const PrimaryGradientButton({
super.key, super.key,
@ -20,41 +17,29 @@ class PrimaryGradientButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final disabled = onPressed == null; final isDark = Theme.of(context).brightness == Brightness.dark;
return Opacity( final bg = isDark ? AppTheme.tealBright : AppTheme.teal;
opacity: disabled ? 0.6 : 1, final fg = isDark ? AppTheme.canvasDark : Colors.white;
child: Material( final disabled = onPressed == null || loading;
color: Colors.transparent, return SizedBox(
borderRadius: BorderRadius.circular(16), height: 52,
child: InkWell( width: double.infinity,
borderRadius: BorderRadius.circular(16), child: FilledButton(
onTap: onPressed, onPressed: disabled && !loading ? null : onPressed,
child: Container( style: FilledButton.styleFrom(
height: 52, backgroundColor: bg,
alignment: Alignment.center, foregroundColor: fg,
decoration: BoxDecoration( disabledBackgroundColor: bg.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
gradient: AppTheme.brandGradient, elevation: 0,
boxShadow: [
BoxShadow(
color: const Color(0xFF6D5BD0).withValues(alpha: 0.35),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: loading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: Text(
label,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 16),
),
),
), ),
child: loading
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: fg),
)
: Text(label, style: TextStyle(color: fg, fontWeight: FontWeight.w700, fontSize: 16)),
), ),
); );
} }