1
0
forked from baron/baron-sso

feat: i18n 개선 및 userfront 로그인/로케일 보완

This commit is contained in:
Lectom C Han
2026-02-12 21:25:26 +09:00
parent b6d3b69cda
commit dfa2fc2406
60 changed files with 5724 additions and 1734 deletions

View File

@@ -0,0 +1,51 @@
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> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
_applyLocale();
}
@override
void didUpdateWidget(LocaleGate oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.localeCode != widget.localeCode) {
_applyLocale();
}
}
Future<void> _applyLocale() async {
final normalized = normalizeLocaleCode(widget.localeCode);
LocaleStorage.write(normalized);
webWindow.setTitle(
tr('ui.userfront.app_title'),
);
if (context.locale.languageCode == normalized) {
return;
}
await context.setLocale(Locale(normalized));
webWindow.setTitle(
tr('ui.userfront.app_title'),
);
}
@override
Widget build(BuildContext context) => widget.child;
}

View File

@@ -0,0 +1,7 @@
import 'locale_storage_stub.dart'
if (dart.library.html) 'locale_storage_web.dart';
abstract class LocaleStorage {
static String? read() => localeStorage.read();
static void write(String locale) => localeStorage.write(locale);
}

View File

@@ -0,0 +1,11 @@
class LocaleStorageImpl {
String? _locale;
String? read() => _locale;
void write(String locale) {
_locale = locale;
}
}
final localeStorage = LocaleStorageImpl();

View File

@@ -0,0 +1,120 @@
// ignore_for_file: avoid_web_libraries_in_flutter
import 'dart:html' as html;
import 'package:flutter/foundation.dart';
class LocaleStorageImpl {
static const _key = 'locale';
static const _legacyKey = 'baron_locale';
static final Map<String, String> _memory = {};
static bool _forceMemory = false;
static bool _forceSession = false;
@visibleForTesting
static void forceMemoryStorageForTests(bool value) {
_forceMemory = value;
if (!value) {
_memory.clear();
}
}
@visibleForTesting
static void forceSessionStorageForTests(bool value) {
_forceSession = value;
}
String? _read(String key) {
if (!_forceMemory && !_forceSession) {
try {
return html.window.localStorage[key];
} catch (_) {
// localStorage 접근이 차단된 경우 sessionStorage로 fallback.
try {
return html.window.sessionStorage[key];
} catch (_) {
// sessionStorage도 차단된 경우 메모리 fallback 사용.
}
}
}
if (!_forceMemory) {
try {
return html.window.sessionStorage[key];
} catch (_) {
// sessionStorage도 차단된 경우 메모리 fallback 사용.
}
}
return _memory[key];
}
void _write(String key, String value) {
if (!_forceMemory && !_forceSession) {
try {
html.window.localStorage[key] = value;
return;
} catch (_) {
// localStorage 접근이 차단된 경우 sessionStorage로 fallback.
try {
html.window.sessionStorage[key] = value;
return;
} catch (_) {
// sessionStorage도 차단된 경우 메모리 fallback 사용.
}
}
}
if (!_forceMemory) {
try {
html.window.sessionStorage[key] = value;
return;
} catch (_) {
// sessionStorage도 차단된 경우 메모리 fallback 사용.
}
}
_memory[key] = value;
}
void _remove(String key) {
if (!_forceMemory && !_forceSession) {
try {
html.window.localStorage.remove(key);
return;
} catch (_) {
// localStorage 접근이 차단된 경우 sessionStorage로 fallback.
try {
html.window.sessionStorage.remove(key);
return;
} catch (_) {
// sessionStorage도 차단된 경우 메모리 fallback 사용.
}
}
}
if (!_forceMemory) {
try {
html.window.sessionStorage.remove(key);
return;
} catch (_) {
// sessionStorage도 차단된 경우 메모리 fallback 사용.
}
}
_memory.remove(key);
}
String? read() {
final current = _read(_key);
if (current != null && current.isNotEmpty) {
return current;
}
final legacy = _read(_legacyKey);
if (legacy != null && legacy.isNotEmpty) {
_write(_key, legacy);
_remove(_legacyKey);
return legacy;
}
return null;
}
void write(String locale) {
_write(_key, locale);
}
}
final localeStorage = LocaleStorageImpl();

