Каркас приложения: авторизация, зоны/устройства/правила, управление устройствами
HTTP-клиент к /api/v1 с Sanctum-токеном (хранится в secure storage), модели зеркалят Laravel API Resources. Экраны: логин, список устройств с живым статусом, детальная страница устройства (история телеметрии, turn-on/turn-off/set-level с проверкой capability), списки зон и правил автоматизации (пока только чтение — формы создания/редактирования впереди).
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'api_config.dart';
|
||||
|
||||
/// Thrown for any non-2xx response. `errors` is populated for Laravel's
|
||||
/// standard 422 validation shape (`{"message": ..., "errors": {field: [...]}}`).
|
||||
class ApiException implements Exception {
|
||||
ApiException(this.statusCode, this.message, {this.errors});
|
||||
|
||||
final int statusCode;
|
||||
final String message;
|
||||
final Map<String, List<String>>? errors;
|
||||
|
||||
bool get isUnauthorized => statusCode == 401;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// Thin wrapper over `package:http` for the `/api/v1` backend: builds the
|
||||
/// URL, attaches the bearer token (set by AuthService after login), decodes
|
||||
/// JSON, and turns non-2xx responses into [ApiException].
|
||||
class ApiClient {
|
||||
String? _token;
|
||||
|
||||
void setToken(String? token) => _token = token;
|
||||
|
||||
Uri _uri(String path) => Uri.parse('${ApiConfig.apiV1Url}$path');
|
||||
|
||||
Map<String, String> get _headers => {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
if (_token != null) 'Authorization': 'Bearer $_token',
|
||||
};
|
||||
|
||||
Future<dynamic> get(String path) async {
|
||||
return _handle(await http.get(_uri(path), headers: _headers));
|
||||
}
|
||||
|
||||
Future<dynamic> post(String path, [Map<String, dynamic>? body]) async {
|
||||
return _handle(await http.post(
|
||||
_uri(path),
|
||||
headers: _headers,
|
||||
body: body == null ? null : jsonEncode(body),
|
||||
));
|
||||
}
|
||||
|
||||
Future<dynamic> put(String path, [Map<String, dynamic>? body]) async {
|
||||
return _handle(await http.put(
|
||||
_uri(path),
|
||||
headers: _headers,
|
||||
body: body == null ? null : jsonEncode(body),
|
||||
));
|
||||
}
|
||||
|
||||
Future<dynamic> delete(String path) async {
|
||||
return _handle(await http.delete(_uri(path), headers: _headers));
|
||||
}
|
||||
|
||||
dynamic _handle(http.Response response) {
|
||||
final ok = response.statusCode >= 200 && response.statusCode < 300;
|
||||
|
||||
if (response.body.isEmpty) {
|
||||
if (ok) return null;
|
||||
throw ApiException(response.statusCode, 'Ошибка запроса (${response.statusCode})');
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
|
||||
if (ok) return decoded;
|
||||
|
||||
final message = decoded is Map && decoded['message'] is String
|
||||
? decoded['message'] as String
|
||||
: 'Ошибка запроса (${response.statusCode})';
|
||||
|
||||
Map<String, List<String>>? errors;
|
||||
if (decoded is Map && decoded['errors'] is Map) {
|
||||
errors = (decoded['errors'] as Map).map(
|
||||
(key, value) => MapEntry(key as String, List<String>.from(value as List)),
|
||||
);
|
||||
}
|
||||
|
||||
throw ApiException(response.statusCode, message, errors: errors);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
/// Backend base URL, without a trailing slash and without `/api/v1`.
|
||||
///
|
||||
/// Override at build/run time, e.g.:
|
||||
/// flutter run --dart-define=API_BASE_URL=http://192.168.1.10:8000
|
||||
///
|
||||
/// Without an override we guess a sane per-platform default for local dev:
|
||||
/// the Android emulator can't reach the host via `localhost` (it has its own
|
||||
/// loopback), so it needs the special `10.0.2.2` alias instead.
|
||||
class ApiConfig {
|
||||
static const _override = String.fromEnvironment('API_BASE_URL');
|
||||
|
||||
static String get baseUrl {
|
||||
if (_override.isNotEmpty) return _override;
|
||||
if (!Platform.isAndroid) return 'http://localhost:8000';
|
||||
return 'http://10.0.2.2:8000';
|
||||
}
|
||||
|
||||
static String get apiV1Url => '$baseUrl/api/v1';
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import '../models/automation_rule.dart';
|
||||
import '../models/device.dart';
|
||||
import '../models/device_type.dart';
|
||||
import '../models/zone.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// Typed wrappers for every `/api/v1` endpoint besides auth (see
|
||||
/// [AuthService] for login/logout/me). One method per backend action —
|
||||
/// no generic "request" escape hatch, so call sites stay self-documenting.
|
||||
class ApiService {
|
||||
ApiService(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
// --- Zones ---
|
||||
|
||||
Future<List<Zone>> zones() async {
|
||||
final json = await _client.get('/zones');
|
||||
return _list(json).map(Zone.fromJson).toList();
|
||||
}
|
||||
|
||||
Future<Zone> createZone({required String name, String? description}) async {
|
||||
final json = await _client.post('/zones', {
|
||||
'name': name,
|
||||
if (description != null && description.isNotEmpty) 'description': description,
|
||||
});
|
||||
return Zone.fromJson(json['data'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<Zone> updateZone(int id, {required String name, String? description}) async {
|
||||
final json = await _client.put('/zones/$id', {
|
||||
'name': name,
|
||||
if (description != null && description.isNotEmpty) 'description': description,
|
||||
});
|
||||
return Zone.fromJson(json['data'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<void> deleteZone(int id) => _client.delete('/zones/$id');
|
||||
|
||||
// --- Device types ---
|
||||
|
||||
Future<List<DeviceType>> deviceTypes() async {
|
||||
final json = await _client.get('/device-types');
|
||||
return _list(json).map(DeviceType.fromJson).toList();
|
||||
}
|
||||
|
||||
// --- Devices ---
|
||||
|
||||
Future<List<Device>> devices() async {
|
||||
final json = await _client.get('/devices');
|
||||
return _list(json).map(Device.fromJson).toList();
|
||||
}
|
||||
|
||||
Future<DeviceDetail> device(int id) async {
|
||||
final json = await _client.get('/devices/$id');
|
||||
return DeviceDetail.fromJson(json as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<Device> createDevice({
|
||||
required int zoneId,
|
||||
required int deviceTypeId,
|
||||
required String name,
|
||||
required String externalId,
|
||||
String protocol = 'mqtt',
|
||||
}) async {
|
||||
final json = await _client.post('/devices', {
|
||||
'zone_id': zoneId,
|
||||
'device_type_id': deviceTypeId,
|
||||
'name': name,
|
||||
'external_id': externalId,
|
||||
'protocol': protocol,
|
||||
});
|
||||
return Device.fromJson(json['data'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<Device> updateDevice(
|
||||
int id, {
|
||||
required int zoneId,
|
||||
required int deviceTypeId,
|
||||
required String name,
|
||||
required String externalId,
|
||||
String protocol = 'mqtt',
|
||||
}) async {
|
||||
final json = await _client.put('/devices/$id', {
|
||||
'zone_id': zoneId,
|
||||
'device_type_id': deviceTypeId,
|
||||
'name': name,
|
||||
'external_id': externalId,
|
||||
'protocol': protocol,
|
||||
});
|
||||
return Device.fromJson(json['data'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<void> deleteDevice(int id) => _client.delete('/devices/$id');
|
||||
|
||||
Future<String> turnOn(int deviceId) => _commandMessage(_client.post('/devices/$deviceId/turn-on'));
|
||||
|
||||
Future<String> turnOff(int deviceId) => _commandMessage(_client.post('/devices/$deviceId/turn-off'));
|
||||
|
||||
Future<String> setLevel(int deviceId, double level) =>
|
||||
_commandMessage(_client.post('/devices/$deviceId/set-level', {'level': level}));
|
||||
|
||||
Future<String> _commandMessage(Future<dynamic> request) async {
|
||||
final json = await request;
|
||||
return (json as Map<String, dynamic>)['message'] as String? ?? 'Готово.';
|
||||
}
|
||||
|
||||
// --- Automation rules ---
|
||||
|
||||
Future<List<AutomationRule>> automationRules() async {
|
||||
final json = await _client.get('/automation-rules');
|
||||
return _list(json).map(AutomationRule.fromJson).toList();
|
||||
}
|
||||
|
||||
Future<AutomationRule> createAutomationRule(Map<String, dynamic> payload) async {
|
||||
final json = await _client.post('/automation-rules', payload);
|
||||
return AutomationRule.fromJson(json['data'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<AutomationRule> updateAutomationRule(int id, Map<String, dynamic> payload) async {
|
||||
final json = await _client.put('/automation-rules/$id', payload);
|
||||
return AutomationRule.fromJson(json['data'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<void> deleteAutomationRule(int id) => _client.delete('/automation-rules/$id');
|
||||
|
||||
List<Map<String, dynamic>> _list(dynamic json) =>
|
||||
List<Map<String, dynamic>>.from((json as Map<String, dynamic>)['data'] as List);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../models/user.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// Session state + token persistence. The token is a Sanctum personal
|
||||
/// access token, named per-device on the backend (see `/login`'s
|
||||
/// `device_name`) so a user could later revoke one device without others.
|
||||
class AuthService extends ChangeNotifier {
|
||||
AuthService(this._apiClient);
|
||||
|
||||
final ApiClient _apiClient;
|
||||
final _storage = const FlutterSecureStorage();
|
||||
static const _tokenKey = 'auth_token';
|
||||
|
||||
User? _user;
|
||||
bool _isLoading = true; // true until restoreSession() resolves once at startup
|
||||
|
||||
User? get user => _user;
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isAuthenticated => _user != null;
|
||||
|
||||
/// Call once at app startup: restores a saved token and validates it
|
||||
/// against `/me` — a saved token can be stale if it was revoked
|
||||
/// server-side (e.g. logged out from another device's session list).
|
||||
Future<void> restoreSession() async {
|
||||
final token = await _storage.read(key: _tokenKey);
|
||||
if (token == null) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
_apiClient.setToken(token);
|
||||
try {
|
||||
final json = await _apiClient.get('/me');
|
||||
_user = User.fromJson(json['data'] as Map<String, dynamic>);
|
||||
} on ApiException {
|
||||
await _storage.delete(key: _tokenKey);
|
||||
_apiClient.setToken(null);
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> login(String email, String password) async {
|
||||
final json = await _apiClient.post('/login', {
|
||||
'email': email,
|
||||
'password': password,
|
||||
'device_name': _deviceName(),
|
||||
});
|
||||
|
||||
final token = json['token'] as String;
|
||||
await _storage.write(key: _tokenKey, value: token);
|
||||
_apiClient.setToken(token);
|
||||
_user = User.fromJson(json['user'] as Map<String, dynamic>);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
await _apiClient.post('/logout');
|
||||
} on ApiException {
|
||||
// Token may already be invalid server-side (expired/revoked) —
|
||||
// still clear local state so the user isn't stuck logged in.
|
||||
}
|
||||
await _storage.delete(key: _tokenKey);
|
||||
_apiClient.setToken(null);
|
||||
_user = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String _deviceName() {
|
||||
if (kIsWeb) return 'web';
|
||||
if (Platform.isIOS) return 'iOS device';
|
||||
if (Platform.isAndroid) return 'Android device';
|
||||
return Platform.operatingSystem;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user