joss
This commit is contained in:
parent
efbbf8bb21
commit
de534b0277
|
|
@ -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:
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 === ///
|
||||||
|
|
|
||||||
24
pubspec.lock
24
pubspec.lock
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue