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

@@ -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)),
),
],
),
);
}
}