첫 커밋: 로컬 프로젝트 업로드

This commit is contained in:
2026-06-10 15:51:34 +09:00
commit 6a8dbeb2e9
1211 changed files with 312864 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart' hide tr;
import 'package:flutter/material.dart';
import 'package:userfront/i18n.dart';
import '../services/web_window.dart';
import 'locale_storage.dart';
import 'locale_utils.dart';
class LocaleGate extends StatefulWidget {
const LocaleGate({super.key, required this.localeCode, required this.child});
final String localeCode;
final Widget child;
@override
State<LocaleGate> createState() => _LocaleGateState();
}
class _LocaleGateState extends State<LocaleGate> {
bool _syncScheduled = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_scheduleLocaleSync();
}
@override
void didUpdateWidget(LocaleGate oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.localeCode != widget.localeCode) {
_scheduleLocaleSync();
}
}
void _scheduleLocaleSync() {
if (_syncScheduled) {
return;
}
_syncScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_syncScheduled = false;
if (!mounted) {
return;
}
unawaited(_applyLocale());
});
}
Future<void> _applyLocale() async {
if (!mounted) {
return;
}
final normalized = normalizeLocaleCode(widget.localeCode);
LocaleStorage.write(normalized);
final localization = EasyLocalization.of(context);
if (localization == null) {
return;
}
if (localization.currentLocale?.languageCode == normalized) {
webWindow.setTitle(tr('ui.userfront.app_title'));
return;
}
await localization.setLocale(Locale(normalized));
if (!mounted) {
return;
}
webWindow.setTitle(tr('ui.userfront.app_title'));
}
@override
Widget build(BuildContext context) => widget.child;
}

View File

@@ -0,0 +1,115 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
const _translationAssetPrefix = 'assets/translations/';
const _templateFileName = 'template.toml';
const _safeFallbackLocaleCode = 'en';
List<String> extractSupportedLocaleCodesFromAssets(Iterable<String> assets) {
final localeCodes = <String>{};
for (final asset in assets) {
if (!asset.startsWith(_translationAssetPrefix) ||
!asset.endsWith('.toml')) {
continue;
}
final fileName = asset.substring(_translationAssetPrefix.length);
if (fileName.contains('/') || fileName == _templateFileName) {
continue;
}
final rawCode = fileName.substring(0, fileName.length - '.toml'.length);
final normalized = rawCode.toLowerCase().replaceAll('_', '-');
if (_isValidLocaleCode(normalized)) {
localeCodes.add(normalized);
}
}
final sorted = localeCodes.toList()..sort();
return sorted;
}
class LocaleRegistry {
static final Set<String> _localeCodes = <String>{};
static bool _initialized = false;
static void primeWithDefaults({
Iterable<String> localeCodes = const ['en', 'ko'],
}) {
if (_localeCodes.isNotEmpty) {
return;
}
_localeCodes.addAll(
localeCodes
.map((code) => code.toLowerCase().replaceAll('_', '-'))
.where(_isValidLocaleCode),
);
if (_localeCodes.isEmpty) {
_localeCodes.add(_safeFallbackLocaleCode);
}
}
static Future<void> initialize({AssetBundle? assetBundle}) async {
if (_initialized) {
return;
}
final bundle = assetBundle ?? rootBundle;
try {
final manifest = await AssetManifest.loadFromAssetBundle(bundle);
final extracted = extractSupportedLocaleCodesFromAssets(
manifest.listAssets(),
);
_localeCodes.addAll(extracted);
} catch (_) {
// manifest 로딩 실패 시 안전 fallback으로 계속 진행합니다.
}
if (_localeCodes.isEmpty) {
_localeCodes.add(_safeFallbackLocaleCode);
}
_initialized = true;
}
static List<String> get supportedLocaleCodes {
final sorted = _localeCodes.toList()..sort();
return List.unmodifiable(sorted);
}
static String get fallbackLocaleCode {
final supported = supportedLocaleCodes;
if (supported.isEmpty) {
return _safeFallbackLocaleCode;
}
if (supported.contains('en')) {
return 'en';
}
return supported.first;
}
static bool contains(String code) {
return _localeCodes.contains(code.toLowerCase());
}
@visibleForTesting
static void setSupportedLocaleCodesForTest(Iterable<String> localeCodes) {
_localeCodes
..clear()
..addAll(
localeCodes
.map((code) => code.toLowerCase().replaceAll('_', '-'))
.where(_isValidLocaleCode),
);
if (_localeCodes.isEmpty) {
_localeCodes.add(_safeFallbackLocaleCode);
}
_initialized = true;
}
@visibleForTesting
static void resetForTest() {
_localeCodes.clear();
_initialized = false;
}
}
bool _isValidLocaleCode(String value) {
return RegExp(r'^[a-z]{2,3}$').hasMatch(value);
}

