diff --git a/userfront/lib/core/theme/app_theme.dart b/userfront/lib/core/theme/app_theme.dart new file mode 100644 index 00000000..328a6b22 --- /dev/null +++ b/userfront/lib/core/theme/app_theme.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; + +ThemeData buildLightTheme() { + final scheme = + ColorScheme.fromSeed( + seedColor: const Color(0xFF1A1F2C), + brightness: Brightness.light, + ).copyWith( + surface: Colors.white, + surfaceContainerLowest: const Color(0xFFF7F8FA), + surfaceContainerLow: const Color(0xFFF3F4F6), + surfaceContainerHighest: const Color(0xFFE5E7EB), + outline: const Color(0xFFD1D5DB), + outlineVariant: const Color(0xFFE5E7EB), + primary: const Color(0xFF1A1F2C), + onPrimary: Colors.white, + onSurface: const Color(0xFF111827), + onSurfaceVariant: const Color(0xFF6B7280), + ); + return _buildTheme(scheme); +} + +ThemeData buildDarkTheme() { + final scheme = + ColorScheme.fromSeed( + seedColor: const Color(0xFF7DD3FC), + brightness: Brightness.dark, + ).copyWith( + surface: const Color(0xFF0F172A), + surfaceContainerLowest: const Color(0xFF020617), + surfaceContainerLow: const Color(0xFF111827), + surfaceContainerHighest: const Color(0xFF1F2937), + outline: const Color(0xFF334155), + outlineVariant: const Color(0xFF1E293B), + primary: const Color(0xFFBAE6FD), + onPrimary: const Color(0xFF082F49), + onSurface: const Color(0xFFF8FAFC), + onSurfaceVariant: const Color(0xFF94A3B8), + ); + return _buildTheme(scheme); +} + +ThemeData _buildTheme(ColorScheme colorScheme) { + final isDark = colorScheme.brightness == Brightness.dark; + final base = ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + fontFamily: 'NotoSansKR', + ); + + return base.copyWith( + scaffoldBackgroundColor: colorScheme.surfaceContainerLowest, + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: NoTransitionsBuilder(), + TargetPlatform.iOS: NoTransitionsBuilder(), + TargetPlatform.linux: NoTransitionsBuilder(), + TargetPlatform.macOS: NoTransitionsBuilder(), + TargetPlatform.windows: NoTransitionsBuilder(), + TargetPlatform.fuchsia: NoTransitionsBuilder(), + }, + ), + appBarTheme: AppBarTheme( + elevation: 0, + centerTitle: false, + backgroundColor: colorScheme.surface, + foregroundColor: colorScheme.onSurface, + surfaceTintColor: Colors.transparent, + ), + cardTheme: CardThemeData( + color: colorScheme.surface, + elevation: 0, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: colorScheme.outlineVariant), + ), + ), + dividerTheme: DividerThemeData( + color: colorScheme.outlineVariant, + thickness: 1, + ), + drawerTheme: DrawerThemeData( + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + ), + dialogTheme: DialogThemeData( + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: isDark ? colorScheme.surfaceContainerLow : colorScheme.surface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: colorScheme.outline), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: colorScheme.outline), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: colorScheme.primary, width: 1.4), + ), + labelStyle: TextStyle(color: colorScheme.onSurfaceVariant), + hintStyle: TextStyle(color: colorScheme.onSurfaceVariant), + prefixIconColor: colorScheme.onSurfaceVariant, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(50), + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.onSurface, + side: BorderSide(color: colorScheme.outline), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + ), + tabBarTheme: TabBarThemeData( + dividerColor: colorScheme.outlineVariant, + labelColor: colorScheme.onSurface, + unselectedLabelColor: colorScheme.onSurfaceVariant, + indicatorColor: colorScheme.primary, + ), + ); +} + +class NoTransitionsBuilder extends PageTransitionsBuilder { + const NoTransitionsBuilder(); + + @override + Widget buildTransitions( + PageRoute route, + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return child; + } +} diff --git a/userfront/lib/core/theme/theme_controller.dart b/userfront/lib/core/theme/theme_controller.dart new file mode 100644 index 00000000..02034217 --- /dev/null +++ b/userfront/lib/core/theme/theme_controller.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class ThemeController extends ValueNotifier { + ThemeController._() : super(ThemeMode.light); + + static const storageKey = 'userfront_theme'; + static final ThemeController instance = ThemeController._(); + + bool get isDark => value == ThemeMode.dark; + + Future restore() async { + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getString(storageKey); + value = stored == 'dark' ? ThemeMode.dark : ThemeMode.light; + } + + Future setThemeMode(ThemeMode mode) async { + if (value != mode) { + value = mode; + } + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + storageKey, + mode == ThemeMode.dark ? 'dark' : 'light', + ); + } + + Future toggle() { + return setThemeMode(isDark ? ThemeMode.light : ThemeMode.dark); + } +} diff --git a/userfront/lib/core/widgets/theme_toggle_button.dart b/userfront/lib/core/widgets/theme_toggle_button.dart new file mode 100644 index 00000000..e0b11d67 --- /dev/null +++ b/userfront/lib/core/widgets/theme_toggle_button.dart @@ -0,0 +1,44 @@ +import 'package:easy_localization/easy_localization.dart' hide tr; +import 'package:flutter/material.dart'; +import 'package:userfront/i18n.dart'; + +import '../theme/theme_controller.dart'; + +class ThemeToggleButton extends StatelessWidget { + const ThemeToggleButton({super.key, this.compact = false}); + + final bool compact; + + @override + Widget build(BuildContext context) { + context.locale; + + return ValueListenableBuilder( + valueListenable: ThemeController.instance, + builder: (context, mode, _) { + final isLight = mode == ThemeMode.light; + final icon = isLight + ? Icons.light_mode_outlined + : Icons.dark_mode_outlined; + final label = isLight + ? tr('ui.common.theme_light', fallback: 'Light') + : tr('ui.common.theme_dark', fallback: 'Dark'); + final tooltip = tr('ui.common.theme_toggle', fallback: '테마 전환'); + + if (compact) { + return IconButton( + tooltip: tooltip, + onPressed: () => ThemeController.instance.toggle(), + icon: Icon(icon), + ); + } + + return OutlinedButton.icon( + onPressed: () => ThemeController.instance.toggle(), + icon: Icon(icon, size: 18), + label: Text(label), + ); + }, + ); + } +} diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index 8fbabee1..deb1a9e0 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -24,6 +24,8 @@ import 'core/services/logger_service.dart'; import 'core/services/null_check_recovery.dart'; 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/i18n/locale_gate.dart'; import 'core/i18n/locale_registry.dart'; import 'core/i18n/locale_utils.dart'; @@ -106,6 +108,7 @@ void main() async { // 0. Initialize Logger LoggerService.init(); + await ThemeController.instance.restore(); // 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화 await _loadBundledFonts(); @@ -366,50 +369,25 @@ class BaronSSOApp extends StatelessWidget { final locale = localization?.currentLocale ?? Locale(resolvePreferredLocaleCode()); - 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()], + 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, ); }, - theme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base - brightness: Brightness.light, - ), - useMaterial3: true, - fontFamily: 'NotoSansKR', - pageTransitionsTheme: const PageTransitionsTheme( - builders: { - TargetPlatform.android: NoTransitionsBuilder(), - TargetPlatform.iOS: NoTransitionsBuilder(), - TargetPlatform.linux: NoTransitionsBuilder(), - TargetPlatform.macOS: NoTransitionsBuilder(), - TargetPlatform.windows: NoTransitionsBuilder(), - TargetPlatform.fuchsia: NoTransitionsBuilder(), - }, - ), - ), - routerConfig: _router, ); } } - -class NoTransitionsBuilder extends PageTransitionsBuilder { - const NoTransitionsBuilder(); - - @override - Widget buildTransitions( - PageRoute route, - BuildContext context, - Animation animation, - Animation secondaryAnimation, - Widget child, - ) { - return child; - } -} diff --git a/userfront/pubspec.lock b/userfront/pubspec.lock index fecd33f1..da86790b 100644 --- a/userfront/pubspec.lock +++ b/userfront/pubspec.lock @@ -485,7 +485,7 @@ packages: source: hosted version: "3.2.0" shared_preferences: - dependency: transitive + dependency: "direct main" description: name: shared_preferences sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" diff --git a/userfront/pubspec.yaml b/userfront/pubspec.yaml index 270c2fb4..71552d8c 100644 --- a/userfront/pubspec.yaml +++ b/userfront/pubspec.yaml @@ -48,6 +48,7 @@ dependencies: easy_localization: ^3.0.7 toml: ^0.15.0 web: ^1.1.0 + shared_preferences: ^2.5.4 dev_dependencies: flutter_test: