Add WiZ provisioning wizard
This commit is contained in:
531
lib/screens/wiz_provisioning_screen.dart
Normal file
531
lib/screens/wiz_provisioning_screen.dart
Normal 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user