Add WiZ provisioning wizard

This commit is contained in:
Artem Kokos
2026-05-16 17:24:28 +07:00
parent 0a635115d4
commit 866a074c03
19 changed files with 2668 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
class WizProvisioningDevice {
final String bssid;
final String? ipAddress;
const WizProvisioningDevice({required this.bssid, this.ipAddress});
}

View File

@@ -0,0 +1,134 @@
enum WizProvisioningPermissionStatus { granted, requestable, settingsRequired }
class WizProvisioningEnvironment {
final String platform;
final int? androidApiLevel;
final bool smartPairingSupported;
final bool wifiSettingsSupported;
final bool appSettingsSupported;
final WizProvisioningPermissionStatus permissionStatus;
final bool locationServicesEnabled;
final bool connectedToWifi;
final String? ssid;
final String? bssid;
final int? frequencyMhz;
const WizProvisioningEnvironment({
required this.platform,
required this.androidApiLevel,
required this.smartPairingSupported,
required this.wifiSettingsSupported,
required this.appSettingsSupported,
required this.permissionStatus,
required this.locationServicesEnabled,
required this.connectedToWifi,
required this.ssid,
required this.bssid,
required this.frequencyMhz,
});
factory WizProvisioningEnvironment.unsupported() =>
const WizProvisioningEnvironment(
platform: 'unknown',
androidApiLevel: null,
smartPairingSupported: false,
wifiSettingsSupported: false,
appSettingsSupported: false,
permissionStatus: WizProvisioningPermissionStatus.granted,
locationServicesEnabled: true,
connectedToWifi: false,
ssid: null,
bssid: null,
frequencyMhz: null,
);
factory WizProvisioningEnvironment.fromMap(Map<String, dynamic> raw) {
return WizProvisioningEnvironment(
platform: raw['platform'] as String? ?? 'unknown',
androidApiLevel: (raw['androidApiLevel'] as num?)?.toInt(),
smartPairingSupported: raw['smartPairingSupported'] == true,
wifiSettingsSupported: raw['wifiSettingsSupported'] == true,
appSettingsSupported: raw['appSettingsSupported'] == true,
permissionStatus: _permissionStatusFromPlatformValue(
raw['permissionStatus'] as String?,
),
locationServicesEnabled: raw['locationServicesEnabled'] != false,
connectedToWifi: raw['connectedToWifi'] == true,
ssid: _normalizeText(raw['ssid']),
bssid: _normalizeText(raw['bssid']),
frequencyMhz: (raw['frequencyMhz'] as num?)?.toInt(),
);
}
bool get permissionsGranted =>
permissionStatus == WizProvisioningPermissionStatus.granted;
bool get permissionRequestable =>
permissionStatus == WizProvisioningPermissionStatus.requestable;
bool get requiresAppSettings =>
permissionStatus == WizProvisioningPermissionStatus.settingsRequired;
bool get isAndroid => platform == 'android';
bool get isOn24Ghz =>
frequencyMhz != null && frequencyMhz! >= 2400 && frequencyMhz! < 2500;
bool get isLikelyOn5Ghz =>
frequencyMhz != null && frequencyMhz! >= 4900 && frequencyMhz! < 6000;
WizProvisioningEnvironment copyWith({
String? platform,
int? androidApiLevel,
bool? smartPairingSupported,
bool? wifiSettingsSupported,
bool? appSettingsSupported,
WizProvisioningPermissionStatus? permissionStatus,
bool? locationServicesEnabled,
bool? connectedToWifi,
String? ssid,
String? bssid,
int? frequencyMhz,
bool clearWifiInfo = false,
}) {
return WizProvisioningEnvironment(
platform: platform ?? this.platform,
androidApiLevel: androidApiLevel ?? this.androidApiLevel,
smartPairingSupported:
smartPairingSupported ?? this.smartPairingSupported,
wifiSettingsSupported:
wifiSettingsSupported ?? this.wifiSettingsSupported,
appSettingsSupported: appSettingsSupported ?? this.appSettingsSupported,
permissionStatus: permissionStatus ?? this.permissionStatus,
locationServicesEnabled:
locationServicesEnabled ?? this.locationServicesEnabled,
connectedToWifi: connectedToWifi ?? this.connectedToWifi,
ssid: clearWifiInfo ? null : (ssid ?? this.ssid),
bssid: clearWifiInfo ? null : (bssid ?? this.bssid),
frequencyMhz: clearWifiInfo ? null : (frequencyMhz ?? this.frequencyMhz),
);
}
static WizProvisioningPermissionStatus _permissionStatusFromPlatformValue(
String? value,
) {
switch (value) {
case 'granted':
return WizProvisioningPermissionStatus.granted;
case 'settings_required':
return WizProvisioningPermissionStatus.settingsRequired;
case 'requestable':
default:
return WizProvisioningPermissionStatus.requestable;
}
}
static String? _normalizeText(Object? raw) {
final text = raw as String?;
if (text == null) {
return null;
}
final trimmed = text.trim();
return trimmed.isEmpty ? null : trimmed;
}
}

