This commit is contained in:
kleponijo 2026-04-29 16:35:18 +07:00
parent efbbf8bb21
commit de534b0277
8 changed files with 749 additions and 2 deletions

3
devtools_options.yaml Normal file
View File

@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@ -0,0 +1,141 @@
import 'package:bloc/bloc.dart';
import 'package:dio/dio.dart';
part 'device_setup_event.dart';
part 'device_setup_state.dart';
class DeviceSetupBloc extends Bloc<DeviceSetupEvent, DeviceSetupState> {
DeviceSetupBloc() : super(const DeviceSetupState()) {
on<CheckEspConnectionEvent>(_onCheckConnection);
on<SendWifiCredentialsEvent>(_onSendCredentials);
on<ResetDeviceSetupEvent>(_onReset);
}
// Dio instance khusus untuk komunikasi ke ESP
// Timeout pendek karena ESP di jaringan lokal, harusnya cepat.
// Kalau timeout berarti HP belum konek ke hotspot ESP.
final Dio _dio = Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 10),
sendTimeout: const Duration(seconds: 5),
),
);
// Cek koneksi ke ESP
Future<void> _onCheckConnection(
CheckEspConnectionEvent event,
Emitter<DeviceSetupState> emit,
) async {
emit(state.copyWith(status: DeviceSetupStatus.checkingConn));
try {
// Ping endpoint root ESP kalau respond berarti HP sudah
// terhubung ke hotspot ESP (192.168.4.1)
final response = await _dio.get(
'http://${state.espIp}/',
options: Options(
// Terima semua status code, yang penting server respond
validateStatus: (_) => true,
),
);
if (response.statusCode != null) {
emit(state.copyWith(status: DeviceSetupStatus.connected));
} else {
emit(state.copyWith(
status: DeviceSetupStatus.notConnected,
errorMessage:
'ESP tidak merespons. Pastikan HP sudah terhubung ke hotspot "Anemometer-Setup".',
));
}
} on DioException catch (e) {
String msg = 'Tidak bisa terhubung ke ESP.';
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout ||
e.type == DioExceptionType.sendTimeout) {
msg =
'Koneksi timeout. Pastikan HP terhubung ke hotspot "Anemometer-Setup" dan coba lagi.';
} else if (e.type == DioExceptionType.connectionError) {
msg =
'Tidak ada koneksi ke ESP. Sambungkan HP ke hotspot "Anemometer-Setup" terlebih dahulu.';
}
emit(state.copyWith(
status: DeviceSetupStatus.notConnected,
errorMessage: msg,
));
}
}
// Kirim SSID + Password ke ESP
Future<void> _onSendCredentials(
SendWifiCredentialsEvent event,
Emitter<DeviceSetupState> emit,
) async {
if (event.ssid.trim().isEmpty) {
emit(state.copyWith(
status: DeviceSetupStatus.failure,
errorMessage: 'SSID tidak boleh kosong.',
));
return;
}
emit(state.copyWith(status: DeviceSetupStatus.sending));
try {
// Kirim sebagai form-urlencoded sama persis dengan
// yang diterima WebServer ESP di route POST /save
final response = await _dio.post(
'http://${state.espIp}/save',
data: {
'ssid': event.ssid.trim(),
'pass': event.password,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
validateStatus: (status) => status != null && status < 500,
),
);
if (response.statusCode == 200) {
emit(state.copyWith(
status: DeviceSetupStatus.success,
successMessage:
'Berhasil! ESP menyimpan WiFi "${event.ssid}" dan akan restart otomatis.\n\n'
'Tunggu beberapa detik, lalu sambungkan HP kembali ke WiFi normal.',
));
} else {
emit(state.copyWith(
status: DeviceSetupStatus.failure,
errorMessage:
'ESP menolak permintaan (${response.statusCode}): ${response.data}',
));
}
} on DioException catch (e) {
// ESP restart setelah terima kredensial koneksi putus itu normal!
// DioException di sini kemungkinan besar karena ESP sudah restart.
if (e.type == DioExceptionType.receiveTimeout ||
e.type == DioExceptionType.connectionError) {
emit(state.copyWith(
status: DeviceSetupStatus.success,
successMessage:
'ESP menerima konfigurasi WiFi dan sedang restart.\n\n'
'Sambungkan HP kembali ke WiFi normal dalam beberapa detik.',
));
} else {
emit(state.copyWith(
status: DeviceSetupStatus.failure,
errorMessage: 'Gagal mengirim ke ESP: ${e.message}',
));
}
}
}
// Reset state
void _onReset(
ResetDeviceSetupEvent event,
Emitter<DeviceSetupState> emit,
) {
emit(const DeviceSetupState());
}
}

