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';
class ThemeController extends ValueNotifier<ThemeMode> {
ThemeController._() : super(ThemeMode.light);
ThemeController._(this.storageKey) : super(ThemeMode.light);
static const storageKey = 'userfront_theme';
static final ThemeController instance = ThemeController._();
static const appStorageKey = 'userfront_theme';
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;

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:userfront/i18n.dart';
import '../theme/theme_controller.dart';
import '../theme/theme_scope.dart';
class ThemeToggleButton extends StatelessWidget {
const ThemeToggleButton({super.key, this.compact = false});
@@ -11,10 +10,11 @@ class ThemeToggleButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
context.locale;
Localizations.localeOf(context);
final controller = ThemeScope.of(context);
return ValueListenableBuilder<ThemeMode>(
valueListenable: ThemeController.instance,
valueListenable: controller,
builder: (context, mode, _) {
final isLight = mode == ThemeMode.light;
final icon = isLight
@@ -28,13 +28,13 @@ class ThemeToggleButton extends StatelessWidget {
if (compact) {
return IconButton(
tooltip: tooltip,
onPressed: () => ThemeController.instance.toggle(),
onPressed: () => controller.toggle(),
icon: Icon(icon),
);
}
return OutlinedButton.icon(
onPressed: () => ThemeController.instance.toggle(),
onPressed: () => controller.toggle(),
icon: Icon(icon, size: 18),
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/theme/app_theme.dart';
import 'core/theme/theme_controller.dart';
import 'core/theme/theme_scope.dart';
import 'core/i18n/locale_gate.dart';
import 'core/i18n/locale_registry.dart';
import 'core/i18n/locale_utils.dart';
@@ -108,7 +109,8 @@ void main() async {
// 0. Initialize Logger
LoggerService.init();
await ThemeController.instance.restore();
await ThemeController.app.restore();
await ThemeController.auth.restore();
// 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화
await _loadBundledFonts();
@@ -180,12 +182,18 @@ final _router = GoRouter(
GoRoute(
path: 'dashboard',
builder: (context, state) {
return const DashboardScreen();
return ScopedTheme(
controller: ThemeController.app,
child: const DashboardScreen(),
);
},
),
GoRoute(
path: 'profile',
builder: (context, state) => const ProfilePage(),
builder: (context, state) => ScopedTheme(
controller: ThemeController.app,
child: const ProfilePage(),
),
),
GoRoute(
path: 'signin',
@@ -195,10 +203,13 @@ final _router = GoRouter(
final redirectUrl =
state.uri.queryParameters['redirect_uri'] ??
state.uri.queryParameters['redirect_url'];
return LoginScreen(
key: state.pageKey,
loginChallenge: loginChallenge,
redirectUrl: redirectUrl,
return ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(
key: state.pageKey,
loginChallenge: loginChallenge,
redirectUrl: redirectUrl,
),
);
},
),
@@ -211,10 +222,13 @@ final _router = GoRouter(
final redirectUrl =
state.uri.queryParameters['redirect_uri'] ??
state.uri.queryParameters['redirect_url'];
return LoginScreen(
key: state.pageKey,
loginChallenge: loginChallenge,
redirectUrl: redirectUrl,
return ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(
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(
path: 'signup',
builder: (context, state) => const SignupScreen(),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const SignupScreen(),
),
),
GoRoute(
path: 'registration',
builder: (context, state) => const SignupScreen(),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const SignupScreen(),
),
),
GoRoute(
path: 'verify',
builder: (context, state) => LoginScreen(key: state.pageKey),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(key: state.pageKey),
),
),
GoRoute(
path: 'verify/:token',
builder: (context, state) {
final token = state.pathParameters['token'];
return LoginScreen(
key: state.pageKey,
verificationToken: token,
return ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(
key: state.pageKey,
verificationToken: token,
),
);
},
),
GoRoute(
path: 'verification',
builder: (context, state) => LoginScreen(key: state.pageKey),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(key: state.pageKey),
),
),
GoRoute(
path: 'l/:shortCode',
builder: (context, state) {
return LoginScreen(key: state.pageKey);
return ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(key: state.pageKey),
);
},
),
GoRoute(
path: 'forgot-password',
builder: (context, state) => const ForgotPasswordScreen(),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const ForgotPasswordScreen(),
),
),
GoRoute(
path: 'recovery',
builder: (context, state) => const ForgotPasswordScreen(),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const ForgotPasswordScreen(),
),
),
GoRoute(
path: 'reset-password',
builder: (context, state) => const ResetPasswordScreen(),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const ResetPasswordScreen(),
),
),
GoRoute(
path: 'error',
builder: (context, state) {
final params = state.uri.queryParameters;
return ErrorScreen(
errorId: params['id'],
errorCode: params['error'],
description: params['error_description'] ?? params['message'],
return ScopedTheme(
controller: ThemeController.auth,
child: ErrorScreen(
errorId: params['id'],
errorCode: params['error'],
description:
params['error_description'] ?? params['message'],
),
);
},
),
GoRoute(
path: 'settings',
builder: (context, state) => ErrorScreen(
errorCode: 'settings_disabled',
description: tr('msg.userfront.settings.disabled'),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: ErrorScreen(
errorCode: 'settings_disabled',
description: tr('msg.userfront.settings.disabled'),
),
),
),
GoRoute(
path: 'approve',
builder: (context, state) =>
ApproveQrScreen(pendingRef: state.uri.queryParameters['ref']),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: ApproveQrScreen(
pendingRef: state.uri.queryParameters['ref'],
),
),
),
GoRoute(
path: 'ql/:ref',
builder: (context, state) =>
ApproveQrScreen(pendingRef: state.pathParameters['ref']),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: ApproveQrScreen(pendingRef: state.pathParameters['ref']),
),
),
GoRoute(
path: 'scan',
builder: (context, state) => const QRScanScreen(),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const QRScanScreen(),
),
),
GoRoute(
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 =
localization?.currentLocale ?? Locale(resolvePreferredLocaleCode());
return ValueListenableBuilder<ThemeMode>(
valueListenable: ThemeController.instance,
builder: (context, themeMode, _) {
return MaterialApp.router(
title: tr('ui.userfront.app_title'),
localizationsDelegates: delegates,
supportedLocales: supportedLocales,
locale: locale,
builder: (context, child) {
return Stack(
children: [if (child != null) child, const ToastViewport()],
);
},
theme: buildLightTheme(),
darkTheme: buildDarkTheme(),
themeMode: themeMode,
routerConfig: _router,
return MaterialApp.router(
title: tr('ui.userfront.app_title'),
localizationsDelegates: delegates,
supportedLocales: supportedLocales,
locale: locale,
builder: (context, child) {
return Stack(
children: [if (child != null) child, const ToastViewport()],
);
},
theme: buildLightTheme(),
darkTheme: buildDarkTheme(),
themeMode: ThemeMode.light,
routerConfig: _router,
);
}
}

View File

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