feat: complete leave approval MVP
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
class AuthSession {
|
||||
const AuthSession({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.idToken,
|
||||
required this.expiresAt,
|
||||
});
|
||||
|
||||
final String accessToken;
|
||||
final String? refreshToken;
|
||||
final String? idToken;
|
||||
final DateTime expiresAt;
|
||||
|
||||
bool get needsRefresh => expiresAt.isBefore(
|
||||
DateTime.now().toUtc().add(const Duration(minutes: 1)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'package:aioa_mobile/core/auth/auth_session.dart';
|
||||
import 'package:aioa_mobile/core/config/runtime_config.dart';
|
||||
import 'package:flutter_appauth/flutter_appauth.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
String get oidcIssuer => RuntimeConfig.oidcIssuer;
|
||||
const oidcClientId = 'aioa-mobile';
|
||||
const oidcRedirectUrl = 'aioa://oauth/callback';
|
||||
const oidcLogoutRedirectUrl = 'aioa://oauth/logout';
|
||||
|
||||
final authSessionProvider =
|
||||
AsyncNotifierProvider<AuthSessionController, AuthSession?>(
|
||||
AuthSessionController.new,
|
||||
);
|
||||
|
||||
class AuthSessionController extends AsyncNotifier<AuthSession?> {
|
||||
static const _storage = FlutterSecureStorage();
|
||||
static const _appAuth = FlutterAppAuth();
|
||||
|
||||
@override
|
||||
Future<AuthSession?> build() => _restore();
|
||||
|
||||
Future<void> login() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final result = await _appAuth.authorizeAndExchangeCode(
|
||||
AuthorizationTokenRequest(
|
||||
oidcClientId,
|
||||
oidcRedirectUrl,
|
||||
issuer: oidcIssuer,
|
||||
scopes: const ['openid', 'profile', 'email', 'offline_access'],
|
||||
promptValues: const ['login'],
|
||||
allowInsecureConnections: oidcIssuer.startsWith('http://'),
|
||||
),
|
||||
);
|
||||
return _storeResponse(result);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
final current = state.value;
|
||||
try {
|
||||
if (current?.idToken != null) {
|
||||
await _appAuth.endSession(
|
||||
EndSessionRequest(
|
||||
idTokenHint: current!.idToken,
|
||||
postLogoutRedirectUrl: oidcLogoutRedirectUrl,
|
||||
issuer: oidcIssuer,
|
||||
allowInsecureConnections: oidcIssuer.startsWith('http://'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await _clear();
|
||||
state = const AsyncData(null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> invalidateDeviceSession() async {
|
||||
await _clear();
|
||||
state = const AsyncData(null);
|
||||
}
|
||||
|
||||
Future<String?> validAccessToken() async {
|
||||
final current = state.value;
|
||||
if (current == null) return null;
|
||||
if (!current.needsRefresh) return current.accessToken;
|
||||
final refreshToken = current.refreshToken;
|
||||
if (refreshToken == null) {
|
||||
await _clear();
|
||||
state = const AsyncData(null);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final refreshed = await _refresh(current);
|
||||
state = AsyncData(refreshed);
|
||||
return refreshed.accessToken;
|
||||
} catch (_) {
|
||||
await _clear();
|
||||
state = const AsyncData(null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<AuthSession?> _restore() async {
|
||||
final values = await Future.wait([
|
||||
_storage.read(key: 'access_token'),
|
||||
_storage.read(key: 'refresh_token'),
|
||||
_storage.read(key: 'id_token'),
|
||||
_storage.read(key: 'expires_at'),
|
||||
]);
|
||||
final accessToken = values[0];
|
||||
final expiresAt = DateTime.tryParse(values[3] ?? '');
|
||||
if (accessToken == null || expiresAt == null) return null;
|
||||
final session = AuthSession(
|
||||
accessToken: accessToken,
|
||||
refreshToken: values[1],
|
||||
idToken: values[2],
|
||||
expiresAt: expiresAt,
|
||||
);
|
||||
if (session.needsRefresh) {
|
||||
if (session.refreshToken == null) {
|
||||
await _clear();
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await _refresh(session);
|
||||
} catch (_) {
|
||||
await _clear();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
Future<AuthSession> _refresh(AuthSession current) async {
|
||||
final response = await _appAuth.token(
|
||||
TokenRequest(
|
||||
oidcClientId,
|
||||
oidcRedirectUrl,
|
||||
issuer: oidcIssuer,
|
||||
refreshToken: current.refreshToken,
|
||||
scopes: const ['openid', 'profile', 'email', 'offline_access'],
|
||||
allowInsecureConnections: oidcIssuer.startsWith('http://'),
|
||||
),
|
||||
);
|
||||
return _storeResponse(response, fallback: current);
|
||||
}
|
||||
|
||||
Future<AuthSession> _storeResponse(
|
||||
TokenResponse response, {
|
||||
AuthSession? fallback,
|
||||
}) async {
|
||||
final accessToken = response.accessToken ?? fallback?.accessToken;
|
||||
final expiresAt =
|
||||
response.accessTokenExpirationDateTime ?? fallback?.expiresAt;
|
||||
if (accessToken == null || expiresAt == null) {
|
||||
throw StateError('OIDC token response is incomplete');
|
||||
}
|
||||
final session = AuthSession(
|
||||
accessToken: accessToken,
|
||||
refreshToken: response.refreshToken ?? fallback?.refreshToken,
|
||||
idToken: response.idToken ?? fallback?.idToken,
|
||||
expiresAt: expiresAt.toUtc(),
|
||||
);
|
||||
await Future.wait([
|
||||
_storage.write(key: 'access_token', value: session.accessToken),
|
||||
_storage.write(key: 'refresh_token', value: session.refreshToken),
|
||||
_storage.write(key: 'id_token', value: session.idToken),
|
||||
_storage.write(
|
||||
key: 'expires_at',
|
||||
value: session.expiresAt.toIso8601String(),
|
||||
),
|
||||
]);
|
||||
return session;
|
||||
}
|
||||
|
||||
Future<void> _clear() async {
|
||||
await Future.wait([
|
||||
_storage.delete(key: 'access_token'),
|
||||
_storage.delete(key: 'refresh_token'),
|
||||
_storage.delete(key: 'id_token'),
|
||||
_storage.delete(key: 'expires_at'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'package:aioa_mobile/core/auth/auth_session_controller.dart';
|
||||
import 'package:aioa_mobile/core/config/runtime_config.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
String get apiBaseUrl => RuntimeConfig.apiBaseUrl;
|
||||
|
||||
final authenticatedHttpClientProvider = Provider<http.Client>((ref) {
|
||||
final inner = http.Client();
|
||||
return AuthenticatedHttpClient(
|
||||
inner: inner,
|
||||
apiOrigin: Uri.parse(apiBaseUrl).origin,
|
||||
tokenProvider: ref.read(authSessionProvider.notifier).validAccessToken,
|
||||
deviceRegistration: ref.watch(deviceRegistrationProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final deviceRegistrationProvider = Provider<DeviceRegistration>((ref) {
|
||||
return DeviceRegistration(
|
||||
inner: http.Client(),
|
||||
baseUrl: apiBaseUrl,
|
||||
onRevoked: ref.read(authSessionProvider.notifier).invalidateDeviceSession,
|
||||
);
|
||||
});
|
||||
|
||||
class AuthenticatedHttpClient extends http.BaseClient {
|
||||
AuthenticatedHttpClient({
|
||||
required this.inner,
|
||||
required this.apiOrigin,
|
||||
required this.tokenProvider,
|
||||
this.deviceRegistration,
|
||||
});
|
||||
|
||||
final http.Client inner;
|
||||
final String apiOrigin;
|
||||
final Future<String?> Function() tokenProvider;
|
||||
final DeviceRegistration? deviceRegistration;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
if (request.url.origin == apiOrigin &&
|
||||
!request.headers.containsKey('Authorization')) {
|
||||
final token = await tokenProvider();
|
||||
if (token != null) {
|
||||
request.headers['Authorization'] = 'Bearer $token';
|
||||
final registration = deviceRegistration;
|
||||
if (registration != null) {
|
||||
request.headers['X-AIOA-Device-Id'] = await registration.ensure(
|
||||
token,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
final response = await inner.send(request);
|
||||
if (response.headers['x-aioa-auth-error'] == 'DEVICE_REVOKED') {
|
||||
await deviceRegistration?.revoked();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@override
|
||||
void close() => inner.close();
|
||||
}
|
||||
|
||||
class DeviceRegistration {
|
||||
DeviceRegistration({
|
||||
required this.inner,
|
||||
required this.baseUrl,
|
||||
required this.onRevoked,
|
||||
});
|
||||
|
||||
static const _storage = FlutterSecureStorage();
|
||||
final http.Client inner;
|
||||
final String baseUrl;
|
||||
final Future<void> Function() onRevoked;
|
||||
String? _registeredToken;
|
||||
String? _deviceId;
|
||||
|
||||
Future<String> ensure(String token) async {
|
||||
final id = _deviceId ??= await _loadOrCreateId();
|
||||
if (_registeredToken == token) return id;
|
||||
final platform = Platform.isIOS
|
||||
? 'IOS'
|
||||
: Platform.isAndroid
|
||||
? 'ANDROID'
|
||||
: 'OTHER';
|
||||
final response = await inner.post(
|
||||
Uri.parse('$baseUrl/devices/register'),
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'id': id,
|
||||
'name': Platform.localHostname.isEmpty
|
||||
? '$platform 设备'
|
||||
: Platform.localHostname,
|
||||
'platform': platform,
|
||||
}),
|
||||
);
|
||||
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||
await revoked();
|
||||
throw const DeviceRevokedException();
|
||||
}
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw DeviceRegistrationException(response.statusCode);
|
||||
}
|
||||
_registeredToken = token;
|
||||
return id;
|
||||
}
|
||||
|
||||
Future<void> revoked() async {
|
||||
_registeredToken = null;
|
||||
await onRevoked();
|
||||
}
|
||||
|
||||
Future<String> currentId() async => _deviceId ??= await _loadOrCreateId();
|
||||
|
||||
Future<String> _loadOrCreateId() async {
|
||||
final existing = await _storage.read(key: 'device_id');
|
||||
if (existing != null) return existing;
|
||||
final random = Random.secure();
|
||||
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
String hex(int start, int end) => bytes
|
||||
.sublist(start, end)
|
||||
.map((value) => value.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
final id =
|
||||
'${hex(0, 4)}-${hex(4, 6)}-${hex(6, 8)}-${hex(8, 10)}-${hex(10, 16)}';
|
||||
await _storage.write(key: 'device_id', value: id);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
class DeviceRevokedException implements Exception {
|
||||
const DeviceRevokedException();
|
||||
}
|
||||
|
||||
class DeviceRegistrationException implements Exception {
|
||||
const DeviceRegistrationException(this.statusCode);
|
||||
final int statusCode;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:aioa_mobile/app/theme.dart';
|
||||
import 'package:aioa_mobile/core/auth/auth_session_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class LoginPage extends ConsumerWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final auth = ref.watch(authSessionProvider);
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildAioaTheme(),
|
||||
home: Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(28),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 36,
|
||||
child: Icon(Icons.apartment_rounded, size: 38),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'AIOA',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineMedium
|
||||
?.copyWith(fontWeight: FontWeight.w900),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text('使用企业账号安全登录', textAlign: TextAlign.center),
|
||||
if (auth.hasError) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'登录失败:${auth.error}',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 28),
|
||||
FilledButton.icon(
|
||||
onPressed: auth.isLoading
|
||||
? null
|
||||
: ref.read(authSessionProvider.notifier).login,
|
||||
icon: auth.isLoading
|
||||
? const SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.login),
|
||||
label: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 14),
|
||||
child: Text('Keycloak 登录'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class RuntimeConfig {
|
||||
static const _configuredApiBaseUrl = String.fromEnvironment(
|
||||
'AIOA_API_BASE_URL',
|
||||
);
|
||||
static const _configuredOidcIssuer = String.fromEnvironment(
|
||||
'AIOA_OIDC_ISSUER',
|
||||
);
|
||||
|
||||
static String get apiBaseUrl => _configuredApiBaseUrl.isNotEmpty
|
||||
? _configuredApiBaseUrl
|
||||
: '${_localOrigin(8080)}/api/v1';
|
||||
|
||||
static String get oidcIssuer => _configuredOidcIssuer.isNotEmpty
|
||||
? _configuredOidcIssuer
|
||||
: 'http://localhost:8081/realms/aioa';
|
||||
|
||||
static String _localOrigin(int port) {
|
||||
final host = defaultTargetPlatform == TargetPlatform.android
|
||||
? '10.0.2.2'
|
||||
: '127.0.0.1';
|
||||
return 'http://$host:$port';
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,17 @@ class DynamicFormDefinition {
|
||||
|
||||
final JsonFormSchema dataSchema;
|
||||
final FormUiSchema uiSchema;
|
||||
|
||||
factory DynamicFormDefinition.fromJson(Map<String, Object?> json) {
|
||||
return DynamicFormDefinition(
|
||||
dataSchema: JsonFormSchema.fromJson(
|
||||
Map<String, Object?>.from(json['dataSchema']! as Map),
|
||||
),
|
||||
uiSchema: FormUiSchema.fromJson(
|
||||
Map<String, Object?>.from(json['uiSchema']! as Map),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class JsonFormSchema {
|
||||
@@ -24,6 +35,23 @@ class JsonFormSchema {
|
||||
final String title;
|
||||
final Map<String, JsonFieldSchema> properties;
|
||||
final Set<String> required;
|
||||
|
||||
factory JsonFormSchema.fromJson(Map<String, Object?> json) {
|
||||
final properties = Map<String, Object?>.from(json['properties']! as Map);
|
||||
return JsonFormSchema(
|
||||
id: json[r'$id']! as String,
|
||||
title: json['title']! as String,
|
||||
properties: properties.map(
|
||||
(key, value) => MapEntry(
|
||||
key,
|
||||
JsonFieldSchema.fromJson(Map<String, Object?>.from(value as Map)),
|
||||
),
|
||||
),
|
||||
required: ((json['required'] as List?) ?? const [])
|
||||
.cast<String>()
|
||||
.toSet(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class JsonFieldSchema {
|
||||
@@ -40,6 +68,16 @@ class JsonFieldSchema {
|
||||
final List<String> enumValues;
|
||||
final int? minLength;
|
||||
final int? maxLength;
|
||||
|
||||
factory JsonFieldSchema.fromJson(Map<String, Object?> json) {
|
||||
return JsonFieldSchema(
|
||||
type: JsonValueType.values.byName(json['type']! as String),
|
||||
format: json['format'] as String?,
|
||||
enumValues: ((json['enum'] as List?) ?? const []).cast<String>(),
|
||||
minLength: json['minLength'] as int?,
|
||||
maxLength: json['maxLength'] as int?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FormUiSchema {
|
||||
@@ -47,6 +85,19 @@ class FormUiSchema {
|
||||
|
||||
final String description;
|
||||
final List<FormSectionSchema> sections;
|
||||
|
||||
factory FormUiSchema.fromJson(Map<String, Object?> json) {
|
||||
return FormUiSchema(
|
||||
description: json['description']! as String,
|
||||
sections: (json['sections']! as List)
|
||||
.map(
|
||||
(value) => FormSectionSchema.fromJson(
|
||||
Map<String, Object?>.from(value as Map),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FormSectionSchema {
|
||||
@@ -54,6 +105,19 @@ class FormSectionSchema {
|
||||
|
||||
final String title;
|
||||
final List<FormControlSchema> controls;
|
||||
|
||||
factory FormSectionSchema.fromJson(Map<String, Object?> json) {
|
||||
return FormSectionSchema(
|
||||
title: json['title']! as String,
|
||||
controls: (json['controls']! as List)
|
||||
.map(
|
||||
(value) => FormControlSchema.fromJson(
|
||||
Map<String, Object?>.from(value as Map),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FormControlSchema {
|
||||
@@ -72,21 +136,41 @@ class FormControlSchema {
|
||||
final String? placeholder;
|
||||
final String? helperText;
|
||||
final Map<String, String> optionLabels;
|
||||
|
||||
factory FormControlSchema.fromJson(Map<String, Object?> json) {
|
||||
return FormControlSchema(
|
||||
field: json['field']! as String,
|
||||
label: json['label']! as String,
|
||||
control: FormControlType.values.byName(json['control']! as String),
|
||||
placeholder: json['placeholder'] as String?,
|
||||
helperText: json['helperText'] as String?,
|
||||
optionLabels: ((json['optionLabels'] as Map?) ?? const {})
|
||||
.cast<String, String>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DynamicFormState {
|
||||
const DynamicFormState({this.values = const {}, this.errors = const {}});
|
||||
const DynamicFormState({
|
||||
this.values = const {},
|
||||
this.errors = const {},
|
||||
this.restoredAt,
|
||||
});
|
||||
|
||||
final Map<String, Object?> values;
|
||||
final Map<String, String> errors;
|
||||
final DateTime? restoredAt;
|
||||
|
||||
DynamicFormState copyWith({
|
||||
Map<String, Object?>? values,
|
||||
Map<String, String>? errors,
|
||||
DateTime? restoredAt,
|
||||
bool clearRestoredAt = false,
|
||||
}) {
|
||||
return DynamicFormState(
|
||||
values: values ?? this.values,
|
||||
errors: errors ?? this.errors,
|
||||
restoredAt: clearRestoredAt ? null : restoredAt ?? this.restoredAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
|
||||
import 'package:aioa_mobile/core/config/runtime_config.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
final pushRegistrationProvider =
|
||||
AsyncNotifierProvider<PushRegistrationController, bool>(
|
||||
PushRegistrationController.new,
|
||||
);
|
||||
|
||||
class PushRegistrationController extends AsyncNotifier<bool> {
|
||||
@override
|
||||
Future<bool> build() async {
|
||||
try {
|
||||
if (Firebase.apps.isEmpty) await Firebase.initializeApp();
|
||||
final messaging = FirebaseMessaging.instance;
|
||||
final settings = await messaging.requestPermission(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
if (settings.authorizationStatus == AuthorizationStatus.denied) {
|
||||
return false;
|
||||
}
|
||||
final token = await messaging.getToken();
|
||||
if (token == null) return false;
|
||||
await _upload(token);
|
||||
messaging.onTokenRefresh.listen(_upload);
|
||||
return true;
|
||||
} catch (_) {
|
||||
// 未提供 Firebase 平台配置时保留站内通知,应用仍可正常启动。
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _upload(String token) async {
|
||||
final id = await ref.read(deviceRegistrationProvider).currentId();
|
||||
final response = await ref
|
||||
.read(authenticatedHttpClientProvider)
|
||||
.put(
|
||||
Uri.parse('${RuntimeConfig.apiBaseUrl}/devices/$id/push-token'),
|
||||
headers: const {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'token': token}),
|
||||
);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw http.ClientException('Push token registration failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user