View File

@@ -0,0 +1,69 @@
import 'dart:ui';
import 'locale_storage.dart';
const supportedLocaleCodes = ['en', 'ko'];
const defaultLocaleCode = 'en';
String normalizeLocaleCode(String? code) {
if (code == null || code.isEmpty) {
return defaultLocaleCode;
}
final normalized = code.toLowerCase();
if (normalized == 'ko' || normalized.startsWith('ko-')) {
return 'ko';
}
if (normalized == 'en' || normalized.startsWith('en-')) {
return 'en';
}
return defaultLocaleCode;
}
String resolvePreferredLocaleCode() {
final stored = LocaleStorage.read();
if (stored != null && supportedLocaleCodes.contains(stored)) {
return stored;
}
final deviceLocale = PlatformDispatcher.instance.locale;
return normalizeLocaleCode(deviceLocale.languageCode);
}
String? extractLocaleFromPath(Uri uri) {
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 segments = uri.pathSegments;
if (segments.isNotEmpty && supportedLocaleCodes.contains(segments.first)) {
final rest = segments.skip(1).join('/');
if (rest.isEmpty) {
return '/';
}
return '/$rest';
}
return uri.path;
}
String buildLocalizedPath(String localeCode, Uri uri) {
final segments = uri.pathSegments;
Iterable<String> restSegments = segments;
if (segments.isNotEmpty) {
final head = segments.first.toLowerCase();
if (supportedLocaleCodes.contains(head) || head.length == 2) {
restSegments = segments.skip(1);
}
}
final newSegments = [localeCode, ...restSegments];
final path = '/${newSegments.join('/')}';
if (uri.queryParameters.isEmpty) {
return path;
}
return Uri(path: path, queryParameters: uri.queryParameters).toString();
}

View File

@@ -0,0 +1,22 @@
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:toml/toml.dart';
class TomlAssetLoader extends AssetLoader {
const TomlAssetLoader();
@override
Future<Map<String, dynamic>> load(String path, Locale locale) async {
final assetPath = '$path/${locale.languageCode}.toml';
try {
final content = await rootBundle.loadString(assetPath);
final document = TomlDocument.parse(content);
return document.toMap();
} catch (e) {
// 로딩 실패 시 빈 맵을 반환해 렌더링을 지속합니다.
return {};
}
}
}

View File

