헤더∙로그인 UI 정비하고 벤더 오류 메시지 안정화
- 로그인 화면 상단에 브랜드 아이콘과 안내 문구를 추가하고 카드/푸터 레이아웃을 정리 - 네비게이션 레일/앱바 구성 재배치, 테마 토글 상단 이동, 그라데이션/브랜드 아이콘 스타일 조정 - ScaffoldMessenger 의존성 문제를 피하도록 벤더 컨트롤러 에러 스낵바 호출 시점을 프레임 이후로 이연 - IMPLEMENTATION_TASKS.md에 이번 변경 내역을 요약하여 진행 상황을 문서화
This commit is contained in:
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user