From 5578bf443f2f94ea4a11aea2164089fd6eb858b8 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Tue, 30 Sep 2025 15:00:23 +0900 Subject: [PATCH] =?UTF-8?q?=ED=97=A4=EB=8D=94=E2=88=99=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20UI=20=EC=A0=95=EB=B9=84=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EB=B2=A4=EB=8D=94=20=EC=98=A4=EB=A5=98=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로그인 화면 상단에 브랜드 아이콘과 안내 문구를 추가하고 카드/푸터 레이아웃을 정리 - 네비게이션 레일/앱바 구성 재배치, 테마 토글 상단 이동, 그라데이션/브랜드 아이콘 스타일 조정 - ScaffoldMessenger 의존성 문제를 피하도록 벤더 컨트롤러 에러 스낵바 호출 시점을 프레임 이후로 이연 - IMPLEMENTATION_TASKS.md에 이번 변경 내역을 요약하여 진행 상황을 문서화 --- doc/IMPLEMENTATION_TASKS.md | 6 + .../login/presentation/pages/login_page.dart | 251 +++++++++++++----- .../presentation/pages/vendor_page.dart | 15 +- lib/widgets/app_shell.dart | 151 ++++++++--- 4 files changed, 305 insertions(+), 118 deletions(-) diff --git a/doc/IMPLEMENTATION_TASKS.md b/doc/IMPLEMENTATION_TASKS.md index 926e8c5..1b3c427 100644 --- a/doc/IMPLEMENTATION_TASKS.md +++ b/doc/IMPLEMENTATION_TASKS.md @@ -2,6 +2,12 @@ 본 체크리스트는 PRD(`doc/PRD_입출고_결재_v2.md`)를 기준으로 shadcn_ui 스타일과 반응형 패턴을 준수하여 화면을 구현하기 위한 단계별 작업 목록입니다. 작업 순서는 ① 코드 시작 전 최종 확인 → ② UI 스캐폴딩/상호작용 구현 → ③ 실제 API 연동(Dio/ApiClient/DI)입니다. Mock 데이터는 사용하지 않습니다. +## 진행 상황 업데이트 (2024-08-08) +- 로그인 화면 상단에 브랜드 아이콘과 메시지를 추가하고 카드·푸터 구성을 재정리하여 Superport v2 공통 UI와 톤을 통일했습니다. +- 사이드바 네비게이션을 가로 정렬 구조로 전환하고 AppBar 브랜드 타이틀/테마 토글/로그아웃 버튼 배치를 재구성해 헤더 UX를 개선했습니다. +- AppBar 전역 그라데이션과 브랜드 아이콘 박스를 정돈(수직 그라데이션, 단색 아이콘 배경)해 시각적 집중도와 일관성을 확보했습니다. +- 벤더 화면 에러 처리 시 `ScaffoldMessenger` 의존성 오류가 발생하지 않도록 메시지 표시를 프레임 이후에 수행하도록 보강했습니다. + ## 0) 코드 시작 전 최종 확인(Repository/환경) - [x] Flutter 버전/채널 확인, `flutter pub get` - [x] `pubspec.yaml` 확인: `go_router`, `shadcn_ui`, `intl`, `two_dimensional_scrollables`, `lucide_icons_flutter` 포함 diff --git a/lib/features/login/presentation/pages/login_page.dart b/lib/features/login/presentation/pages/login_page.dart index 5d3ccaf..24ee948 100644 --- a/lib/features/login/presentation/pages/login_page.dart +++ b/lib/features/login/presentation/pages/login_page.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -64,81 +66,188 @@ class _LoginPageState extends State { final theme = ShadTheme.of(context); return Scaffold( - body: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 460), - child: Padding( - padding: const EdgeInsets.all(24), - child: ShadCard( - title: Text('Superport v2 로그인', style: theme.textTheme.h3), - description: Text( - '사번 또는 이메일과 비밀번호를 입력하여 대시보드로 이동합니다.', - style: theme.textTheme.muted, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ShadInput( - controller: idController, - placeholder: const Text('사번 또는 이메일'), - autofillHints: const [AutofillHints.username], - leading: const Icon(LucideIcons.user), - ), - const SizedBox(height: 16), - ShadInput( - controller: passwordController, - placeholder: const Text('비밀번호'), - obscureText: true, - autofillHints: const [AutofillHints.password], - leading: const Icon(LucideIcons.lock), - ), - const SizedBox(height: 12), - Row( - children: [ - ShadSwitch( - value: rememberMe, - onChanged: (value) => - setState(() => rememberMe = value), - ), - const SizedBox(width: 12), - Text('자동 로그인', style: theme.textTheme.small), - ], - ), - const SizedBox(height: 24), - if (errorMessage != null) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text( - errorMessage!, - style: theme.textTheme.small.copyWith( - color: theme.colorScheme.destructive, - ), - ), - ), - ShadButton( - onPressed: isLoading ? null : _handleSubmit, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - if (isLoading) ...[ - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(width: 8), - ], - const Text('로그인'), - ], - ), - ), - ], - ), + backgroundColor: theme.colorScheme.background, + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildHeader(theme), + const SizedBox(height: 32), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: _buildLoginCard(theme), + ), + const SizedBox(height: 24), + _buildFooter(theme), + ], ), ), ), ), ); } + + /// 로그인 헤더에 브랜드 아이콘과 안내 문구를 배치한다. + Widget _buildHeader(ShadThemeData theme) { + return Column( + children: [ + DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + theme.colorScheme.primary, + theme.colorScheme.primary.withValues(alpha: 0.8), + theme.colorScheme.primary.withValues(alpha: 0.6), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: theme.colorScheme.primary.withValues(alpha: 0.3), + blurRadius: 24, + offset: const Offset(0, 12), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: TweenAnimationBuilder( + duration: const Duration(milliseconds: 1400), + tween: Tween(begin: 0, end: 1), + curve: Curves.easeInOut, + builder: (context, value, child) { + return Transform.rotate( + angle: value * 2 * math.pi * 0.08, + child: child, + ); + }, + child: Icon( + LucideIcons.ship, + size: 48, + color: theme.colorScheme.primaryForeground, + ), + ), + ), + ), + const SizedBox(height: 24), + Text( + 'Superport v2', + style: theme.textTheme.h2.copyWith( + foreground: Paint() + ..shader = LinearGradient( + colors: [ + theme.colorScheme.primary, + theme.colorScheme.primary.withValues(alpha: 0.7), + ], + ).createShader(const Rect.fromLTWH(0, 0, 220, 80)), + ), + ), + const SizedBox(height: 8), + Text( + '포트 운영 전용 스마트 ERP', + style: theme.textTheme.muted, + textAlign: TextAlign.center, + ), + ], + ); + } + + /// 로그인 폼 카드 레이아웃을 구성한다. + Widget _buildLoginCard(ShadThemeData theme) { + return ShadCard( + title: Text('Superport v2 로그인', style: theme.textTheme.h3), + description: Text( + '사번 또는 이메일과 비밀번호를 입력하여 대시보드로 이동합니다.', + style: theme.textTheme.muted, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ShadInput( + controller: idController, + placeholder: const Text('사번 또는 이메일'), + autofillHints: const [AutofillHints.username], + leading: const Icon(LucideIcons.user), + ), + const SizedBox(height: 16), + ShadInput( + controller: passwordController, + placeholder: const Text('비밀번호'), + obscureText: true, + autofillHints: const [AutofillHints.password], + leading: const Icon(LucideIcons.lock), + ), + const SizedBox(height: 12), + Row( + children: [ + ShadSwitch( + value: rememberMe, + onChanged: (value) => setState(() => rememberMe = value), + ), + const SizedBox(width: 12), + Text('자동 로그인', style: theme.textTheme.small), + ], + ), + const SizedBox(height: 24), + if (errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + errorMessage!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ), + ShadButton( + onPressed: isLoading ? null : _handleSubmit, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (isLoading) ...[ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 8), + ], + const Text('로그인'), + ], + ), + ), + ], + ), + ); + } + + /// 로그인 하단에 간단한 안내 메시지를 노출한다. + Widget _buildFooter(ShadThemeData theme) { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Icon(LucideIcons.packagePlus, size: 18), + SizedBox(width: 12), + Icon(LucideIcons.packageMinus, size: 18), + SizedBox(width: 12), + Icon(LucideIcons.handshake, size: 18), + ], + ), + const SizedBox(height: 16), + Text( + '입고 · 출고 · 대여 전 과정을 한 곳에서 관리하세요.', + style: theme.textTheme.small, + textAlign: TextAlign.center, + ), + ], + ); + } } diff --git a/lib/features/masters/vendor/presentation/pages/vendor_page.dart b/lib/features/masters/vendor/presentation/pages/vendor_page.dart index 720f649..227866e 100644 --- a/lib/features/masters/vendor/presentation/pages/vendor_page.dart +++ b/lib/features/masters/vendor/presentation/pages/vendor_page.dart @@ -116,10 +116,17 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { final error = _controller.errorMessage; if (error != null && error != _lastError && mounted) { _lastError = error; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(error))); - _controller.clearError(); + // 스캐폴드 메시지는 프레임 빌드 이후 안전하게 호출한다. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger != null) { + messenger.showSnackBar(SnackBar(content: Text(error))); + } + _controller.clearError(); + }); } } diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index db9d36d..e8f53b0 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -29,18 +29,27 @@ class AppShell extends StatelessWidget { if (manager.can(page.path, PermissionAction.view)) page, ]; final pages = filteredPages.isEmpty ? allAppPages : filteredPages; + final themeController = ThemeControllerScope.of(context); + final appBar = _GradientAppBar( + title: const _BrandTitle(), + actions: [ + _ThemeMenuButton( + mode: themeController.mode, + onChanged: themeController.update, + ), + const SizedBox(width: 8), + IconButton( + tooltip: '로그아웃', + icon: const Icon(lucide.LucideIcons.logOut), + onPressed: () => context.go(loginRoutePath), + ), + const SizedBox(width: 8), + ], + ); + if (isWide) { return Scaffold( - appBar: AppBar( - title: const Text('Superport v2'), - actions: [ - IconButton( - tooltip: '로그아웃', - icon: const Icon(lucide.LucideIcons.logOut), - onPressed: () => context.go(loginRoutePath), - ), - ], - ), + appBar: appBar, body: Row( children: [ _NavigationRail(currentLocation: currentLocation, pages: pages), @@ -52,16 +61,7 @@ class AppShell extends StatelessWidget { } return Scaffold( - appBar: AppBar( - title: const Text('Superport v2'), - actions: [ - IconButton( - tooltip: '로그아웃', - icon: const Icon(lucide.LucideIcons.logOut), - onPressed: () => context.go(loginRoutePath), - ), - ], - ), + appBar: appBar, drawer: Drawer( child: SafeArea( child: _NavigationList( @@ -92,18 +92,14 @@ class _NavigationRail extends StatelessWidget { final selectedIndex = _selectedIndex(currentLocation, pages); final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final themeController = ThemeControllerScope.of(context); - final currentThemeMode = themeController.mode; return Container( - width: 104, + width: 220, decoration: BoxDecoration( border: Border(right: BorderSide(color: colorScheme.outlineVariant)), ), child: Column( children: [ - const SizedBox(height: 24), - const FlutterLogo(size: 48), const SizedBox(height: 24), Expanded( child: ListView.builder( @@ -134,11 +130,10 @@ class _NavigationRail extends StatelessWidget { }, child: Padding( padding: const EdgeInsets.symmetric( - vertical: 12, - horizontal: 8, + vertical: 10, + horizontal: 12, ), - child: Column( - mainAxisSize: MainAxisSize.min, + child: Row( children: [ Icon( page.icon, @@ -147,13 +142,14 @@ class _NavigationRail extends StatelessWidget { ? colorScheme.primary : colorScheme.onSurfaceVariant, ), - const SizedBox(height: 6), - Text( - page.label, - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: textStyle, + const SizedBox(width: 12), + Expanded( + child: Text( + page.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle, + ), ), ], ), @@ -164,13 +160,6 @@ class _NavigationRail extends StatelessWidget { }, ), ), - Padding( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 16), - child: _ThemeMenuButton( - mode: currentThemeMode, - onChanged: themeController.update, - ), - ), ], ), ); @@ -228,6 +217,82 @@ class _NavigationList extends StatelessWidget { } } +class _BrandTitle extends StatelessWidget { + const _BrandTitle(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.primary, + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Icon( + lucide.LucideIcons.ship, + size: 28, + color: colorScheme.onPrimary, + ), + ), + ), + const SizedBox(width: 12), + Text( + 'Superport v2', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} + +class _GradientAppBar extends StatelessWidget implements PreferredSizeWidget { + const _GradientAppBar({required this.title, required this.actions}); + + final Widget title; + final List actions; + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return AppBar( + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + automaticallyImplyLeading: false, + titleSpacing: 16, + toolbarHeight: kToolbarHeight, + title: title, + actions: actions, + flexibleSpace: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + colorScheme.primary.withValues(alpha: 0), + colorScheme.primary.withValues(alpha: 0.2), + ], + stops: const [0, 1], + ), + ), + ), + ); + } +} + class _ThemeMenuButton extends StatelessWidget { const _ThemeMenuButton({required this.mode, required this.onChanged});