헤더∙로그인 UI 정비하고 벤더 오류 메시지 안정화

- 로그인 화면 상단에 브랜드 아이콘과 안내 문구를 추가하고 카드/푸터 레이아웃을 정리

- 네비게이션 레일/앱바 구성 재배치, 테마 토글 상단 이동, 그라데이션/브랜드 아이콘 스타일 조정

- ScaffoldMessenger 의존성 문제를 피하도록 벤더 컨트롤러 에러 스낵바 호출 시점을 프레임 이후로 이연

- IMPLEMENTATION_TASKS.md에 이번 변경 내역을 요약하여 진행 상황을 문서화
This commit is contained in:
JiWoong Sul
2025-09-30 15:00:23 +09:00
parent 47c87dc118
commit 5578bf443f
4 changed files with 305 additions and 118 deletions

View File

@@ -2,6 +2,12 @@
본 체크리스트는 PRD(`doc/PRD_입출고_결재_v2.md`)를 기준으로 shadcn_ui 스타일과 반응형 패턴을 준수하여 화면을 구현하기 위한 단계별 작업 목록입니다. 작업 순서는 ① 코드 시작 전 최종 확인 → ② UI 스캐폴딩/상호작용 구현 → ③ 실제 API 연동(Dio/ApiClient/DI)입니다. Mock 데이터는 사용하지 않습니다. 본 체크리스트는 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/환경) ## 0) 코드 시작 전 최종 확인(Repository/환경)
- [x] Flutter 버전/채널 확인, `flutter pub get` - [x] Flutter 버전/채널 확인, `flutter pub get`
- [x] `pubspec.yaml` 확인: `go_router`, `shadcn_ui`, `intl`, `two_dimensional_scrollables`, `lucide_icons_flutter` 포함 - [x] `pubspec.yaml` 확인: `go_router`, `shadcn_ui`, `intl`, `two_dimensional_scrollables`, `lucide_icons_flutter` 포함

View File

@@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:shadcn_ui/shadcn_ui.dart';
@@ -64,81 +66,188 @@ class _LoginPageState extends State<LoginPage> {
final theme = ShadTheme.of(context); final theme = ShadTheme.of(context);
return Scaffold( return Scaffold(
body: Center( backgroundColor: theme.colorScheme.background,
child: ConstrainedBox( body: SafeArea(
constraints: const BoxConstraints(maxWidth: 460), child: Center(
child: Padding( child: SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40),
child: ShadCard( child: Column(
title: Text('Superport v2 로그인', style: theme.textTheme.h3), mainAxisAlignment: MainAxisAlignment.center,
description: Text( children: [
'사번 또는 이메일과 비밀번호를 입력하여 대시보드로 이동합니다.', _buildHeader(theme),
style: theme.textTheme.muted, const SizedBox(height: 32),
), ConstrainedBox(
child: Column( constraints: const BoxConstraints(maxWidth: 460),
crossAxisAlignment: CrossAxisAlignment.stretch, child: _buildLoginCard(theme),
children: [ ),
ShadInput( const SizedBox(height: 24),
controller: idController, _buildFooter(theme),
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 _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<double>(
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,
),
],
);
}
} }

View File

@@ -116,10 +116,17 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
final error = _controller.errorMessage; final error = _controller.errorMessage;
if (error != null && error != _lastError && mounted) { if (error != null && error != _lastError && mounted) {
_lastError = error; _lastError = error;
ScaffoldMessenger.of( // 스캐폴드 메시지는 프레임 빌드 이후 안전하게 호출한다.
context, WidgetsBinding.instance.addPostFrameCallback((_) {
).showSnackBar(SnackBar(content: Text(error))); if (!mounted) {
_controller.clearError(); return;
}
final messenger = ScaffoldMessenger.maybeOf(context);
if (messenger != null) {
messenger.showSnackBar(SnackBar(content: Text(error)));
}
_controller.clearError();
});
} }
} }

View File

@@ -29,18 +29,27 @@ class AppShell extends StatelessWidget {
if (manager.can(page.path, PermissionAction.view)) page, if (manager.can(page.path, PermissionAction.view)) page,
]; ];
final pages = filteredPages.isEmpty ? allAppPages : filteredPages; 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) { if (isWide) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: appBar,
title: const Text('Superport v2'),
actions: [
IconButton(
tooltip: '로그아웃',
icon: const Icon(lucide.LucideIcons.logOut),
onPressed: () => context.go(loginRoutePath),
),
],
),
body: Row( body: Row(
children: [ children: [
_NavigationRail(currentLocation: currentLocation, pages: pages), _NavigationRail(currentLocation: currentLocation, pages: pages),
@@ -52,16 +61,7 @@ class AppShell extends StatelessWidget {
} }
return Scaffold( return Scaffold(
appBar: AppBar( appBar: appBar,
title: const Text('Superport v2'),
actions: [
IconButton(
tooltip: '로그아웃',
icon: const Icon(lucide.LucideIcons.logOut),
onPressed: () => context.go(loginRoutePath),
),
],
),
drawer: Drawer( drawer: Drawer(
child: SafeArea( child: SafeArea(
child: _NavigationList( child: _NavigationList(
@@ -92,18 +92,14 @@ class _NavigationRail extends StatelessWidget {
final selectedIndex = _selectedIndex(currentLocation, pages); final selectedIndex = _selectedIndex(currentLocation, pages);
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final themeController = ThemeControllerScope.of(context);
final currentThemeMode = themeController.mode;
return Container( return Container(
width: 104, width: 220,
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border(right: BorderSide(color: colorScheme.outlineVariant)), border: Border(right: BorderSide(color: colorScheme.outlineVariant)),
), ),
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 24),
const FlutterLogo(size: 48),
const SizedBox(height: 24), const SizedBox(height: 24),
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
@@ -134,11 +130,10 @@ class _NavigationRail extends StatelessWidget {
}, },
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
vertical: 12, vertical: 10,
horizontal: 8, horizontal: 12,
), ),
child: Column( child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
page.icon, page.icon,
@@ -147,13 +142,14 @@ class _NavigationRail extends StatelessWidget {
? colorScheme.primary ? colorScheme.primary
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
), ),
const SizedBox(height: 6), const SizedBox(width: 12),
Text( Expanded(
page.label, child: Text(
textAlign: TextAlign.center, page.label,
maxLines: 2, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: textStyle, 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<Widget> 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 { class _ThemeMenuButton extends StatelessWidget {
const _ThemeMenuButton({required this.mode, required this.onChanged}); const _ThemeMenuButton({required this.mode, required this.onChanged});