헤더∙로그인 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

@@ -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<LoginPage> {
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<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;
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();
});
}
}