From 1e53b66abb52227abc649e1d9090ec694f50ca44 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 8 Apr 2026 17:45:51 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=ED=94=8C=EB=9E=AB=20UI=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/core/theme/theme_controller.dart | 11 +- userfront/lib/core/theme/theme_scope.dart | 44 + .../lib/core/widgets/theme_toggle_button.dart | 12 +- .../auth/presentation/login_screen.dart | 1036 +++++++++-------- userfront/lib/main.dart | 164 ++- userfront/test/theme_controller_test.dart | 16 +- 6 files changed, 730 insertions(+), 553 deletions(-) create mode 100644 userfront/lib/core/theme/theme_scope.dart diff --git a/userfront/lib/core/theme/theme_controller.dart b/userfront/lib/core/theme/theme_controller.dart index 02034217..5e00b4b9 100644 --- a/userfront/lib/core/theme/theme_controller.dart +++ b/userfront/lib/core/theme/theme_controller.dart @@ -2,10 +2,15 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; class ThemeController extends ValueNotifier { - 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; diff --git a/userfront/lib/core/theme/theme_scope.dart b/userfront/lib/core/theme/theme_scope.dart new file mode 100644 index 00000000..2f912d5f --- /dev/null +++ b/userfront/lib/core/theme/theme_scope.dart @@ -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(); + 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( + valueListenable: controller, + builder: (context, mode, _) { + return Theme( + data: mode == ThemeMode.dark ? buildDarkTheme() : buildLightTheme(), + child: child, + ); + }, + ), + ); + } +} diff --git a/userfront/lib/core/widgets/theme_toggle_button.dart b/userfront/lib/core/widgets/theme_toggle_button.dart index e0b11d67..05737ad8 100644 --- a/userfront/lib/core/widgets/theme_toggle_button.dart +++ b/userfront/lib/core/widgets/theme_toggle_button.dart @@ -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( - 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), ); diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 8e02a8a8..bcb02973 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -1389,6 +1389,73 @@ class _LoginScreenState extends ConsumerState final theme = Theme.of(context); final colorScheme = theme.colorScheme; final mutedColor = colorScheme.onSurfaceVariant; + final inputForegroundColor = colorScheme.brightness == Brightness.dark + ? const Color(0xFFE2E8F0) + : const Color(0xFF334155); + final primaryColor = colorScheme.brightness == Brightness.dark + ? const Color(0xFF93C5FD) + : const Color(0xFF1E3A8A); + final onPrimaryColor = colorScheme.brightness == Brightness.dark + ? const Color(0xFF0F172A) + : Colors.white; + final inputDecorationTheme = theme.inputDecorationTheme.copyWith( + filled: false, + fillColor: Colors.transparent, + contentPadding: const EdgeInsets.symmetric(horizontal: 18, vertical: 18), + isDense: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.outline), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.outline), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: primaryColor, width: 1.6), + ), + labelStyle: TextStyle(color: inputForegroundColor), + floatingLabelStyle: TextStyle(color: primaryColor), + hintStyle: TextStyle(color: inputForegroundColor), + prefixIconColor: inputForegroundColor, + ); + final localTheme = theme.copyWith( + inputDecorationTheme: inputDecorationTheme, + tabBarTheme: theme.tabBarTheme.copyWith( + dividerColor: colorScheme.outlineVariant, + indicatorColor: primaryColor, + labelColor: colorScheme.onSurface, + unselectedLabelColor: mutedColor, + labelStyle: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + unselectedLabelStyle: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + backgroundColor: primaryColor, + foregroundColor: onPrimaryColor, + textStyle: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: primaryColor, + textStyle: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ); if (_verificationOnly && _verificationApproved) { return Scaffold( @@ -1408,379 +1475,345 @@ class _LoginScreenState extends ConsumerState backgroundColor: colorScheme.surfaceContainerLowest, body: LayoutBuilder( builder: (context, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: Container( - constraints: const BoxConstraints(maxWidth: 400), - padding: const EdgeInsets.all(24), - child: Card( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - tr('ui.userfront.app_title'), - style: theme.textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, + return Theme( + data: localTheme, + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 480), + padding: const EdgeInsets.symmetric( + horizontal: 28, + vertical: 40, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tr('ui.userfront.app_title'), + style: theme.textTheme.headlineMedium?.copyWith( + fontSize: 34, + fontWeight: FontWeight.w800, + letterSpacing: -0.7, ), - if (_drySendEnabled) ...[ - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, + textAlign: TextAlign.center, + ), + if (_drySendEnabled) ...[ + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + decoration: BoxDecoration( + color: const Color(0xFFFFF3CD), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0xFFFFC107), ), - decoration: BoxDecoration( - color: const Color(0xFFFFF3CD), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: const Color(0xFFFFC107), + ), + child: Row( + children: [ + const Icon( + Icons.warning_amber_rounded, + color: Color(0xFF8A6D3B), ), - ), - child: Row( - children: [ - const Icon( - Icons.warning_amber_rounded, - color: Color(0xFF8A6D3B), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - tr('msg.userfront.login.dry_send'), - style: const TextStyle( - color: Color(0xFF8A6D3B), - fontSize: 12, - ), + const SizedBox(width: 10), + Expanded( + child: Text( + tr('msg.userfront.login.dry_send'), + style: const TextStyle( + color: Color(0xFF8A6D3B), + fontSize: 12, ), ), - ], - ), + ), + ], ), - ], - const SizedBox(height: 40), - - TabBar( + ), + ], + const SizedBox(height: 52), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 34), + child: TabBar( controller: _tabController, + indicatorSize: TabBarIndicatorSize.label, tabs: [ Tab(text: tr('ui.userfront.login.tabs.password')), Tab(text: tr('ui.userfront.login.tabs.link')), Tab(text: tr('ui.userfront.login.tabs.qr')), ], ), - const SizedBox(height: 24), - - SizedBox( - height: 350, - child: TabBarView( - controller: _tabController, - children: [ - Padding( - padding: const EdgeInsets.only(top: 16.0), - child: Column( - children: [ - TextField( - key: const ValueKey( - 'password_login_id_input', - ), - controller: _passwordLoginIdController, - decoration: InputDecoration( - labelText: - _loginIdLabel ?? - tr( - 'ui.userfront.login.field.login_id', - ), - prefixIcon: const Icon( - Icons.person_outline, - ), - ), - onSubmitted: (_) => - _handlePasswordLogin(), - ), - const SizedBox(height: 16), - TextField( - key: const ValueKey( - 'password_login_password_input', - ), - focusNode: _passwordFocusNode, - controller: _passwordController, - obscureText: true, - decoration: InputDecoration( - labelText: tr( - 'ui.userfront.login.field.password', - ), - prefixIcon: const Icon( - Icons.lock_outline, - ), - ), - onSubmitted: (_) => - _handlePasswordLogin(), - ), - if (_isPasswordCapsLockOn) ...[ - const SizedBox(height: 8), - Row( - children: [ - const Icon( - Icons.keyboard_capslock_rounded, - size: 18, - color: Colors.orange, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _capsLockWarningText(context), - style: const TextStyle( - color: Colors.orange, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ], - const SizedBox(height: 24), - FilledButton( - key: const ValueKey( - 'password_login_submit_button', - ), - onPressed: _handlePasswordLogin, - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight( - 50, - ), - ), - child: Text( - tr( - 'ui.userfront.login.action.submit', - ), - ), - ), - ], - ), - ), - - Padding( - padding: const EdgeInsets.only(top: 16.0), - child: Column( - children: [ - if (_linkPendingRef == null) ...[ + ), + const SizedBox(height: 28), + SizedBox( + height: 360, + child: TabBarView( + controller: _tabController, + children: [ + Padding( + padding: const EdgeInsets.only(top: 20), + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 356, + ), + child: Column( + children: [ TextField( - controller: _linkIdController, + key: const ValueKey( + 'password_login_id_input', + ), + controller: + _passwordLoginIdController, decoration: InputDecoration( - labelText: tr( - 'ui.userfront.login.field.login_id', - ), - hintText: '', + labelText: + _loginIdLabel ?? + tr( + 'ui.userfront.login.field.login_id', + ), prefixIcon: const Icon( Icons.person_outline, + size: 22, ), ), onSubmitted: (_) => - _handleLinkLogin(), + _handlePasswordLogin(), ), - const SizedBox(height: 24), - FilledButton( - onPressed: _handleLinkLogin, - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight( - 50, + const SizedBox(height: 18), + TextField( + key: const ValueKey( + 'password_login_password_input', + ), + focusNode: _passwordFocusNode, + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.login.field.password', + ), + prefixIcon: const Icon( + Icons.lock_outline, + size: 22, ), ), - child: Text( - tr('ui.userfront.login.link.send'), - ), + onSubmitted: (_) => + _handlePasswordLogin(), ), - const SizedBox(height: 24), - Text( - tr('msg.userfront.login.link.helper'), - style: TextStyle( - color: mutedColor, - fontSize: 12, - ), - textAlign: TextAlign.center, - ), - ], - if (_linkPendingRef != null) ...[ - if (_linkExpired) ...[ - Text( - tr( - 'msg.userfront.login.link_timeout', - ), - textAlign: TextAlign.center, - style: TextStyle( - color: mutedColor, - fontSize: 12, - ), - ), - const SizedBox(height: 12), - FilledButton( - onPressed: () { - setState(_resetLinkLoginState); - }, - style: FilledButton.styleFrom( - minimumSize: - const Size.fromHeight(45), - ), - child: Text( - tr('ui.common.refresh'), - ), - ), - ] else ...[ - Text( - tr( - 'msg.userfront.login.link.short_code_help', - ), - style: TextStyle( - color: mutedColor, - fontSize: 12, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 12), + if (_isPasswordCapsLockOn) ...[ + const SizedBox(height: 10), Row( children: [ - Expanded( - flex: 2, - child: TextField( - controller: - _shortCodePrefixController, - textCapitalization: - TextCapitalization - .characters, - decoration: InputDecoration( - labelText: tr( - 'ui.userfront.login.short_code.prefix', - ), - hintText: 'AB', - hintStyle: TextStyle( - color: mutedColor, - ), - ), - maxLength: 2, - ), + const Icon( + Icons.keyboard_capslock_rounded, + size: 18, + color: Colors.orange, ), const SizedBox(width: 8), Expanded( - flex: 4, - child: TextField( - controller: - _shortCodeDigitsController, - keyboardType: - TextInputType.number, - decoration: InputDecoration( - labelText: tr( - 'ui.userfront.login.short_code.digits', - ), - hintText: '345678', - hintStyle: TextStyle( - color: mutedColor, - ), - suffixText: - _linkExpireSeconds > 0 - ? tr( - 'ui.userfront.login.short_code.expire_time', - params: { - 'time': _formatTime( - _linkExpireSeconds, - ), - }, - ) - : null, + child: Text( + _capsLockWarningText(context), + style: const TextStyle( + color: Colors.orange, + fontSize: 12, + fontWeight: FontWeight.w600, ), - maxLength: 6, ), ), ], ), - const SizedBox(height: 12), - FilledButton( - onPressed: () { - final prefix = - _shortCodePrefixController - .text - .trim() - .toUpperCase(); - final digits = - _shortCodeDigitsController - .text - .trim(); - if (prefix.length != 2 || - digits.length != 6) { - _showError( - tr( - 'msg.userfront.login.short_code.invalid', - ), - ); - return; - } - _verifyShortCode(prefix + digits); - }, - style: FilledButton.styleFrom( - minimumSize: - const Size.fromHeight(45), + ], + const SizedBox(height: 28), + FilledButton( + key: const ValueKey( + 'password_login_submit_button', + ), + onPressed: _handlePasswordLogin, + child: Text( + tr( + 'ui.userfront.login.action.submit', ), + ), + ), + ], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 20), + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 356, + ), + child: Column( + children: [ + if (_linkPendingRef == null) ...[ + TextField( + controller: _linkIdController, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.login.field.login_id', + ), + hintText: '', + prefixIcon: const Icon( + Icons.person_outline, + size: 22, + ), + ), + onSubmitted: (_) => + _handleLinkLogin(), + ), + const SizedBox(height: 28), + FilledButton( + onPressed: _handleLinkLogin, child: Text( tr( - 'ui.userfront.login.short_code.submit', + 'ui.userfront.login.link.send', ), ), ), - const SizedBox(height: 12), - TextButton( - onPressed: () { - if (_linkResendSeconds > 0) { - _showInfo( - tr( - 'msg.userfront.login.link.resend_wait', - params: { - 'time': _formatTime( - _linkResendSeconds, - ), - }, - ), - ); - return; - } - final loginId = - _lastLinkLoginId ?? - _linkIdController.text.trim(); - if (loginId.isEmpty) { - _showError( - tr( - 'msg.userfront.login.link.missing_login_id', - ), - ); - return; - } - _startEnchantedFlow( - loginId, - isEmail: - _lastLinkIsEmail || - loginId.contains('@'), - codeOnly: false, - ); - }, - child: Text( - _linkResendSeconds > 0 - ? tr( - 'ui.userfront.login.link.resend_with_time', - params: { - 'time': _formatTime( - _linkResendSeconds, - ), - }, - ) - : tr('ui.common.resend'), + const SizedBox(height: 24), + Text( + tr( + 'msg.userfront.login.link.helper', ), + style: TextStyle( + color: mutedColor, + fontSize: 12, + height: 1.5, + ), + textAlign: TextAlign.center, ), - if (!_lastLinkIsEmail) ...[ - const SizedBox(height: 4), + ], + if (_linkPendingRef != null) ...[ + if (_linkExpired) ...[ + Text( + tr( + 'msg.userfront.login.link_timeout', + ), + textAlign: TextAlign.center, + style: TextStyle( + color: mutedColor, + fontSize: 12, + ), + ), + const SizedBox(height: 14), + FilledButton( + onPressed: () { + setState(_resetLinkLoginState); + }, + child: Text( + tr('ui.common.refresh'), + ), + ), + ] else ...[ + Text( + tr( + 'msg.userfront.login.link.short_code_help', + ), + style: TextStyle( + color: mutedColor, + fontSize: 12, + height: 1.5, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + flex: 2, + child: TextField( + controller: + _shortCodePrefixController, + textCapitalization: + TextCapitalization + .characters, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.login.short_code.prefix', + ), + hintText: 'AB', + hintStyle: TextStyle( + color: mutedColor, + ), + counterText: '', + ), + maxLength: 2, + ), + ), + const SizedBox(width: 10), + Expanded( + flex: 4, + child: TextField( + controller: + _shortCodeDigitsController, + keyboardType: + TextInputType.number, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.login.short_code.digits', + ), + hintText: '345678', + hintStyle: TextStyle( + color: mutedColor, + ), + counterText: '', + suffixText: + _linkExpireSeconds > 0 + ? tr( + 'ui.userfront.login.short_code.expire_time', + params: { + 'time': _formatTime( + _linkExpireSeconds, + ), + }, + ) + : null, + ), + maxLength: 6, + ), + ), + ], + ), + const SizedBox(height: 14), + FilledButton( + onPressed: () { + final prefix = + _shortCodePrefixController + .text + .trim() + .toUpperCase(); + final digits = + _shortCodeDigitsController + .text + .trim(); + if (prefix.length != 2 || + digits.length != 6) { + _showError( + tr( + 'msg.userfront.login.short_code.invalid', + ), + ); + return; + } + _verifyShortCode( + prefix + digits, + ); + }, + child: Text( + tr( + 'ui.userfront.login.short_code.submit', + ), + ), + ), + const SizedBox(height: 14), TextButton( onPressed: () { if (_linkResendSeconds > 0) { @@ -1803,182 +1836,219 @@ class _LoginScreenState extends ConsumerState if (loginId.isEmpty) { _showError( tr( - 'msg.userfront.login.link.missing_phone', + 'msg.userfront.login.link.missing_login_id', ), ); return; } _startEnchantedFlow( loginId, - isEmail: false, - codeOnly: true, + isEmail: + _lastLinkIsEmail || + loginId.contains('@'), + codeOnly: false, ); }, child: Text( - tr( - 'ui.userfront.login.link.code_only', - params: { - 'time': _formatTime( - _linkResendSeconds, - ), - }, - ), + _linkResendSeconds > 0 + ? tr( + 'ui.userfront.login.link.resend_with_time', + params: { + 'time': _formatTime( + _linkResendSeconds, + ), + }, + ) + : tr('ui.common.resend'), ), ), + if (!_lastLinkIsEmail) ...[ + const SizedBox(height: 4), + TextButton( + onPressed: () { + if (_linkResendSeconds > 0) { + _showInfo( + tr( + 'msg.userfront.login.link.resend_wait', + params: { + 'time': _formatTime( + _linkResendSeconds, + ), + }, + ), + ); + return; + } + final loginId = + _lastLinkLoginId ?? + _linkIdController.text + .trim(); + if (loginId.isEmpty) { + _showError( + tr( + 'msg.userfront.login.link.missing_phone', + ), + ); + return; + } + _startEnchantedFlow( + loginId, + isEmail: false, + codeOnly: true, + ); + }, + child: Text( + tr( + 'ui.userfront.login.link.code_only', + params: { + 'time': _formatTime( + _linkResendSeconds, + ), + }, + ), + ), + ), + ], ], ], ], - ], + ), ), ), - - Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (_isQrLoading) - const CircularProgressIndicator() - else if (_qrExpired) - Column( - children: [ - Text( - tr( - 'msg.userfront.login.qr_expired', - ), - textAlign: TextAlign.center, - style: TextStyle( - color: mutedColor, - fontSize: 12, - ), - ), - const SizedBox(height: 12), - FilledButton( - onPressed: _startQrFlow, - style: FilledButton.styleFrom( - minimumSize: - const Size.fromHeight(45), - ), - child: Text( - tr('ui.common.refresh'), - ), - ), - ], - ) - else if (_qrImageBase64 != null) - Column( - crossAxisAlignment: - CrossAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all( - color: colorScheme.outline, - ), - borderRadius: - BorderRadius.circular(12), - ), - child: QrImageView( - data: _qrImageBase64!, - version: QrVersions.auto, - size: 200.0, - backgroundColor: Colors.white, - ), - ), - const SizedBox(height: 12), - Text( - _qrRemainingSeconds > 0 - ? tr( - 'ui.userfront.login.qr.remaining', - params: { - 'time': _formatTime( - _qrRemainingSeconds, - ), - }, - ) - : tr( - 'ui.userfront.login.qr.expired', - ), - textAlign: TextAlign.center, - style: TextStyle( - color: _qrRemainingSeconds > 30 - ? Colors.blue - : Colors.red, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - tr( - 'msg.userfront.login.qr.scan_hint', - ), - textAlign: TextAlign.center, - style: TextStyle( - color: mutedColor, - fontSize: 12, - ), - ), - TextButton( - onPressed: _startQrFlow, - child: Text( - tr( - 'ui.userfront.login.qr.refresh', - ), - ), - ), - ], - ) - else - Text( - tr( - 'msg.userfront.login.qr.load_failed', - ), - textAlign: TextAlign.center, - ), - ], - ), - ], - ), - ), - const SizedBox(height: 16), - Column( - children: [ - TextButton( - onPressed: () => - context.push('/forgot-password'), - child: Text( - tr('ui.userfront.login.forgot_password'), - ), ), - Row( + Column( mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Text( - tr('msg.userfront.login.no_account'), - style: TextStyle( - color: mutedColor, - fontSize: 14, + if (_isQrLoading) + const CircularProgressIndicator() + else if (_qrExpired) + Column( + children: [ + Text( + tr('msg.userfront.login.qr_expired'), + textAlign: TextAlign.center, + style: TextStyle( + color: mutedColor, + fontSize: 12, + ), + ), + const SizedBox(height: 14), + FilledButton( + onPressed: _startQrFlow, + child: Text(tr('ui.common.refresh')), + ), + ], + ) + else if (_qrImageBase64 != null) + Column( + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + border: Border.all( + color: colorScheme.outline, + ), + borderRadius: BorderRadius.circular( + 18, + ), + ), + child: QrImageView( + data: _qrImageBase64!, + version: QrVersions.auto, + size: 200.0, + backgroundColor: Colors.white, + ), + ), + const SizedBox(height: 14), + Text( + _qrRemainingSeconds > 0 + ? tr( + 'ui.userfront.login.qr.remaining', + params: { + 'time': _formatTime( + _qrRemainingSeconds, + ), + }, + ) + : tr( + 'ui.userfront.login.qr.expired', + ), + textAlign: TextAlign.center, + style: TextStyle( + color: _qrRemainingSeconds > 30 + ? primaryColor + : Colors.red, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + tr( + 'msg.userfront.login.qr.scan_hint', + ), + textAlign: TextAlign.center, + style: TextStyle( + color: mutedColor, + fontSize: 12, + height: 1.5, + ), + ), + TextButton( + onPressed: _startQrFlow, + child: Text( + tr('ui.userfront.login.qr.refresh'), + ), + ), + ], + ) + else + Text( + tr('msg.userfront.login.qr.load_failed'), + textAlign: TextAlign.center, ), - ), - TextButton( - onPressed: () => context.push('/signup'), - child: Text( - tr('ui.userfront.login.signup'), - ), - ), ], ), ], ), - const SizedBox(height: 6), - const Wrap( - alignment: WrapAlignment.center, - spacing: 10, - runSpacing: 10, - children: [ThemeToggleButton(), LanguageSelector()], - ), - ], - ), + ), + const SizedBox(height: 18), + Column( + children: [ + TextButton( + onPressed: () => context.push('/forgot-password'), + child: Text( + tr('ui.userfront.login.forgot_password'), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + tr('msg.userfront.login.no_account'), + style: TextStyle( + color: mutedColor, + fontSize: 14, + ), + ), + TextButton( + onPressed: () => context.push('/signup'), + child: Text(tr('ui.userfront.login.signup')), + ), + ], + ), + ], + ), + const SizedBox(height: 12), + const Wrap( + alignment: WrapAlignment.center, + spacing: 10, + runSpacing: 10, + children: [ThemeToggleButton(), LanguageSelector()], + ), + ], ), ), ), diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index deb1a9e0..774ecb66 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -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( - 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, ); } } diff --git a/userfront/test/theme_controller_test.dart b/userfront/test/theme_controller_test.dart index 1c829b93..447255c9 100644 --- a/userfront/test/theme_controller_test.dart +++ b/userfront/test/theme_controller_test.dart @@ -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'); }); }