1
0
forked from baron/baron-sso

로그인 화면 플랫 UI 수정

This commit is contained in:
2026-04-08 17:45:51 +09:00
parent 332b657add
commit 1e53b66abb
6 changed files with 730 additions and 553 deletions

View File

@@ -2,10 +2,15 @@ import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
class ThemeController extends ValueNotifier<ThemeMode> { class ThemeController extends ValueNotifier<ThemeMode> {
ThemeController._() : super(ThemeMode.light); ThemeController._(this.storageKey) : super(ThemeMode.light);
static const storageKey = 'userfront_theme'; static const appStorageKey = 'userfront_theme';
static final ThemeController instance = ThemeController._(); static const authStorageKey = 'userfront_auth_theme';
static final ThemeController app = ThemeController._(appStorageKey);
static final ThemeController auth = ThemeController._(authStorageKey);
static final ThemeController instance = app;
final String storageKey;
bool get isDark => value == ThemeMode.dark; bool get isDark => value == ThemeMode.dark;

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'app_theme.dart';
import 'theme_controller.dart';
class ThemeScope extends InheritedWidget {
const ThemeScope({super.key, required this.controller, required Widget child})
: super(child: child);
final ThemeController controller;
static ThemeController of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<ThemeScope>();
return scope?.controller ?? ThemeController.app;
}
@override
bool updateShouldNotify(ThemeScope oldWidget) {
return oldWidget.controller != controller;
}
}
class ScopedTheme extends StatelessWidget {
const ScopedTheme({super.key, required this.controller, required this.child});
final ThemeController controller;
final Widget child;
@override
Widget build(BuildContext context) {
return ThemeScope(
controller: controller,
child: ValueListenableBuilder<ThemeMode>(
valueListenable: controller,
builder: (context, mode, _) {
return Theme(
data: mode == ThemeMode.dark ? buildDarkTheme() : buildLightTheme(),
child: child,
);
},
),
);
}
}

View File

@@ -1,8 +1,7 @@
import 'package:easy_localization/easy_localization.dart' hide tr;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:userfront/i18n.dart'; import 'package:userfront/i18n.dart';
import '../theme/theme_controller.dart'; import '../theme/theme_scope.dart';
class ThemeToggleButton extends StatelessWidget { class ThemeToggleButton extends StatelessWidget {
const ThemeToggleButton({super.key, this.compact = false}); const ThemeToggleButton({super.key, this.compact = false});
@@ -11,10 +10,11 @@ class ThemeToggleButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
context.locale; Localizations.localeOf(context);
final controller = ThemeScope.of(context);
return ValueListenableBuilder<ThemeMode>( return ValueListenableBuilder<ThemeMode>(
valueListenable: ThemeController.instance, valueListenable: controller,
builder: (context, mode, _) { builder: (context, mode, _) {
final isLight = mode == ThemeMode.light; final isLight = mode == ThemeMode.light;
final icon = isLight final icon = isLight
@@ -28,13 +28,13 @@ class ThemeToggleButton extends StatelessWidget {
if (compact) { if (compact) {
return IconButton( return IconButton(
tooltip: tooltip, tooltip: tooltip,
onPressed: () => ThemeController.instance.toggle(), onPressed: () => controller.toggle(),
icon: Icon(icon), icon: Icon(icon),
); );
} }
return OutlinedButton.icon( return OutlinedButton.icon(
onPressed: () => ThemeController.instance.toggle(), onPressed: () => controller.toggle(),
icon: Icon(icon, size: 18), icon: Icon(icon, size: 18),
label: Text(label), label: Text(label),
); );

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,7 @@ import 'core/services/web_window.dart';
import 'core/notifiers/auth_notifier.dart'; import 'core/notifiers/auth_notifier.dart';
import 'core/theme/app_theme.dart'; import 'core/theme/app_theme.dart';
import 'core/theme/theme_controller.dart'; import 'core/theme/theme_controller.dart';
import 'core/theme/theme_scope.dart';
import 'core/i18n/locale_gate.dart'; import 'core/i18n/locale_gate.dart';
import 'core/i18n/locale_registry.dart'; import 'core/i18n/locale_registry.dart';
import 'core/i18n/locale_utils.dart'; import 'core/i18n/locale_utils.dart';
@@ -108,7 +109,8 @@ void main() async {
// 0. Initialize Logger // 0. Initialize Logger
LoggerService.init(); LoggerService.init();
await ThemeController.instance.restore(); await ThemeController.app.restore();
await ThemeController.auth.restore();
// 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화 // 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화
await _loadBundledFonts(); await _loadBundledFonts();
@@ -180,12 +182,18 @@ final _router = GoRouter(
GoRoute( GoRoute(
path: 'dashboard', path: 'dashboard',
builder: (context, state) { builder: (context, state) {
return const DashboardScreen(); return ScopedTheme(
controller: ThemeController.app,
child: const DashboardScreen(),
);
}, },
), ),
GoRoute( GoRoute(
path: 'profile', path: 'profile',
builder: (context, state) => const ProfilePage(), builder: (context, state) => ScopedTheme(
controller: ThemeController.app,
child: const ProfilePage(),
),
), ),
GoRoute( GoRoute(
path: 'signin', path: 'signin',
@@ -195,10 +203,13 @@ final _router = GoRouter(
final redirectUrl = final redirectUrl =
state.uri.queryParameters['redirect_uri'] ?? state.uri.queryParameters['redirect_uri'] ??
state.uri.queryParameters['redirect_url']; state.uri.queryParameters['redirect_url'];
return LoginScreen( return ScopedTheme(
key: state.pageKey, controller: ThemeController.auth,
loginChallenge: loginChallenge, child: LoginScreen(
redirectUrl: redirectUrl, key: state.pageKey,
loginChallenge: loginChallenge,
redirectUrl: redirectUrl,
),
); );
}, },
), ),
@@ -211,10 +222,13 @@ final _router = GoRouter(
final redirectUrl = final redirectUrl =
state.uri.queryParameters['redirect_uri'] ?? state.uri.queryParameters['redirect_uri'] ??
state.uri.queryParameters['redirect_url']; state.uri.queryParameters['redirect_url'];
return LoginScreen( return ScopedTheme(
key: state.pageKey, controller: ThemeController.auth,
loginChallenge: loginChallenge, child: LoginScreen(
redirectUrl: redirectUrl, key: state.pageKey,
loginChallenge: loginChallenge,
redirectUrl: redirectUrl,
),
); );
}, },
), ),
@@ -230,88 +244,137 @@ final _router = GoRouter(
), ),
); );
} }
return ConsentScreen(consentChallenge: consentChallenge); return ScopedTheme(
controller: ThemeController.auth,
child: ConsentScreen(consentChallenge: consentChallenge),
);
}, },
), ),
GoRoute( GoRoute(
path: 'signup', path: 'signup',
builder: (context, state) => const SignupScreen(), builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const SignupScreen(),
),
), ),
GoRoute( GoRoute(
path: 'registration', path: 'registration',
builder: (context, state) => const SignupScreen(), builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const SignupScreen(),
),
), ),
GoRoute( GoRoute(
path: 'verify', path: 'verify',
builder: (context, state) => LoginScreen(key: state.pageKey), builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(key: state.pageKey),
),
), ),
GoRoute( GoRoute(
path: 'verify/:token', path: 'verify/:token',
builder: (context, state) { builder: (context, state) {
final token = state.pathParameters['token']; final token = state.pathParameters['token'];
return LoginScreen( return ScopedTheme(
key: state.pageKey, controller: ThemeController.auth,
verificationToken: token, child: LoginScreen(
key: state.pageKey,
verificationToken: token,
),
); );
}, },
), ),
GoRoute( GoRoute(
path: 'verification', path: 'verification',
builder: (context, state) => LoginScreen(key: state.pageKey), builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(key: state.pageKey),
),
), ),
GoRoute( GoRoute(
path: 'l/:shortCode', path: 'l/:shortCode',
builder: (context, state) { builder: (context, state) {
return LoginScreen(key: state.pageKey); return ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(key: state.pageKey),
);
}, },
), ),
GoRoute( GoRoute(
path: 'forgot-password', path: 'forgot-password',
builder: (context, state) => const ForgotPasswordScreen(), builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const ForgotPasswordScreen(),
),
), ),
GoRoute( GoRoute(
path: 'recovery', path: 'recovery',
builder: (context, state) => const ForgotPasswordScreen(), builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const ForgotPasswordScreen(),
),
), ),
GoRoute( GoRoute(
path: 'reset-password', path: 'reset-password',
builder: (context, state) => const ResetPasswordScreen(), builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const ResetPasswordScreen(),
),
), ),
GoRoute( GoRoute(
path: 'error', path: 'error',
builder: (context, state) { builder: (context, state) {
final params = state.uri.queryParameters; final params = state.uri.queryParameters;
return ErrorScreen( return ScopedTheme(
errorId: params['id'], controller: ThemeController.auth,
errorCode: params['error'], child: ErrorScreen(
description: params['error_description'] ?? params['message'], errorId: params['id'],
errorCode: params['error'],
description:
params['error_description'] ?? params['message'],
),
); );
}, },
), ),
GoRoute( GoRoute(
path: 'settings', path: 'settings',
builder: (context, state) => ErrorScreen( builder: (context, state) => ScopedTheme(
errorCode: 'settings_disabled', controller: ThemeController.auth,
description: tr('msg.userfront.settings.disabled'), child: ErrorScreen(
errorCode: 'settings_disabled',
description: tr('msg.userfront.settings.disabled'),
),
), ),
), ),
GoRoute( GoRoute(
path: 'approve', path: 'approve',
builder: (context, state) => builder: (context, state) => ScopedTheme(
ApproveQrScreen(pendingRef: state.uri.queryParameters['ref']), controller: ThemeController.auth,
child: ApproveQrScreen(
pendingRef: state.uri.queryParameters['ref'],
),
),
), ),
GoRoute( GoRoute(
path: 'ql/:ref', path: 'ql/:ref',
builder: (context, state) => builder: (context, state) => ScopedTheme(
ApproveQrScreen(pendingRef: state.pathParameters['ref']), controller: ThemeController.auth,
child: ApproveQrScreen(pendingRef: state.pathParameters['ref']),
),
), ),
GoRoute( GoRoute(
path: 'scan', path: 'scan',
builder: (context, state) => const QRScanScreen(), builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const QRScanScreen(),
),
), ),
GoRoute( GoRoute(
path: 'admin/users', path: 'admin/users',
builder: (context, state) => const UserManagementScreen(), builder: (context, state) => ScopedTheme(
controller: ThemeController.app,
child: const UserManagementScreen(),
),
), ),
], ],
), ),
@@ -369,25 +432,20 @@ class BaronSSOApp extends StatelessWidget {
final locale = final locale =
localization?.currentLocale ?? Locale(resolvePreferredLocaleCode()); localization?.currentLocale ?? Locale(resolvePreferredLocaleCode());
return ValueListenableBuilder<ThemeMode>( return MaterialApp.router(
valueListenable: ThemeController.instance, title: tr('ui.userfront.app_title'),
builder: (context, themeMode, _) { localizationsDelegates: delegates,
return MaterialApp.router( supportedLocales: supportedLocales,
title: tr('ui.userfront.app_title'), locale: locale,
localizationsDelegates: delegates, builder: (context, child) {
supportedLocales: supportedLocales, return Stack(
locale: locale, children: [if (child != null) child, const ToastViewport()],
builder: (context, child) {
return Stack(
children: [if (child != null) child, const ToastViewport()],
);
},
theme: buildLightTheme(),
darkTheme: buildDarkTheme(),
themeMode: themeMode,
routerConfig: _router,
); );
}, },
theme: buildLightTheme(),
darkTheme: buildDarkTheme(),
themeMode: ThemeMode.light,
routerConfig: _router,
); );
} }
} }

View File

@@ -8,25 +8,25 @@ void main() {
setUp(() async { setUp(() async {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
await ThemeController.instance.setThemeMode(ThemeMode.light); await ThemeController.app.setThemeMode(ThemeMode.light);
}); });
test('저장된 dark 값을 복원한다', () async { test('저장된 dark 값을 복원한다', () async {
SharedPreferences.setMockInitialValues({ SharedPreferences.setMockInitialValues({
ThemeController.storageKey: 'dark', ThemeController.appStorageKey: 'dark',
}); });
await ThemeController.instance.restore(); await ThemeController.app.restore();
expect(ThemeController.instance.value, ThemeMode.dark); expect(ThemeController.app.value, ThemeMode.dark);
}); });
test('toggle 결과를 저장한다', () async { test('toggle 결과를 저장한다', () async {
await ThemeController.instance.restore(); await ThemeController.app.restore();
await ThemeController.instance.toggle(); await ThemeController.app.toggle();
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
expect(ThemeController.instance.value, ThemeMode.dark); expect(ThemeController.app.value, ThemeMode.dark);
expect(prefs.getString(ThemeController.storageKey), 'dark'); expect(prefs.getString(ThemeController.appStorageKey), 'dark');
}); });
} }