View File

@ -0,0 +1,17 @@
part of 'device_setup_bloc.dart';
abstract class DeviceSetupEvent {}
/// Cek apakah HP sedang terhubung ke hotspot ESP
class CheckEspConnectionEvent extends DeviceSetupEvent {}
/// Kirim SSID + password baru ke ESP via HTTP
class SendWifiCredentialsEvent extends DeviceSetupEvent {
final String ssid;
final String password;
SendWifiCredentialsEvent({required this.ssid, required this.password});
}
/// Reset state ke awal (misalnya saat user keluar screen)
class ResetDeviceSetupEvent extends DeviceSetupEvent {}

View File

@ -0,0 +1,39 @@
part of 'device_setup_bloc.dart';
enum DeviceSetupStatus {
idle, // belum ada aksi
checkingConn, // sedang cek koneksi ke ESP
notConnected, // HP belum konek ke hotspot ESP
connected, // HP sudah konek ke hotspot ESP, siap kirim
sending, // sedang kirim SSID+password ke ESP
success, // ESP berhasil terima & akan restart
failure, // gagal (timeout, ESP tidak respond, dll)
}
class DeviceSetupState {
final DeviceSetupStatus status;
final String? errorMessage;
final String? successMessage;
final String espIp;
const DeviceSetupState({
this.status = DeviceSetupStatus.idle,
this.errorMessage,
this.successMessage,
this.espIp = '192.168.4.1', // default IP ESP saat AP mode
});
DeviceSetupState copyWith({
DeviceSetupStatus? status,
String? errorMessage,
String? successMessage,
String? espIp,
}) {
return DeviceSetupState(
status: status ?? this.status,
errorMessage: errorMessage,
successMessage: successMessage,
espIp: espIp ?? this.espIp,
);
}
}

View File

@ -0,0 +1,507 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/device_setup_bloc.dart';
class DeviceSetupScreen extends StatefulWidget {
const DeviceSetupScreen({super.key});
@override
State<DeviceSetupScreen> createState() => _DeviceSetupScreenState();
}
class _DeviceSetupScreenState extends State<DeviceSetupScreen> {
final _ssidController = TextEditingController();
final _passwordController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _obscurePassword = true;
@override
void dispose() {
_ssidController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => DeviceSetupBloc(),
child: Scaffold(
appBar: AppBar(
title: const Text('Setup WiFi Anemometer'),
centerTitle: true,
elevation: 0,
),
body: BlocConsumer<DeviceSetupBloc, DeviceSetupState>(
listener: (context, state) {
if (state.status == DeviceSetupStatus.success) {
_showResultDialog(context,
success: true, message: state.successMessage ?? '');
} else if (state.status == DeviceSetupStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage ?? 'Terjadi kesalahan.'),
backgroundColor: Colors.red.shade700,
behavior: SnackBarBehavior.floating,
),
);
}
},
builder: (context, state) {
return SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Info Banner
_InfoBanner(status: state.status),
const SizedBox(height: 24),
// Step 1: Panduan konek hotspot
_StepCard(
step: 1,
title: 'Nyalakan Perangkat Anemometer',
description:
'Pastikan ESP32 anemometer sudah menyala dan belum pernah '
'dikonfigurasi WiFi-nya. LED akan berkedip menandakan '
'mode hotspot aktif.',
isDone: state.status == DeviceSetupStatus.connected ||
state.status == DeviceSetupStatus.sending ||
state.status == DeviceSetupStatus.success,
),
const SizedBox(height: 12),
_StepCard(
step: 2,
title: 'Sambungkan HP ke Hotspot ESP',
description:
'Buka Pengaturan WiFi di HP kamu, lalu sambungkan ke:\n\n'
'📶 Anemometer-Setup\n\n'
'Hotspot ini tidak punya password. Setelah tersambung, '
'kembali ke aplikasi ini.',
isDone: state.status == DeviceSetupStatus.connected ||
state.status == DeviceSetupStatus.sending ||
state.status == DeviceSetupStatus.success,
trailing: _OpenWifiSettingsButton(status: state.status),
),
const SizedBox(height: 12),
// Step 3: Cek koneksi
_StepCard(
step: 3,
title: 'Verifikasi Koneksi ke ESP',
description: state.status == DeviceSetupStatus.notConnected
? state.errorMessage ?? 'Belum terhubung ke ESP.'
: state.status == DeviceSetupStatus.connected ||
state.status == DeviceSetupStatus.sending ||
state.status == DeviceSetupStatus.success
? '✅ HP sudah terhubung ke ESP. Lanjutkan ke langkah berikutnya.'
: 'Tekan tombol di bawah untuk memverifikasi koneksi ke ESP.',
isDone: state.status == DeviceSetupStatus.connected ||
state.status == DeviceSetupStatus.sending ||
state.status == DeviceSetupStatus.success,
trailing: state.status == DeviceSetupStatus.connected ||
state.status == DeviceSetupStatus.sending ||
state.status == DeviceSetupStatus.success
? null
: _CheckConnectionButton(state: state),
),
const SizedBox(height: 12),
// Step 4: Isi form WiFi
_StepCard(
step: 4,
title: 'Masukkan Kredensial WiFi',
description: 'Isi SSID dan password WiFi yang ingin '
'digunakan oleh anemometer.',
isDone: state.status == DeviceSetupStatus.success,
child: state.status == DeviceSetupStatus.connected ||
state.status == DeviceSetupStatus.sending
? _WifiForm(
ssidController: _ssidController,
passwordController: _passwordController,
formKey: _formKey,
obscurePassword: _obscurePassword,
onToggleObscure: () => setState(
() => _obscurePassword = !_obscurePassword),
onSubmit: state.status == DeviceSetupStatus.sending
? null
: () {
if (_formKey.currentState!.validate()) {
context.read<DeviceSetupBloc>().add(
SendWifiCredentialsEvent(
ssid: _ssidController.text,
password:
_passwordController.text,
),
);
}
},
isLoading:
state.status == DeviceSetupStatus.sending,
)
: const SizedBox.shrink(),
),
],
),
);
},
),
),
);
}
void _showResultDialog(
BuildContext context, {
required bool success,
required String message,
}) {
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Row(
children: [
Icon(
success ? Icons.check_circle_rounded : Icons.error_rounded,
color: success ? Colors.green : Colors.red,
),
const SizedBox(width: 8),
Text(success ? 'Berhasil!' : 'Gagal'),
],
),
content: Text(message),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(); // tutup dialog
Navigator.of(context).pop(); // kembali ke wind_speed_screen
},
child: const Text('Selesai'),
),
],
),
);
}
}
//
// Widgets internal
//
class _InfoBanner extends StatelessWidget {
final DeviceSetupStatus status;
const _InfoBanner({required this.status});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.wifi_tethering_rounded,
color: theme.colorScheme.primary, size: 28),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Setup WiFi Anemometer',
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
'Ikuti langkah-langkah berikut untuk mengkonfigurasi '
'koneksi WiFi pada perangkat anemometer.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onPrimaryContainer,
),
),
],
),
),
],
),
);
}
}
class _StepCard extends StatelessWidget {
final int step;
final String title;
final String description;
final bool isDone;
final Widget? trailing;
final Widget? child;
const _StepCard({
required this.step,
required this.title,
required this.description,
this.isDone = false,
this.trailing,
this.child,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color:
isDone ? Colors.green.shade300 : theme.colorScheme.outlineVariant,
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Step badge
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isDone
? Colors.green
: theme.colorScheme.primaryContainer,
),
child: Center(
child: isDone
? const Icon(Icons.check, size: 16, color: Colors.white)
: Text(
'$step',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
if (trailing != null) ...[
const SizedBox(width: 8),
trailing!,
],
],
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.only(left: 38),
child: Text(
description,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.5,
),
),
),
if (child != null) ...[
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.only(left: 38),
child: child!,
),
],
],
),
),
);
}
}
class _OpenWifiSettingsButton extends StatelessWidget {
final DeviceSetupStatus status;
const _OpenWifiSettingsButton({required this.status});
@override
Widget build(BuildContext context) {
// Tidak perlu tampil kalau sudah connected
if (status == DeviceSetupStatus.connected ||
status == DeviceSetupStatus.sending ||
status == DeviceSetupStatus.success) {
return const SizedBox.shrink();
}
return TextButton.icon(
onPressed: () {
// Buka pengaturan WiFi sistem HP
// Butuh package: app_settings (opsional)
// AppSettings.openWIFISettings();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Buka Pengaturan WiFi HP → sambungkan ke "Anemometer-Setup"'),
behavior: SnackBarBehavior.floating,
),
);
},
icon: const Icon(Icons.settings_rounded, size: 16),
label: const Text('Buka Setting'),
);
}
}
class _CheckConnectionButton extends StatelessWidget {
final DeviceSetupState state;
const _CheckConnectionButton({required this.state});
@override
Widget build(BuildContext context) {
final isChecking = state.status == DeviceSetupStatus.checkingConn;
return ElevatedButton.icon(
onPressed: isChecking
? null
: () =>
context.read<DeviceSetupBloc>().add(CheckEspConnectionEvent()),
icon: isChecking
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.wifi_find_rounded, size: 16),
label: Text(isChecking ? 'Mengecek...' : 'Cek Koneksi'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
textStyle: const TextStyle(fontSize: 13),
),
);
}
}
class _WifiForm extends StatelessWidget {
final TextEditingController ssidController;
final TextEditingController passwordController;
final GlobalKey<FormState> formKey;
final bool obscurePassword;
final VoidCallback onToggleObscure;
final VoidCallback? onSubmit;
final bool isLoading;
const _WifiForm({
required this.ssidController,
required this.passwordController,
required this.formKey,
required this.obscurePassword,
required this.onToggleObscure,
required this.onSubmit,
required this.isLoading,
});
@override
Widget build(BuildContext context) {
return Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// SSID
TextFormField(
controller: ssidController,
decoration: InputDecoration(
labelText: 'Nama WiFi (SSID)',
hintText: 'Contoh: RumahKu_2.4GHz',
prefixIcon: const Icon(Icons.wifi_rounded),
border:
OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
contentPadding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
),
textInputAction: TextInputAction.next,
validator: (v) => (v == null || v.trim().isEmpty)
? 'SSID tidak boleh kosong'
: null,
),
const SizedBox(height: 12),
// Password
TextFormField(
controller: passwordController,
obscureText: obscurePassword,
decoration: InputDecoration(
labelText: 'Password WiFi',
hintText: 'Kosongkan jika WiFi terbuka',
prefixIcon: const Icon(Icons.lock_rounded),
suffixIcon: IconButton(
icon: Icon(
obscurePassword
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
),
onPressed: onToggleObscure,
),
border:
OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
contentPadding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
),
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => onSubmit?.call(),
),
const SizedBox(height: 16),
// Submit button
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: onSubmit,
icon: isLoading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.send_rounded),
label: Text(
isLoading ? 'Mengirim ke ESP...' : 'Kirim ke Anemometer'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
),
),
),
if (isLoading) ...[
const SizedBox(height: 10),
Text(
'⏳ Mengirim konfigurasi WiFi ke ESP...\n'
'Koneksi akan terputus sebentar saat ESP restart.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
height: 1.5,
),
),
],
],
),
);
}
}

