532 lines
20 KiB
Dart
532 lines
20 KiB
Dart
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)),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|