Compare commits
2
Commits
6a00826194
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6749cdff0 | ||
|
|
45b8733d22 |
@@ -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;
|
||||
}
|
||||
}
|
||||
+40
-109
@@ -1,122 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'core/api_client.dart';
|
||||
import 'core/api_service.dart';
|
||||
import 'core/auth_service.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
runApp(const App());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
// This widget is the root of your application.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Flutter Demo',
|
||||
theme: ThemeData(
|
||||
// This is the theme of your application.
|
||||
//
|
||||
// TRY THIS: Try running your application with "flutter run". You'll see
|
||||
// the application has a purple toolbar. Then, without quitting the app,
|
||||
// try changing the seedColor in the colorScheme below to Colors.green
|
||||
// and then invoke "hot reload" (save your changes or press the "hot
|
||||
// reload" button in a Flutter-supported IDE, or press "r" if you used
|
||||
// the command line to start the app).
|
||||
//
|
||||
// Notice that the counter didn't reset back to zero; the application
|
||||
// state is not lost during the reload. To reset the state, use hot
|
||||
// restart instead.
|
||||
//
|
||||
// This works for code too, not just values: Most code changes can be
|
||||
// tested with just a hot reload.
|
||||
colorScheme: .fromSeed(seedColor: Colors.deepPurple),
|
||||
),
|
||||
home: const MyHomePage(title: 'Flutter Demo Home Page'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
const MyHomePage({super.key, required this.title});
|
||||
|
||||
// This widget is the home page of your application. It is stateful, meaning
|
||||
// that it has a State object (defined below) that contains fields that affect
|
||||
// how it looks.
|
||||
|
||||
// This class is the configuration for the state. It holds the values (in this
|
||||
// case the title) provided by the parent (in this case the App widget) and
|
||||
// used by the build method of the State. Fields in a Widget subclass are
|
||||
// always marked "final".
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<MyHomePage> createState() => _MyHomePageState();
|
||||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
int _counter = 0;
|
||||
|
||||
void _incrementCounter() {
|
||||
setState(() {
|
||||
// This call to setState tells the Flutter framework that something has
|
||||
// changed in this State, which causes it to rerun the build method below
|
||||
// so that the display can reflect the updated values. If we changed
|
||||
// _counter without calling setState(), then the build method would not be
|
||||
// called again, and so nothing would appear to happen.
|
||||
_counter++;
|
||||
});
|
||||
}
|
||||
class App extends StatelessWidget {
|
||||
const App({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// This method is rerun every time setState is called, for instance as done
|
||||
// by the _incrementCounter method above.
|
||||
//
|
||||
// The Flutter framework has been optimized to make rerunning build methods
|
||||
// fast, so that you can just rebuild anything that needs updating rather
|
||||
// than having to individually change instances of widgets.
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// TRY THIS: Try changing the color here to a specific color (to
|
||||
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
|
||||
// change color while the other colors stay the same.
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
// Here we take the value from the MyHomePage object that was created by
|
||||
// the App.build method, and use it to set our appbar title.
|
||||
title: Text(widget.title),
|
||||
),
|
||||
body: Center(
|
||||
// Center is a layout widget. It takes a single child and positions it
|
||||
// in the middle of the parent.
|
||||
child: Column(
|
||||
// Column is also a layout widget. It takes a list of children and
|
||||
// arranges them vertically. By default, it sizes itself to fit its
|
||||
// children horizontally, and tries to be as tall as its parent.
|
||||
//
|
||||
// Column has various properties to control how it sizes itself and
|
||||
// how it positions its children. Here we use mainAxisAlignment to
|
||||
// center the children vertically; the main axis here is the vertical
|
||||
// axis because Columns are vertical (the cross axis would be
|
||||
// horizontal).
|
||||
//
|
||||
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
|
||||
// action in the IDE, or press "p" in the console), to see the
|
||||
// wireframe for each widget.
|
||||
mainAxisAlignment: .center,
|
||||
children: [
|
||||
const Text('You have pushed the button this many times:'),
|
||||
Text(
|
||||
'$_counter',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
],
|
||||
final apiClient = ApiClient();
|
||||
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
Provider<ApiClient>.value(value: apiClient),
|
||||
Provider<ApiService>(create: (_) => ApiService(apiClient)),
|
||||
ChangeNotifierProvider<AuthService>(
|
||||
create: (_) => AuthService(apiClient)..restoreSession(),
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _incrementCounter,
|
||||
tooltip: 'Increment',
|
||||
child: const Icon(Icons.add),
|
||||
],
|
||||
child: MaterialApp(
|
||||
title: 'Домашняя автоматизация',
|
||||
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal)),
|
||||
home: const AuthGate(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Swaps between login and the app shell based on session state, so nothing
|
||||
/// else in the tree needs to know how auth is restored or persisted.
|
||||
class AuthGate extends StatelessWidget {
|
||||
const AuthGate({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = context.watch<AuthService>();
|
||||
|
||||
if (auth.isLoading) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
return auth.isAuthenticated ? const HomeScreen() : const LoginScreen();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'device.dart';
|
||||
import 'zone.dart';
|
||||
|
||||
class AutomationRule {
|
||||
AutomationRule({
|
||||
required this.id,
|
||||
required this.conditionSensorType,
|
||||
required this.conditionOperator,
|
||||
required this.conditionValue,
|
||||
required this.actionType,
|
||||
required this.actionParams,
|
||||
required this.isActive,
|
||||
this.zone,
|
||||
this.targetDevice,
|
||||
this.conditionSourceDevice,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final Zone? zone;
|
||||
final Device? targetDevice;
|
||||
final Device? conditionSourceDevice;
|
||||
final String conditionSensorType;
|
||||
final String conditionOperator; // ">", "<", ">=", "<=", "=="
|
||||
final double conditionValue;
|
||||
final String actionType; // "turn_on", "turn_off", "set_level", ...
|
||||
final Map<String, dynamic> actionParams;
|
||||
final bool isActive;
|
||||
|
||||
/// `action_params.level` for `set_level` rules, if present.
|
||||
num? get level => actionParams['level'] as num?;
|
||||
|
||||
factory AutomationRule.fromJson(Map<String, dynamic> json) => AutomationRule(
|
||||
id: json['id'] as int,
|
||||
zone: json['zone'] is Map ? Zone.fromJson(json['zone'] as Map<String, dynamic>) : null,
|
||||
targetDevice:
|
||||
json['target_device'] is Map ? Device.fromJson(json['target_device'] as Map<String, dynamic>) : null,
|
||||
conditionSourceDevice: json['condition_source_device'] is Map
|
||||
? Device.fromJson(json['condition_source_device'] as Map<String, dynamic>)
|
||||
: null,
|
||||
conditionSensorType: json['condition_sensor_type'] as String,
|
||||
conditionOperator: json['condition_operator'] as String,
|
||||
conditionValue: (json['condition_value'] as num).toDouble(),
|
||||
actionType: json['action_type'] as String,
|
||||
actionParams: Map<String, dynamic>.from(json['action_params'] as Map? ?? const {}),
|
||||
isActive: json['is_active'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'device_type.dart';
|
||||
import 'zone.dart';
|
||||
|
||||
class Device {
|
||||
Device({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.externalId,
|
||||
required this.protocol,
|
||||
required this.status,
|
||||
this.zone,
|
||||
this.deviceType,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final String externalId;
|
||||
final String protocol;
|
||||
final String status; // "online" | "offline" | "unknown"
|
||||
final Zone? zone;
|
||||
final DeviceType? deviceType;
|
||||
|
||||
factory Device.fromJson(Map<String, dynamic> json) => Device(
|
||||
id: json['id'] as int,
|
||||
name: json['name'] as String,
|
||||
externalId: json['external_id'] as String,
|
||||
protocol: json['protocol'] as String,
|
||||
status: json['status'] as String? ?? 'unknown',
|
||||
zone: json['zone'] is Map ? Zone.fromJson(json['zone'] as Map<String, dynamic>) : null,
|
||||
deviceType: json['device_type'] is Map
|
||||
? DeviceType.fromJson(json['device_type'] as Map<String, dynamic>)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// The extra data returned only by `GET /devices/{id}`: Device Shadow
|
||||
/// snapshot (Redis) plus recent telemetry readings (ClickHouse, sensors only).
|
||||
class DeviceDetail {
|
||||
DeviceDetail({
|
||||
required this.device,
|
||||
required this.lastSeen,
|
||||
required this.desiredState,
|
||||
required this.reportedState,
|
||||
required this.telemetry,
|
||||
});
|
||||
|
||||
final Device device;
|
||||
final DateTime? lastSeen;
|
||||
final Map<String, dynamic> desiredState;
|
||||
final Map<String, dynamic> reportedState;
|
||||
final List<TelemetryReading> telemetry;
|
||||
|
||||
factory DeviceDetail.fromJson(Map<String, dynamic> json) {
|
||||
final shadow = json['shadow'] as Map<String, dynamic>;
|
||||
|
||||
return DeviceDetail(
|
||||
device: Device.fromJson(json['data'] as Map<String, dynamic>),
|
||||
lastSeen: shadow['last_seen'] != null ? DateTime.tryParse(shadow['last_seen'] as String) : null,
|
||||
desiredState: Map<String, dynamic>.from(shadow['desired_state'] as Map? ?? const {}),
|
||||
reportedState: Map<String, dynamic>.from(shadow['reported_state'] as Map? ?? const {}),
|
||||
telemetry: (json['telemetry'] as List? ?? const [])
|
||||
.map((e) => TelemetryReading.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TelemetryReading {
|
||||
TelemetryReading({required this.sensorType, required this.value, required this.recordedAt});
|
||||
|
||||
final String sensorType;
|
||||
final double value;
|
||||
final String recordedAt;
|
||||
|
||||
factory TelemetryReading.fromJson(Map<String, dynamic> json) => TelemetryReading(
|
||||
sensorType: json['sensor_type'] as String,
|
||||
value: (json['value'] as num).toDouble(),
|
||||
recordedAt: json['recorded_at'] as String,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
class DeviceType {
|
||||
DeviceType({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.category,
|
||||
required this.capabilities,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String code;
|
||||
final String category; // "actuator" | "sensor"
|
||||
final List<String> capabilities;
|
||||
|
||||
bool get isSensor => category == 'sensor';
|
||||
bool get isActuator => category == 'actuator';
|
||||
|
||||
bool supports(String capability) => capabilities.contains(capability);
|
||||
|
||||
factory DeviceType.fromJson(Map<String, dynamic> json) => DeviceType(
|
||||
id: json['id'] as int,
|
||||
code: json['code'] as String,
|
||||
category: json['category'] as String,
|
||||
capabilities: List<String>.from(json['capabilities'] as List? ?? const []),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
class User {
|
||||
User({required this.id, required this.name, required this.email, required this.role});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final String email;
|
||||
final String role; // "owner" | "viewer"
|
||||
|
||||
bool get isOwner => role == 'owner';
|
||||
|
||||
factory User.fromJson(Map<String, dynamic> json) => User(
|
||||
id: json['id'] as int,
|
||||
name: json['name'] as String,
|
||||
email: json['email'] as String,
|
||||
role: json['role'] as String,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
class Zone {
|
||||
Zone({required this.id, required this.name, this.description, this.devicesCount});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final String? description;
|
||||
final int? devicesCount;
|
||||
|
||||
factory Zone.fromJson(Map<String, dynamic> json) => Zone(
|
||||
id: json['id'] as int,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String?,
|
||||
devicesCount: json['devices_count'] as int?,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../core/api_client.dart';
|
||||
import '../core/api_service.dart';
|
||||
import '../models/automation_rule.dart';
|
||||
import '../models/device.dart';
|
||||
import '../models/device_type.dart';
|
||||
import '../models/zone.dart';
|
||||
|
||||
const _operators = ['>', '<', '>=', '<=', '=', '!='];
|
||||
|
||||
/// Create/edit automation rule form. Pass an existing [rule] to edit it in
|
||||
/// place; omit it to create a new one. Pops with `true` on success.
|
||||
///
|
||||
/// Dropdown options (sensor/actuator devices, sensor types, action types)
|
||||
/// are all derived from the backend's device/device-type data, never
|
||||
/// hardcoded — a new device_type with new capabilities shows up here
|
||||
/// automatically, per the platform's "don't hardcode the domain" rule.
|
||||
class AutomationRuleFormScreen extends StatefulWidget {
|
||||
const AutomationRuleFormScreen({super.key, this.rule});
|
||||
|
||||
final AutomationRule? rule;
|
||||
|
||||
bool get isEditing => rule != null;
|
||||
|
||||
@override
|
||||
State<AutomationRuleFormScreen> createState() => _AutomationRuleFormScreenState();
|
||||
}
|
||||
|
||||
class _AutomationRuleFormScreenState extends State<AutomationRuleFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final _conditionValueController =
|
||||
TextEditingController(text: widget.rule?.conditionValue.toString());
|
||||
late final _levelController = TextEditingController(text: widget.rule?.level?.toString());
|
||||
|
||||
late Future<(List<Zone>, List<Device>, List<DeviceType>)> _optionsFuture;
|
||||
|
||||
int? _zoneId;
|
||||
int? _targetDeviceId;
|
||||
int? _conditionSourceDeviceId;
|
||||
String? _conditionSensorType;
|
||||
late String _conditionOperator;
|
||||
String? _actionType;
|
||||
late bool _isActive;
|
||||
|
||||
bool _isSubmitting = false;
|
||||
Map<String, List<String>> _fieldErrors = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_zoneId = widget.rule?.zone?.id;
|
||||
_targetDeviceId = widget.rule?.targetDevice?.id;
|
||||
_conditionSourceDeviceId = widget.rule?.conditionSourceDevice?.id;
|
||||
_conditionSensorType = widget.rule?.conditionSensorType;
|
||||
_conditionOperator = widget.rule?.conditionOperator ?? _operators.first;
|
||||
_actionType = widget.rule?.actionType;
|
||||
_isActive = widget.rule?.isActive ?? true;
|
||||
|
||||
final apiService = context.read<ApiService>();
|
||||
_optionsFuture = (apiService.zones(), apiService.devices(), apiService.deviceTypes()).wait;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_conditionValueController.dispose();
|
||||
_levelController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final missing = <String, List<String>>{
|
||||
if (_zoneId == null) 'zone_id': ['Выберите зону'],
|
||||
if (_targetDeviceId == null) 'target_device_id': ['Выберите целевое устройство'],
|
||||
if (_conditionSourceDeviceId == null) 'condition_source_device_id': ['Выберите датчик'],
|
||||
if (_conditionSensorType == null) 'condition_sensor_type': ['Выберите тип показания'],
|
||||
if (_actionType == null) 'action_type': ['Выберите действие'],
|
||||
};
|
||||
if (missing.isNotEmpty) {
|
||||
setState(() => _fieldErrors = missing);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
_fieldErrors = {};
|
||||
});
|
||||
|
||||
final payload = <String, dynamic>{
|
||||
'zone_id': _zoneId,
|
||||
'target_device_id': _targetDeviceId,
|
||||
'condition_source_device_id': _conditionSourceDeviceId,
|
||||
'condition_sensor_type': _conditionSensorType,
|
||||
'condition_operator': _conditionOperator,
|
||||
'condition_value': double.parse(_conditionValueController.text),
|
||||
'action_type': _actionType,
|
||||
'is_active': _isActive,
|
||||
if (_actionType == 'set_level') 'level': double.parse(_levelController.text),
|
||||
};
|
||||
|
||||
final apiService = context.read<ApiService>();
|
||||
|
||||
try {
|
||||
if (widget.isEditing) {
|
||||
await apiService.updateAutomationRule(widget.rule!.id, payload);
|
||||
} else {
|
||||
await apiService.createAutomationRule(payload);
|
||||
}
|
||||
if (mounted) Navigator.of(context).pop(true);
|
||||
} on ApiException catch (e) {
|
||||
setState(() => _fieldErrors = e.errors ?? {});
|
||||
if (e.errors == null && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.isEditing ? 'Редактировать правило' : 'Новое правило')),
|
||||
body: FutureBuilder<(List<Zone>, List<Device>, List<DeviceType>)>(
|
||||
future: _optionsFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('${snapshot.error}'));
|
||||
}
|
||||
|
||||
final (zones, devices, deviceTypes) = snapshot.data!;
|
||||
final sensorDevices = devices.where((d) => d.deviceType?.isSensor ?? false).toList();
|
||||
final actuatorDevices = devices.where((d) => d.deviceType?.isActuator ?? false).toList();
|
||||
final sensorTypeOptions = deviceTypes
|
||||
.where((t) => t.isSensor)
|
||||
.expand((t) => t.capabilities)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
final actionTypeOptions = deviceTypes
|
||||
.where((t) => t.isActuator)
|
||||
.expand((t) => t.capabilities)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: _zoneId,
|
||||
decoration: InputDecoration(labelText: 'Зона', errorText: _fieldErrors['zone_id']?.first),
|
||||
items: zones.map((z) => DropdownMenuItem(value: z.id, child: Text(z.name))).toList(),
|
||||
onChanged: (value) => setState(() => _zoneId = value),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('ЕСЛИ', style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: _conditionSourceDeviceId,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Датчик',
|
||||
errorText: _fieldErrors['condition_source_device_id']?.first,
|
||||
),
|
||||
items: sensorDevices.map((d) => DropdownMenuItem(value: d.id, child: Text(d.name))).toList(),
|
||||
onChanged: (value) => setState(() => _conditionSourceDeviceId = value),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _conditionSensorType,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Тип показания',
|
||||
errorText: _fieldErrors['condition_sensor_type']?.first,
|
||||
),
|
||||
items: sensorTypeOptions.map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
|
||||
onChanged: (value) => setState(() => _conditionSensorType = value),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: _conditionOperator,
|
||||
decoration: const InputDecoration(labelText: 'Оператор'),
|
||||
items: _operators.map((op) => DropdownMenuItem(value: op, child: Text(op))).toList(),
|
||||
onChanged: (value) => setState(() => _conditionOperator = value!),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _conditionValueController,
|
||||
decoration: const InputDecoration(labelText: 'Значение'),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (value) =>
|
||||
(value == null || double.tryParse(value) == null) ? 'Введите число' : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('ТО', style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _actionType,
|
||||
decoration:
|
||||
InputDecoration(labelText: 'Действие', errorText: _fieldErrors['action_type']?.first),
|
||||
items: actionTypeOptions.map((a) => DropdownMenuItem(value: a, child: Text(a))).toList(),
|
||||
onChanged: (value) => setState(() => _actionType = value),
|
||||
),
|
||||
if (_actionType == 'set_level') ...[
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _levelController,
|
||||
decoration: const InputDecoration(labelText: 'Уровень'),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (value) => (_actionType == 'set_level' && double.tryParse(value ?? '') == null)
|
||||
? 'Введите число'
|
||||
: null,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: _targetDeviceId,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Целевое устройство',
|
||||
errorText: _fieldErrors['target_device_id']?.first,
|
||||
),
|
||||
items: actuatorDevices.map((d) => DropdownMenuItem(value: d.id, child: Text(d.name))).toList(),
|
||||
onChanged: (value) => setState(() => _targetDeviceId = value),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Активно'),
|
||||
value: _isActive,
|
||||
onChanged: (value) => setState(() => _isActive = value),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _isSubmitting ? null : _submit,
|
||||
child: _isSubmitting
|
||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: Text(widget.isEditing ? 'Сохранить' : 'Создать'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../core/api_service.dart';
|
||||
import '../core/auth_service.dart';
|
||||
import '../models/automation_rule.dart';
|
||||
import 'automation_rule_form_screen.dart';
|
||||
|
||||
class AutomationRulesTab extends StatefulWidget {
|
||||
const AutomationRulesTab({super.key});
|
||||
|
||||
@override
|
||||
State<AutomationRulesTab> createState() => _AutomationRulesTabState();
|
||||
}
|
||||
|
||||
class _AutomationRulesTabState extends State<AutomationRulesTab> {
|
||||
late Future<List<AutomationRule>> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = _load();
|
||||
}
|
||||
|
||||
Future<List<AutomationRule>> _load() => context.read<ApiService>().automationRules();
|
||||
|
||||
Future<void> _refresh() async {
|
||||
final future = _load();
|
||||
setState(() => _future = future);
|
||||
await future;
|
||||
}
|
||||
|
||||
Future<void> _openForm({AutomationRule? rule}) async {
|
||||
final saved = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute(builder: (_) => AutomationRuleFormScreen(rule: rule)),
|
||||
);
|
||||
if (saved == true) _refresh();
|
||||
}
|
||||
|
||||
Future<bool> _confirmDelete(AutomationRule rule) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Удалить правило?'),
|
||||
content: const Text('Правило будет удалено без возможности восстановления.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Отмена')),
|
||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Удалить')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return false;
|
||||
|
||||
try {
|
||||
await context.read<ApiService>().deleteAutomationRule(rule.id);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isOwner = context.watch<AuthService>().user?.isOwner ?? false;
|
||||
|
||||
return Scaffold(
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: FutureBuilder<List<AutomationRule>>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text('${snapshot.error}', textAlign: TextAlign.center),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final rules = snapshot.data!;
|
||||
if (rules.isEmpty) {
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text('Правил пока нет.', textAlign: TextAlign.center),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: rules.length,
|
||||
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final rule = rules[index];
|
||||
final condition =
|
||||
'${rule.conditionSourceDevice?.name ?? '?'}: ${rule.conditionSensorType} ${rule.conditionOperator} ${rule.conditionValue}';
|
||||
final action = rule.level != null
|
||||
? '${rule.actionType} (${rule.level}) → ${rule.targetDevice?.name ?? '?'}'
|
||||
: '${rule.actionType} → ${rule.targetDevice?.name ?? '?'}';
|
||||
|
||||
final tile = ListTile(
|
||||
leading: Icon(rule.isActive ? Icons.bolt : Icons.bolt_outlined),
|
||||
title: Text('ЕСЛИ $condition'),
|
||||
subtitle: Text('ТО $action'),
|
||||
isThreeLine: true,
|
||||
onTap: isOwner ? () => _openForm(rule: rule) : null,
|
||||
);
|
||||
|
||||
if (!isOwner) return tile;
|
||||
|
||||
return Dismissible(
|
||||
key: ValueKey(rule.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: const Icon(Icons.delete, color: Colors.white),
|
||||
),
|
||||
confirmDismiss: (_) => _confirmDelete(rule),
|
||||
child: tile,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
floatingActionButton: isOwner
|
||||
? FloatingActionButton(onPressed: () => _openForm(), child: const Icon(Icons.add))
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../core/api_client.dart';
|
||||
import '../core/api_service.dart';
|
||||
import '../models/device.dart';
|
||||
import '../widgets/status_badge.dart';
|
||||
|
||||
class DeviceDetailScreen extends StatefulWidget {
|
||||
const DeviceDetailScreen({super.key, required this.deviceId});
|
||||
|
||||
final int deviceId;
|
||||
|
||||
@override
|
||||
State<DeviceDetailScreen> createState() => _DeviceDetailScreenState();
|
||||
}
|
||||
|
||||
class _DeviceDetailScreenState extends State<DeviceDetailScreen> {
|
||||
late Future<DeviceDetail> _future;
|
||||
bool _isSendingCommand = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = _load();
|
||||
}
|
||||
|
||||
Future<DeviceDetail> _load() => context.read<ApiService>().device(widget.deviceId);
|
||||
|
||||
Future<void> _refresh() async {
|
||||
final future = _load();
|
||||
setState(() => _future = future);
|
||||
await future;
|
||||
}
|
||||
|
||||
Future<void> _runCommand(Future<String> Function() command) async {
|
||||
setState(() => _isSendingCommand = true);
|
||||
try {
|
||||
final message = await command();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
await _refresh();
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSendingCommand = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _promptSetLevel(int deviceId) async {
|
||||
final apiService = context.read<ApiService>();
|
||||
double level = 50;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setDialogState) => AlertDialog(
|
||||
title: const Text('Установить уровень'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('${level.round()}'),
|
||||
Slider(
|
||||
value: level,
|
||||
min: 0,
|
||||
max: 100,
|
||||
divisions: 100,
|
||||
onChanged: (value) => setDialogState(() => level = value),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Отмена')),
|
||||
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Отправить')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await _runCommand(() => apiService.setLevel(deviceId, level));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final apiService = context.read<ApiService>();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Устройство')),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: FutureBuilder<DeviceDetail>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text('${snapshot.error}', textAlign: TextAlign.center),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final detail = snapshot.data!;
|
||||
final device = detail.device;
|
||||
final capabilities = device.deviceType?.capabilities ?? const [];
|
||||
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(device.name, style: Theme.of(context).textTheme.headlineSmall),
|
||||
),
|
||||
StatusBadge(status: device.status),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text('${device.zone?.name ?? '—'} · ${device.deviceType?.code ?? device.protocol}'),
|
||||
if (detail.lastSeen != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text('Последний сигнал: ${detail.lastSeen}'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (capabilities.contains('turn_on') || capabilities.contains('turn_off'))
|
||||
Row(
|
||||
children: [
|
||||
if (capabilities.contains('turn_on'))
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _isSendingCommand ? null : () => _runCommand(() => apiService.turnOn(device.id)),
|
||||
child: const Text('Включить'),
|
||||
),
|
||||
),
|
||||
if (capabilities.contains('turn_on') && capabilities.contains('turn_off'))
|
||||
const SizedBox(width: 12),
|
||||
if (capabilities.contains('turn_off'))
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed:
|
||||
_isSendingCommand ? null : () => _runCommand(() => apiService.turnOff(device.id)),
|
||||
child: const Text('Выключить'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (capabilities.contains('set_level')) ...[
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: _isSendingCommand ? null : () => _promptSetLevel(device.id),
|
||||
child: const Text('Установить уровень'),
|
||||
),
|
||||
],
|
||||
if (detail.reportedState.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
Text('Сообщённое состояние', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
_KeyValueTable(data: detail.reportedState),
|
||||
],
|
||||
if (detail.telemetry.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
Text('История показаний', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
...detail.telemetry.map(
|
||||
(reading) => ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('${reading.sensorType}: ${reading.value}'),
|
||||
subtitle: Text(reading.recordedAt),
|
||||
dense: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KeyValueTable extends StatelessWidget {
|
||||
const _KeyValueTable({required this.data});
|
||||
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: data.entries
|
||||
.map((entry) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Text('${entry.key}: ${entry.value}'),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../core/api_client.dart';
|
||||
import '../core/api_service.dart';
|
||||
import '../models/device.dart';
|
||||
import '../models/device_type.dart';
|
||||
import '../models/zone.dart';
|
||||
|
||||
/// Create/edit device form. Pass an existing [device] to edit it in place;
|
||||
/// omit it to create a new one. Pops with `true` on success.
|
||||
class DeviceFormScreen extends StatefulWidget {
|
||||
const DeviceFormScreen({super.key, this.device});
|
||||
|
||||
final Device? device;
|
||||
|
||||
bool get isEditing => device != null;
|
||||
|
||||
@override
|
||||
State<DeviceFormScreen> createState() => _DeviceFormScreenState();
|
||||
}
|
||||
|
||||
class _DeviceFormScreenState extends State<DeviceFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final _nameController = TextEditingController(text: widget.device?.name);
|
||||
late final _externalIdController = TextEditingController(text: widget.device?.externalId);
|
||||
late final _protocolController = TextEditingController(text: widget.device?.protocol ?? 'mqtt');
|
||||
|
||||
late Future<(List<Zone>, List<DeviceType>)> _optionsFuture;
|
||||
int? _zoneId;
|
||||
int? _deviceTypeId;
|
||||
|
||||
bool _isSubmitting = false;
|
||||
Map<String, List<String>> _fieldErrors = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_zoneId = widget.device?.zone?.id;
|
||||
_deviceTypeId = widget.device?.deviceType?.id;
|
||||
final apiService = context.read<ApiService>();
|
||||
_optionsFuture = (apiService.zones(), apiService.deviceTypes()).wait;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_externalIdController.dispose();
|
||||
_protocolController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
if (_zoneId == null || _deviceTypeId == null) {
|
||||
setState(() => _fieldErrors = {
|
||||
if (_zoneId == null) 'zone_id': ['Выберите зону'],
|
||||
if (_deviceTypeId == null) 'device_type_id': ['Выберите тип устройства'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
_fieldErrors = {};
|
||||
});
|
||||
|
||||
final apiService = context.read<ApiService>();
|
||||
final name = _nameController.text.trim();
|
||||
final externalId = _externalIdController.text.trim();
|
||||
final protocol = _protocolController.text.trim().isEmpty ? 'mqtt' : _protocolController.text.trim();
|
||||
|
||||
try {
|
||||
if (widget.isEditing) {
|
||||
await apiService.updateDevice(
|
||||
widget.device!.id,
|
||||
zoneId: _zoneId!,
|
||||
deviceTypeId: _deviceTypeId!,
|
||||
name: name,
|
||||
externalId: externalId,
|
||||
protocol: protocol,
|
||||
);
|
||||
} else {
|
||||
await apiService.createDevice(
|
||||
zoneId: _zoneId!,
|
||||
deviceTypeId: _deviceTypeId!,
|
||||
name: name,
|
||||
externalId: externalId,
|
||||
protocol: protocol,
|
||||
);
|
||||
}
|
||||
if (mounted) Navigator.of(context).pop(true);
|
||||
} on ApiException catch (e) {
|
||||
setState(() => _fieldErrors = e.errors ?? {});
|
||||
if (e.errors == null && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.isEditing ? 'Редактировать устройство' : 'Новое устройство')),
|
||||
body: FutureBuilder<(List<Zone>, List<DeviceType>)>(
|
||||
future: _optionsFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('${snapshot.error}'));
|
||||
}
|
||||
|
||||
final (zones, deviceTypes) = snapshot.data!;
|
||||
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: _zoneId,
|
||||
decoration: InputDecoration(labelText: 'Зона', errorText: _fieldErrors['zone_id']?.first),
|
||||
items: zones.map((z) => DropdownMenuItem(value: z.id, child: Text(z.name))).toList(),
|
||||
onChanged: (value) => setState(() => _zoneId = value),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: _deviceTypeId,
|
||||
decoration:
|
||||
InputDecoration(labelText: 'Тип устройства', errorText: _fieldErrors['device_type_id']?.first),
|
||||
items: deviceTypes
|
||||
.map((t) => DropdownMenuItem(value: t.id, child: Text('${t.code} (${t.category})')))
|
||||
.toList(),
|
||||
onChanged: (value) => setState(() => _deviceTypeId = value),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(labelText: 'Название', errorText: _fieldErrors['name']?.first),
|
||||
validator: (value) => (value == null || value.trim().isEmpty) ? 'Введите название' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _externalIdController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'External ID',
|
||||
helperText: 'Идентификатор физического устройства (ESP32 chip id и т.п.)',
|
||||
errorText: _fieldErrors['external_id']?.first,
|
||||
),
|
||||
validator: (value) => (value == null || value.trim().isEmpty) ? 'Введите external_id' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _protocolController,
|
||||
decoration: InputDecoration(labelText: 'Протокол', errorText: _fieldErrors['protocol']?.first),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _isSubmitting ? null : _submit,
|
||||
child: _isSubmitting
|
||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: Text(widget.isEditing ? 'Сохранить' : 'Создать'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../core/api_service.dart';
|
||||
import '../core/auth_service.dart';
|
||||
import '../models/device.dart';
|
||||
import '../widgets/status_badge.dart';
|
||||
import 'device_detail_screen.dart';
|
||||
import 'device_form_screen.dart';
|
||||
|
||||
class DevicesTab extends StatefulWidget {
|
||||
const DevicesTab({super.key});
|
||||
|
||||
@override
|
||||
State<DevicesTab> createState() => _DevicesTabState();
|
||||
}
|
||||
|
||||
class _DevicesTabState extends State<DevicesTab> {
|
||||
late Future<List<Device>> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = _load();
|
||||
}
|
||||
|
||||
Future<List<Device>> _load() => context.read<ApiService>().devices();
|
||||
|
||||
Future<void> _refresh() async {
|
||||
final future = _load();
|
||||
setState(() => _future = future);
|
||||
await future;
|
||||
}
|
||||
|
||||
Future<void> _openForm({Device? device}) async {
|
||||
final saved = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute(builder: (_) => DeviceFormScreen(device: device)),
|
||||
);
|
||||
if (saved == true) _refresh();
|
||||
}
|
||||
|
||||
Future<void> _delete(Device device) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Удалить устройство?'),
|
||||
content: Text('«${device.name}» будет удалено без возможности восстановления.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Отмена')),
|
||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Удалить')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
try {
|
||||
await context.read<ApiService>().deleteDevice(device.id);
|
||||
_refresh();
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isOwner = context.watch<AuthService>().user?.isOwner ?? false;
|
||||
|
||||
return Scaffold(
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: FutureBuilder<List<Device>>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return _ErrorList(message: '${snapshot.error}', onRetry: _refresh);
|
||||
}
|
||||
|
||||
final devices = snapshot.data!;
|
||||
if (devices.isEmpty) {
|
||||
return _ErrorList(message: 'Устройств пока нет.', onRetry: _refresh);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: devices.length,
|
||||
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final device = devices[index];
|
||||
return ListTile(
|
||||
title: Text(device.name),
|
||||
subtitle: Text('${device.zone?.name ?? '—'} · ${device.deviceType?.code ?? device.protocol}'),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
StatusBadge(status: device.status),
|
||||
if (isOwner)
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') _openForm(device: device);
|
||||
if (value == 'delete') _delete(device);
|
||||
},
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(value: 'edit', child: Text('Редактировать')),
|
||||
PopupMenuItem(value: 'delete', child: Text('Удалить')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => DeviceDetailScreen(deviceId: device.id)),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
floatingActionButton: isOwner
|
||||
? FloatingActionButton(onPressed: () => _openForm(), child: const Icon(Icons.add))
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorList extends StatelessWidget {
|
||||
const _ErrorList({required this.message, required this.onRetry});
|
||||
|
||||
final String message;
|
||||
final Future<void> Function() onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 64, horizontal: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(message, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(onPressed: onRetry, child: const Text('Повторить')),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../core/auth_service.dart';
|
||||
import 'automation_rules_tab.dart';
|
||||
import 'devices_tab.dart';
|
||||
import 'zones_tab.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
int _index = 0;
|
||||
|
||||
static const _titles = ['Устройства', 'Зоны', 'Правила'];
|
||||
static const _tabs = [DevicesTab(), ZonesTab(), AutomationRulesTab()];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_titles[_index]),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
tooltip: 'Выйти',
|
||||
onPressed: () => context.read<AuthService>().logout(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: IndexedStack(index: _index, children: _tabs),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _index,
|
||||
onDestinationSelected: (index) => setState(() => _index = index),
|
||||
destinations: const [
|
||||
NavigationDestination(icon: Icon(Icons.devices_other), label: 'Устройства'),
|
||||
NavigationDestination(icon: Icon(Icons.room), label: 'Зоны'),
|
||||
NavigationDestination(icon: Icon(Icons.rule), label: 'Правила'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../core/api_client.dart';
|
||||
import '../core/auth_service.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
bool _isSubmitting = false;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await context.read<AuthService>().login(_emailController.text.trim(), _passwordController.text);
|
||||
} on ApiException catch (e) {
|
||||
setState(() => _errorMessage = e.message);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Домашняя автоматизация',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
decoration: const InputDecoration(labelText: 'Email'),
|
||||
validator: (value) => (value == null || value.isEmpty) ? 'Введите email' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
decoration: const InputDecoration(labelText: 'Пароль'),
|
||||
validator: (value) => (value == null || value.isEmpty) ? 'Введите пароль' : null,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
),
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _isSubmitting ? null : _submit,
|
||||
child: _isSubmitting
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Войти'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../core/api_client.dart';
|
||||
import '../core/api_service.dart';
|
||||
import '../models/zone.dart';
|
||||
|
||||
/// Create/edit zone form. Pass an existing [zone] to edit it in place;
|
||||
/// omit it to create a new one. Pops with `true` on success so the caller
|
||||
/// knows to refresh its list.
|
||||
class ZoneFormScreen extends StatefulWidget {
|
||||
const ZoneFormScreen({super.key, this.zone});
|
||||
|
||||
final Zone? zone;
|
||||
|
||||
bool get isEditing => zone != null;
|
||||
|
||||
@override
|
||||
State<ZoneFormScreen> createState() => _ZoneFormScreenState();
|
||||
}
|
||||
|
||||
class _ZoneFormScreenState extends State<ZoneFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final _nameController = TextEditingController(text: widget.zone?.name);
|
||||
late final _descriptionController = TextEditingController(text: widget.zone?.description);
|
||||
|
||||
bool _isSubmitting = false;
|
||||
Map<String, List<String>> _fieldErrors = {};
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
_fieldErrors = {};
|
||||
});
|
||||
|
||||
final apiService = context.read<ApiService>();
|
||||
final name = _nameController.text.trim();
|
||||
final description = _descriptionController.text.trim();
|
||||
|
||||
try {
|
||||
if (widget.isEditing) {
|
||||
await apiService.updateZone(widget.zone!.id, name: name, description: description);
|
||||
} else {
|
||||
await apiService.createZone(name: name, description: description);
|
||||
}
|
||||
if (mounted) Navigator.of(context).pop(true);
|
||||
} on ApiException catch (e) {
|
||||
setState(() => _fieldErrors = e.errors ?? {});
|
||||
if (e.errors == null && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.isEditing ? 'Редактировать зону' : 'Новая зона')),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(labelText: 'Название', errorText: _fieldErrors['name']?.first),
|
||||
validator: (value) => (value == null || value.trim().isEmpty) ? 'Введите название' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
decoration: InputDecoration(labelText: 'Описание', errorText: _fieldErrors['description']?.first),
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _isSubmitting ? null : _submit,
|
||||
child: _isSubmitting
|
||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: Text(widget.isEditing ? 'Сохранить' : 'Создать'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../core/api_service.dart';
|
||||
import '../core/auth_service.dart';
|
||||
import '../models/zone.dart';
|
||||
import 'zone_form_screen.dart';
|
||||
|
||||
class ZonesTab extends StatefulWidget {
|
||||
const ZonesTab({super.key});
|
||||
|
||||
@override
|
||||
State<ZonesTab> createState() => _ZonesTabState();
|
||||
}
|
||||
|
||||
class _ZonesTabState extends State<ZonesTab> {
|
||||
late Future<List<Zone>> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = _load();
|
||||
}
|
||||
|
||||
Future<List<Zone>> _load() => context.read<ApiService>().zones();
|
||||
|
||||
Future<void> _refresh() async {
|
||||
final future = _load();
|
||||
setState(() => _future = future);
|
||||
await future;
|
||||
}
|
||||
|
||||
Future<void> _openForm({Zone? zone}) async {
|
||||
final saved = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute(builder: (_) => ZoneFormScreen(zone: zone)),
|
||||
);
|
||||
if (saved == true) _refresh();
|
||||
}
|
||||
|
||||
Future<bool> _confirmDelete(Zone zone) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Удалить зону?'),
|
||||
content: Text('«${zone.name}» будет удалена без возможности восстановления.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Отмена')),
|
||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Удалить')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return false;
|
||||
|
||||
try {
|
||||
await context.read<ApiService>().deleteZone(zone.id);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isOwner = context.watch<AuthService>().user?.isOwner ?? false;
|
||||
|
||||
return Scaffold(
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: FutureBuilder<List<Zone>>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text('${snapshot.error}', textAlign: TextAlign.center),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final zones = snapshot.data!;
|
||||
return ListView.separated(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: zones.length,
|
||||
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final zone = zones[index];
|
||||
final tile = ListTile(
|
||||
title: Text(zone.name),
|
||||
subtitle: zone.description != null ? Text(zone.description!) : null,
|
||||
trailing: Text('${zone.devicesCount ?? 0} устр.'),
|
||||
onTap: isOwner ? () => _openForm(zone: zone) : null,
|
||||
);
|
||||
|
||||
if (!isOwner) return tile;
|
||||
|
||||
return Dismissible(
|
||||
key: ValueKey(zone.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: const Icon(Icons.delete, color: Colors.white),
|
||||
),
|
||||
confirmDismiss: (_) => _confirmDelete(zone),
|
||||
child: tile,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
floatingActionButton: isOwner
|
||||
? FloatingActionButton(onPressed: () => _openForm(), child: const Icon(Icons.add))
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Small colored dot + label for a device's live status ("online" /
|
||||
/// "offline" / "unknown"), used anywhere a device is listed.
|
||||
class StatusBadge extends StatelessWidget {
|
||||
const StatusBadge({super.key, required this.status});
|
||||
|
||||
final String status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (color, label) = switch (status) {
|
||||
'online' => (Colors.green, 'онлайн'),
|
||||
'offline' => (Colors.red, 'офлайн'),
|
||||
_ => (Colors.grey, 'неизвестно'),
|
||||
};
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: TextStyle(color: color, fontSize: 12)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
jni
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import flutter_secure_storage_darwin
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||
}
|
||||
|
||||
+302
-1
@@ -1,6 +1,14 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -33,6 +41,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -41,6 +57,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -57,6 +81,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
ffi_leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi_leak_tracker
|
||||
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.2"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -70,11 +110,112 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "15e8c8fe269fdf7d469b23008ab3df521c8b826ed345820532364c31bdebace6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.0"
|
||||
flutter_secure_storage_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_darwin
|
||||
sha256: ac6d76a752de0cd738334eb4b21743fc4943f449f5b6e308f18838b048c02ac0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.0"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.2"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
jni_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_flutter
|
||||
sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
jni_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_util
|
||||
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -107,6 +248,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -131,6 +280,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: nested
|
||||
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.5.0"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -139,6 +312,94 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.6"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.2"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: provider
|
||||
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5+1"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
record_use:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_use
|
||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -192,6 +453,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -208,6 +477,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.12.2 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
flutter: ">=3.38.4"
|
||||
|
||||
@@ -34,6 +34,9 @@ dependencies:
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
http: ^1.6.0
|
||||
provider: ^6.1.5+1
|
||||
flutter_secure_storage: ^11.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
+13
-19
@@ -1,30 +1,24 @@
|
||||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:home_automation_mobile/main.dart';
|
||||
import 'package:home_automation_mobile/screens/login_screen.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const MyApp());
|
||||
testWidgets('Login screen shows email/password fields and a submit button', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: LoginScreen()));
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
expect(find.text('Email'), findsOneWidget);
|
||||
expect(find.text('Пароль'), findsOneWidget);
|
||||
expect(find.text('Войти'), findsOneWidget);
|
||||
});
|
||||
|
||||
// Tap the '+' icon and trigger a frame.
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
testWidgets('Login screen validates empty fields before submitting', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: LoginScreen()));
|
||||
|
||||
await tester.tap(find.text('Войти'));
|
||||
await tester.pump();
|
||||
|
||||
// Verify that our counter has incremented.
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
expect(find.text('Введите email'), findsOneWidget);
|
||||
expect(find.text('Введите пароль'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
jni
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
Reference in New Issue
Block a user