Stabilize auth flow and profile images
This commit is contained in:
+133
-2
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user