View File

@ -7,6 +7,7 @@ import 'widgets/period_selector.dart';
import '../../shared/utils/pdf/pdf_export_service.dart'; import '../../shared/utils/pdf/pdf_export_service.dart';
import '../../shared/widgets/export_pdf_button.dart'; import '../../shared/widgets/export_pdf_button.dart';
import '../../device_setup/views/device_setup_screen.dart';
class WindSpeedScreen extends StatelessWidget { class WindSpeedScreen extends StatelessWidget {
const WindSpeedScreen({super.key}); const WindSpeedScreen({super.key});
@ -24,6 +25,20 @@ class WindSpeedScreen extends StatelessWidget {
elevation: 0, elevation: 0,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
foregroundColor: Colors.black, foregroundColor: Colors.black,
actions: [
IconButton(
tooltip: 'Pengaturan Perangkat',
icon: const Icon(Icons.settings_rounded),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const DeviceSetupScreen(),
),
);
},
),
],
), ),
/// === Body area, lokasi dan tata letak Widgets === /// /// === Body area, lokasi dan tata letak Widgets === ///

View File

@ -145,6 +145,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.8" version: "1.0.8"
dio:
dependency: "direct main"
description:
name: dio
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
url: "https://pub.dev"
source: hosted
version: "5.9.2"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
equatable: equatable:
dependency: "direct main" dependency: "direct main"
description: description:
@ -472,6 +488,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.17.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
monitoring_repository: monitoring_repository:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -9,6 +9,7 @@ dependencies:
bloc_concurrency: ^0.2.5 bloc_concurrency: ^0.2.5
cloud_firestore: ^6.1.1 cloud_firestore: ^6.1.1
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
dio: ^5.9.2
equatable: ^2.0.5 equatable: ^2.0.5
firebase_auth: ^6.1.3 firebase_auth: ^6.1.3
firebase_core: ^4.3.0 firebase_core: ^4.3.0
@ -22,11 +23,11 @@ dependencies:
intl: ^0.20.2 intl: ^0.20.2
monitoring_repository: monitoring_repository:
path: packages/monitoring_repository path: packages/monitoring_repository
pdf: ^3.11.1
printing: ^5.13.1
rxdart: ^0.27.7 rxdart: ^0.27.7
user_repository: user_repository:
path: packages/user_repository path: packages/user_repository
pdf: ^3.11.1
printing: ^5.13.1
dev_dependencies: dev_dependencies:
flutter_lints: ^3.0.0 flutter_lints: ^3.0.0