Stabilize auth flow and profile images

This commit is contained in:
Codex
2026-07-20 13:38:39 +09:00
parent 57caca8dc8
commit 5d3eee7a16
128 changed files with 28860 additions and 1468 deletions
+133 -2
View File
@@ -1,9 +1,140 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'src/features/auth/data/auth_session_store.dart';
import 'src/features/auth/domain/auth_models.dart';
import 'src/app.dart';
import 'src/smoke/smoke_overrides.dart';
void main() {
const _preauthToken = String.fromEnvironment('TDC114_PREAUTH_TOKEN');
const _preauthExpiresAt = String.fromEnvironment('TDC114_PREAUTH_EXPIRES_AT');
const _preauthUserId = String.fromEnvironment('TDC114_PREAUTH_USER_ID');
const _preauthUserName = String.fromEnvironment('TDC114_PREAUTH_USER_NAME');
const _preauthUserPhone = String.fromEnvironment('TDC114_PREAUTH_USER_PHONE');
const _preauthTenantId = String.fromEnvironment('TDC114_PREAUTH_TENANT_ID');
const _preauthTenantName = String.fromEnvironment('TDC114_PREAUTH_TENANT_NAME');
const _preauthTenantSlug = String.fromEnvironment('TDC114_PREAUTH_TENANT_SLUG');
const _preauthDepartment = String.fromEnvironment('TDC114_PREAUTH_DEPARTMENT');
const _preauthGrade = String.fromEnvironment('TDC114_PREAUTH_GRADE');
const _preauthPosition = String.fromEnvironment('TDC114_PREAUTH_POSITION');
const _preauthJobTitle = String.fromEnvironment('TDC114_PREAUTH_JOB_TITLE');
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
runApp(const ProviderScope(child: Tdc114PlusApp()));
runApp(const _BootstrapApp());
}
class _BootstrapApp extends StatefulWidget {
const _BootstrapApp();
@override
State<_BootstrapApp> createState() => _BootstrapAppState();
}
class _BootstrapAppState extends State<_BootstrapApp> {
late final Future<void> _bootstrapFuture;
@override
void initState() {
super.initState();
_bootstrapFuture = _seedSmokeSessionIfConfigured();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<void>(
future: _bootstrapFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
body: SafeArea(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: const [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('앱을 준비하고 있습니다...'),
],
),
),
),
),
);
}
if (snapshot.hasError) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('앱 시작 중 문제가 발생했습니다.'),
const SizedBox(height: 12),
Text('${snapshot.error}', textAlign: TextAlign.center),
],
),
),
),
),
),
);
}
return ProviderScope(
overrides: useSmokeMockDirectory
? [smokeDirectoryOverride, smokeOrganizationOverride]
: const [],
child: const Tdc114PlusApp(),
);
},
);
}
}
Future<void> _seedSmokeSessionIfConfigured() async {
if (_preauthToken.isEmpty ||
_preauthExpiresAt.isEmpty ||
_preauthUserId.isEmpty ||
_preauthUserName.isEmpty ||
_preauthUserPhone.isEmpty ||
_preauthTenantId.isEmpty ||
_preauthTenantName.isEmpty ||
_preauthTenantSlug.isEmpty) {
return;
}
final expiresAt = DateTime.tryParse(_preauthExpiresAt);
if (expiresAt == null) {
return;
}
await const AuthSessionStore().save(
PhoneLoginResponse(
status: 'ok',
token: _preauthToken,
expiresAt: expiresAt,
user: LoginUser(
id: _preauthUserId,
name: _preauthUserName,
phoneNumber: _preauthUserPhone,
tenantId: _preauthTenantId,
tenantName: _preauthTenantName,
tenantSlug: _preauthTenantSlug,
department: _valueOrNull(_preauthDepartment),
grade: _valueOrNull(_preauthGrade),
position: _valueOrNull(_preauthPosition),
jobTitle: _valueOrNull(_preauthJobTitle),
),
),
);
}
String? _valueOrNull(String value) => value.trim().isEmpty ? null : value;
+1 -1
View File
@@ -14,7 +14,7 @@ class Tdc114PlusApp extends StatelessWidget {
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF006D77)),
useMaterial3: true,
),
routerConfig: appRouter,
routerConfig: createAppRouter(),
);
}
}
@@ -0,0 +1,60 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
abstract class ContactLauncher {
const ContactLauncher();
Future<bool> call(String phoneNumber);
Future<bool> sms(String phoneNumber);
}
class UrlLauncherContactLauncher implements ContactLauncher {
const UrlLauncherContactLauncher();
@override
Future<bool> call(String phoneNumber) async {
final uri = buildCallUri(phoneNumber);
if (uri == null) {
return false;
}
return launchUrl(uri);
}
@override
Future<bool> sms(String phoneNumber) async {
final uri = buildSmsUri(phoneNumber);
if (uri == null) {
return false;
}
return launchUrl(uri);
}
}
Uri? buildCallUri(String phoneNumber) {
final normalized = _normalizePhoneNumber(phoneNumber);
if (normalized == null) {
return null;
}
return Uri(scheme: 'tel', path: normalized);
}
Uri? buildSmsUri(String phoneNumber) {
final normalized = _normalizePhoneNumber(phoneNumber);
if (normalized == null) {
return null;
}
return Uri(scheme: 'sms', path: normalized);
}
String? _normalizePhoneNumber(String value) {
final normalized = value.replaceAll(RegExp(r'[^0-9+]'), '');
if (normalized.isEmpty) {
return null;
}
return normalized;
}
final contactLauncherProvider = Provider<ContactLauncher>((ref) {
return const UrlLauncherContactLauncher();
});
+84 -9
View File
@@ -1,22 +1,97 @@
class AppEnvironment {
const AppEnvironment({
required this.ssoBaseUrl,
required this.orgFrontBaseUrl,
required this.apiBaseUrl,
required this.authApiBaseUrl,
required this.directoryApiBaseUrl,
required this.organizationApiBaseUrl,
required this.orgContextApiBaseUrl,
required this.orgContextTenantSlug,
required this.baronKeyId,
required this.baronKeySecret,
required this.appVersion,
required this.buildTimestamp,
});
factory AppEnvironment.fromDartDefine() {
return const AppEnvironment(
ssoBaseUrl: String.fromEnvironment(
'SSO_BASE_URL',
defaultValue: 'https://sso.example.invalid',
const fallbackBaseUrl = 'https://sso.example.invalid';
const ssoBaseUrl = String.fromEnvironment(
'SSO_BASE_URL',
defaultValue: 'https://sso.hmac.kr',
);
const sharedApiBaseUrl = String.fromEnvironment(
'TDC114_API_BASE',
defaultValue: fallbackBaseUrl,
);
const authApiBaseOverride = String.fromEnvironment(
'TDC114_AUTH_API_BASE',
defaultValue: '',
);
const directoryApiBaseOverride = String.fromEnvironment(
'TDC114_DIRECTORY_API_BASE',
defaultValue: '',
);
const organizationApiBaseOverride = String.fromEnvironment(
'TDC114_ORGANIZATION_API_BASE',
defaultValue: '',
);
const orgContextApiBaseOverride = String.fromEnvironment(
'TDC114_ORG_CONTEXT_API_BASE',
defaultValue: '',
);
const orgContextTenantSlug = String.fromEnvironment(
'TDC114_ORG_CONTEXT_TENANT_SLUG',
defaultValue: 'hanmac-family',
);
const baronKeyId = String.fromEnvironment(
'TDC114_BARON_KEY_ID',
defaultValue: '',
);
const baronKeySecret = String.fromEnvironment(
'TDC114_BARON_KEY_SECRET',
defaultValue: '',
);
return AppEnvironment(
ssoBaseUrl: ssoBaseUrl,
apiBaseUrl: sharedApiBaseUrl,
authApiBaseUrl: authApiBaseOverride.isEmpty
? sharedApiBaseUrl
: authApiBaseOverride,
directoryApiBaseUrl: directoryApiBaseOverride.isEmpty
? sharedApiBaseUrl
: directoryApiBaseOverride,
organizationApiBaseUrl: organizationApiBaseOverride.isEmpty
? sharedApiBaseUrl
: organizationApiBaseOverride,
orgContextApiBaseUrl: orgContextApiBaseOverride.isEmpty
? (organizationApiBaseOverride.isEmpty
? sharedApiBaseUrl
: organizationApiBaseOverride)
: orgContextApiBaseOverride,
orgContextTenantSlug: orgContextTenantSlug,
baronKeyId: baronKeyId,
baronKeySecret: baronKeySecret,
appVersion: const String.fromEnvironment(
'APP_VERSION',
defaultValue: '0.1.0',
),
orgFrontBaseUrl: String.fromEnvironment(
'ORGFRONT_BASE_URL',
defaultValue: 'https://orgfront.example.invalid',
buildTimestamp: const String.fromEnvironment(
'TDC114_BUILD_TIME',
defaultValue: '',
),
);
}
final String ssoBaseUrl;
final String orgFrontBaseUrl;
final String apiBaseUrl;
final String authApiBaseUrl;
final String directoryApiBaseUrl;
final String organizationApiBaseUrl;
final String orgContextApiBaseUrl;
final String orgContextTenantSlug;
final String baronKeyId;
final String baronKeySecret;
final String appVersion;
final String buildTimestamp;
}
@@ -0,0 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app_environment.dart';
final appEnvironmentProvider = Provider<AppEnvironment>((ref) {
return AppEnvironment.fromDartDefine();
});
+15
View File
@@ -21,3 +21,18 @@ class ApiError {
return {'error': error, 'code': code, 'details': details};
}
}
class ApiException implements Exception {
const ApiException({required this.statusCode, required this.apiError});
final int statusCode;
final ApiError apiError;
String get code => apiError.code;
String get message => apiError.error;
@override
String toString() {
return 'ApiException($statusCode, $code, $message)';
}
}
@@ -0,0 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
final httpClientProvider = Provider<http.Client>((ref) {
final client = http.Client();
ref.onDispose(client.close);
return client;
});
+20 -13
View File
@@ -1,18 +1,25 @@
import 'package:go_router/go_router.dart';
import '../../features/auth/presentation/auth_gate_screen.dart';
import '../../features/auth/presentation/login_screen.dart';
import '../../features/directory/presentation/directory_screen.dart';
final appRouter = GoRouter(
initialLocation: LoginScreen.routePath,
routes: [
GoRoute(
path: LoginScreen.routePath,
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: DirectoryScreen.routePath,
builder: (context, state) => const DirectoryScreen(),
),
],
);
GoRouter createAppRouter() {
return GoRouter(
initialLocation: AuthGateScreen.routePath,
routes: [
GoRoute(
path: AuthGateScreen.routePath,
builder: (context, state) => const AuthGateScreen(),
),
GoRoute(
path: LoginScreen.routePath,
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: DirectoryScreen.routePath,
builder: (context, state) => const DirectoryScreen(),
),
],
);
}
@@ -0,0 +1,104 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../core/network/api_error.dart';
import '../domain/auth_models.dart';
class AuthApiClient {
const AuthApiClient({
required this.httpClient,
required this.baseUri,
this.timeout = const Duration(seconds: 10),
});
final http.Client httpClient;
final Uri baseUri;
final Duration timeout;
Future<PhoneLoginResponse> phoneLogin(PhoneLoginRequest request) async {
final response = await httpClient
.post(
_resolve('/api/v1/tdc114plus/auth/phone-login'),
headers: const {
'accept': 'application/json',
'content-type': 'application/json',
},
body: jsonEncode(request.toJson()),
)
.timeout(timeout);
final decoded = _decodeObject(response.body);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
statusCode: response.statusCode,
apiError: ApiError.fromJson(decoded),
);
}
return PhoneLoginResponse.fromJson(decoded);
}
Future<PhoneLoginLinkInitResponse> requestPhoneLoginLink(
PhoneLoginLinkInitRequest request,
) async {
final response = await httpClient
.post(
_resolve('/api/v1/auth/link/init'),
headers: const {
'accept': 'application/json',
'content-type': 'application/json',
},
body: jsonEncode(request.toJson()),
)
.timeout(timeout);
final decoded = _decodeObject(response.body);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
statusCode: response.statusCode,
apiError: ApiError.fromJson(decoded),
);
}
return PhoneLoginLinkInitResponse.fromJson(decoded);
}
Future<PhoneLoginLinkPollResponse> pollPhoneLoginLink(
PhoneLoginLinkPollRequest request,
) async {
final response = await httpClient
.post(
_resolve('/api/v1/auth/link/poll'),
headers: const {
'accept': 'application/json',
'content-type': 'application/json',
},
body: jsonEncode(request.toJson()),
)
.timeout(timeout);
final decoded = _decodeObject(response.body);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
statusCode: response.statusCode,
apiError: ApiError.fromJson(decoded),
);
}
return PhoneLoginLinkPollResponse.fromJson(decoded);
}
Uri _resolve(String path) {
final normalizedBase = baseUri.path.endsWith('/')
? baseUri
: baseUri.replace(path: '${baseUri.path}/');
return normalizedBase.resolve(path.replaceFirst(RegExp(r'^/'), ''));
}
Map<String, dynamic> _decodeObject(String body) {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
return decoded;
}
throw const FormatException('Expected JSON object response');
}
}
@@ -0,0 +1,196 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/config/app_environment_provider.dart';
import '../../../core/network/api_error.dart';
import '../../../core/network/http_client_provider.dart';
import '../domain/auth_models.dart';
import 'auth_api_client.dart';
import 'auth_session_store.dart';
abstract class AuthRepository {
const AuthRepository();
Future<PhoneLoginLinkInitResponse> requestPhoneLoginLink(String phoneNumber);
Future<PhoneLoginLinkPollResponse> pollPhoneLoginLink(String pendingRef);
Future<PhoneLoginLinkInitResponse?> loadPendingPhoneLoginLink();
Future<void> clearPendingPhoneLoginLink();
Future<StoredAuthSession?> loadSession();
Future<void> logout();
}
abstract class LegacyPhoneLoginRepository {
const LegacyPhoneLoginRepository();
Future<PhoneLoginResponse> phoneLogin(String phoneNumber);
}
class RemoteAuthRepository
implements AuthRepository, LegacyPhoneLoginRepository {
const RemoteAuthRepository({
required this.apiClient,
required this.sessionStore,
required this.appVersion,
});
final AuthApiClient apiClient;
final AuthSessionStore sessionStore;
final String appVersion;
@override
Future<PhoneLoginResponse> phoneLogin(String phoneNumber) async {
final normalizedPhone = _normalizePhoneNumber(phoneNumber);
if (normalizedPhone.length < 9) {
throw _invalidPhoneNumber();
}
final response = await apiClient.phoneLogin(
PhoneLoginRequest(
phoneNumber: normalizedPhone,
device: LoginDeviceInfo(
platform: _platformName(),
appVersion: appVersion,
deviceName: _deviceName(),
),
),
);
await sessionStore.save(response);
return response;
}
@override
Future<PhoneLoginLinkInitResponse> requestPhoneLoginLink(
String phoneNumber,
) async {
final normalizedPhone = _normalizePhoneNumber(phoneNumber);
if (normalizedPhone.length < 9) {
throw _invalidPhoneNumber();
}
final response = await apiClient.requestPhoneLoginLink(
PhoneLoginLinkInitRequest(
phoneNumber: normalizedPhone,
device: LoginDeviceInfo(
platform: _platformName(),
appVersion: appVersion,
deviceName: _deviceName(),
),
),
);
await sessionStore.savePendingLink(response);
return response;
}
@override
Future<PhoneLoginLinkPollResponse> pollPhoneLoginLink(
String pendingRef,
) async {
final response = await apiClient.pollPhoneLoginLink(
PhoneLoginLinkPollRequest(pendingRef: pendingRef),
);
final session = response.session;
debugPrint(
'RemoteAuthRepository.pollPhoneLoginLink status=${response.status} hasSession=${session != null} token=${session?.token ?? ''} expiresAt=${session?.expiresAt.toUtc().toIso8601String() ?? ''}',
);
if (session != null) {
await sessionStore.save(session);
await sessionStore.clearPendingLink();
} else if (response.isExpired) {
await sessionStore.clearPendingLink();
}
return response;
}
@override
Future<PhoneLoginLinkInitResponse?> loadPendingPhoneLoginLink() {
return sessionStore.loadPendingLink();
}
@override
Future<void> clearPendingPhoneLoginLink() {
return sessionStore.clearPendingLink();
}
@override
Future<StoredAuthSession?> loadSession() => sessionStore.load();
@override
Future<void> logout() => sessionStore.clear();
String _normalizePhoneNumber(String value) {
return value.replaceAll(RegExp(r'[^0-9+]'), '');
}
ApiException _invalidPhoneNumber() {
return const ApiException(
statusCode: 400,
apiError: ApiError(
error: '전화번호 형식을 확인해 주세요.',
code: 'invalid_phone_number',
details: {},
),
);
}
String _platformName() {
if (kIsWeb) {
return 'web';
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return 'android';
case TargetPlatform.iOS:
return 'ios';
case TargetPlatform.macOS:
return 'macos';
case TargetPlatform.windows:
return 'windows';
case TargetPlatform.linux:
return 'linux';
case TargetPlatform.fuchsia:
return 'fuchsia';
}
}
String _deviceName() {
if (kIsWeb) {
return 'flutter-web';
}
return _platformName();
}
}
final authApiClientProvider = Provider<AuthApiClient>((ref) {
final environment = ref.watch(appEnvironmentProvider);
return AuthApiClient(
httpClient: ref.watch(httpClientProvider),
baseUri: Uri.parse(environment.authApiBaseUrl),
);
});
final authRepositoryProvider = Provider<AuthRepository>((ref) {
final environment = ref.watch(appEnvironmentProvider);
return RemoteAuthRepository(
apiClient: ref.watch(authApiClientProvider),
sessionStore: ref.watch(authSessionStoreProvider),
appVersion: environment.appVersion,
);
});
final legacyPhoneLoginRepositoryProvider = Provider<LegacyPhoneLoginRepository>(
(ref) {
final environment = ref.watch(appEnvironmentProvider);
return RemoteAuthRepository(
apiClient: ref.watch(authApiClientProvider),
sessionStore: ref.watch(authSessionStoreProvider),
appVersion: environment.appVersion,
);
},
);
@@ -0,0 +1,178 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../domain/auth_models.dart';
class AuthSessionStore {
const AuthSessionStore();
static const _tokenKey = 'tdc114plus.auth.token';
static const _expiresAtKey = 'tdc114plus.auth.expiresAt';
static const _userKey = 'tdc114plus.auth.user';
static const _orgContextCredentialKey =
'tdc114plus.auth.orgContextCredential';
static const _pendingRefKey = 'tdc114plus.auth.pending.ref';
static const _pendingExpiresAtKey = 'tdc114plus.auth.pending.expiresAt';
static const _pendingResendAfterAtKey =
'tdc114plus.auth.pending.resendAfterAt';
static const _pendingPollIntervalKey =
'tdc114plus.auth.pending.pollInterval';
static const _pendingProviderKey = 'tdc114plus.auth.pending.provider';
Future<void> save(PhoneLoginResponse response) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, response.token);
await prefs.setString(
_expiresAtKey,
response.expiresAt.toUtc().toIso8601String(),
);
await prefs.setString(_userKey, jsonEncode(response.user.toJson()));
final orgContextCredential = response.orgContextCredential;
if (orgContextCredential == null) {
await prefs.remove(_orgContextCredentialKey);
} else {
await prefs.setString(
_orgContextCredentialKey,
jsonEncode(orgContextCredential.toJson()),
);
}
debugPrint(
'AuthSessionStore.save token=${response.token} tokenLength=${response.token.length} expiresAt=${response.expiresAt.toUtc().toIso8601String()} orgContextCredential=${orgContextCredential != null}',
);
}
Future<void> savePendingLink(PhoneLoginLinkInitResponse response) async {
final prefs = await SharedPreferences.getInstance();
final now = DateTime.now().toUtc();
await prefs.setString(_pendingRefKey, response.pendingRef);
await prefs.setString(
_pendingExpiresAtKey,
now.add(Duration(seconds: response.expiresIn)).toIso8601String(),
);
await prefs.setString(
_pendingResendAfterAtKey,
now.add(Duration(seconds: response.resendAfter)).toIso8601String(),
);
await prefs.setInt(_pendingPollIntervalKey, response.interval);
final provider = response.provider;
if (provider == null || provider.trim().isEmpty) {
await prefs.remove(_pendingProviderKey);
} else {
await prefs.setString(_pendingProviderKey, provider);
}
}
Future<PhoneLoginLinkInitResponse?> loadPendingLink() async {
final prefs = await SharedPreferences.getInstance();
final pendingRef = prefs.getString(_pendingRefKey);
final expiresAtValue = prefs.getString(_pendingExpiresAtKey);
if (pendingRef == null ||
pendingRef.trim().isEmpty ||
expiresAtValue == null) {
return null;
}
final now = DateTime.now().toUtc();
final expiresAt = DateTime.tryParse(expiresAtValue)?.toUtc();
if (expiresAt == null || !expiresAt.isAfter(now)) {
await clearPendingLink();
return null;
}
final resendAfterAt = DateTime.tryParse(
prefs.getString(_pendingResendAfterAtKey) ?? '',
)?.toUtc();
final expiresIn = expiresAt.difference(now).inSeconds;
final resendAfter = resendAfterAt == null || !resendAfterAt.isAfter(now)
? 0
: resendAfterAt.difference(now).inSeconds;
return PhoneLoginLinkInitResponse(
status: 'pending',
pendingRef: pendingRef,
expiresIn: expiresIn,
interval: prefs.getInt(_pendingPollIntervalKey) ?? 3,
resendAfter: resendAfter,
provider: prefs.getString(_pendingProviderKey),
);
}
Future<void> clearPendingLink() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_pendingRefKey);
await prefs.remove(_pendingExpiresAtKey);
await prefs.remove(_pendingResendAfterAtKey);
await prefs.remove(_pendingPollIntervalKey);
await prefs.remove(_pendingProviderKey);
}
Future<StoredAuthSession?> load() async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(_tokenKey);
final expiresAtValue = prefs.getString(_expiresAtKey);
final userValue = prefs.getString(_userKey);
final orgContextCredentialValue = prefs.getString(_orgContextCredentialKey);
if (token == null || expiresAtValue == null || userValue == null) {
debugPrint(
'AuthSessionStore.load missing token=${token != null} expiresAt=${expiresAtValue != null} user=${userValue != null}',
);
return null;
}
final session = StoredAuthSession(
token: token,
expiresAt: DateTime.parse(expiresAtValue),
user: LoginUser.fromJson(jsonDecode(userValue) as Map<String, dynamic>),
orgContextCredential: _decodeOrgContextCredential(
orgContextCredentialValue,
),
);
debugPrint(
'AuthSessionStore.load token=${session.token} tokenLength=${session.token.length} expiresAt=${session.expiresAt.toUtc().toIso8601String()} expired=${session.isExpired} orgContextCredential=${session.orgContextCredential != null}',
);
return session;
}
Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
await prefs.remove(_expiresAtKey);
await prefs.remove(_userKey);
await prefs.remove(_orgContextCredentialKey);
await clearPendingLink();
debugPrint('AuthSessionStore.clear');
}
OrgContextCredential? _decodeOrgContextCredential(String? value) {
if (value == null || value.trim().isEmpty) {
return null;
}
try {
return OrgContextCredential.fromJsonOrNull(jsonDecode(value));
} catch (_) {
return null;
}
}
}
class StoredAuthSession {
const StoredAuthSession({
required this.token,
required this.expiresAt,
required this.user,
this.orgContextCredential,
});
final String token;
final DateTime expiresAt;
final LoginUser user;
final OrgContextCredential? orgContextCredential;
bool get isExpired => !expiresAt.isAfter(DateTime.now().toUtc());
}
final authSessionStoreProvider = Provider<AuthSessionStore>((ref) {
return const AuthSessionStore();
});
@@ -101,19 +101,24 @@ class PhoneLoginResponse {
required this.token,
required this.expiresAt,
required this.user,
this.orgContextCredential,
});
final String status;
final String token;
final DateTime expiresAt;
final LoginUser user;
final OrgContextCredential? orgContextCredential;
factory PhoneLoginResponse.fromJson(Map<String, dynamic> json) {
return PhoneLoginResponse(
status: json['status'] as String? ?? '',
token: json['token'] as String? ?? '',
token: json['token'] as String? ?? json['accessToken'] as String? ?? '',
expiresAt: DateTime.parse(json['expiresAt'] as String),
user: LoginUser.fromJson(json['user'] as Map<String, dynamic>? ?? {}),
orgContextCredential: OrgContextCredential.fromJsonOrNull(
json['orgContextCredential'] ?? json['org_context'],
),
);
}
@@ -123,10 +128,176 @@ class PhoneLoginResponse {
'token': token,
'expiresAt': expiresAt.toUtc().toIso8601String(),
'user': user.toJson(),
if (orgContextCredential != null)
'orgContextCredential': orgContextCredential!.toJson(),
};
}
}
class OrgContextCredential {
const OrgContextCredential({
required this.baseUrl,
required this.tenantSlug,
required this.keyId,
required this.keySecret,
this.expiresAt,
});
final String baseUrl;
final String tenantSlug;
final String keyId;
final String keySecret;
final DateTime? expiresAt;
bool get isUsable {
return baseUrl.trim().isNotEmpty &&
tenantSlug.trim().isNotEmpty &&
keyId.trim().isNotEmpty &&
keySecret.trim().isNotEmpty &&
(expiresAt == null || expiresAt!.isAfter(DateTime.now().toUtc()));
}
factory OrgContextCredential.fromJson(Map<String, dynamic> json) {
return OrgContextCredential(
baseUrl: _readString(json, const ['baseUrl', 'base_url']),
tenantSlug: _readString(json, const ['tenantSlug', 'tenant_slug']),
keyId: _readString(json, const ['keyId', 'key_id']),
keySecret: _readString(json, const ['keySecret', 'key_secret']),
expiresAt: _readDate(json['expiresAt'] ?? json['expires_at']),
);
}
static OrgContextCredential? fromJsonOrNull(Object? value) {
if (value is! Map) {
return null;
}
final credential = OrgContextCredential.fromJson(
Map<String, dynamic>.from(value),
);
return credential.isUsable ? credential : null;
}
Map<String, dynamic> toJson() {
return {
'baseUrl': baseUrl,
'tenantSlug': tenantSlug,
'keyId': keyId,
'keySecret': keySecret,
if (expiresAt != null) 'expiresAt': expiresAt!.toUtc().toIso8601String(),
};
}
static String _readString(Map<String, dynamic> json, List<String> keys) {
for (final key in keys) {
final value = json[key];
if (value is String && value.trim().isNotEmpty) {
return value.trim();
}
}
return '';
}
static DateTime? _readDate(Object? value) {
if (value is! String || value.trim().isEmpty) {
return null;
}
return DateTime.tryParse(value)?.toUtc();
}
}
class PhoneLoginLinkInitRequest {
const PhoneLoginLinkInitRequest({
required this.phoneNumber,
required this.device,
});
final String phoneNumber;
final LoginDeviceInfo device;
Map<String, dynamic> toJson() {
return {'phoneNumber': phoneNumber, 'device': device.toJson()};
}
}
class PhoneLoginLinkInitResponse {
const PhoneLoginLinkInitResponse({
required this.status,
required this.pendingRef,
required this.expiresIn,
required this.interval,
required this.resendAfter,
this.provider,
});
final String status;
final String pendingRef;
final int expiresIn;
final int interval;
final int resendAfter;
final String? provider;
factory PhoneLoginLinkInitResponse.fromJson(Map<String, dynamic> json) {
return PhoneLoginLinkInitResponse(
status: json['status'] as String? ?? '',
pendingRef: json['pendingRef'] as String? ?? '',
expiresIn: json['expiresIn'] as int? ?? 180,
interval: json['interval'] as int? ?? json['pollInterval'] as int? ?? 3,
resendAfter: json['resendAfter'] as int? ?? 30,
provider: json['provider'] as String?,
);
}
}
class PhoneLoginLinkPollRequest {
const PhoneLoginLinkPollRequest({required this.pendingRef});
final String pendingRef;
Map<String, dynamic> toJson() {
return {'pendingRef': pendingRef};
}
}
class PhoneLoginLinkPollResponse {
const PhoneLoginLinkPollResponse({
required this.status,
this.code,
this.interval,
this.session,
});
final String status;
final String? code;
final int? interval;
final PhoneLoginResponse? session;
bool get isPending =>
status == 'pending' ||
code == 'authorization_pending' ||
code == 'slow_down';
bool get isExpired => code == 'expired_token';
factory PhoneLoginLinkPollResponse.fromJson(Map<String, dynamic> json) {
PhoneLoginResponse? session;
if (json['session'] is Map<String, dynamic>) {
session = PhoneLoginResponse.fromJson(
json['session'] as Map<String, dynamic>,
);
} else if ((json['token'] != null || json['accessToken'] != null) &&
json['expiresAt'] != null) {
session = PhoneLoginResponse.fromJson(json);
}
return PhoneLoginLinkPollResponse(
status: json['status'] as String? ?? '',
code: json['code'] as String?,
interval: json['interval'] as int? ?? json['pollInterval'] as int?,
session: session,
);
}
}
class UserPermissions {
const UserPermissions({
required this.directory,
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../data/auth_repository.dart';
import '../data/auth_session_store.dart';
import 'login_screen.dart';
import '../../directory/presentation/directory_screen.dart';
class AuthGateScreen extends ConsumerStatefulWidget {
const AuthGateScreen({super.key});
static const routePath = '/';
@override
ConsumerState<AuthGateScreen> createState() => _AuthGateScreenState();
}
class _AuthGateScreenState extends ConsumerState<AuthGateScreen> {
var _resolved = false;
@override
Widget build(BuildContext context) {
return FutureBuilder<StoredAuthSession?>(
future: ref.read(authRepositoryProvider).loadSession(),
builder: (context, snapshot) {
if (!_resolved && snapshot.connectionState == ConnectionState.done) {
_resolved = true;
final router = GoRouter.of(context);
WidgetsBinding.instance.addPostFrameCallback((_) async {
final session = snapshot.data;
if (session == null || session.isExpired) {
if (session != null) {
await ref.read(authRepositoryProvider).logout();
}
if (mounted) {
router.go(LoginScreen.routePath);
}
return;
}
if (mounted) {
router.go(DirectoryScreen.routePath);
}
});
}
return const Scaffold(
body: SafeArea(child: Center(child: CircularProgressIndicator())),
);
},
);
}
}
@@ -1,72 +1,705 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import '../../../core/config/app_environment_provider.dart';
import '../../../core/network/api_error.dart';
import '../data/auth_repository.dart';
import '../domain/auth_models.dart';
import '../../directory/presentation/directory_screen.dart';
class LoginScreen extends StatefulWidget {
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
static const routePath = '/login';
@override
State<LoginScreen> createState() => _LoginScreenState();
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
class _LoginScreenState extends ConsumerState<LoginScreen>
with WidgetsBindingObserver {
final _phoneController = TextEditingController();
Timer? _pollTimer;
Timer? _countdownTimer;
var _isSubmitting = false;
var _pollInFlight = false;
String? _errorMessage;
String? _statusMessage;
PhoneLoginLinkInitResponse? _pendingLink;
var _secondsUntilExpiry = 0;
var _secondsUntilResend = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
unawaited(_restorePendingLink());
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_pollTimer?.cancel();
_countdownTimer?.cancel();
_phoneController.dispose();
super.dispose();
}
void _submit() {
if (_phoneController.text.trim().isEmpty) {
bool get _hasPendingLink => _pendingLink != null;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state != AppLifecycleState.resumed || !_hasPendingLink) {
return;
}
context.go(DirectoryScreen.routePath);
_ensurePolling();
unawaited(_pollPendingLink());
}
Future<void> _submit() async {
final phoneNumber = _phoneController.text.trim();
if (phoneNumber.isEmpty) {
setState(() {
_statusMessage = null;
_errorMessage = '전화번호를 입력해 주세요.';
});
return;
}
setState(() {
_isSubmitting = true;
_pendingLink = null;
_secondsUntilExpiry = 0;
_secondsUntilResend = 0;
_errorMessage = null;
_statusMessage = '로그인 링크를 요청하고 있습니다.';
});
try {
_pollTimer?.cancel();
_countdownTimer?.cancel();
await ref.read(authRepositoryProvider).clearPendingPhoneLoginLink();
final response = await ref
.read(authRepositoryProvider)
.requestPhoneLoginLink(phoneNumber);
if (!mounted) {
return;
}
_startPendingLinkFlow(response);
setState(() {
_statusMessage = '문자 링크를 누른 뒤 TDC114PLUS 앱으로 돌아오세요.';
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_statusMessage = null;
_errorMessage = '로그인 링크 요청 실패: ${_messageForError(error)}';
});
} finally {
if (mounted) {
setState(() => _isSubmitting = false);
}
}
}
Future<void> _restorePendingLink() async {
final pendingLink = await ref
.read(authRepositoryProvider)
.loadPendingPhoneLoginLink();
if (!mounted || pendingLink == null) {
return;
}
setState(() {
_pendingLink = pendingLink;
_secondsUntilExpiry = pendingLink.expiresIn;
_secondsUntilResend = pendingLink.resendAfter;
_statusMessage = '이전 로그인 승인 상태를 확인하고 있습니다.';
_errorMessage = null;
});
_startPendingTimers(pendingLink);
unawaited(_pollPendingLink());
}
void _startPendingLinkFlow(PhoneLoginLinkInitResponse response) {
_pollTimer?.cancel();
_countdownTimer?.cancel();
setState(() {
_pendingLink = response;
_secondsUntilExpiry = response.expiresIn;
_secondsUntilResend = response.resendAfter;
});
_startPendingTimers(response);
}
void _startPendingTimers(PhoneLoginLinkInitResponse response) {
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) {
return;
}
final nextExpiry = _secondsUntilExpiry > 0 ? _secondsUntilExpiry - 1 : 0;
final nextResend = _secondsUntilResend > 0 ? _secondsUntilResend - 1 : 0;
setState(() {
_secondsUntilExpiry = nextExpiry;
_secondsUntilResend = nextResend;
});
if (nextExpiry == 0) {
_pollTimer?.cancel();
_countdownTimer?.cancel();
setState(() {
_pendingLink = null;
_statusMessage = null;
_errorMessage = '로그인 링크 유효시간이 지났습니다. 다시 요청해 주세요.';
});
unawaited(
ref.read(authRepositoryProvider).clearPendingPhoneLoginLink(),
);
}
});
_ensurePolling();
unawaited(_pollPendingLink());
}
void _ensurePolling() {
final pendingLink = _pendingLink;
if (pendingLink == null || _pollTimer?.isActive == true) {
return;
}
_pollTimer = Timer.periodic(
Duration(seconds: pendingLink.interval.clamp(1, 30)),
(_) => _pollPendingLink(),
);
}
Future<void> _pollPendingLink() async {
final pendingRef = _pendingLink?.pendingRef;
if (pendingRef == null || _pollInFlight) {
return;
}
_pollInFlight = true;
try {
final response = await ref
.read(authRepositoryProvider)
.pollPhoneLoginLink(pendingRef);
if (!mounted) {
return;
}
if (response.session != null) {
_pollTimer?.cancel();
_countdownTimer?.cancel();
setState(() {
_pendingLink = null;
_statusMessage = '로그인이 완료되었습니다. 앱 화면으로 이동합니다.';
_errorMessage = null;
});
if (mounted) {
context.go(DirectoryScreen.routePath);
}
return;
}
if (response.isExpired) {
_pollTimer?.cancel();
_countdownTimer?.cancel();
setState(() {
_pendingLink = null;
_statusMessage = null;
_errorMessage = '로그인 링크 유효시간이 지났습니다. 다시 요청해 주세요.';
});
unawaited(
ref.read(authRepositoryProvider).clearPendingPhoneLoginLink(),
);
return;
}
if (mounted) {
setState(() {
_errorMessage = null;
_statusMessage = '문자 링크를 누른 뒤 TDC114PLUS 앱으로 돌아오세요.';
});
}
final nextInterval = response.interval;
if (nextInterval != null &&
_pendingLink != null &&
nextInterval != _pendingLink!.interval) {
_pollTimer?.cancel();
_pollTimer = Timer.periodic(
Duration(seconds: nextInterval.clamp(1, 30)),
(_) => _pollPendingLink(),
);
setState(() {
_pendingLink = PhoneLoginLinkInitResponse(
status: _pendingLink!.status,
pendingRef: _pendingLink!.pendingRef,
expiresIn: _secondsUntilExpiry,
interval: nextInterval,
resendAfter: _secondsUntilResend,
provider: _pendingLink!.provider,
);
});
}
} catch (error) {
if (!mounted) {
return;
}
if (error is ApiException &&
(error.code == 'pending_ref_not_found' ||
error.code == 'pending_ref_expired')) {
_pollTimer?.cancel();
_countdownTimer?.cancel();
unawaited(
ref.read(authRepositoryProvider).clearPendingPhoneLoginLink(),
);
setState(() {
_pendingLink = null;
_secondsUntilExpiry = 0;
_secondsUntilResend = 0;
_statusMessage = null;
_errorMessage = '이전 로그인 요청이 만료되었습니다. 다시 보내기를 눌러 주세요.';
});
return;
}
setState(() {
_errorMessage = '승인 상태 확인 실패: ${_messageForError(error)}';
});
} finally {
_pollInFlight = false;
}
}
void _resetPendingState() {
_pollTimer?.cancel();
_countdownTimer?.cancel();
unawaited(ref.read(authRepositoryProvider).clearPendingPhoneLoginLink());
setState(() {
_pendingLink = null;
_secondsUntilExpiry = 0;
_secondsUntilResend = 0;
_statusMessage = null;
_errorMessage = null;
});
}
String _messageForError(Object error) {
if (error is ApiException) {
final message = error.apiError.error.trim();
if (message.isNotEmpty) {
return message;
}
if (error.code.isNotEmpty) {
return error.code;
}
return '서버 응답 오류(${error.statusCode})';
}
if (error is TimeoutException) {
return '서버 응답 시간이 초과되었습니다.';
}
if (error is SocketException || error is http.ClientException) {
return '인증 서버에 연결하지 못했습니다.';
}
if (error is Exception &&
error.toString().contains('invalid_phone_number')) {
return '전화번호 형식을 확인해 주세요.';
}
return '로그인 링크를 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.';
}
@override
Widget build(BuildContext context) {
final environment = ref.watch(appEnvironmentProvider);
final buildTimestamp = environment.buildTimestamp.trim();
final canResend = !_isSubmitting && _secondsUntilResend == 0;
final keyboardInset = MediaQuery.viewInsetsOf(context).bottom;
final keyboardVisible = keyboardInset > 0;
const background = Color(0xFF030616);
const surface = Color(0xFF10182A);
const surfaceBorder = Color(0xFF1D2A44);
const primary = Color(0xFFA8DEFF);
const onDark = Color(0xFFE8F2FF);
const muted = Color(0xFFA8B4C7);
return Scaffold(
appBar: AppBar(title: const Text('tdc114plus')),
backgroundColor: background,
appBar: AppBar(
backgroundColor: const Color(0xFF0E1729),
foregroundColor: onDark,
title: const Text('Baron SW 포털'),
actions: const [
Padding(
padding: EdgeInsets.only(right: 16),
child: Icon(Icons.dark_mode_outlined),
),
],
),
bottomNavigationBar: keyboardVisible
? AnimatedPadding(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
padding: EdgeInsets.only(bottom: keyboardInset),
child: SafeArea(
top: false,
child: Container(
color: background,
padding: const EdgeInsets.fromLTRB(24, 10, 24, 12),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: _buildLoginLinkButton(canResend: canResend),
),
),
),
)
: null,
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(24),
children: [
const SizedBox(height: 32),
Text(
'Baron SSO 로그인',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Baron SSO에 등록된 전화번호를 입력하세요.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 24),
TextField(
controller: _phoneController,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.done,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: '전화번호',
prefixIcon: Icon(Icons.phone_android),
child: Center(
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(24, 24, 24, keyboardVisible ? 96 : 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: DecoratedBox(
decoration: BoxDecoration(
color: surface,
border: Border.all(color: surfaceBorder),
borderRadius: BorderRadius.circular(24),
boxShadow: const [
BoxShadow(
color: Color(0x66000000),
blurRadius: 24,
offset: Offset(0, 16),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'TDC114PLUS',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.w900,
letterSpacing: 0,
),
),
const SizedBox(height: 6),
const Text(
'Baron SSO Login',
textAlign: TextAlign.center,
style: TextStyle(
color: muted,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 24),
const CircleAvatar(
radius: 42,
backgroundColor: Color(0xFF26334C),
child: Icon(Icons.person, color: primary, size: 44),
),
const SizedBox(height: 24),
Text(
'로그인 링크 발송',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
color: primary,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 12),
const Text(
'문자 승인 후 앱으로 돌아오면 자동 로그인됩니다.',
textAlign: TextAlign.center,
style: TextStyle(color: muted, height: 1.5),
),
if (buildTimestamp.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'빌드 시각: $buildTimestamp',
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0x99FFFFFF),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
const SizedBox(height: 24),
DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xFF0A5D43),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0xFF2FA977)),
boxShadow: const [
BoxShadow(
color: Color(0x553CD694),
blurRadius: 22,
offset: Offset(0, 10),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(
Icons.phone_android,
color: Colors.white,
size: 44,
),
const SizedBox(width: 14),
Expanded(
child: TextField(
controller: _phoneController,
enabled:
!_hasPendingLink && !_isSubmitting,
keyboardType: TextInputType.phone,
inputFormatters: const [
_PhoneNumberTextInputFormatter(),
],
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.w800,
letterSpacing: 0,
),
cursorColor: Colors.white,
decoration: const InputDecoration(
border: InputBorder.none,
isDense: true,
counterText: '',
hintText: '010-0000-0000',
hintStyle: TextStyle(
color: Color(0x66FFFFFF),
fontSize: 24,
fontWeight: FontWeight.w800,
letterSpacing: 0,
),
),
),
),
],
),
const SizedBox(height: 8),
const Divider(
height: 1,
thickness: 3,
indent: 72,
endIndent: 12,
color: Color(0x553CD694),
),
const SizedBox(height: 10),
const Text(
'문자 수신 가능한 전화번호를 입력하세요.',
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xCCFFFFFF),
fontSize: 13,
),
),
],
),
),
),
if (_statusMessage != null) ...[
const SizedBox(height: 18),
Text(
_statusMessage!,
textAlign: TextAlign.center,
style: const TextStyle(color: primary, height: 1.5),
),
],
if (_errorMessage != null) ...[
const SizedBox(height: 12),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFFFF9CA3),
height: 1.5,
),
),
],
if (_hasPendingLink) ...[
const SizedBox(height: 20),
_PendingLinkStatus(
expiresIn: _secondsUntilExpiry,
resendAfter: _secondsUntilResend,
provider: _pendingLink?.provider,
),
],
if (!keyboardVisible) ...[
const SizedBox(height: 24),
_buildLoginLinkButton(canResend: canResend),
if (_hasPendingLink) ...[
const SizedBox(height: 10),
OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: onDark,
side: const BorderSide(color: Color(0xFF34425C)),
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
onPressed: _resetPendingState,
child: const Text('다른 번호 입력'),
),
],
],
],
),
),
),
onSubmitted: (_) => _submit(),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _submit,
icon: const Icon(Icons.login),
label: const Text('로그인'),
),
],
),
),
),
);
}
Widget _buildLoginLinkButton({required bool canResend}) {
const primary = Color(0xFFA8DEFF);
return FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: primary,
foregroundColor: const Color(0xFF07101F),
disabledBackgroundColor: const Color(0xFF314158),
disabledForegroundColor: const Color(0xFF91A0B6),
minimumSize: const Size.fromHeight(54),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: _isSubmitting || (_hasPendingLink && !canResend)
? null
: _submit,
icon: _isSubmitting
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(_hasPendingLink ? Icons.refresh : Icons.link),
label: Text(
_isSubmitting
? '로그인 링크 요청 중'
: _hasPendingLink
? '로그인 링크 다시 보내기'
: '로그인 링크 보내기',
),
);
}
}
class _PhoneNumberTextInputFormatter extends TextInputFormatter {
const _PhoneNumberTextInputFormatter();
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final digits = newValue.text.replaceAll(RegExp(r'[^0-9]'), '');
final limited = digits.length > 11 ? digits.substring(0, 11) : digits;
final formatted = _formatPhoneDigits(limited);
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
String _formatPhoneDigits(String digits) {
if (digits.length <= 3) {
return digits;
}
if (digits.length <= 7) {
return '${digits.substring(0, 3)}-${digits.substring(3)}';
}
return '${digits.substring(0, 3)}-${digits.substring(3, 7)}-${digits.substring(7)}';
}
}
class _PendingLinkStatus extends StatelessWidget {
const _PendingLinkStatus({
required this.expiresIn,
required this.resendAfter,
required this.provider,
});
final int expiresIn;
final int resendAfter;
final String? provider;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF07101F),
border: Border.all(color: const Color(0xFF263653)),
borderRadius: BorderRadius.circular(14),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'승인 대기 중',
style: TextStyle(
color: Color(0xFFA8DEFF),
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
const Text(
'문자 링크를 승인한 뒤 이 앱으로 돌아오면 자동으로 로그인됩니다.',
style: TextStyle(color: Color(0xFFE8F2FF), height: 1.45),
),
const SizedBox(height: 8),
Text(
'남은 유효시간: ${_formatDuration(expiresIn)}',
style: const TextStyle(color: Color(0xFFA8B4C7)),
),
Text(
'재전송 가능까지: ${_formatDuration(resendAfter)}',
style: const TextStyle(color: Color(0xFFA8B4C7)),
),
if (provider != null && provider!.trim().isNotEmpty)
Text(
'인증 공급자: $provider',
style: const TextStyle(color: Color(0xFFA8B4C7)),
),
],
),
);
}
static String _formatDuration(int seconds) {
final safeSeconds = seconds < 0 ? 0 : seconds;
final minutes = safeSeconds ~/ 60;
final remainingSeconds = safeSeconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}';
}
}
@@ -0,0 +1,115 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import '../../../core/config/app_environment_provider.dart';
import '../../../core/network/api_error.dart';
import '../../../core/network/http_client_provider.dart';
import '../../auth/data/auth_session_store.dart';
import '../domain/employee.dart';
class DirectoryApiClient {
const DirectoryApiClient({
required this.httpClient,
required this.baseUri,
required this.sessionStore,
this.timeout = const Duration(seconds: 10),
});
final http.Client httpClient;
final Uri baseUri;
final AuthSessionStore sessionStore;
final Duration timeout;
Future<EmployeeListResponse> listEmployees({
String? query,
String? tenantId,
String? tenantSlug,
String? department,
int limit = 50,
int offset = 0,
String? cursor,
}) async {
final response = await httpClient
.get(
_resolve(
'/api/v1/tdc114plus/directory/employees',
queryParameters: {
'q': query,
'tenantId': tenantId,
'tenantSlug': tenantSlug,
'department': department,
'limit': '$limit',
'offset': '$offset',
'cursor': cursor,
},
),
headers: await _headers(),
)
.timeout(timeout);
return EmployeeListResponse.fromJson(_decodeOk(response));
}
Future<EmployeeDetail> getEmployee(String employeeId) async {
final response = await httpClient
.get(
_resolve('/api/v1/tdc114plus/directory/employees/$employeeId'),
headers: await _headers(),
)
.timeout(timeout);
return EmployeeDetail.fromJson(_decodeOk(response));
}
Future<Map<String, String>> _headers() async {
final session = await sessionStore.load();
return {
'accept': 'application/json',
if (session?.token != null) 'authorization': 'Bearer ${session!.token}',
};
}
Map<String, dynamic> _decodeOk(http.Response response) {
final decoded = _decodeObject(response.body);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
statusCode: response.statusCode,
apiError: ApiError.fromJson(decoded),
);
}
return decoded;
}
Uri _resolve(String path, {Map<String, String?> queryParameters = const {}}) {
final normalizedBase = baseUri.path.endsWith('/')
? baseUri
: baseUri.replace(path: '${baseUri.path}/');
final uri = normalizedBase.resolve(path.replaceFirst(RegExp(r'^/'), ''));
final filteredQuery = Map<String, String>.fromEntries(
queryParameters.entries
.where((entry) {
return entry.value != null && entry.value!.trim().isNotEmpty;
})
.map((entry) => MapEntry(entry.key, entry.value!)),
);
return uri.replace(queryParameters: filteredQuery);
}
Map<String, dynamic> _decodeObject(String body) {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
return decoded;
}
throw const FormatException('Expected JSON object response');
}
}
final directoryApiClientProvider = Provider<DirectoryApiClient>((ref) {
final environment = ref.watch(appEnvironmentProvider);
return DirectoryApiClient(
httpClient: ref.watch(httpClientProvider),
baseUri: Uri.parse(environment.directoryApiBaseUrl),
sessionStore: ref.watch(authSessionStoreProvider),
);
});
@@ -0,0 +1,227 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../organization/data/org_context_api_client.dart';
import 'directory_api_client.dart';
import '../domain/employee.dart';
class DirectoryQuery {
const DirectoryQuery({
required this.query,
required this.tenantSlug,
this.department,
this.tenantSlugs = const <String>[],
});
final String query;
final String tenantSlug;
final String? department;
final List<String> tenantSlugs;
String? get apiQuery => query.trim().isEmpty ? null : query.trim();
String? get apiTenantSlug => tenantSlug == 'all' ? null : tenantSlug;
List<String>? get apiTenantSlugs {
if (tenantSlugs.isNotEmpty) {
return tenantSlugs;
}
final slug = apiTenantSlug;
return slug == null ? null : [slug];
}
String? get apiDepartment {
if (department == null) {
return null;
}
final normalized = department!.trim();
return normalized.isEmpty ? null : normalized;
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
other is DirectoryQuery &&
other.query == query &&
other.tenantSlug == tenantSlug &&
other.department == department &&
listEquals(other.tenantSlugs, tenantSlugs);
}
@override
int get hashCode =>
Object.hash(query, tenantSlug, department, Object.hashAll(tenantSlugs));
}
abstract class DirectoryRepository {
const DirectoryRepository();
Future<List<Employee>> loadEmployees(DirectoryQuery query);
}
abstract class EmployeeDetailRepository {
const EmployeeDetailRepository();
Future<EmployeeDetail> loadEmployeeDetail(String employeeId);
}
class RemoteDirectoryRepository implements DirectoryRepository {
const RemoteDirectoryRepository({required this.orgContextApiClient});
final OrgContextApiClient orgContextApiClient;
@override
Future<List<Employee>> loadEmployees(DirectoryQuery query) async {
final snapshot = await orgContextApiClient.fetchOrgContext(
tenantSlug: query.apiTenantSlug,
);
final employees = snapshot.employees.where((employee) {
return _matchesTenant(employee, query.apiTenantSlugs) &&
_matchesDepartment(employee, query.apiDepartment) &&
_matchesQuery(employee, query.apiQuery);
}).toList();
employees.sort(_compareEmployees);
return employees;
}
bool _matchesTenant(Employee employee, List<String>? tenantSlugs) {
return tenantSlugs == null || tenantSlugs.contains(employee.tenantSlug);
}
bool _matchesDepartment(Employee employee, String? department) {
if (department == null) {
return true;
}
return employee.department == department ||
employee.tenantName == department;
}
bool _matchesQuery(Employee employee, String? query) {
if (query == null) {
return true;
}
final normalizedQuery = query.toLowerCase();
final queryDigits = query.replaceAll(RegExp(r'[^0-9]'), '');
final fields = [
employee.name,
employee.phoneNumber,
employee.phoneDisplay,
employee.email,
employee.tenantName,
employee.department,
employee.grade,
employee.position,
employee.jobTitle,
].whereType<String>();
return fields.any((field) {
final normalizedField = field.toLowerCase();
if (normalizedField.contains(normalizedQuery)) {
return true;
}
if (queryDigits.isEmpty) {
return false;
}
final fieldDigits = field.replaceAll(RegExp(r'[^0-9]'), '');
return fieldDigits.contains(queryDigits);
});
}
}
class RemoteEmployeeDetailRepository implements EmployeeDetailRepository {
const RemoteEmployeeDetailRepository({required this.directoryApiClient});
final DirectoryApiClient directoryApiClient;
@override
Future<EmployeeDetail> loadEmployeeDetail(String employeeId) {
return directoryApiClient.getEmployee(employeeId);
}
}
int _compareEmployees(Employee left, Employee right) {
final tenantCompare = left.tenantName.compareTo(right.tenantName);
if (tenantCompare != 0) {
return tenantCompare;
}
final departmentCompare = (left.department ?? '').compareTo(
right.department ?? '',
);
if (departmentCompare != 0) {
return departmentCompare;
}
final leaderCompare = _leaderPriority(left).compareTo(
_leaderPriority(right),
);
if (leaderCompare != 0) {
return leaderCompare;
}
final rankCompare = _rankPriority(left).compareTo(_rankPriority(right));
if (rankCompare != 0) {
return rankCompare;
}
final nameCompare = left.name.compareTo(right.name);
if (nameCompare != 0) {
return nameCompare;
}
return (left.sortOrder ?? 1 << 30).compareTo(right.sortOrder ?? 1 << 30);
}
int _leaderPriority(Employee employee) {
if (employee.isManager) {
return 0;
}
final position = employee.position?.trim() ?? '';
return position.contains('팀장') ? 0 : 1;
}
int _rankPriority(Employee employee) {
const priorityByToken = {
'사장': 0,
'부사장': 1,
'수석': 2,
'전무': 3,
'상무': 4,
'이사': 5,
'책임': 6,
'부장': 7,
'선임': 8,
'과장': 9,
'대리': 10,
'사원': 11,
};
final values = [
employee.grade?.trim() ?? '',
employee.position?.trim() ?? '',
employee.jobTitle?.trim() ?? '',
];
for (final value in values) {
for (final entry in priorityByToken.entries) {
if (value.contains(entry.key)) {
return entry.value;
}
}
}
return priorityByToken.length + 1;
}
final directoryRepositoryProvider = Provider<DirectoryRepository>((ref) {
return RemoteDirectoryRepository(
orgContextApiClient: ref.watch(orgContextApiClientProvider),
);
});
final employeeDetailRepositoryProvider = Provider<EmployeeDetailRepository>((ref) {
return RemoteEmployeeDetailRepository(
directoryApiClient: ref.watch(directoryApiClientProvider),
);
});
final directoryEmployeesProvider = FutureProvider.autoDispose
.family<List<Employee>, DirectoryQuery>((ref, query) {
return ref.watch(directoryRepositoryProvider).loadEmployees(query);
});
@@ -0,0 +1,216 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import '../../../core/config/app_environment_provider.dart';
import '../../../core/network/http_client_provider.dart';
import '../../auth/data/auth_session_store.dart';
import '../domain/employee.dart';
class ProfileImageApiClient {
ProfileImageApiClient({
required this.httpClient,
required this.baseUri,
required this.sessionStore,
this.timeout = const Duration(seconds: 5),
this.cacheTtl = const Duration(minutes: 10),
});
static final Map<String, _ProfileImageCacheEntry> _cache = {};
static final RegExp _uuidPattern = RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
caseSensitive: false,
);
final http.Client httpClient;
final Uri baseUri;
final AuthSessionStore sessionStore;
final Duration timeout;
final Duration cacheTtl;
Future<String?> resolveImageUrl(Employee employee) async {
final explicitUrl = employee.profileImageUrl?.trim() ?? '';
if (explicitUrl.isNotEmpty) {
return explicitUrl;
}
final uuidFallbackUrl = _uuidFallbackUrl(employee.id);
final email = employee.email?.trim().toLowerCase() ?? '';
if (email.isEmpty) {
_debug(
'skip auth lookup: employee=${employee.name}, id=${employee.id}, email missing, uuidFallback=${uuidFallbackUrl != null}',
);
return uuidFallbackUrl;
}
final session = await sessionStore.load();
if (session == null || session.isExpired || session.token.trim().isEmpty) {
_debug(
'skip auth lookup: employee=${employee.name}, email=$email, session unavailable, uuidFallback=${uuidFallbackUrl != null}',
);
return uuidFallbackUrl;
}
final companyCode = _resolveCompanyCode(employee, email: email);
final cacheKey = '${companyCode ?? ''}|$email';
final now = DateTime.now().toUtc();
final cached = _cache[cacheKey];
if (cached != null && cached.expiresAt.isAfter(now)) {
final resolved = await cached.future;
return resolved ?? uuidFallbackUrl;
}
final future = _fetchImageUrl(
companyCode: companyCode,
email: email,
accessToken: session.token.trim(),
);
_cache[cacheKey] = _ProfileImageCacheEntry(
future: future,
expiresAt: now.add(cacheTtl),
);
try {
final resolved = await future;
if (resolved == null && uuidFallbackUrl != null) {
_debug(
'auth lookup empty: employee=${employee.name}, email=$email, fallback to uuid url',
);
}
return resolved ?? uuidFallbackUrl;
} catch (_) {
if (identical(_cache[cacheKey]?.future, future)) {
_cache.remove(cacheKey);
}
if (uuidFallbackUrl != null) {
_debug(
'auth lookup failed: employee=${employee.name}, email=$email, fallback to uuid url',
);
}
return uuidFallbackUrl;
}
}
Future<String?> _fetchImageUrl({
required String? companyCode,
required String email,
required String accessToken,
}) async {
final appSessionToken = accessToken.trim();
final queryParameters = <String, String>{'email': email};
if (companyCode != null && companyCode.isNotEmpty) {
queryParameters['comp'] = companyCode;
}
_debug(
'request profile image: email=$email, comp=${companyCode ?? ''}, base=${baseUri.toString()}',
);
final response = await httpClient
.get(
_resolve('/api/v1/profile-image', queryParameters: queryParameters),
headers: {
'accept': 'application/json',
if (appSessionToken.isNotEmpty)
'Authorization': 'Bearer $appSessionToken',
if (appSessionToken.isNotEmpty) 'X-App-Session': appSessionToken,
},
)
.timeout(timeout);
if (response.statusCode < 200 || response.statusCode >= 300) {
_debug(
'profile image response not ok: email=$email, status=${response.statusCode}',
);
return null;
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
return null;
}
final found = decoded['found'] as bool? ?? false;
final imageUrl = decoded['imageUrl'] as String? ?? '';
if (!found || imageUrl.trim().isEmpty) {
_debug('profile image not found: email=$email');
return null;
}
_debug('profile image resolved: email=$email, url=${imageUrl.trim()}');
return imageUrl.trim();
}
Uri _resolve(
String path, {
Map<String, String> queryParameters = const {},
}) {
final normalizedBase = baseUri.path.endsWith('/')
? baseUri
: baseUri.replace(path: '${baseUri.path}/');
final resolved = normalizedBase.resolve(path.replaceFirst(RegExp(r'^/'), ''));
return resolved.replace(queryParameters: queryParameters);
}
String? _uuidFallbackUrl(String employeeId) {
final normalized = employeeId.trim();
if (!_uuidPattern.hasMatch(normalized)) {
return null;
}
return 'https://baroncs.co.kr/employee_img/$normalized.jpg';
}
static void _debug(String message) {
if (kDebugMode) {
debugPrint('[ProfileImageApiClient] $message');
}
}
}
String? _resolveCompanyCode(Employee employee, {required String email}) {
final tenantSlug = employee.tenantSlug.trim().toLowerCase();
final tenantName = employee.tenantName.trim().toLowerCase();
if (tenantSlug.contains('saman') || tenantName.contains('삼안')) {
return 'SAMAN';
}
if (tenantSlug.contains('hanmac') || tenantName.contains('한맥')) {
return 'HANMAC';
}
if (tenantSlug == 'ptc' || tenantName == 'ptc') {
return 'PTC';
}
if (tenantSlug == 'tdc' || tenantName == 'tdc') {
return 'TDC';
}
if (email.endsWith('@samaneng.com')) {
return 'SAMAN';
}
if (email.endsWith('@hanmaceng.co.kr')) {
return 'HANMAC';
}
return null;
}
class _ProfileImageCacheEntry {
const _ProfileImageCacheEntry({
required this.future,
required this.expiresAt,
});
final Future<String?> future;
final DateTime expiresAt;
}
final profileImageApiClientProvider = Provider<ProfileImageApiClient>((ref) {
final environment = ref.watch(appEnvironmentProvider);
return ProfileImageApiClient(
httpClient: ref.watch(httpClientProvider),
baseUri: Uri.parse(environment.authApiBaseUrl),
sessionStore: ref.watch(authSessionStoreProvider),
);
});
@@ -15,6 +15,7 @@ class Employee {
this.status,
this.profileImageUrl,
this.sortOrder,
this.isManager = false,
});
final String id;
@@ -32,6 +33,7 @@ class Employee {
final String? status;
final String? profileImageUrl;
final int? sortOrder;
final bool isManager;
factory Employee.fromJson(Map<String, dynamic> json) {
return Employee(
@@ -50,6 +52,7 @@ class Employee {
status: json['status'] as String?,
profileImageUrl: json['profileImageUrl'] as String?,
sortOrder: json['sortOrder'] as int?,
isManager: json['isManager'] as bool? ?? false,
);
}
@@ -70,6 +73,7 @@ class Employee {
'status': status,
'profileImageUrl': profileImageUrl,
'sortOrder': sortOrder,
'isManager': isManager,
};
}
}
@@ -151,6 +155,7 @@ class EmployeeDetail extends Employee {
super.status,
super.profileImageUrl,
super.sortOrder,
super.isManager,
required this.joinedTenants,
required this.actions,
});
@@ -175,6 +180,7 @@ class EmployeeDetail extends Employee {
status: json['status'] as String?,
profileImageUrl: json['profileImageUrl'] as String?,
sortOrder: json['sortOrder'] as int?,
isManager: json['isManager'] as bool? ?? false,
joinedTenants: (json['joinedTenants'] as List<dynamic>? ?? [])
.map((item) => TenantRef.fromJson(item as Map<String, dynamic>))
.toList(),
@@ -0,0 +1,228 @@
import '../../organization/domain/organization_models.dart';
class TenantNavigationModel {
const TenantNavigationModel({
required this.selectedTenant,
required this.selectedCompanyTenant,
required this.pinnedTenant,
required this.companyTenants,
required this.breadcrumbTenants,
required this.visibleChips,
required this.childTenants,
required this.selectedTenantSubtreeSlugs,
});
final TenantSummary? selectedTenant;
final TenantSummary? selectedCompanyTenant;
final TenantSummary? pinnedTenant;
final List<TenantSummary> companyTenants;
final List<TenantSummary> breadcrumbTenants;
final List<TenantSummary> visibleChips;
final List<TenantSummary> childTenants;
final List<String> selectedTenantSubtreeSlugs;
bool get showsCompanyDirectory => selectedTenant == null;
bool get hasChildren => childTenants.isNotEmpty;
}
TenantNavigationModel buildTenantNavigationModel({
required List<TenantSummary> tenants,
required String selectedTenantSlug,
required String pinnedTenantSlug,
}) {
final selectedTenant = _findTenantBySlug(tenants, selectedTenantSlug);
final pinnedTenant = _findTenantBySlug(tenants, pinnedTenantSlug);
final companyTenants = [
for (final tenant in tenants)
if (tenant.type == 'COMPANY') tenant,
];
final breadcrumbTenants = selectedTenant == null
? const <TenantSummary>[]
: _buildBreadcrumbTenants(tenants, selectedTenant);
final selectedCompanyTenant = _resolveSelectedCompanyTenant(
selectedTenant: selectedTenant,
breadcrumbTenants: breadcrumbTenants,
);
final childTenants = selectedTenant == null
? companyTenants
: [
for (final tenant in tenants)
if (tenant.parentId == selectedTenant.id) tenant,
];
final selectedTenantSubtreeSlugs = selectedTenant == null
? const <String>[]
: buildTenantSubtreeSlugs(tenants: tenants, rootTenant: selectedTenant);
final visibleChips = <TenantSummary>[];
final seenSlugs = <String>{};
void addChip(TenantSummary tenant) {
if (tenant.slug.trim().isEmpty || !seenSlugs.add(tenant.slug)) {
return;
}
visibleChips.add(tenant);
}
for (final tenant in companyTenants) {
addChip(tenant);
}
if (pinnedTenant != null && pinnedTenant.type != 'COMPANY') {
addChip(pinnedTenant);
}
for (final tenant in breadcrumbTenants) {
if (tenant.type != 'COMPANY') {
addChip(tenant);
}
}
return TenantNavigationModel(
selectedTenant: selectedTenant,
selectedCompanyTenant: selectedCompanyTenant,
pinnedTenant: pinnedTenant,
companyTenants: companyTenants,
breadcrumbTenants: breadcrumbTenants,
visibleChips: visibleChips,
childTenants: childTenants,
selectedTenantSubtreeSlugs: selectedTenantSubtreeSlugs,
);
}
List<String> buildTenantSubtreeSlugs({
required List<TenantSummary> tenants,
required TenantSummary rootTenant,
}) {
final tenantsByParentId = <String, List<TenantSummary>>{};
for (final tenant in tenants) {
final parentId = tenant.parentId;
if (parentId == null || parentId.trim().isEmpty) {
continue;
}
tenantsByParentId
.putIfAbsent(parentId, () => <TenantSummary>[])
.add(tenant);
}
final slugs = <String>[];
final queue = <TenantSummary>[rootTenant];
final visitedIds = <String>{};
while (queue.isNotEmpty) {
final current = queue.removeAt(0);
if (!visitedIds.add(current.id)) {
continue;
}
if (current.slug.trim().isNotEmpty) {
slugs.add(current.slug);
}
queue.addAll(tenantsByParentId[current.id] ?? const <TenantSummary>[]);
}
return slugs;
}
String resolvePinnedTenantSlug({
required List<TenantSummary> tenants,
required String companySlug,
required String? departmentName,
}) {
final normalizedDepartment = departmentName?.trim();
if (normalizedDepartment == null || normalizedDepartment.isEmpty) {
return companySlug;
}
final companyTenant = _findTenantBySlug(tenants, companySlug);
if (companyTenant == null) {
return companySlug;
}
final match = _findDescendantTenantByName(
tenants: tenants,
rootTenantId: companyTenant.id,
tenantName: normalizedDepartment,
);
return match?.slug ?? companySlug;
}
List<TenantSummary> _buildBreadcrumbTenants(
List<TenantSummary> tenants,
TenantSummary selectedTenant,
) {
final tenantsById = {for (final tenant in tenants) tenant.id: tenant};
final reversed = <TenantSummary>[selectedTenant];
var current = selectedTenant;
while (current.parentId != null && current.parentId!.trim().isNotEmpty) {
final parent = tenantsById[current.parentId!];
if (parent == null) {
break;
}
reversed.add(parent);
current = parent;
}
return reversed.reversed.toList(growable: false);
}
TenantSummary? _findTenantBySlug(List<TenantSummary> tenants, String slug) {
for (final tenant in tenants) {
if (tenant.slug == slug) {
return tenant;
}
}
return null;
}
TenantSummary? _resolveSelectedCompanyTenant({
required TenantSummary? selectedTenant,
required List<TenantSummary> breadcrumbTenants,
}) {
if (selectedTenant == null) {
return null;
}
if (selectedTenant.type == 'COMPANY') {
return selectedTenant;
}
for (final tenant in breadcrumbTenants) {
if (tenant.type == 'COMPANY') {
return tenant;
}
}
return null;
}
TenantSummary? _findDescendantTenantByName({
required List<TenantSummary> tenants,
required String rootTenantId,
required String tenantName,
}) {
final normalizedName = tenantName.trim().toLowerCase();
if (normalizedName.isEmpty) {
return null;
}
final tenantsByParentId = <String, List<TenantSummary>>{};
for (final tenant in tenants) {
final parentId = tenant.parentId;
if (parentId == null || parentId.trim().isEmpty) {
continue;
}
tenantsByParentId
.putIfAbsent(parentId, () => <TenantSummary>[])
.add(tenant);
}
final queue = <String>[rootTenantId];
final visited = <String>{};
while (queue.isNotEmpty) {
final currentId = queue.removeAt(0);
if (!visited.add(currentId)) {
continue;
}
for (final tenant
in tenantsByParentId[currentId] ?? const <TenantSummary>[]) {
if (tenant.name.trim().toLowerCase() == normalizedName) {
return tenant;
}
queue.add(tenant.id);
}
}
return null;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,100 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../domain/favorite_employee.dart';
abstract class FavoritesRepository {
const FavoritesRepository();
Future<List<FavoriteEmployee>> loadFavorites();
Future<void> toggleFavorite(String employeeId);
}
class SharedPreferencesFavoritesRepository implements FavoritesRepository {
SharedPreferencesFavoritesRepository({
required SharedPreferencesAsync preferences,
}) : this.withStore(SharedPreferencesFavoritesStore(preferences));
const SharedPreferencesFavoritesRepository.withStore(this.store);
static const _storageKey = 'tdc114plus.favoriteEmployees';
final FavoritesKeyValueStore store;
@override
Future<List<FavoriteEmployee>> loadFavorites() async {
final encoded = await store.getString(_storageKey);
if (encoded == null || encoded.isEmpty) {
return const [];
}
final decoded = jsonDecode(encoded) as List<dynamic>;
return decoded
.map((item) => FavoriteEmployee.fromJson(item as Map<String, dynamic>))
.where((favorite) => favorite.employeeId.isNotEmpty)
.toList();
}
@override
Future<void> toggleFavorite(String employeeId) async {
final normalizedId = employeeId.trim();
if (normalizedId.isEmpty) {
return;
}
final favorites = await loadFavorites();
final nextFavorites = favorites
.where((favorite) => favorite.employeeId != normalizedId)
.toList();
if (nextFavorites.length == favorites.length) {
nextFavorites.add(
FavoriteEmployee(employeeId: normalizedId, createdAt: DateTime.now()),
);
}
await store.setString(
_storageKey,
jsonEncode(nextFavorites.map((favorite) => favorite.toJson()).toList()),
);
}
}
abstract class FavoritesKeyValueStore {
const FavoritesKeyValueStore();
Future<String?> getString(String key);
Future<void> setString(String key, String value);
}
class SharedPreferencesFavoritesStore implements FavoritesKeyValueStore {
const SharedPreferencesFavoritesStore(this._preferences);
final SharedPreferencesAsync _preferences;
@override
Future<String?> getString(String key) {
return _preferences.getString(key);
}
@override
Future<void> setString(String key, String value) {
return _preferences.setString(key, value);
}
}
final favoritesRepositoryProvider = Provider<FavoritesRepository>((ref) {
return SharedPreferencesFavoritesRepository(
preferences: SharedPreferencesAsync(),
);
});
final favoriteEmployeeIdsProvider = FutureProvider<Set<String>>((ref) async {
final favorites = await ref
.watch(favoritesRepositoryProvider)
.loadFavorites();
return favorites.map((favorite) => favorite.employeeId).toSet();
});
@@ -0,0 +1,503 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import '../../../core/config/app_environment_provider.dart';
import '../../../core/network/api_error.dart';
import '../../../core/network/http_client_provider.dart';
import '../../directory/domain/employee.dart';
import '../../auth/data/auth_session_store.dart';
import '../domain/organization_models.dart';
class OrgContextApiClient {
const OrgContextApiClient({
required this.httpClient,
required this.baseUri,
required this.keyId,
required this.keySecret,
required this.tenantSlug,
this.sessionStore,
this.timeout = const Duration(seconds: 15),
this.cacheTtl = const Duration(minutes: 5),
});
static final Map<String, _OrgContextCacheEntry> _cache = {};
final http.Client httpClient;
final Uri baseUri;
final String keyId;
final String keySecret;
final String tenantSlug;
final AuthSessionStore? sessionStore;
final Duration timeout;
final Duration cacheTtl;
Future<OrgChartSnapshot> fetchOrgContext({
bool forceRefresh = false,
String? tenantSlug,
}) async {
final credential = await _activeCredential(tenantSlug: tenantSlug);
final cacheKey = credential.cacheKey;
final now = DateTime.now().toUtc();
final cached = _cache[cacheKey];
if (!forceRefresh && cached != null && cached.expiresAt.isAfter(now)) {
return cached.future;
}
final future = _fetchOrgContext(credential);
_cache[cacheKey] = _OrgContextCacheEntry(
future: future,
expiresAt: now.add(cacheTtl),
);
try {
return await future;
} catch (_) {
if (identical(_cache[cacheKey]?.future, future)) {
_cache.remove(cacheKey);
}
rethrow;
}
}
static void clearCacheForTesting() {
_cache.clear();
}
Future<OrgChartSnapshot> _fetchOrgContext(
_OrgContextCredentialSnapshot credential,
) async {
final response = await httpClient
.get(
_resolve(
credential.baseUri,
'/api/v1/integrations/org-context',
queryParameters: {
'tenantSlug': credential.tenantSlug,
'includeUsers': 'true',
'includeUserIds': 'true',
},
),
headers: _headers(credential),
)
.timeout(timeout);
return _toSnapshot(_decodeOk(response));
}
Future<_OrgContextCredentialSnapshot> _activeCredential({
String? tenantSlug,
}) async {
final session = await _session();
final sessionCredential = session?.orgContextCredential;
final requestedTenantSlug = tenantSlug?.trim();
if (sessionCredential != null && sessionCredential.isUsable) {
return _OrgContextCredentialSnapshot(
baseUri: Uri.parse(sessionCredential.baseUrl),
tenantSlug: requestedTenantSlug == null || requestedTenantSlug.isEmpty
? sessionCredential.tenantSlug
: requestedTenantSlug,
keyId: sessionCredential.keyId,
keySecret: sessionCredential.keySecret,
appSessionToken: session?.token ?? '',
);
}
return _OrgContextCredentialSnapshot(
baseUri: baseUri,
tenantSlug: requestedTenantSlug == null || requestedTenantSlug.isEmpty
? this.tenantSlug
: requestedTenantSlug,
keyId: keyId,
keySecret: keySecret,
appSessionToken: session?.token ?? '',
);
}
Future<StoredAuthSession?> _session() async {
final store = sessionStore;
if (store == null) {
debugPrint('OrgContextApiClient._session no sessionStore');
return null;
}
final session = await store.load();
if (session == null || session.isExpired) {
debugPrint(
'OrgContextApiClient._session unavailable session=${session != null} expired=${session?.isExpired ?? true}',
);
return null;
}
debugPrint(
'OrgContextApiClient._session tokenLength=${session.token.length} expiresAt=${session.expiresAt.toUtc().toIso8601String()}',
);
return session;
}
Map<String, String> _headers(_OrgContextCredentialSnapshot credential) {
final appSessionToken = credential.appSessionToken.trim();
final headers = {
'accept': 'application/json',
if (appSessionToken.isNotEmpty) 'Authorization': 'Bearer $appSessionToken',
if (appSessionToken.isNotEmpty) 'X-App-Session': appSessionToken,
if (credential.keyId.trim().isNotEmpty)
'X-Baron-Key-ID': credential.keyId.trim(),
if (credential.keySecret.trim().isNotEmpty)
'X-Baron-Key-Secret': credential.keySecret.trim(),
};
debugPrint(
'OrgContextApiClient._headers headers=$headers tenantSlug=${credential.tenantSlug} base=${credential.baseUri}',
);
return headers;
}
Map<String, dynamic> _decodeOk(http.Response response) {
final decoded = _decodeObject(response.body);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
statusCode: response.statusCode,
apiError: ApiError.fromJson(decoded),
);
}
return decoded;
}
Uri _resolve(
Uri baseUri,
String path, {
Map<String, String?> queryParameters = const {},
}) {
final normalizedBase = baseUri.path.endsWith('/')
? baseUri
: baseUri.replace(path: '${baseUri.path}/');
final uri = normalizedBase.resolve(path.replaceFirst(RegExp(r'^/'), ''));
final filteredQuery = Map<String, String>.fromEntries(
queryParameters.entries
.where((entry) {
return entry.value != null && entry.value!.trim().isNotEmpty;
})
.map((entry) => MapEntry(entry.key, entry.value!)),
);
return uri.replace(queryParameters: filteredQuery);
}
Map<String, dynamic> _decodeObject(String body) {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
return decoded;
}
throw const FormatException('Expected JSON object response');
}
OrgChartSnapshot _toSnapshot(Map<String, dynamic> json) {
final issuedAt = _parseDate(json['issuedAt']) ?? DateTime.now().toUtc();
final tenantById = <String, TenantSummary>{};
final ownMemberCountByTenantId = <String, int>{};
final tree = json['tree'];
if (tree is Map<String, dynamic>) {
_collectTenantTree(tree, tenantById);
}
final tenantItems = <Map<String, dynamic>>[];
for (final item in json['tenants'] as List<dynamic>? ?? const []) {
if (item is! Map<String, dynamic>) {
continue;
}
tenantItems.add(item);
final tenant = _tenantFromJson(item);
if (tenant.id.isNotEmpty) {
tenantById[tenant.id] = tenant;
ownMemberCountByTenantId[tenant.id] = _memberList(item).length;
}
}
final employeesByMembership = <String, Employee>{};
for (final item in tenantItems) {
final tenant = _tenantFromJson(item);
if (tenant.id.isNotEmpty) {
tenantById[tenant.id] = tenant;
}
for (final employee in _employeesFromTenant(item, tenant)) {
if (employee.id.isNotEmpty) {
employeesByMembership['${tenant.id}:${employee.id}'] = employee;
}
}
}
final countedTenantById = _recountTenantMemberCounts(
tenantById: tenantById,
ownMemberCountByTenantId: ownMemberCountByTenantId,
);
final tenants = countedTenantById.values.toList()
..sort((a, b) {
final parentCompare = (a.parentId ?? '').compareTo(b.parentId ?? '');
if (parentCompare != 0) {
return parentCompare;
}
return a.name.compareTo(b.name);
});
final employees = employeesByMembership.values.toList();
return OrgChartSnapshot(
tenants: tenants,
employees: employees,
generatedAt: issuedAt.toUtc(),
cache: const OrgChartCacheInfo(source: 'baron-org-context', hit: false),
);
}
Map<String, TenantSummary> _recountTenantMemberCounts({
required Map<String, TenantSummary> tenantById,
required Map<String, int> ownMemberCountByTenantId,
}) {
final childrenByParentId = <String, List<TenantSummary>>{};
for (final tenant in tenantById.values) {
final parentId = tenant.parentId;
if (parentId == null || parentId.trim().isEmpty) {
continue;
}
childrenByParentId
.putIfAbsent(parentId, () => <TenantSummary>[])
.add(tenant);
}
final totalCountByTenantId = <String, int>{};
int totalFor(String tenantId) {
final cached = totalCountByTenantId[tenantId];
if (cached != null) {
return cached;
}
final ownCount =
ownMemberCountByTenantId[tenantId] ??
tenantById[tenantId]?.memberCount ??
0;
final total = (childrenByParentId[tenantId] ?? const <TenantSummary>[])
.fold<int>(ownCount, (sum, child) => sum + totalFor(child.id));
totalCountByTenantId[tenantId] = total;
return total;
}
return {
for (final entry in tenantById.entries)
entry.key: _copyTenantWithCounts(
entry.value,
memberCount:
ownMemberCountByTenantId[entry.key] ?? entry.value.memberCount,
totalMemberCount: totalFor(entry.key),
),
};
}
TenantSummary _copyTenantWithCounts(
TenantSummary tenant, {
required int memberCount,
required int totalMemberCount,
}) {
return TenantSummary(
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
type: tenant.type,
parentId: tenant.parentId,
memberCount: memberCount,
totalMemberCount: totalMemberCount,
);
}
void _collectTenantTree(
Map<String, dynamic> json,
Map<String, TenantSummary> tenantById,
) {
final tenant = _tenantFromJson(json);
if (tenant.id.isNotEmpty) {
tenantById[tenant.id] = tenant;
}
final children = json['children'];
if (children is List<dynamic>) {
for (final child in children) {
if (child is Map<String, dynamic>) {
_collectTenantTree(child, tenantById);
}
}
}
}
TenantSummary _tenantFromJson(Map<String, dynamic> json) {
final memberCount = _readInt(json['memberCount']);
final totalMemberCount = _readInt(json['totalMemberCount']) ?? memberCount;
return TenantSummary(
id: _readString(json, ['id']),
name: _readString(json, ['name']),
slug: _readString(json, ['slug']),
type: _readString(json, ['type', 'orgUnitType']),
parentId: _nullableString(json['parentId']),
memberCount: memberCount ?? _memberList(json).length,
totalMemberCount: totalMemberCount ?? _memberList(json).length,
);
}
List<Employee> _employeesFromTenant(
Map<String, dynamic> tenantJson,
TenantSummary tenant,
) {
final members = _memberList(tenantJson);
return members.indexed.map((entry) {
final index = entry.$1;
final member = entry.$2;
final rawPhone = _readString(member, [
'phoneNumber',
'phone',
'mobile',
'mobilePhone',
]);
final email = _nullableString(member['email']);
final memberId = _readString(member, [
'id',
'userId',
'employeeId',
'loginId',
]);
final id = memberId.isNotEmpty
? memberId
: [
tenant.id,
email,
_readString(member, ['name']),
rawPhone,
].whereType<String>().where((value) => value.isNotEmpty).join(':');
return Employee(
id: id,
name: _readString(member, ['name']),
phoneNumber: rawPhone,
phoneDisplay: _formatPhone(rawPhone),
email: email,
tenantId: tenant.id,
tenantName: tenant.name,
tenantSlug: tenant.slug,
department: _nullableString(member['department']),
grade: _nullableString(member['grade']),
position: _nullableString(member['position']),
jobTitle: _nullableString(member['jobTitle']),
status: _nullableString(member['status']),
profileImageUrl: _nullableString(member['profileImageUrl']),
sortOrder: _readInt(member['sortOrder']) ?? index,
isManager: member['isManager'] as bool? ?? false,
);
}).toList();
}
List<Map<String, dynamic>> _memberList(Map<String, dynamic> json) {
final members = json['members'];
if (members is! List<dynamic>) {
return const [];
}
return members
.whereType<Map>()
.map((member) => Map<String, dynamic>.from(member))
.toList();
}
DateTime? _parseDate(Object? value) {
if (value is! String || value.trim().isEmpty) {
return null;
}
return DateTime.tryParse(value);
}
int? _readInt(Object? value) {
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
if (value is String) {
return int.tryParse(value);
}
return null;
}
String _readString(Map<String, dynamic> json, List<String> keys) {
for (final key in keys) {
final value = _nullableString(json[key]);
if (value != null) {
return value;
}
}
return '';
}
String? _nullableString(Object? value) {
if (value is! String) {
return null;
}
final trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
String? _formatPhone(String value) {
var digits = value.replaceAll(RegExp(r'[^0-9]'), '');
if (digits.startsWith('82') && digits.length >= 11) {
digits = '0${digits.substring(2)}';
}
if (digits.length == 11 && digits.startsWith('010')) {
return '${digits.substring(0, 3)}-${digits.substring(3, 7)}-${digits.substring(7)}';
}
return value.trim().isEmpty ? null : value.trim();
}
}
final orgContextApiClientProvider = Provider<OrgContextApiClient>((ref) {
final environment = ref.watch(appEnvironmentProvider);
return OrgContextApiClient(
httpClient: ref.watch(httpClientProvider),
baseUri: Uri.parse(environment.orgContextApiBaseUrl),
keyId: environment.baronKeyId,
keySecret: environment.baronKeySecret,
tenantSlug: environment.orgContextTenantSlug,
sessionStore: ref.watch(authSessionStoreProvider),
);
});
final orgContextSnapshotProvider = FutureProvider.autoDispose<OrgChartSnapshot>(
(ref) {
return ref.watch(orgContextApiClientProvider).fetchOrgContext();
},
);
class _OrgContextCredentialSnapshot {
const _OrgContextCredentialSnapshot({
required this.baseUri,
required this.tenantSlug,
required this.keyId,
required this.keySecret,
required this.appSessionToken,
});
final Uri baseUri;
final String tenantSlug;
final String keyId;
final String keySecret;
final String appSessionToken;
String get cacheKey {
return [
baseUri.toString(),
tenantSlug,
appSessionToken.isEmpty ? keyId : appSessionToken,
].join('|');
}
}
class _OrgContextCacheEntry {
const _OrgContextCacheEntry({required this.future, required this.expiresAt});
final Future<OrgChartSnapshot> future;
final DateTime expiresAt;
}
@@ -0,0 +1,195 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import '../../../core/config/app_environment_provider.dart';
import '../../../core/network/api_error.dart';
import '../../../core/network/http_client_provider.dart';
import '../../auth/data/auth_session_store.dart';
import '../domain/organization_models.dart';
import 'org_context_api_client.dart';
abstract class OrganizationRepository {
const OrganizationRepository();
Future<TenantListResponse> listTenants();
Future<OrgChartSnapshot> getOrgChart({
String? tenantId,
bool refresh = false,
});
}
class OrganizationApiClient {
const OrganizationApiClient({
required this.httpClient,
required this.baseUri,
required this.sessionStore,
this.timeout = const Duration(seconds: 10),
});
final http.Client httpClient;
final Uri baseUri;
final AuthSessionStore sessionStore;
final Duration timeout;
Future<TenantListResponse> listTenants() async {
final response = await httpClient
.get(
_resolve('/api/v1/tdc114plus/organization/tenants'),
headers: await _headers(),
)
.timeout(timeout);
return TenantListResponse.fromJson(_decodeOk(response));
}
Future<OrgChartSnapshot> getOrgChart({
String? tenantId,
bool refresh = false,
}) async {
final response = await httpClient
.get(
_resolve(
'/api/v1/tdc114plus/organization/orgchart',
queryParameters: {
'tenantId': tenantId,
if (refresh) 'refresh': 'true',
},
),
headers: await _headers(),
)
.timeout(timeout);
return OrgChartSnapshot.fromJson(_decodeOk(response));
}
Future<Map<String, String>> _headers() async {
final session = await sessionStore.load();
return {
'accept': 'application/json',
if (session?.token != null) 'authorization': 'Bearer ${session!.token}',
};
}
Map<String, dynamic> _decodeOk(http.Response response) {
final decoded = _decodeObject(response.body);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
statusCode: response.statusCode,
apiError: ApiError.fromJson(decoded),
);
}
return decoded;
}
Uri _resolve(String path, {Map<String, String?> queryParameters = const {}}) {
final normalizedBase = baseUri.path.endsWith('/')
? baseUri
: baseUri.replace(path: '${baseUri.path}/');
final uri = normalizedBase.resolve(path.replaceFirst(RegExp(r'^/'), ''));
final filteredQuery = Map<String, String>.fromEntries(
queryParameters.entries
.where((entry) {
return entry.value != null && entry.value!.trim().isNotEmpty;
})
.map((entry) => MapEntry(entry.key, entry.value!)),
);
return uri.replace(queryParameters: filteredQuery);
}
Map<String, dynamic> _decodeObject(String body) {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
return decoded;
}
throw const FormatException('Expected JSON object response');
}
}
class RemoteOrganizationRepository implements OrganizationRepository {
const RemoteOrganizationRepository({required this.apiClient});
final OrganizationApiClient apiClient;
@override
Future<TenantListResponse> listTenants() => apiClient.listTenants();
@override
Future<OrgChartSnapshot> getOrgChart({
String? tenantId,
bool refresh = false,
}) {
return apiClient.getOrgChart(tenantId: tenantId, refresh: refresh);
}
}
class OrgContextOrganizationRepository implements OrganizationRepository {
const OrgContextOrganizationRepository({required this.orgContextApiClient});
final OrgContextApiClient orgContextApiClient;
@override
Future<TenantListResponse> listTenants() async {
final snapshot = await orgContextApiClient.fetchOrgContext();
return TenantListResponse(
items: snapshot.tenants,
generatedAt: snapshot.generatedAt,
);
}
@override
Future<OrgChartSnapshot> getOrgChart({
String? tenantId,
bool refresh = false,
}) async {
final snapshot = await orgContextApiClient.fetchOrgContext();
if (tenantId == null || tenantId.trim().isEmpty) {
return snapshot;
}
return OrgChartSnapshot(
tenants: snapshot.tenants
.where(
(tenant) => tenant.id == tenantId || tenant.parentId == tenantId,
)
.toList(),
employees: snapshot.employees
.where((employee) => employee.tenantId == tenantId)
.toList(),
generatedAt: snapshot.generatedAt,
cache: snapshot.cache,
);
}
}
final organizationApiClientProvider = Provider<OrganizationApiClient>((ref) {
final environment = ref.watch(appEnvironmentProvider);
return OrganizationApiClient(
httpClient: ref.watch(httpClientProvider),
baseUri: Uri.parse(environment.organizationApiBaseUrl),
sessionStore: ref.watch(authSessionStoreProvider),
);
});
final organizationRepositoryProvider = Provider<OrganizationRepository>((ref) {
return OrgContextOrganizationRepository(
orgContextApiClient: ref.watch(orgContextApiClientProvider),
);
});
final tenantListProvider = FutureProvider.autoDispose<List<TenantSummary>>((
ref,
) async {
final response = await ref
.watch(organizationRepositoryProvider)
.listTenants();
return response.items;
});
final orgChartProvider = FutureProvider.autoDispose
.family<OrgChartSnapshot, String?>((ref, tenantId) async {
return ref
.watch(organizationRepositoryProvider)
.getOrgChart(tenantId: tenantId);
});
+95
View File
@@ -0,0 +1,95 @@
import '../features/directory/data/directory_repository.dart';
import '../features/directory/data/mock_directory_data.dart';
import '../features/directory/domain/employee.dart';
import '../features/organization/data/organization_api_client.dart';
import '../features/organization/domain/organization_models.dart';
const useSmokeMockDirectory = bool.fromEnvironment(
'TDC114_SMOKE_USE_MOCK_DIRECTORY',
);
final smokeDirectoryOverride = directoryRepositoryProvider.overrideWithValue(
const _SmokeDirectoryRepository(),
);
final smokeOrganizationOverride = organizationRepositoryProvider
.overrideWithValue(const _SmokeOrganizationRepository());
class _SmokeDirectoryRepository implements DirectoryRepository {
const _SmokeDirectoryRepository();
@override
Future<List<Employee>> loadEmployees(DirectoryQuery query) async {
final normalizedQuery = query.query.trim();
final normalizedDigits = _digitsOnly(normalizedQuery);
final selectedTenant = _findTenantBySlug(query.apiTenantSlug);
return mockEmployees.where((employee) {
final matchesTenant =
query.apiTenantSlug == null ||
employee.tenantSlug == query.apiTenantSlug ||
(selectedTenant != null &&
selectedTenant.type != 'COMPANY' &&
(employee.department ?? '') == selectedTenant.name);
final matchesDepartment =
query.apiDepartment == null ||
(employee.department ?? '') == query.apiDepartment;
if (!matchesTenant) {
return false;
}
if (!matchesDepartment) {
return false;
}
if (normalizedQuery.isEmpty) {
return true;
}
final matchesName = employee.name.contains(normalizedQuery);
final matchesDepartmentQuery = (employee.department ?? '').contains(
normalizedQuery,
);
final matchesPhone =
normalizedDigits.isNotEmpty &&
(_digitsOnly(employee.phoneNumber).contains(normalizedDigits) ||
_digitsOnly(
employee.phoneDisplay ?? '',
).contains(normalizedDigits));
return matchesName || matchesDepartmentQuery || matchesPhone;
}).toList();
}
String _digitsOnly(String value) => value.replaceAll(RegExp(r'[^0-9]'), '');
TenantSummary? _findTenantBySlug(String? slug) {
if (slug == null || slug.trim().isEmpty) {
return null;
}
for (final tenant in mockTenants) {
if (tenant.slug == slug) {
return tenant;
}
}
return null;
}
}
class _SmokeOrganizationRepository implements OrganizationRepository {
const _SmokeOrganizationRepository();
@override
Future<OrgChartSnapshot> getOrgChart({
String? tenantId,
bool refresh = false,
}) {
throw UnimplementedError();
}
@override
Future<TenantListResponse> listTenants() async {
return TenantListResponse(
items: mockTenants,
generatedAt: DateTime.parse('2026-07-02T00:00:00Z'),
);
}
}