헤더∙로그인 UI 정비하고 벤더 오류 메시지 안정화
- 로그인 화면 상단에 브랜드 아이콘과 안내 문구를 추가하고 카드/푸터 레이아웃을 정리 - 네비게이션 레일/앱바 구성 재배치, 테마 토글 상단 이동, 그라데이션/브랜드 아이콘 스타일 조정 - ScaffoldMessenger 의존성 문제를 피하도록 벤더 컨트롤러 에러 스낵바 호출 시점을 프레임 이후로 이연 - IMPLEMENTATION_TASKS.md에 이번 변경 내역을 요약하여 진행 상황을 문서화
This commit is contained in:
@@ -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` 포함
|
||||
|
||||
@@ -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,12 +66,100 @@ class _LoginPageState extends State<LoginPage> {
|
||||
final theme = ShadTheme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
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(24),
|
||||
child: ShadCard(
|
||||
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(
|
||||
'사번 또는 이메일과 비밀번호를 입력하여 대시보드로 이동합니다.',
|
||||
@@ -97,8 +187,7 @@ class _LoginPageState extends State<LoginPage> {
|
||||
children: [
|
||||
ShadSwitch(
|
||||
value: rememberMe,
|
||||
onChanged: (value) =>
|
||||
setState(() => rememberMe = value),
|
||||
onChanged: (value) => setState(() => rememberMe = value),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text('자동 로그인', style: theme.textTheme.small),
|
||||
@@ -135,10 +224,30 @@ class _LoginPageState extends State<LoginPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 로그인 하단에 간단한 안내 메시지를 노출한다.
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
// 스캐폴드 메시지는 프레임 빌드 이후 안전하게 호출한다.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
if (messenger != null) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(error)));
|
||||
}
|
||||
_controller.clearError();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,18 +29,27 @@ class AppShell extends StatelessWidget {
|
||||
if (manager.can(page.path, PermissionAction.view)) page,
|
||||
];
|
||||
final pages = filteredPages.isEmpty ? allAppPages : filteredPages;
|
||||
if (isWide) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Superport v2'),
|
||||
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,
|
||||
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,14 +142,15 @@ class _NavigationRail extends StatelessWidget {
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
page.label,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
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<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 {
|
||||
const _ThemeMenuButton({required this.mode, required this.onChanged});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user