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] `core-backend/` · `ai-service/` · 하드게이트·거부권·초대·되돌리기·L0~L2 QA
- [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 후속
@ -30,8 +37,8 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
- 앱 코드는 클로즈드 베타 직전 수준
- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료**
- 진행 중: **N4 FCM 코드 경로** → Master 시크릿 대기 → Android UI QA
- 실 FCM 전송·Android 실기기 탭 · 사람 PoC 실행은 남음
- UI: **Quiet Ink** (틸 액센트 · 라이트/다크) 전면 적용 중 — FCM 실기기는 잠시 후순위
- 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음
### NEXT 순서

View File

@ -258,7 +258,7 @@ class _ChatScreenState extends State<ChatScreen> {
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer.withOpacity(0.85),
color: theme.colorScheme.errorContainer.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(16),
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),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer.withOpacity(0.85),
color: theme.colorScheme.secondaryContainer.withValues(alpha: 0.95),
borderRadius: BorderRadius.circular(16),
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) {
final scheme = Theme.of(context).colorScheme;
final palette = [scheme.primaryContainer, scheme.tertiaryContainer, scheme.secondaryContainer];
final isDark = Theme.of(context).brightness == Brightness.dark;
final palette = isDark
? const [Color(0xFF1F3A37), Color(0xFF2A3140), Color(0xFF3A3224)]
: const [Color(0xFFD9F3F0), Color(0xFFE8E6E1), Color(0xFFFFE8C8)];
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];
final isDark = Theme.of(context).brightness == Brightness.dark;
final palette = isDark
? const [Color(0xFF2DD4BF), Color(0xFFF2F1EF), Color(0xFFFBBF24)]
: const [Color(0xFF0F766E), Color(0xFF1C1B1A), Color(0xFFB45309)];
return palette[seed % palette.length];
}
@ -251,26 +255,19 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
),
if (_rooms.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(32, 48, 32, 32),
padding: const EdgeInsets.fromLTRB(32, 56, 32, 32),
child: Column(
children: [
Container(
width: 72,
height: 72,
width: 68,
height: 68,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: AppTheme.brandGradient,
boxShadow: [
BoxShadow(
color: const Color(0xFF6D5BD0).withValues(alpha: 0.3),
blurRadius: 24,
offset: const Offset(0, 10),
),
],
color: theme.colorScheme.primaryContainer,
),
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),
const SizedBox(height: 8),
Text(
@ -280,10 +277,10 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.45,
height: 1.5,
),
),
const SizedBox(height: 16),
const SizedBox(height: 18),
FilledButton.tonalIcon(
onPressed: () async {
await Navigator.of(context).push(
@ -299,49 +296,73 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
),
for (final room in _rooms)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: ListTile(
leading: CircleAvatar(
backgroundColor: _avatarColor(context, room.id),
child: Icon(
room.isGroup ? Icons.groups_outlined : Icons.person_outline,
color: _onAvatarColor(context, room.id),
),
),
title: Text(
_titleFor(room, me),
style: theme.textTheme.titleSmall,
),
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,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
child: Material(
color: AppTheme.glassFill(theme.brightness),
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ChatScreen(
conversationId: room.id,
title: _titleFor(room, me),
),
),
);
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),

View File

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

View File

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

View File

@ -1,95 +1,133 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// Central design tokens for (Ykavu) "aurora glass" direction.
/// `backgroundGradient` + the glow orbs in [GradientBackdrop] paint behind
/// every screen (wired into `MaterialApp.builder`); cards/inputs/app bars
/// are semi-transparent "glass" surfaces with a soft colored shadow so they
/// 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 ).
/// Quiet Ink calm messenger direction for .
/// Light: warm off-white canvas, charcoal ink, single deep-teal accent.
/// Dark: charcoal surfaces, same teal (brighter), twin stays warm amber.
/// Avoids aurora/purple glass so chat reads as a daily messenger, not a demo.
class 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(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0xFF6D5BD0), Color(0xFFA855C9), Color(0xFFE0609A)],
colors: [teal, Color(0xFF0D9488)],
);
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
/// 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].
/// Soft near-flat wash (not aurora). Used by [GradientBackdrop].
static LinearGradient backgroundGradient(Brightness brightness) {
if (brightness == Brightness.dark) {
return const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF201A3D), Color(0xFF16213E), Color(0xFF351C3C)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF12141A), canvasDark],
);
}
return const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFDCCBFF), Color(0xFFC3E4FF), Color(0xFFFFC9E8)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFF3F2EE), canvasLight],
);
}
/// Soft blurred accent blobs layered over [backgroundGradient] by
/// [GradientBackdrop] for depth ("aurora glow").
static Color glowPrimary(Brightness brightness) => brightness == Brightness.dark
? const Color(0xFF8B7CF6).withValues(alpha: 0.35)
: const Color(0xFFB79CFF).withValues(alpha: 0.55);
static Color glowPrimary(Brightness brightness) =>
(brightness == Brightness.dark ? tealBright : teal).withValues(alpha: 0.06);
static Color glowSecondary(Brightness brightness) => brightness == Brightness.dark
? const Color(0xFFE879C4).withValues(alpha: 0.25)
: const Color(0xFFFFA9DC).withValues(alpha: 0.50);
static Color glowSecondary(Brightness brightness) => Colors.transparent;
/// "Glass" fill for cards/panels semi-transparent so the gradient
/// behind still reads through, with a bright border for the classic
/// glassmorphism edge highlight and a tinted shadow so the surface
/// 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);
/// Panel fill (replaces glassmorphism).
static Color glassFill(Brightness brightness) =>
brightness == Brightness.dark ? surfaceDark : surfaceLight;
static Color glassBorder(Brightness brightness) => brightness == Brightness.dark
? Colors.white.withValues(alpha: 0.16)
: Colors.white.withValues(alpha: 0.85);
static Color glassBorder(Brightness brightness) =>
brightness == Brightness.dark ? lineDark : lineLight;
static Color glassShadow(Brightness brightness) => brightness == Brightness.dark
? Colors.black.withValues(alpha: 0.45)
: const Color(0xFF6D5BD0).withValues(alpha: 0.18);
static Color glassShadow(Brightness brightness) =>
brightness == Brightness.dark ? Colors.black.withValues(alpha: 0.35) : const Color(0xFF1C1B1A).withValues(alpha: 0.06);
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 dark() => _build(Brightness.dark);
static ThemeData _build(Brightness brightness) {
final scheme = ColorScheme.fromSeed(seedColor: _seed, brightness: brightness);
final glass = glassFill(brightness);
final glassEdge = glassBorder(brightness);
final glassShadowColor = glassShadow(brightness);
final glassShape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
side: BorderSide(color: glassEdge, width: 1),
final isDark = brightness == Brightness.dark;
final primary = isDark ? tealBright : teal;
final onPrimary = isDark ? canvasDark : Colors.white;
final scheme = ColorScheme(
brightness: brightness,
primary: primary,
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(
useMaterial3: true,
brightness: brightness,
colorScheme: scheme,
// Transparent so the gradient painted by MaterialApp.builder shows
// through behind every Scaffold.
scaffoldBackgroundColor: Colors.transparent,
visualDensity: VisualDensity.comfortable,
textTheme: _textTheme(),
visualDensity: VisualDensity.standard,
textTheme: textTheme,
primaryTextTheme: textTheme,
appBarTheme: AppBarTheme(
backgroundColor: Colors.transparent,
foregroundColor: scheme.onSurface,
@ -97,127 +135,128 @@ class AppTheme {
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: scheme.onSurface,
letterSpacing: -0.2,
),
titleTextStyle: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700, letterSpacing: -0.3),
),
cardTheme: CardThemeData(
elevation: 8,
shadowColor: glassShadowColor,
color: glass,
shape: glassShape,
elevation: 0,
shadowColor: shadow,
color: panel,
shape: shape,
margin: EdgeInsets.zero,
),
listTileTheme: ListTileThemeData(
iconColor: scheme.onSurfaceVariant,
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(
filled: true,
fillColor: glass,
fillColor: isDark ? const Color(0xFF14161C) : const Color(0xFFF0EFEC),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: glassEdge, width: 1),
borderSide: BorderSide(color: edge),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: glassEdge, width: 1),
borderSide: BorderSide(color: edge),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: scheme.primary, width: 1.6),
borderSide: BorderSide(color: primary, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: scheme.error, width: 1.2),
borderSide: BorderSide(color: scheme.error),
),
focusedErrorBorder: OutlineInputBorder(
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(
style: FilledButton.styleFrom(
backgroundColor: primary,
foregroundColor: onPrimary,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
textStyle: const TextStyle(fontWeight: FontWeight.w700),
elevation: 0,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: scheme.onSurface,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
side: BorderSide(color: edge),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: primary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(
foregroundColor: scheme.onSurfaceVariant,
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(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: glassEdge, width: 1),
),
side: BorderSide.none,
backgroundColor: glass,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
side: BorderSide(color: edge),
backgroundColor: panel,
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: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
backgroundColor: primary,
foregroundColor: onPrimary,
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: isDark ? surfaceDark : inkLight,
contentTextStyle: TextStyle(color: isDark ? inkDark : Colors.white),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
bannerTheme: MaterialBannerThemeData(
backgroundColor: glass,
padding: const EdgeInsets.all(16),
),
dialogTheme: DialogThemeData(
backgroundColor: brightness == Brightness.dark
? const Color(0xFF211D3D)
: Colors.white.withValues(alpha: 0.95),
backgroundColor: panel,
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() {
return const TextTheme(
displayLarge: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -1.2),
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),
static TextTheme _textTheme(TextTheme base, Color ink) {
TextStyle s(TextStyle? t, {FontWeight? w, double? size, double? ls, double? h}) =>
(t ?? const TextStyle()).copyWith(
color: ink,
fontWeight: w,
fontSize: size,
letterSpacing: ls,
height: h,
);
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';
/// The app icon mark: a gradient-filled rounded badge with a bold
/// "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.
/// Quiet Ink brand mark solid teal tile with a "Y" monogram.
class BrandMark extends StatelessWidget {
const BrandMark({super.key, this.size = 64});
@ -13,18 +10,21 @@ class BrandMark extends StatelessWidget {
@override
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(
width: size,
height: size,
decoration: BoxDecoration(
color: fill,
borderRadius: BorderRadius.circular(radius),
gradient: AppTheme.brandGradient,
boxShadow: [
BoxShadow(
color: const Color(0xFF6D5BD0).withValues(alpha: 0.38),
blurRadius: size * 0.35,
offset: Offset(0, size * 0.12),
color: AppTheme.teal.withValues(alpha: isDark ? 0.25 : 0.18),
blurRadius: size * 0.22,
offset: Offset(0, size * 0.08),
),
],
),
@ -32,10 +32,11 @@ class BrandMark extends StatelessWidget {
child: Text(
'Y',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
fontSize: size * 0.46,
color: fg,
fontWeight: FontWeight.w800,
fontSize: size * 0.44,
height: 1,
letterSpacing: -0.5,
),
),
);

View File

@ -1,14 +1,8 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// Paints the app-wide aurora gradient + soft blurred glow orbs behind
/// [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]).
/// Quiet canvas behind every screen near-flat wash, no aurora orbs.
class GradientBackdrop extends StatelessWidget {
const GradientBackdrop({super.key, required this.child});
@ -17,43 +11,9 @@ class GradientBackdrop extends StatelessWidget {
@override
Widget build(BuildContext context) {
final brightness = Theme.of(context).brightness;
return Stack(
fit: StackFit.expand,
children: [
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),
),
),
return DecoratedBox(
decoration: BoxDecoration(gradient: AppTheme.backgroundGradient(brightness)),
child: child,
);
}
}

View File

@ -2,9 +2,7 @@ import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// Renders [text] filled with [AppTheme.brandGradient] instead of a flat
/// color used for the "와카뷰" wordmark so the brand reads as designed
/// rather than default black-on-gradient body text.
/// Brand wordmark ink (not rainbow gradient) so Quiet Ink stays calm.
class GradientText extends StatelessWidget {
const GradientText(this.text, {super.key, this.style, this.textAlign});
@ -14,10 +12,11 @@ class GradientText extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ShaderMask(
blendMode: BlendMode.srcIn,
shaderCallback: (bounds) => AppTheme.brandGradient.createShader(Rect.fromLTWH(0, 0, bounds.width, bounds.height)),
child: Text(text, style: style, textAlign: textAlign),
final ink = Theme.of(context).brightness == Brightness.dark ? AppTheme.inkDark : AppTheme.inkLight;
return Text(
text,
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 '../theme/app_theme.dart';
/// Twin messages get a dashed border + badge (PRD §3.1 ) so an
/// auto-sent bubble never reads as something the human actually typed.
/// Twin messages get a dashed border + badge (PRD §3.1 ).
class MessageBubble extends StatelessWidget {
const MessageBubble({
super.key,
@ -31,6 +30,7 @@ class MessageBubble extends StatelessWidget {
final twin = message.isTwin;
final retracted = message.retracted;
final accent = AppTheme.twinAccent(theme.brightness);
final brightness = theme.brightness;
final Color bg;
final Color fg;
@ -38,18 +38,18 @@ class MessageBubble extends StatelessWidget {
bg = scheme.surfaceContainerHigh;
fg = scheme.onSurfaceVariant;
} else if (isMine) {
bg = scheme.primaryContainer;
fg = scheme.onPrimaryContainer;
bg = AppTheme.mineBubble(brightness);
fg = AppTheme.mineBubbleFg(brightness);
} else {
bg = scheme.surfaceContainerHighest;
bg = AppTheme.peerBubble(brightness);
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),
bottomLeft: Radius.circular(isMine ? 18 : 5),
bottomRight: Radius.circular(isMine ? 5 : 18),
);
final bubble = Container(
@ -94,12 +94,12 @@ class MessageBubble extends StatelessWidget {
else
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),
Text(
_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(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
padding: const EdgeInsets.symmetric(vertical: 3, horizontal: 12),
child: Align(
alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
child: content,
@ -155,11 +155,10 @@ class _DashedRRectPainter extends CustomPainter {
final paint = Paint()
..color = color
..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 path = Path()..addRRect(rrect);
final dashed = _dashPath(path, dashLength: 5, gapLength: 4);
canvas.drawPath(dashed, paint);
canvas.drawPath(_dashPath(path, dashLength: 5, gapLength: 4), paint);
}
Path _dashPath(Path source, {required double dashLength, required double gapLength}) {

View File

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

View File

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