View File

@@ -0,0 +1,23 @@
enum WizProvisioningFailureKind {
noActiveHome,
unsupportedPlatform,
missingPermissions,
locationServicesDisabled,
wifiUnavailable,
invalidSsid,
provisioningTimedOut,
provisioningFailed,
rescanFailed,
}
class WizProvisioningFailure {
final WizProvisioningFailureKind kind;
final String message;
final String? details;
const WizProvisioningFailure({
required this.kind,
required this.message,
this.details,
});
}

View File

@@ -0,0 +1,114 @@
import 'wiz_provisioning_device.dart';
import 'wiz_provisioning_environment.dart';
import 'wiz_provisioning_failure.dart';
enum WizProvisioningStatus {
initial,
loadingEnvironment,
attentionRequired,
ready,
provisioning,
rescanning,
success,
failure,
unsupported,
}
class WizRescanSummary {
final int found;
final int added;
final int updated;
final int removedOffline;
final int pendingRemoval;
final int online;
const WizRescanSummary({
required this.found,
required this.added,
required this.updated,
required this.removedOffline,
required this.pendingRemoval,
required this.online,
});
factory WizRescanSummary.fromMap(Map<String, dynamic> raw) {
return WizRescanSummary(
found: (raw['found'] as num?)?.toInt() ?? 0,
added: (raw['added'] as num?)?.toInt() ?? 0,
updated: (raw['updated'] as num?)?.toInt() ?? 0,
removedOffline: (raw['removed_offline'] as num?)?.toInt() ?? 0,
pendingRemoval: (raw['pending_removal'] as num?)?.toInt() ?? 0,
online: (raw['online'] as num?)?.toInt() ?? 0,
);
}
}
class WizProvisioningState {
final WizProvisioningStatus status;
final WizProvisioningEnvironment environment;
final String? activeHomeName;
final WizProvisioningFailure? failure;
final WizRescanSummary? rescanSummary;
final List<WizProvisioningDevice> provisionedDevices;
final List<String> timeline;
final String? notice;
const WizProvisioningState({
required this.status,
required this.environment,
required this.activeHomeName,
required this.failure,
required this.rescanSummary,
required this.provisionedDevices,
required this.timeline,
required this.notice,
});
factory WizProvisioningState.initial() => WizProvisioningState(
status: WizProvisioningStatus.initial,
environment: WizProvisioningEnvironment.unsupported(),
activeHomeName: null,
failure: null,
rescanSummary: null,
provisionedDevices: const [],
timeline: const [],
notice: null,
);
bool get isBusy =>
status == WizProvisioningStatus.loadingEnvironment ||
status == WizProvisioningStatus.provisioning ||
status == WizProvisioningStatus.rescanning;
bool get canStart =>
status == WizProvisioningStatus.ready ||
status == WizProvisioningStatus.failure ||
status == WizProvisioningStatus.attentionRequired;
WizProvisioningState copyWith({
WizProvisioningStatus? status,
WizProvisioningEnvironment? environment,
String? activeHomeName,
WizProvisioningFailure? failure,
bool clearFailure = false,
WizRescanSummary? rescanSummary,
bool clearRescanSummary = false,
List<WizProvisioningDevice>? provisionedDevices,
List<String>? timeline,
String? notice,
bool clearNotice = false,
}) {
return WizProvisioningState(
status: status ?? this.status,
environment: environment ?? this.environment,
activeHomeName: activeHomeName ?? this.activeHomeName,
failure: clearFailure ? null : (failure ?? this.failure),
rescanSummary: clearRescanSummary
? null
: (rescanSummary ?? this.rescanSummary),
provisionedDevices: provisionedDevices ?? this.provisionedDevices,
timeline: timeline ?? this.timeline,
notice: clearNotice ? null : (notice ?? this.notice),
);
}
}

View File

@@ -0,0 +1,15 @@
class WizProvisioningTiming {
final Duration provisioningTimeout;
final Duration settleAfterFirstResponse;
final Duration initialRescanDelay;
final Duration retryRescanDelay;
final int maxRescanAttempts;
const WizProvisioningTiming({
this.provisioningTimeout = const Duration(seconds: 45),
this.settleAfterFirstResponse = const Duration(seconds: 3),
this.initialRescanDelay = const Duration(seconds: 3),
this.retryRescanDelay = const Duration(seconds: 4),
this.maxRescanAttempts = 3,
});
}

View File

@@ -0,0 +1,361 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/error_message.dart';
import '../../homes/providers/homes_providers.dart';
import '../../remote/providers/remote_providers.dart';
import '../../shared/providers/core_providers.dart';
import '../models/wiz_provisioning_device.dart';
import '../models/wiz_provisioning_environment.dart';
import '../models/wiz_provisioning_failure.dart';
import '../models/wiz_provisioning_state.dart';
import '../models/wiz_provisioning_timing.dart';
import '../services/wiz_provisioning_platform_service.dart';
import '../services/wiz_smart_pairing_service.dart';
final wizProvisioningPlatformServiceProvider =
Provider<WizProvisioningPlatformService>(
(ref) => const DeviceWizProvisioningPlatformService(),
);
final wizSmartPairingServiceProvider = Provider<WizSmartPairingService>(
(ref) => EspTouchWizSmartPairingService(),
);
final wizProvisioningTimingProvider = Provider<WizProvisioningTiming>(
(ref) => const WizProvisioningTiming(),
);
final wizProvisioningProvider =
NotifierProvider<WizProvisioningNotifier, WizProvisioningState>(
WizProvisioningNotifier.new,
);
class WizProvisioningNotifier extends Notifier<WizProvisioningState> {
WizProvisioningPlatformService get _platform =>
ref.read(wizProvisioningPlatformServiceProvider);
WizSmartPairingService get _smartPairing =>
ref.read(wizSmartPairingServiceProvider);
WizProvisioningTiming get _timing => ref.read(wizProvisioningTimingProvider);
@override
WizProvisioningState build() {
final smartPairing = ref.watch(wizSmartPairingServiceProvider);
ref.onDispose(() {
unawaited(smartPairing.stopProvisioning());
});
return WizProvisioningState.initial();
}
Future<void> initialize() async {
final home = ref.read(currentHomeProvider);
state = state.copyWith(
status: WizProvisioningStatus.loadingEnvironment,
activeHomeName: home?.name,
clearFailure: true,
clearNotice: true,
);
final environment = await _platform.inspectEnvironment();
state = state.copyWith(
environment: environment,
activeHomeName: home?.name,
);
if (home == null) {
_setFailure(
const WizProvisioningFailure(
kind: WizProvisioningFailureKind.noActiveHome,
message:
'Сначала выберите активный дом. Мастер использует именно его сервер Ignis для финального discovery.',
),
environment: environment,
);
return;
}
if (!environment.smartPairingSupported || !environment.isAndroid) {
state = state.copyWith(
status: WizProvisioningStatus.unsupported,
failure: const WizProvisioningFailure(
kind: WizProvisioningFailureKind.unsupportedPlatform,
message:
'В этой сборке мастер Smart Pairing поддерживается только на Android.',
),
);
return;
}
if (!environment.permissionsGranted) {
state = state.copyWith(
status: WizProvisioningStatus.attentionRequired,
failure: const WizProvisioningFailure(
kind: WizProvisioningFailureKind.missingPermissions,
message:
'Нужны разрешения на доступ к Wi-Fi окружению, иначе приложение не сможет проверить текущую сеть.',
),
);
return;
}
if (!environment.locationServicesEnabled) {
_setFailure(
const WizProvisioningFailure(
kind: WizProvisioningFailureKind.locationServicesDisabled,
message:
'На Android системные сервисы геолокации должны быть включены, иначе SSID/BSSID домашней Wi-Fi часто недоступны.',
),
environment: environment,
);
return;
}
if (!environment.connectedToWifi || environment.ssid == null) {
_setFailure(
const WizProvisioningFailure(
kind: WizProvisioningFailureKind.wifiUnavailable,
message:
'Сначала подключите телефон к домашней Wi-Fi сети 2.4 GHz, к которой должна присоединиться лампа.',
),
environment: environment,
);
return;
}
final notice = environment.isLikelyOn5Ghz
? 'Телефон, похоже, сидит на 5 GHz. WiZ-лампы подключаются только к 2.4 GHz, поэтому лучше переключиться на нужную сеть до старта pairing.'
: null;
state = state.copyWith(
status: WizProvisioningStatus.ready,
clearFailure: true,
clearRescanSummary: true,
provisionedDevices: const [],
notice: notice,
timeline: _appendTimeline(
state.timeline,
'Окружение проверено: готово к smart pairing.',
),
);
}
Future<void> requestPermissions() async {
await _platform.requestPermissions();
await initialize();
}
Future<void> openWifiSettings() => _platform.openWifiSettings();
Future<void> openAppSettings() => _platform.openAppSettings();
Future<void> startProvisioning({
required String ssid,
required String password,
String? bssid,
}) async {
final normalizedSsid = ssid.trim();
if (normalizedSsid.isEmpty) {
_setFailure(
const WizProvisioningFailure(
kind: WizProvisioningFailureKind.invalidSsid,
message: 'Укажите SSID домашней Wi-Fi сети.',
),
);
return;
}
final home = ref.read(currentHomeProvider);
if (home == null) {
_setFailure(
const WizProvisioningFailure(
kind: WizProvisioningFailureKind.noActiveHome,
message:
'Активный дом потерялся. Вернитесь на экран домов и выберите его заново.',
),
);
return;
}
final devices = <WizProvisioningDevice>[];
StreamSubscription<WizProvisioningDevice>? subscription;
final firstResponse = Completer<void>();
state = state.copyWith(
status: WizProvisioningStatus.provisioning,
clearFailure: true,
clearNotice: true,
clearRescanSummary: true,
provisionedDevices: const [],
timeline: _appendTimeline(
state.timeline,
'Старт smart pairing для сети "$normalizedSsid".',
),
);
try {
final stream = _smartPairing.startProvisioning(
ssid: normalizedSsid,
password: password,
bssid: bssid,
);
subscription = stream.listen(
(device) {
final duplicate = devices.any((item) => item.bssid == device.bssid);
if (duplicate) {
return;
}
devices.add(device);
state = state.copyWith(
provisionedDevices: List<WizProvisioningDevice>.unmodifiable(
devices,
),
timeline: _appendTimeline(
state.timeline,
'Лампа подтвердила pairing: ${device.bssid}${device.ipAddress == null ? '' : ' (${device.ipAddress})'}.',
),
);
if (!firstResponse.isCompleted) {
firstResponse.complete();
}
},
onError: (Object error, StackTrace stackTrace) {
if (!firstResponse.isCompleted) {
firstResponse.completeError(error, stackTrace);
}
},
);
await firstResponse.future.timeout(_timing.provisioningTimeout);
await Future<void>.delayed(_timing.settleAfterFirstResponse);
await _smartPairing.stopProvisioning();
await subscription.cancel();
subscription = null;
state = state.copyWith(
status: WizProvisioningStatus.rescanning,
timeline: _appendTimeline(
state.timeline,
'Pairing подтверждён, запускаю повторный discovery на сервере Ignis.',
),
);
final summary = await _rescanUntilSettled();
final notice = (summary.added == 0 && summary.updated == 0)
? 'Лампа ответила на smart pairing, но backend не нашёл новое устройство как added/updated. Возможно, устройство уже было известно или ему нужно чуть больше времени.'
: null;
if (ref.read(groupsLoadStateProvider).status != GroupsLoadStatus.idle) {
await ref.read(groupsProvider.notifier).refresh();
}
state = state.copyWith(
status: WizProvisioningStatus.success,
rescanSummary: summary,
notice: notice,
timeline: _appendTimeline(
state.timeline,
'Discovery завершён: found=${summary.found}, added=${summary.added}, updated=${summary.updated}, online=${summary.online}.',
),
);
} on TimeoutException {
_setFailure(
const WizProvisioningFailure(
kind: WizProvisioningFailureKind.provisioningTimedOut,
message:
'Лампа не ответила вовремя. Проверьте, что она в pairing mode, телефон подключён к 2.4 GHz Wi-Fi, и попробуйте ещё раз.',
),
);
} on WizProvisioningFailure catch (failure) {
_setFailure(failure);
} catch (error) {
final message = describeLoadError(error);
_setFailure(
WizProvisioningFailure(
kind: WizProvisioningFailureKind.provisioningFailed,
message: 'Smart pairing завершился ошибкой.',
details: message,
),
);
} finally {
await subscription?.cancel();
await _smartPairing.stopProvisioning();
}
}
Future<void> cancelProvisioning({bool keepCurrentState = true}) async {
await _smartPairing.stopProvisioning();
if (!keepCurrentState) {
return;
}
final fallbackStatus =
state.environment.permissionsGranted &&
state.environment.locationServicesEnabled &&
state.environment.connectedToWifi &&
state.activeHomeName != null
? WizProvisioningStatus.ready
: WizProvisioningStatus.attentionRequired;
state = state.copyWith(
status: fallbackStatus,
notice: 'Текущая сессия pairing остановлена.',
timeline: _appendTimeline(state.timeline, 'Сессия pairing отменена.'),
);
}
Future<WizRescanSummary> _rescanUntilSettled() async {
final api = ref.read(apiProvider);
Object? lastError;
WizRescanSummary? lastSummary;
for (var attempt = 0; attempt < _timing.maxRescanAttempts; attempt += 1) {
await Future<void>.delayed(
attempt == 0 ? _timing.initialRescanDelay : _timing.retryRescanDelay,
);
try {
final response = await api.rescanNetwork();
lastSummary = WizRescanSummary.fromMap(
Map<String, dynamic>.from(response.data as Map),
);
state = state.copyWith(rescanSummary: lastSummary);
if (lastSummary.added > 0 || lastSummary.updated > 0) {
return lastSummary;
}
} catch (error) {
lastError = error;
}
}
if (lastSummary != null) {
return lastSummary;
}
throw WizProvisioningFailure(
kind: WizProvisioningFailureKind.rescanFailed,
message:
'Лампа приняла настройки, но финальный discovery на сервере Ignis не удался.',
details: lastError == null ? null : describeLoadError(lastError),
);
}
void _setFailure(
WizProvisioningFailure failure, {
WizProvisioningEnvironment? environment,
}) {
final resolvedEnvironment = environment ?? state.environment;
final status =
failure.kind == WizProvisioningFailureKind.unsupportedPlatform
? WizProvisioningStatus.unsupported
: WizProvisioningStatus.failure;
state = state.copyWith(
status: status,
environment: resolvedEnvironment,
failure: failure,
timeline: _appendTimeline(state.timeline, failure.message),
);
}
List<String> _appendTimeline(List<String> current, String event) {
return List<String>.unmodifiable(<String>[...current, event]);
}
}

