diff --git a/lib/core/api_client.dart b/lib/core/api_client.dart new file mode 100644 index 0000000..859abfd --- /dev/null +++ b/lib/core/api_client.dart @@ -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>? 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 get _headers => { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + if (_token != null) 'Authorization': 'Bearer $_token', + }; + + Future get(String path) async { + return _handle(await http.get(_uri(path), headers: _headers)); + } + + Future post(String path, [Map? body]) async { + return _handle(await http.post( + _uri(path), + headers: _headers, + body: body == null ? null : jsonEncode(body), + )); + } + + Future put(String path, [Map? body]) async { + return _handle(await http.put( + _uri(path), + headers: _headers, + body: body == null ? null : jsonEncode(body), + )); + } + + Future 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>? errors; + if (decoded is Map && decoded['errors'] is Map) { + errors = (decoded['errors'] as Map).map( + (key, value) => MapEntry(key as String, List.from(value as List)), + ); + } + + throw ApiException(response.statusCode, message, errors: errors); + } +} diff --git a/lib/core/api_config.dart b/lib/core/api_config.dart new file mode 100644 index 0000000..6890897 --- /dev/null +++ b/lib/core/api_config.dart @@ -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'; +} diff --git a/lib/core/api_service.dart b/lib/core/api_service.dart new file mode 100644 index 0000000..2664ce3 --- /dev/null +++ b/lib/core/api_service.dart @@ -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> zones() async { + final json = await _client.get('/zones'); + return _list(json).map(Zone.fromJson).toList(); + } + + Future 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); + } + + Future 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); + } + + Future deleteZone(int id) => _client.delete('/zones/$id'); + + // --- Device types --- + + Future> deviceTypes() async { + final json = await _client.get('/device-types'); + return _list(json).map(DeviceType.fromJson).toList(); + } + + // --- Devices --- + + Future> devices() async { + final json = await _client.get('/devices'); + return _list(json).map(Device.fromJson).toList(); + } + + Future device(int id) async { + final json = await _client.get('/devices/$id'); + return DeviceDetail.fromJson(json as Map); + } + + Future 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); + } + + Future 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); + } + + Future deleteDevice(int id) => _client.delete('/devices/$id'); + + Future turnOn(int deviceId) => _commandMessage(_client.post('/devices/$deviceId/turn-on')); + + Future turnOff(int deviceId) => _commandMessage(_client.post('/devices/$deviceId/turn-off')); + + Future setLevel(int deviceId, double level) => + _commandMessage(_client.post('/devices/$deviceId/set-level', {'level': level})); + + Future _commandMessage(Future request) async { + final json = await request; + return (json as Map)['message'] as String? ?? 'Готово.'; + } + + // --- Automation rules --- + + Future> automationRules() async { + final json = await _client.get('/automation-rules'); + return _list(json).map(AutomationRule.fromJson).toList(); + } + + Future createAutomationRule(Map payload) async { + final json = await _client.post('/automation-rules', payload); + return AutomationRule.fromJson(json['data'] as Map); + } + + Future updateAutomationRule(int id, Map payload) async { + final json = await _client.put('/automation-rules/$id', payload); + return AutomationRule.fromJson(json['data'] as Map); + } + + Future deleteAutomationRule(int id) => _client.delete('/automation-rules/$id'); + + List> _list(dynamic json) => + List>.from((json as Map)['data'] as List); +} diff --git a/lib/core/auth_service.dart b/lib/core/auth_service.dart new file mode 100644 index 0000000..2b757cf --- /dev/null +++ b/lib/core/auth_service.dart @@ -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 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); + } on ApiException { + await _storage.delete(key: _tokenKey); + _apiClient.setToken(null); + } + + _isLoading = false; + notifyListeners(); + } + + Future 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); + notifyListeners(); + } + + Future 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; + } +} diff --git a/lib/main.dart b/lib/main.dart index 244a702..4fc2b89 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - 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.value(value: apiClient), + Provider(create: (_) => ApiService(apiClient)), + ChangeNotifierProvider( + 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(); + + if (auth.isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + return auth.isAuthenticated ? const HomeScreen() : const LoginScreen(); + } +} diff --git a/lib/models/automation_rule.dart b/lib/models/automation_rule.dart new file mode 100644 index 0000000..0787df0 --- /dev/null +++ b/lib/models/automation_rule.dart @@ -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 actionParams; + final bool isActive; + + /// `action_params.level` for `set_level` rules, if present. + num? get level => actionParams['level'] as num?; + + factory AutomationRule.fromJson(Map json) => AutomationRule( + id: json['id'] as int, + zone: json['zone'] is Map ? Zone.fromJson(json['zone'] as Map) : null, + targetDevice: + json['target_device'] is Map ? Device.fromJson(json['target_device'] as Map) : null, + conditionSourceDevice: json['condition_source_device'] is Map + ? Device.fromJson(json['condition_source_device'] as Map) + : 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.from(json['action_params'] as Map? ?? const {}), + isActive: json['is_active'] as bool? ?? false, + ); +} diff --git a/lib/models/device.dart b/lib/models/device.dart new file mode 100644 index 0000000..709b865 --- /dev/null +++ b/lib/models/device.dart @@ -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 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) : null, + deviceType: json['device_type'] is Map + ? DeviceType.fromJson(json['device_type'] as Map) + : 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 desiredState; + final Map reportedState; + final List telemetry; + + factory DeviceDetail.fromJson(Map json) { + final shadow = json['shadow'] as Map; + + return DeviceDetail( + device: Device.fromJson(json['data'] as Map), + lastSeen: shadow['last_seen'] != null ? DateTime.tryParse(shadow['last_seen'] as String) : null, + desiredState: Map.from(shadow['desired_state'] as Map? ?? const {}), + reportedState: Map.from(shadow['reported_state'] as Map? ?? const {}), + telemetry: (json['telemetry'] as List? ?? const []) + .map((e) => TelemetryReading.fromJson(e as Map)) + .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 json) => TelemetryReading( + sensorType: json['sensor_type'] as String, + value: (json['value'] as num).toDouble(), + recordedAt: json['recorded_at'] as String, + ); +} diff --git a/lib/models/device_type.dart b/lib/models/device_type.dart new file mode 100644 index 0000000..13caee1 --- /dev/null +++ b/lib/models/device_type.dart @@ -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 capabilities; + + bool get isSensor => category == 'sensor'; + bool get isActuator => category == 'actuator'; + + bool supports(String capability) => capabilities.contains(capability); + + factory DeviceType.fromJson(Map json) => DeviceType( + id: json['id'] as int, + code: json['code'] as String, + category: json['category'] as String, + capabilities: List.from(json['capabilities'] as List? ?? const []), + ); +} diff --git a/lib/models/user.dart b/lib/models/user.dart new file mode 100644 index 0000000..61b2f49 --- /dev/null +++ b/lib/models/user.dart @@ -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 json) => User( + id: json['id'] as int, + name: json['name'] as String, + email: json['email'] as String, + role: json['role'] as String, + ); +} diff --git a/lib/models/zone.dart b/lib/models/zone.dart new file mode 100644 index 0000000..5c24b35 --- /dev/null +++ b/lib/models/zone.dart @@ -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 json) => Zone( + id: json['id'] as int, + name: json['name'] as String, + description: json['description'] as String?, + devicesCount: json['devices_count'] as int?, + ); +} diff --git a/lib/screens/automation_rules_tab.dart b/lib/screens/automation_rules_tab.dart new file mode 100644 index 0000000..5ffbf9f --- /dev/null +++ b/lib/screens/automation_rules_tab.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../core/api_service.dart'; +import '../models/automation_rule.dart'; + +class AutomationRulesTab extends StatefulWidget { + const AutomationRulesTab({super.key}); + + @override + State createState() => _AutomationRulesTabState(); +} + +class _AutomationRulesTabState extends State { + late Future> _future; + + @override + void initState() { + super.initState(); + _future = _load(); + } + + Future> _load() => context.read().automationRules(); + + Future _refresh() async { + final future = _load(); + setState(() => _future = future); + await future; + } + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + onRefresh: _refresh, + child: FutureBuilder>( + 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 ?? '?'}'; + + return ListTile( + leading: Icon(rule.isActive ? Icons.bolt : Icons.bolt_outlined), + title: Text('ЕСЛИ $condition'), + subtitle: Text('ТО $action'), + isThreeLine: true, + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/screens/device_detail_screen.dart b/lib/screens/device_detail_screen.dart new file mode 100644 index 0000000..ed42aab --- /dev/null +++ b/lib/screens/device_detail_screen.dart @@ -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 createState() => _DeviceDetailScreenState(); +} + +class _DeviceDetailScreenState extends State { + late Future _future; + bool _isSendingCommand = false; + + @override + void initState() { + super.initState(); + _future = _load(); + } + + Future _load() => context.read().device(widget.deviceId); + + Future _refresh() async { + final future = _load(); + setState(() => _future = future); + await future; + } + + Future _runCommand(Future 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 _promptSetLevel(int deviceId) async { + final apiService = context.read(); + double level = 50; + + final confirmed = await showDialog( + 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(); + + return Scaffold( + appBar: AppBar(title: const Text('Устройство')), + body: RefreshIndicator( + onRefresh: _refresh, + child: FutureBuilder( + 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 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(), + ); + } +} diff --git a/lib/screens/devices_tab.dart b/lib/screens/devices_tab.dart new file mode 100644 index 0000000..545ecb6 --- /dev/null +++ b/lib/screens/devices_tab.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../core/api_service.dart'; +import '../models/device.dart'; +import '../widgets/status_badge.dart'; +import 'device_detail_screen.dart'; + +class DevicesTab extends StatefulWidget { + const DevicesTab({super.key}); + + @override + State createState() => _DevicesTabState(); +} + +class _DevicesTabState extends State { + late Future> _future; + + @override + void initState() { + super.initState(); + _future = _load(); + } + + Future> _load() => context.read().devices(); + + Future _refresh() async { + final future = _load(); + setState(() => _future = future); + await future; + } + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + onRefresh: _refresh, + child: FutureBuilder>( + 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: StatusBadge(status: device.status), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => DeviceDetailScreen(deviceId: device.id)), + ), + ); + }, + ); + }, + ), + ); + } +} + +class _ErrorList extends StatelessWidget { + const _ErrorList({required this.message, required this.onRetry}); + + final String message; + final Future 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('Повторить')), + ], + ), + ), + ], + ); + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart new file mode 100644 index 0000000..c2d1b67 --- /dev/null +++ b/lib/screens/home_screen.dart @@ -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 createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + 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().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: 'Правила'), + ], + ), + ); + } +} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart new file mode 100644 index 0000000..caac29c --- /dev/null +++ b/lib/screens/login_screen.dart @@ -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 createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + + bool _isSubmitting = false; + String? _errorMessage; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + + setState(() { + _isSubmitting = true; + _errorMessage = null; + }); + + try { + await context.read().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('Войти'), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/zones_tab.dart b/lib/screens/zones_tab.dart new file mode 100644 index 0000000..75f803b --- /dev/null +++ b/lib/screens/zones_tab.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../core/api_service.dart'; +import '../models/zone.dart'; + +class ZonesTab extends StatefulWidget { + const ZonesTab({super.key}); + + @override + State createState() => _ZonesTabState(); +} + +class _ZonesTabState extends State { + late Future> _future; + + @override + void initState() { + super.initState(); + _future = _load(); + } + + Future> _load() => context.read().zones(); + + Future _refresh() async { + final future = _load(); + setState(() => _future = future); + await future; + } + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + onRefresh: _refresh, + child: FutureBuilder>( + 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]; + return ListTile( + title: Text(zone.name), + subtitle: zone.description != null ? Text(zone.description!) : null, + trailing: Text('${zone.devicesCount ?? 0} устр.'), + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/widgets/status_badge.dart b/lib/widgets/status_badge.dart new file mode 100644 index 0000000..9cc7797 --- /dev/null +++ b/lib/widgets/status_badge.dart @@ -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)), + ], + ); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..d0e7f79 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include 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); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..ce58916 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,9 +3,11 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..67d652c 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import flutter_secure_storage_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 582d01a..f21ca4a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml index b129c3d..34ca3a9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/test/widget_test.dart b/test/widget_test.dart index b909fa2..7f026eb 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -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); }); } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8b6d468..0c50753 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b93c4c3..d0b33f8 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,9 +3,11 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES)