Каркас приложения: авторизация, зоны/устройства/правила, управление устройствами

HTTP-клиент к /api/v1 с Sanctum-токеном (хранится в secure storage),
модели зеркалят Laravel API Resources. Экраны: логин, список устройств
с живым статусом, детальная страница устройства (история телеметрии,
turn-on/turn-off/set-level с проверкой capability), списки зон и правил
автоматизации (пока только чтение — формы создания/редактирования впереди).
This commit is contained in:
2026-08-12 01:12:39 +05:00
parent 6a00826194
commit 45b8733d22
25 changed files with 1531 additions and 129 deletions
+87
View File
@@ -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);
}
}
+21
View File
@@ -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';
}
+129
View File
@@ -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);
}
+83
View File
@@ -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;
}
}