View File

@@ -0,0 +1,62 @@
import 'package:flutter/services.dart';
import '../models/wiz_provisioning_environment.dart';
abstract class WizProvisioningPlatformService {
Future<WizProvisioningEnvironment> inspectEnvironment();
Future<void> requestPermissions();
Future<void> openWifiSettings();
Future<void> openAppSettings();
}
class DeviceWizProvisioningPlatformService
implements WizProvisioningPlatformService {
const DeviceWizProvisioningPlatformService();
static const _channel = MethodChannel('ignis/wiz_provisioning');
@override
Future<WizProvisioningEnvironment> inspectEnvironment() async {
try {
final raw = await _channel.invokeMapMethod<Object?, Object?>(
'getProvisioningEnvironment',
);
if (raw == null) {
return WizProvisioningEnvironment.unsupported();
}
return WizProvisioningEnvironment.fromMap(Map<String, dynamic>.from(raw));
} on MissingPluginException {
return WizProvisioningEnvironment.unsupported();
}
}
@override
Future<void> requestPermissions() async {
try {
await _channel.invokeMethod<void>('requestProvisioningPermissions');
} on MissingPluginException {
return;
}
}
@override
Future<void> openWifiSettings() async {
try {
await _channel.invokeMethod<void>('openWifiSettings');
} on MissingPluginException {
return;
}
}
@override
Future<void> openAppSettings() async {
try {
await _channel.invokeMethod<void>('openAppSettings');
} on MissingPluginException {
return;
}
}
}