View File

@@ -0,0 +1,59 @@
import 'package:flutter/foundation.dart';
import 'locale_storage_backend.dart';
import 'locale_storage_stub.dart'
if (dart.library.js_interop) 'locale_storage_web.dart';
abstract class LocaleStorage {
static bool _forceMemory = false;
static bool _forceSession = false;
static void _syncTestMode() {
if (_forceMemory) {
localeStorage.setTestMode(LocaleStorageTestMode.memoryOnly);
return;
}
if (_forceSession) {
localeStorage.setTestMode(LocaleStorageTestMode.sessionOnly);
return;
}
localeStorage.setTestMode(LocaleStorageTestMode.normal);
}
static String? read() => localeStorage.read();
static void write(String locale) => localeStorage.write(locale);
@visibleForTesting
static void setTestModeForTests(LocaleStorageTestMode mode) {
_forceMemory = mode == LocaleStorageTestMode.memoryOnly;
_forceSession = mode == LocaleStorageTestMode.sessionOnly;
_syncTestMode();
}
@visibleForTesting
static void clearForTests() {
localeStorage.clearForTests();
_forceMemory = false;
_forceSession = false;
}
@visibleForTesting
static void seedLegacyForTests(String locale) {
localeStorage.seedLegacyForTests(locale);
}
@visibleForTesting
static LocaleStorageDebugState debugStateForTests() {
return localeStorage.debugStateForTests();
}
static void forceMemoryStorageForTests(bool value) {
_forceMemory = value;
_syncTestMode();
}
static void forceSessionStorageForTests(bool value) {
_forceSession = value;
_syncTestMode();
}
}

View File

@@ -0,0 +1,38 @@
import 'package:flutter/foundation.dart';
enum LocaleStorageTestMode { normal, sessionOnly, memoryOnly }
@immutable
class LocaleStorageDebugState {
const LocaleStorageDebugState({
required this.mode,
this.localCurrent,
this.localLegacy,
this.sessionCurrent,
this.sessionLegacy,
this.memoryCurrent,
this.memoryLegacy,
});
final LocaleStorageTestMode mode;
final String? localCurrent;
final String? localLegacy;
final String? sessionCurrent;
final String? sessionLegacy;
final String? memoryCurrent;
final String? memoryLegacy;
}
abstract interface class LocaleStorageBackend {
String? read();
void write(String locale);
void setTestMode(LocaleStorageTestMode mode);
void clearForTests();
void seedLegacyForTests(String locale);
LocaleStorageDebugState debugStateForTests();
}

View File

@@ -0,0 +1,245 @@
import 'locale_storage_backend.dart';
import 'locale_storage_policy.dart';
enum _StorageTarget { local, session, memory }
abstract interface class LocaleStorageTarget {
String? read(String key);
bool write(String key, String value);
bool remove(String key);
void clear();
}
class LocaleStorageNoopTarget implements LocaleStorageTarget {
const LocaleStorageNoopTarget();
@override
String? read(String key) => null;
@override
bool write(String key, String value) => false;
@override
bool remove(String key) => false;
@override
void clear() {}
}
class LocaleStorageCallbackTarget implements LocaleStorageTarget {
LocaleStorageCallbackTarget({
required this.readCallback,
required this.writeCallback,
required this.removeCallback,
required this.clearCallback,
});
final String? Function(String key) readCallback;
final void Function(String key, String value) writeCallback;
final void Function(String key) removeCallback;
final void Function() clearCallback;
@override
String? read(String key) => readCallback(key);
@override
bool write(String key, String value) {
writeCallback(key, value);
return true;
}
@override
bool remove(String key) {
removeCallback(key);
return true;
}
@override
void clear() => clearCallback();
}
class LocaleStorageEngine implements LocaleStorageBackend {
LocaleStorageEngine({
required LocaleStorageTarget localTarget,
required LocaleStorageTarget sessionTarget,
}) : _localTarget = localTarget,
_sessionTarget = sessionTarget;
final LocaleStorageTarget _localTarget;
final LocaleStorageTarget _sessionTarget;
final Map<String, String> _memory = {};
LocaleStorageTestMode _mode = LocaleStorageTestMode.normal;
List<_StorageTarget> _fallbackTargets() {
switch (_mode) {
case LocaleStorageTestMode.normal:
return [
_StorageTarget.local,
_StorageTarget.session,
_StorageTarget.memory,
];
case LocaleStorageTestMode.sessionOnly:
return [_StorageTarget.session, _StorageTarget.memory];
case LocaleStorageTestMode.memoryOnly:
return [_StorageTarget.memory];
}
}
String? _safeReadTarget(_StorageTarget target, String key) {
try {
switch (target) {
case _StorageTarget.local:
return _localTarget.read(key);
case _StorageTarget.session:
return _sessionTarget.read(key);
case _StorageTarget.memory:
return _memory[key];
}
} catch (_) {
return null;
}
}
bool _safeWriteTarget(_StorageTarget target, String key, String value) {
try {
switch (target) {
case _StorageTarget.local:
return _localTarget.write(key, value);
case _StorageTarget.session:
return _sessionTarget.write(key, value);
case _StorageTarget.memory:
_memory[key] = value;
return true;
}
} catch (_) {
return false;
}
}
bool _safeRemoveTarget(_StorageTarget target, String key) {
try {
switch (target) {
case _StorageTarget.local:
return _localTarget.remove(key);
case _StorageTarget.session:
return _sessionTarget.remove(key);
case _StorageTarget.memory:
_memory.remove(key);
return true;
}
} catch (_) {
return false;
}
}
void _safeClearTarget(_StorageTarget target) {
try {
switch (target) {
case _StorageTarget.local:
_localTarget.clear();
case _StorageTarget.session:
_sessionTarget.clear();
case _StorageTarget.memory:
_memory.clear();
}
} catch (_) {
// 테스트 정리 단계에서는 clear 예외를 무시합니다.
}
}
String? _readByKey(String key) {
for (final target in _fallbackTargets()) {
final value = _safeReadTarget(target, key);
if (value != null) {
return value;
}
}
return null;
}
void _writeByKey(String key, String value) {
for (final target in _fallbackTargets()) {
if (_safeWriteTarget(target, key, value)) {
return;
}
}
}
void _removeEverywhere(String key) {
_safeRemoveTarget(_StorageTarget.local, key);
_safeRemoveTarget(_StorageTarget.session, key);
_memory.remove(key);
}
@override
String? read() {
final current = _readByKey(LocaleStoragePolicy.currentKey);
if (LocaleStoragePolicy.hasValue(current)) {
return current;
}
final legacy = _readByKey(LocaleStoragePolicy.legacyKey);
if (LocaleStoragePolicy.shouldMigrateLegacy(
current: current,
legacy: legacy,
) &&
legacy != null) {
_writeByKey(LocaleStoragePolicy.currentKey, legacy);
_removeEverywhere(LocaleStoragePolicy.legacyKey);
return legacy;
}
return null;
}
@override
void write(String locale) {
_writeByKey(LocaleStoragePolicy.currentKey, locale);
}
@override
void setTestMode(LocaleStorageTestMode mode) {
_mode = mode;
}
@override
void clearForTests() {
_safeClearTarget(_StorageTarget.local);
_safeClearTarget(_StorageTarget.session);
_memory.clear();
_mode = LocaleStorageTestMode.normal;
}
@override
void seedLegacyForTests(String locale) {
_writeByKey(LocaleStoragePolicy.legacyKey, locale);
}
@override
LocaleStorageDebugState debugStateForTests() {
return LocaleStorageDebugState(
mode: _mode,
localCurrent: _safeReadTarget(
_StorageTarget.local,
LocaleStoragePolicy.currentKey,
),
localLegacy: _safeReadTarget(
_StorageTarget.local,
LocaleStoragePolicy.legacyKey,
),
sessionCurrent: _safeReadTarget(
_StorageTarget.session,
LocaleStoragePolicy.currentKey,
),
sessionLegacy: _safeReadTarget(
_StorageTarget.session,
LocaleStoragePolicy.legacyKey,
),
memoryCurrent: _memory[LocaleStoragePolicy.currentKey],
memoryLegacy: _memory[LocaleStoragePolicy.legacyKey],
);
}
}