@@ -11,18 +11,34 @@ class AuthProxyService {
if (!dotenv.isInitialized) {
return fallback;
}
return dotenv.env[key] ?? fallback;
final value = dotenv.env[key];
if (value == null || value.trim().isEmpty) {
return fallback;
}
return value;
}
static String _fallbackOrigin() {
try {
final origin = Uri.base.origin;
if (origin.isNotEmpty && origin != 'null') {
return origin;
}
} catch (_) {}
return 'http://sso.hmac.kr';
}
static String get _baseUrl {
final rawUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
final rawUrl = _envOrDefault('BACKEND_URL', _fallbackOrigin());
// 배포 환경에서 $ 기호나 공백이 섞여 들어오는 경우를 방지하기 위해 정제합니다.
return rawUrl.replaceAll(r'$', '').trim().replaceAll(RegExp(r'/$'), '');
}
static bool get _isProd {
final env = _envOrDefault('APP_ENV', 'dev').toLowerCase();
return env == 'prod' || env == 'production';
}
static bool get isProdEnv => _isProd;
static bool _shouldSendDrySend(bool? drySend) {
if (_isProd) {
@@ -76,13 +92,14 @@ class AuthProxyService {
}
}
static Future<int> getSessionStatus({String? token, bool useCookie = false}) async {
static Future<int> getSessionStatus({
String? token,
bool useCookie = false,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/user/me');
final client = createHttpClient(withCredentials: useCookie);
try {
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
@@ -100,12 +117,9 @@ class AuthProxyService {
bool? drySend,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init');
final userfrontUrl = _envOrDefault('USERFRONT_URL', 'http://sso.hmac.kr');
final body = <String, dynamic>{
'loginId': loginId,
'uri': userfrontUrl,
};
final userfrontUrl = _envOrDefault('USERFRONT_URL', _fallbackOrigin());
final body = <String, dynamic>{'loginId': loginId, 'uri': userfrontUrl};
if (_shouldSendDrySend(drySend)) {
body['drySend'] = true;
}
@@ -115,7 +129,7 @@ class AuthProxyService {
if (codeOnly == true) {
body['codeOnly'] = true;
}
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
@@ -133,15 +147,15 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> pollEnchantedLink(String pendingRef) async {
static Future<Map<String, dynamic>> pollEnchantedLink(
String pendingRef,
) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/poll');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'pendingRef': pendingRef,
}),
body: jsonEncode({'pendingRef': pendingRef}),
);
if (response.statusCode == 200) {
@@ -157,16 +171,16 @@ class AuthProxyService {
);
}
static Future<Map<String, dynamic>> verifyMagicLink(String token, {bool verifyOnly = false}) async {
static Future<Map<String, dynamic>> verifyMagicLink(
String token, {
bool verifyOnly = false,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/magic-link/verify');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'token': token,
'verifyOnly': verifyOnly,
}),
body: jsonEncode({'token': token, 'verifyOnly': verifyOnly}),
);
if (response.statusCode == 200) {
@@ -223,10 +237,7 @@ class AuthProxyService {
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'shortCode': shortCode,
'verifyOnly': verifyOnly,
}),
body: jsonEncode({'shortCode': shortCode, 'verifyOnly': verifyOnly}),
);
if (response.statusCode == 200) {
@@ -240,13 +251,18 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> loginWithPassword(String loginId, String password, {String? loginChallenge}) async {
static Future<Map<String, dynamic>> loginWithPassword(
String loginId,
String password, {
String? loginChallenge,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/password/login');
final payload = {
'loginId': loginId,
'password': password,
if (loginChallenge != null && loginChallenge.isNotEmpty) 'login_challenge': loginChallenge,
if (loginChallenge != null && loginChallenge.isNotEmpty)
'login_challenge': loginChallenge,
};
final response = await http.post(
@@ -267,13 +283,17 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.login_failed',
fallback: '로그인에 실패했습니다.',
),
);
}
}
static Future<Map<String, dynamic>> getConsentInfo(String consentChallenge) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/consent').replace(queryParameters: {'consent_challenge': consentChallenge});
static Future<Map<String, dynamic>> getConsentInfo(
String consentChallenge,
) async {
final url = Uri.parse(
'$_baseUrl/api/v1/auth/consent',
).replace(queryParameters: {'consent_challenge': consentChallenge});
final response = await http.get(
url,
headers: {'Content-Type': 'application/json'},
@@ -287,17 +307,17 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.consent_fetch',
fallback: '동의 정보를 가져오지 못했습니다.',
),
);
}
}
static Future<Map<String, dynamic>> acceptConsent(String consentChallenge, {List<String>? grantScope}) async {
static Future<Map<String, dynamic>> acceptConsent(
String consentChallenge, {
List<String>? grantScope,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/consent/accept');
final body = <String, dynamic>{
'consent_challenge': consentChallenge,
};
final body = <String, dynamic>{'consent_challenge': consentChallenge};
if (grantScope != null) {
body['grant_scope'] = grantScope;
}
@@ -316,17 +336,16 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.consent_accept',
fallback: '동의 처리에 실패했습니다.',
),
);
}
}
static Future<Map<String, dynamic>> rejectConsent(String consentChallenge) async {
static Future<Map<String, dynamic>> rejectConsent(
String consentChallenge,
) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/consent/reject');
final body = <String, dynamic>{
'consent_challenge': consentChallenge,
};
final body = <String, dynamic>{'consent_challenge': consentChallenge};
final response = await http.post(
url,
@@ -342,7 +361,6 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.consent_reject',
fallback: '동의 거부에 실패했습니다.',
),
);
}
@@ -353,9 +371,7 @@ class AuthProxyService {
String? token,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/oidc/login/accept');
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
@@ -364,9 +380,7 @@ class AuthProxyService {
final response = await client.post(
url,
headers: headers,
body: jsonEncode({
'login_challenge': loginChallenge,
}),
body: jsonEncode({'login_challenge': loginChallenge}),
);
if (response.statusCode == 200) {
@@ -377,7 +391,6 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.oidc_accept',
fallback: 'OIDC 로그인 승인에 실패했습니다.',
),
);
}
@@ -386,8 +399,10 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> initiatePasswordReset(String loginId, {bool? drySend}) async {
static Future<Map<String, dynamic>> initiatePasswordReset(
String loginId, {
bool? drySend,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/initiate');
final response = await http.post(
url,
@@ -406,7 +421,6 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.password_reset_init',
fallback: '비밀번호 재설정을 시작하지 못했습니다.',
),
);
}
@@ -424,7 +438,9 @@ class AuthProxyService {
if (token != null && token.isNotEmpty) {
query['token'] = token;
}
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/complete').replace(queryParameters: query);
final url = Uri.parse(
'$_baseUrl/api/v1/auth/password/reset/complete',
).replace(queryParameters: query);
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
@@ -439,7 +455,6 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.password_reset_complete',
fallback: '비밀번호 재설정에 실패했습니다.',
),
);
}
@@ -447,13 +462,11 @@ class AuthProxyService {
static Future<void> sendSms(String phoneNumber) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/sms');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'phoneNumber': phoneNumber,
}),
body: jsonEncode({'phoneNumber': phoneNumber}),
);
if (response.statusCode != 200) {
@@ -465,16 +478,16 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> verifySmsCode(String phoneNumber, String code) async {
static Future<Map<String, dynamic>> verifySmsCode(
String phoneNumber,
String code,
) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/verify-sms');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'phoneNumber': phoneNumber,
'code': code,
}),
body: jsonEncode({'phoneNumber': phoneNumber, 'code': code}),
);
if (response.statusCode == 200) {
@@ -532,10 +545,10 @@ class AuthProxyService {
String? token,
bool withCredentials = false,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/qr/approve'); // Mapping to ScanQRLogin on backend
final payload = <String, dynamic>{
'pendingRef': pendingRef,
};
final url = Uri.parse(
'$_baseUrl/api/v1/auth/qr/approve',
); // Mapping to ScanQRLogin on backend
final payload = <String, dynamic>{'pendingRef': pendingRef};
if (token != null && token.isNotEmpty) {
payload['token'] = token;
}
@@ -593,7 +606,7 @@ class AuthProxyService {
String? displayName,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/admin/users');
final response = await http.post(
url,
headers: {
@@ -617,7 +630,10 @@ class AuthProxyService {
}
}
static Future<List<dynamic>> listUsers(String adminPassword, {String? query}) async {
static Future<List<dynamic>> listUsers(
String adminPassword, {
String? query,
}) async {
var uri = Uri.parse('$_baseUrl/api/v1/admin/users');
if (query != null && query.isNotEmpty) {
uri = uri.replace(queryParameters: {'text': query});
@@ -646,7 +662,7 @@ class AuthProxyService {
static Future<void> deleteUser(String adminPassword, String loginId) async {
final encodedId = Uri.encodeComponent(loginId);
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId');
final response = await http.delete(
url,
headers: {
@@ -664,10 +680,14 @@ class AuthProxyService {
}
}
static Future<void> updateUserStatus(String adminPassword, String loginId, String status) async {
static Future<void> updateUserStatus(
String adminPassword,
String loginId,
String status,
) async {
final encodedId = Uri.encodeComponent(loginId);
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId/status');
final response = await http.patch(
url,
headers: {
@@ -695,7 +715,7 @@ class AuthProxyService {
}) async {
final encodedId = Uri.encodeComponent(loginId);
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId');
final body = <String, dynamic>{};
if (email != null) body['email'] = email;
if (phone != null) body['phone'] = phone;
@@ -725,18 +745,13 @@ class AuthProxyService {
final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
try {
final response = await client.get(
url,
headers: headers,
);
final response = await client.get(url, headers: headers);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
@@ -758,18 +773,13 @@ class AuthProxyService {
final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
try {
final response = await client.delete(
url,
headers: headers,
);
final response = await client.delete(url, headers: headers);
if (response.statusCode != 200) {
final errorBody = jsonDecode(response.body);
@@ -777,7 +787,6 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.linked_app_revoke',
fallback: '연동 해지에 실패했습니다.',
),
);
}
@@ -786,7 +795,11 @@ class AuthProxyService {
}
}
static Future<void> sendLog(String level, String message, {Map<String, dynamic>? data}) async {
static Future<void> sendLog(
String level,
String message, {
Map<String, dynamic>? data,
}) async {
if (!_canSendClientLog()) {
return;
}
@@ -808,11 +821,15 @@ class AuthProxyService {
}
}
static Future<void> logError(String message, {dynamic error, StackTrace? stackTrace}) async {
static Future<void> logError(
String message, {
dynamic error,
StackTrace? stackTrace,
}) async {
final data = <String, dynamic>{};
if (error != null) data['error'] = error.toString();
if (stackTrace != null) data['stack'] = stackTrace.toString();
await sendLog('ERROR', message, data: data);
}
@@ -861,7 +878,7 @@ class AuthProxyService {
static Future<void> sendSignupCode(String target, String type) async {
final path = type == 'email' ? 'send-email-code' : 'send-sms-code';
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/$path');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
@@ -877,17 +894,17 @@ class AuthProxyService {
}
}
static Future<bool> verifySignupCode(String target, String type, String code) async {
static Future<bool> verifySignupCode(
String target,
String type,
String code,
) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/verify-code');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'target': target,
'type': type,
'code': code,
}),
body: jsonEncode({'target': target, 'type': type, 'code': code}),
);
if (response.statusCode == 200) {
@@ -908,7 +925,7 @@ class AuthProxyService {
required bool termsAccepted,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/signup');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},

View File

@@ -1,4 +1,6 @@
class WebWindow {
void setTitle(String title) {}
void redirectTo(String url) {}
void alert(String message) {}

View File

@@ -3,6 +3,10 @@
import 'dart:html' as html;
class WebWindow {
void setTitle(String title) {
html.document.title = title;
}
void redirectTo(String url) {
html.window.location.href = url;
}

View File

@@ -0,0 +1,65 @@
import 'package:easy_localization/easy_localization.dart' hide tr;
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:userfront/i18n.dart';
import '../i18n/locale_storage.dart';
import '../i18n/locale_utils.dart';
class LanguageSelector extends StatelessWidget {
const LanguageSelector({super.key, this.compact = false});
final bool compact;
@override
Widget build(BuildContext context) {
final current = context.locale.languageCode;
final items = [
DropdownMenuItem(
value: 'ko',
child: Text(tr('ui.common.language_ko')),
),
DropdownMenuItem(
value: 'en',
child: Text(tr('ui.common.language_en', fallback: 'English')),
),
];
final iconSize = compact ? 16.0 : 18.0;
final dropdown = DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: current,
items: items,
isDense: true,
icon: Icon(Icons.arrow_drop_down, size: compact ? 18 : 20),
onChanged: (value) async {
if (value == null || value == current) {
return;
}
LocaleStorage.write(value);
await context.setLocale(Locale(value));
final uri = GoRouterState.of(context).uri;
final target = buildLocalizedPath(value, uri);
if (context.mounted) {
context.go(target);
}
},
),
);
return Padding(
padding: EdgeInsets.only(top: compact ? 0 : 2),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: compact ? 24 : 28),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.language, size: iconSize),
const SizedBox(width: 6),
dropdown,
],
),
),
);
}
}