View File

@@ -0,0 +1,93 @@
import 'dart:async';
import 'package:esp_smartconfig/esp_smartconfig.dart';
import '../models/wiz_provisioning_device.dart';
abstract class WizSmartPairingService {
Stream<WizProvisioningDevice> startProvisioning({
required String ssid,
required String password,
String? bssid,
});
Future<void> stopProvisioning();
}
class EspTouchWizSmartPairingService implements WizSmartPairingService {
Provisioner? _provisioner;
StreamSubscription<ProvisioningResponse>? _subscription;
StreamController<WizProvisioningDevice>? _controller;
@override
Stream<WizProvisioningDevice> startProvisioning({
required String ssid,
required String password,
String? bssid,
}) {
if (_provisioner != null || _controller != null) {
throw StateError('Provisioning is already running');
}
final provisioner = Provisioner.espTouch();
final controller = StreamController<WizProvisioningDevice>.broadcast();
_provisioner = provisioner;
_controller = controller;
_subscription = provisioner.listen(
(response) {
controller.add(
WizProvisioningDevice(
bssid: response.bssidText,
ipAddress: response.ipAddressText,
),
);
},
onError: controller.addError,
onDone: () async {
if (!controller.isClosed) {
await controller.close();
}
},
);
final request = ProvisioningRequest.fromStrings(
ssid: ssid,
bssid: (bssid == null || bssid.trim().isEmpty)
? '00:00:00:00:00:00'
: bssid.trim(),
password: password.isEmpty ? null : password,
);
unawaited(
provisioner.start(request).catchError((
Object error,
StackTrace stack,
) async {
if (!controller.isClosed) {
controller.addError(error, stack);
await controller.close();
}
}),
);
return controller.stream;
}
@override
Future<void> stopProvisioning() async {
final provisioner = _provisioner;
_provisioner = null;
try {
provisioner?.stop();
} finally {
await _subscription?.cancel();
_subscription = null;
if (_controller != null && !_controller!.isClosed) {
await _controller!.close();
}
_controller = null;
}
}
}