View File

@@ -0,0 +1,13 @@
class LocaleStoragePolicy {
static const currentKey = 'locale';
static const legacyKey = 'baron_locale';
static bool hasValue(String? value) => value != null && value.isNotEmpty;
static bool shouldMigrateLegacy({
required String? current,
required String? legacy,
}) {
return !hasValue(current) && hasValue(legacy);
}
}

View File

@@ -0,0 +1,7 @@
import 'locale_storage_backend.dart';
import 'locale_storage_engine.dart';
final LocaleStorageBackend localeStorage = LocaleStorageEngine(
localTarget: const LocaleStorageNoopTarget(),
sessionTarget: const LocaleStorageNoopTarget(),
);

View File

@@ -0,0 +1,22 @@
// ignore_for_file: avoid_web_libraries_in_flutter
import 'package:web/web.dart' as web;
import 'locale_storage_backend.dart';
import 'locale_storage_engine.dart';
final LocaleStorageBackend localeStorage = LocaleStorageEngine(
localTarget: LocaleStorageCallbackTarget(
readCallback: (key) => web.window.localStorage.getItem(key),
writeCallback: (key, value) => web.window.localStorage.setItem(key, value),
removeCallback: (key) => web.window.localStorage.removeItem(key),
clearCallback: () => web.window.localStorage.clear(),
),
sessionTarget: LocaleStorageCallbackTarget(
readCallback: (key) => web.window.sessionStorage.getItem(key),
writeCallback: (key, value) =>
web.window.sessionStorage.setItem(key, value),
removeCallback: (key) => web.window.sessionStorage.removeItem(key),
clearCallback: () => web.window.sessionStorage.clear(),
),
);

View File

@@ -0,0 +1,117 @@
import 'dart:ui';
import 'locale_storage.dart';
import 'locale_registry.dart';
String get defaultLocaleCode => LocaleRegistry.fallbackLocaleCode;
String normalizeLocaleCode(String? code) {
final supportedLocaleCodes = LocaleRegistry.supportedLocaleCodes;
final fallbackLocaleCode = LocaleRegistry.fallbackLocaleCode;
if (code == null || code.isEmpty) {
return fallbackLocaleCode;
}
final normalized = code.toLowerCase().replaceAll('_', '-');
if (supportedLocaleCodes.contains(normalized)) {
return normalized;
}
final languageCode = normalized.split('-').first;
if (supportedLocaleCodes.contains(languageCode)) {
return languageCode;
}
return fallbackLocaleCode;
}
String resolvePreferredLocaleCode() {
final stored = LocaleStorage.read();
if (stored != null && stored.isNotEmpty) {
final normalizedStored = normalizeLocaleCode(stored);
if (LocaleRegistry.contains(normalizedStored)) {
return normalizedStored;
}
}
final deviceLocale = PlatformDispatcher.instance.locale;
final countryCode = deviceLocale.countryCode;
final languageTag = countryCode == null || countryCode.isEmpty
? deviceLocale.languageCode
: '${deviceLocale.languageCode}-$countryCode';
return normalizeLocaleCode(languageTag);
}
String? extractLocaleFromPath(Uri uri) {
final supportedLocaleCodes = LocaleRegistry.supportedLocaleCodes;
if (uri.pathSegments.isEmpty) {
return null;
}
final code = uri.pathSegments.first.toLowerCase();
if (supportedLocaleCodes.contains(code)) {
return code;
}
return null;
}
String stripLocalePath(Uri uri) {
final supportedLocaleCodes = LocaleRegistry.supportedLocaleCodes;
final segments = uri.pathSegments;
if (segments.isNotEmpty &&
supportedLocaleCodes.contains(segments.first.toLowerCase())) {
final rest = segments.skip(1).join('/');
if (rest.isEmpty) {
return '/';
}
return '/$rest';
}
return uri.path;
}
String buildLocalizedPath(String localeCode, Uri uri) {
final supportedLocaleCodes = LocaleRegistry.supportedLocaleCodes;
final segments = uri.pathSegments;
Iterable<String> restSegments = segments;
if (segments.isNotEmpty) {
final head = segments.first.toLowerCase();
if (supportedLocaleCodes.contains(head)) {
restSegments = segments.skip(1);
}
}
final newPath = '/${[localeCode, ...restSegments].join('/')}';
// Return only the path and query part to avoid GoRouter confusion with full URLs
final newUri = uri.replace(path: newPath);
String result = newUri.path;
if (newUri.hasQuery) {
result += '?${newUri.query}';
}
if (newUri.hasFragment) {
result += '#${newUri.fragment}';
}
return result;
}
String buildSigninRedirectPath(String localeCode, Uri uri) {
final newPath = '/$localeCode/signin';
final newUri = uri.replace(path: newPath);
String result = newUri.path;
if (newUri.hasQuery) {
result += '?${newUri.query}';
}
if (newUri.hasFragment) {
result += '#${newUri.fragment}';
}
return result;
}
String buildLocalizedHomePath(Uri uri, {String? preferredLocaleCode}) {
final resolvedLocale =
extractLocaleFromPath(uri) ??
normalizeLocaleCode(preferredLocaleCode ?? resolvePreferredLocaleCode());
return '/$resolvedLocale/dashboard';
}
String buildLocalizedSigninPath(Uri uri, {String? preferredLocaleCode}) {
final resolvedLocale =
extractLocaleFromPath(uri) ??
normalizeLocaleCode(preferredLocaleCode ?? resolvePreferredLocaleCode());
return '/$resolvedLocale/signin';
}

View File

@@ -0,0 +1,54 @@
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
import '../../i18n_data.dart';
class TomlAssetLoader extends AssetLoader {
const TomlAssetLoader();
@override
Future<Map<String, dynamic>> load(String path, Locale locale) async {
final languageCode = locale.languageCode.toLowerCase();
return switch (languageCode) {
'ko' => _normalizedKoStrings,
'en' => _normalizedEnStrings,
_ => _normalizedEnStrings,
};
}
}
final Map<String, dynamic> _normalizedKoStrings = _normalizeFlatTranslations(
koStrings,
);
final Map<String, dynamic> _normalizedEnStrings = _normalizeFlatTranslations(
enStrings,
);
Map<String, dynamic> _normalizeFlatTranslations(Map<String, String> flatMap) =>
Map.fromEntries(
flatMap.entries
.where((entry) => _isUserfrontTranslationKey(entry.key))
.map(
(entry) =>
MapEntry(entry.key, _normalizeLocalizationValue(entry.value)),
),
);
bool _isUserfrontTranslationKey(String key) {
return key.startsWith('domain.') ||
key.startsWith('err.userfront.') ||
key.startsWith('msg.userfront.') ||
key.startsWith('ui.userfront.') ||
key.startsWith('ui.common.');
}
String _normalizeLocalizationValue(String value) {
return value
.replaceAllMapped(
RegExp(r'\{\{\s*([a-zA-Z0-9_]+)\s*\}\}'),
(match) => '{${match.group(1)}}',
)
.replaceAll(r'\\n', '\n')
.replaceAll(r'\n', '\n');
}