View File

@@ -2,6 +2,7 @@ export '../features/api_keys/providers/api_keys_providers.dart';
export '../features/auth/providers/auth_providers.dart';
export '../features/homes/providers/homes_providers.dart';
export '../features/homes/providers/location_providers.dart';
export '../features/provisioning/providers/wiz_provisioning_providers.dart';
export '../features/remote/providers/remote_providers.dart';
export '../features/schedules/providers/tasks_providers.dart';
export '../features/shared/providers/core_providers.dart';

View File

@@ -13,6 +13,7 @@ import '../models/home_config.dart';
import '../providers/providers.dart';
import 'home_edit_screen.dart';
import 'homes_screen.dart';
import 'wiz_provisioning_screen.dart';
enum SettingsEntryPoint { homes, remote }
@@ -90,6 +91,15 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen>
spacing: 8,
runSpacing: 8,
children: [
FilledButton.icon(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const WizProvisioningScreen(),
),
),
icon: const Icon(Icons.lightbulb_outline),
label: const Text('Подключить WiZ-лампу'),
),
FilledButton.tonalIcon(
onPressed: () => _openHomeEditor(context, currentHome),
icon: const Icon(Icons.edit_location_alt_outlined),

View File

@@ -0,0 +1,531 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../features/provisioning/models/wiz_provisioning_environment.dart';
import '../features/provisioning/models/wiz_provisioning_state.dart';
import '../features/provisioning/providers/wiz_provisioning_providers.dart';
class WizProvisioningScreen extends ConsumerStatefulWidget {
const WizProvisioningScreen({super.key});
@override
ConsumerState<WizProvisioningScreen> createState() =>
_WizProvisioningScreenState();
}
class _WizProvisioningScreenState extends ConsumerState<WizProvisioningScreen>
with WidgetsBindingObserver {
final _formKey = GlobalKey<FormState>();
final _ssidCtrl = TextEditingController();
final _bssidCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
bool _ssidTouched = false;
bool _bssidTouched = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
Future<void>.microtask(
() => ref.read(wizProvisioningProvider.notifier).initialize(),
);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
ref
.read(wizProvisioningProvider.notifier)
.cancelProvisioning(keepCurrentState: false);
ref.invalidate(wizProvisioningProvider);
_ssidCtrl.dispose();
_bssidCtrl.dispose();
_passwordCtrl.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
Future<void>.microtask(
() => ref.read(wizProvisioningProvider.notifier).initialize(),
);
}
}
@override
Widget build(BuildContext context) {
final provisioningState = ref.watch(wizProvisioningProvider);
_syncControllers(provisioningState.environment);
final bottomInset = MediaQuery.paddingOf(context).bottom;
final environment = provisioningState.environment;
final failure = provisioningState.failure;
final canRequestPermissions =
!environment.permissionsGranted && environment.permissionRequestable;
final canOpenAppSettings =
environment.requiresAppSettings && environment.appSettingsSupported;
final needsWifiSettings =
!environment.connectedToWifi && environment.wifiSettingsSupported;
return Scaffold(
appBar: AppBar(title: const Text('ПОДКЛЮЧЕНИЕ WIZ')),
body: SafeArea(
top: false,
bottom: true,
child: RefreshIndicator(
color: Colors.deepOrange,
onRefresh: () =>
ref.read(wizProvisioningProvider.notifier).initialize(),
child: ListView(
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomInset + 24),
children: [
_SectionCard(
title: 'Что делает мастер',
child: const Text(
'Эта версия использует smart pairing: телефон остаётся в домашней Wi-Fi сети и передаёт её настройки новой лампе. Это Android-only поток и он лучше всего работает, когда телефон уже сидит на 2.4 GHz.',
style: TextStyle(color: Colors.white70),
),
),
_SectionCard(
title: 'Активный дом',
child: Text(
provisioningState.activeHomeName == null
? 'Не выбран'
: provisioningState.activeHomeName!,
style: TextStyle(
color: provisioningState.activeHomeName == null
? Colors.redAccent
: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
_SectionCard(
title: 'Окружение',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_InfoRow(
label: 'Платформа',
value: environment.isAndroid
? 'Android ${environment.androidApiLevel ?? '?'}'
: environment.platform,
),
_InfoRow(
label: 'Разрешения',
value: _permissionStatusLabel(
environment.permissionStatus,
),
),
_InfoRow(
label: 'Wi-Fi',
value: environment.connectedToWifi
? (environment.ssid ?? 'Подключено')
: 'Нет подключения',
),
_InfoRow(
label: 'BSSID',
value: environment.bssid ?? 'Не удалось определить',
),
_InfoRow(
label: 'Диапазон',
value: environment.frequencyMhz == null
? 'Неизвестно'
: '${environment.frequencyMhz} MHz',
),
if (environment.isLikelyOn5Ghz)
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text(
'Сейчас похоже активен 5 GHz. Для WiZ лучше заранее переключиться на 2.4 GHz.',
style: TextStyle(color: Colors.amberAccent),
),
),
if (!environment.locationServicesEnabled)
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text(
'На Android системная геолокация должна быть включена, иначе SSID/BSSID часто скрываются системой.',
style: TextStyle(color: Colors.amberAccent),
),
),
],
),
),
if (failure != null)
_SectionCard(
title: 'Проблема',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
failure.message,
style: const TextStyle(color: Colors.redAccent),
),
if (failure.details != null) ...[
const SizedBox(height: 8),
Text(
failure.details!,
style: const TextStyle(color: Colors.white54),
),
],
],
),
),
if (provisioningState.notice != null)
_SectionCard(
title: 'Примечание',
child: Text(
provisioningState.notice!,
style: const TextStyle(color: Colors.white70),
),
),
_SectionCard(
title: 'Шаги перед стартом',
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'1. Убедитесь, что телефон подключён к домашней 2.4 GHz Wi-Fi.',
style: TextStyle(color: Colors.white70),
),
SizedBox(height: 6),
Text(
'2. Переведите лампу в pairing mode: если нужно, несколько раз выключите и включите питание до пульсации.',
style: TextStyle(color: Colors.white70),
),
SizedBox(height: 6),
Text(
'3. Держите телефон рядом с лампой и не сворачивайте приложение до конца pairing.',
style: TextStyle(color: Colors.white70),
),
],
),
),
_SectionCard(
title: 'Домашняя Wi-Fi',
child: Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
children: [
TextFormField(
controller: _ssidCtrl,
decoration: const InputDecoration(
labelText: 'SSID',
hintText: 'Например: Home-2G',
prefixIcon: Icon(Icons.wifi),
),
onChanged: (_) => _ssidTouched = true,
validator: (value) {
if ((value?.trim().isEmpty ?? true)) {
return 'Укажите SSID';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _bssidCtrl,
decoration: const InputDecoration(
labelText: 'BSSID (опционально)',
hintText: 'aa:bb:cc:dd:ee:ff',
prefixIcon: Icon(Icons.router_outlined),
),
onChanged: (_) => _bssidTouched = true,
),
const SizedBox(height: 12),
TextFormField(
controller: _passwordCtrl,
decoration: const InputDecoration(
labelText: 'Пароль Wi-Fi',
hintText: 'Оставьте пустым для открытой сети',
prefixIcon: Icon(Icons.key),
),
obscureText: true,
),
],
),
),
),
_ActionSection(
state: provisioningState,
canRequestPermissions: canRequestPermissions,
canOpenAppSettings: canOpenAppSettings,
needsWifiSettings: needsWifiSettings,
onRequestPermissions: () => ref
.read(wizProvisioningProvider.notifier)
.requestPermissions(),
onOpenAppSettings: () => ref
.read(wizProvisioningProvider.notifier)
.openAppSettings(),
onOpenWifiSettings: () => ref
.read(wizProvisioningProvider.notifier)
.openWifiSettings(),
onRefresh: () =>
ref.read(wizProvisioningProvider.notifier).initialize(),
onStart: _startProvisioning,
onCancel: () => ref
.read(wizProvisioningProvider.notifier)
.cancelProvisioning(),
),
if (provisioningState.provisionedDevices.isNotEmpty)
_SectionCard(
title: 'Ответившие устройства',
child: Column(
children: [
for (final device in provisioningState.provisionedDevices)
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(
Icons.lightbulb_outline,
color: Colors.deepOrange,
),
title: Text(device.bssid),
subtitle: device.ipAddress == null
? null
: Text(device.ipAddress!),
),
],
),
),
if (provisioningState.rescanSummary != null)
_SectionCard(
title: 'Результат discovery',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_InfoRow(
label: 'Найдено',
value: '${provisioningState.rescanSummary!.found}',
),
_InfoRow(
label: 'Добавлено',
value: '${provisioningState.rescanSummary!.added}',
),
_InfoRow(
label: 'Обновлено',
value: '${provisioningState.rescanSummary!.updated}',
),
_InfoRow(
label: 'Онлайн',
value: '${provisioningState.rescanSummary!.online}',
),
],
),
),
if (provisioningState.timeline.isNotEmpty)
_SectionCard(
title: 'Ход выполнения',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final event in provisioningState.timeline)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'$event',
style: const TextStyle(color: Colors.white60),
),
),
],
),
),
],
),
),
),
);
}
void _syncControllers(WizProvisioningEnvironment environment) {
if (!_ssidTouched &&
environment.ssid != null &&
environment.ssid != _ssidCtrl.text) {
_ssidCtrl.text = environment.ssid!;
}
if (!_bssidTouched &&
environment.bssid != null &&
environment.bssid != _bssidCtrl.text) {
_bssidCtrl.text = environment.bssid!;
}
}
Future<void> _startProvisioning() async {
if (!_formKey.currentState!.validate()) {
return;
}
await ref
.read(wizProvisioningProvider.notifier)
.startProvisioning(
ssid: _ssidCtrl.text,
password: _passwordCtrl.text,
bssid: _bssidCtrl.text.trim().isEmpty ? null : _bssidCtrl.text,
);
}
String _permissionStatusLabel(WizProvisioningPermissionStatus status) {
switch (status) {
case WizProvisioningPermissionStatus.granted:
return 'Выданы';
case WizProvisioningPermissionStatus.requestable:
return 'Нужно запросить';
case WizProvisioningPermissionStatus.settingsRequired:
return 'Нужно открыть настройки приложения';
}
}
}
class _ActionSection extends StatelessWidget {
final WizProvisioningState state;
final bool canRequestPermissions;
final bool canOpenAppSettings;
final bool needsWifiSettings;
final VoidCallback onRequestPermissions;
final VoidCallback onOpenAppSettings;
final VoidCallback onOpenWifiSettings;
final VoidCallback onRefresh;
final VoidCallback onStart;
final VoidCallback onCancel;
const _ActionSection({
required this.state,
required this.canRequestPermissions,
required this.canOpenAppSettings,
required this.needsWifiSettings,
required this.onRequestPermissions,
required this.onOpenAppSettings,
required this.onOpenWifiSettings,
required this.onRefresh,
required this.onStart,
required this.onCancel,
});
@override
Widget build(BuildContext context) {
final canStartProvisioning =
!state.isBusy &&
state.activeHomeName != null &&
state.environment.permissionsGranted &&
state.environment.locationServicesEnabled &&
state.environment.connectedToWifi &&
state.status != WizProvisioningStatus.unsupported;
return _SectionCard(
title: 'Действия',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (state.isBusy)
const Padding(
padding: EdgeInsets.only(bottom: 12),
child: LinearProgressIndicator(color: Colors.deepOrange),
),
FilledButton.icon(
onPressed: canStartProvisioning ? onStart : null,
icon: const Icon(Icons.flash_on),
label: Text(
state.status == WizProvisioningStatus.success
? 'Повторить pairing'
: 'Запустить smart pairing',
),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: state.isBusy ? onCancel : onRefresh,
icon: Icon(
state.isBusy ? Icons.stop_circle_outlined : Icons.refresh,
),
label: Text(state.isBusy ? 'Остановить' : 'Переобновить окружение'),
),
if (canRequestPermissions) ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: state.isBusy ? null : onRequestPermissions,
icon: const Icon(Icons.privacy_tip_outlined),
label: const Text('Выдать разрешения'),
),
],
if (canOpenAppSettings) ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: state.isBusy ? null : onOpenAppSettings,
icon: const Icon(Icons.settings_applications_outlined),
label: const Text('Открыть настройки приложения'),
),
],
if (needsWifiSettings) ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: state.isBusy ? null : onOpenWifiSettings,
icon: const Icon(Icons.wifi_find_outlined),
label: const Text('Открыть настройки Wi-Fi'),
),
],
],
),
);
}
}
class _SectionCard extends StatelessWidget {
final String title;
final Widget child;
const _SectionCard({required this.title, required this.child});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 12),
child,
],
),
),
);
}
}
class _InfoRow extends StatelessWidget {
final String label;
final String value;
const _InfoRow({required this.label, required this.value});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 112,
child: Text(label, style: const TextStyle(color: Colors.white38)),
),
Expanded(
child: Text(value, style: const TextStyle(color: Colors.white70)),
),
],
),
);
}
}