Merge branch 'branch' of https://github.com/kleponijo/klimatologi into branch
This commit is contained in:
commit
6cc1b7f575
|
|
@ -15,21 +15,19 @@ class MyAppView extends StatelessWidget {
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
colorScheme: ColorScheme.light(
|
colorScheme: ColorScheme.light(
|
||||||
background: Colors.grey.shade100,
|
surface: Colors.grey.shade100,
|
||||||
onBackground: Colors.black,
|
onSurface: Colors.black,
|
||||||
primary: Colors.blue,
|
primary: Colors.blue,
|
||||||
onPrimary: Colors.white
|
onPrimary: Colors.white),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
home: BlocBuilder<AuthenticationBloc, AuthenticationState>(
|
home: BlocBuilder<AuthenticationBloc, AuthenticationState>(
|
||||||
builder: ((context, state){
|
builder: ((context, state) {
|
||||||
if(state.status == AuthenticationStatus.authenticated){
|
if (state.status == AuthenticationStatus.authenticated) {
|
||||||
return HomeScreen();
|
return HomeScreen();
|
||||||
} else {
|
} else {
|
||||||
return WelcomeScreen();
|
return WelcomeScreen();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -7,20 +7,19 @@ import 'package:user_repository/user_repository.dart';
|
||||||
part 'authentication_event.dart';
|
part 'authentication_event.dart';
|
||||||
part 'authentication_state.dart';
|
part 'authentication_state.dart';
|
||||||
|
|
||||||
class AuthenticationBloc extends Bloc<AuthenticationEvent, AuthenticationState> {
|
class AuthenticationBloc
|
||||||
|
extends Bloc<AuthenticationEvent, AuthenticationState> {
|
||||||
final UserRepository userRepository;
|
final UserRepository userRepository;
|
||||||
late final StreamSubscription<MyUser?> _userSubscription;
|
late final StreamSubscription<MyUser?> _userSubscription;
|
||||||
|
|
||||||
AuthenticationBloc({
|
AuthenticationBloc({required this.userRepository})
|
||||||
required this.userRepository
|
: super(const AuthenticationState.unknown()) {
|
||||||
}) : super(const AuthenticationState.unknown()) {
|
|
||||||
_userSubscription = userRepository.user.listen((user) {
|
_userSubscription = userRepository.user.listen((user) {
|
||||||
add(AuthenticationUserChanged(user));
|
add(AuthenticationUserChanged(user));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
on<AuthenticationUserChanged>((event, emit) {
|
on<AuthenticationUserChanged>((event, emit) {
|
||||||
if(event.user != MyUser.empty) {
|
if (event.user != null && event.user != MyUser.empty) {
|
||||||
emit(AuthenticationState.authenticated(event.user!));
|
emit(AuthenticationState.authenticated(event.user!));
|
||||||
} else {
|
} else {
|
||||||
emit(AuthenticationState.unauthenticated());
|
emit(AuthenticationState.unauthenticated());
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
|
||||||
emit(SignInProcess());
|
emit(SignInProcess());
|
||||||
try {
|
try {
|
||||||
await _userRepository.signIn(event.email, event.password);
|
await _userRepository.signIn(event.email, event.password);
|
||||||
|
emit(SignInSuccess());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(SignInFailure());
|
emit(SignInFailure());
|
||||||
}
|
}
|
||||||
|
|
@ -25,7 +26,6 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
|
||||||
emit(SignInSuccess());
|
emit(SignInSuccess());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(SignInFailure());
|
emit(SignInFailure());
|
||||||
emit(SignInInitial());
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,31 +21,44 @@ class _SignInScreenState extends State<SignInScreen> {
|
||||||
bool obscurePassword = true;
|
bool obscurePassword = true;
|
||||||
String? _errorMsg;
|
String? _errorMsg;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
emailController.dispose();
|
||||||
|
passwordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocListener<SignInBloc, SignInState>(
|
return BlocListener<SignInBloc, SignInState>(
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
if (state is SignInProcess) {
|
if (state is SignInProcess) {
|
||||||
setState(() {
|
setState(() => signInRequired = true);
|
||||||
signInRequired = true;
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
setState(() {
|
setState(() => signInRequired = false);
|
||||||
signInRequired = false;
|
|
||||||
});
|
|
||||||
if (state is SignInFailure) {
|
if (state is SignInFailure) {
|
||||||
setState(() {
|
setState(() => _errorMsg = 'Email atau password salah. Coba lagi.');
|
||||||
// PERUBAHAN: Pesan error lebih umum karena Google gagal belum tentu soal password
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
_errorMsg = 'Sign in failed. Please try again.';
|
SnackBar(
|
||||||
});
|
content: Text(_errorMsg!),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (state is SignInSuccess) {
|
||||||
|
setState(() => _errorMsg = null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Form(
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Email field
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.of(context).size.width * 0.9,
|
width: MediaQuery.of(context).size.width * 0.9,
|
||||||
child: MyTextField(
|
child: MyTextField(
|
||||||
|
|
@ -54,14 +67,22 @@ class _SignInScreenState extends State<SignInScreen> {
|
||||||
obscureText: false,
|
obscureText: false,
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
prefixIcon: const Icon(CupertinoIcons.mail_solid),
|
prefixIcon: const Icon(CupertinoIcons.mail_solid),
|
||||||
errorMsg: _errorMsg,
|
// FIX: tidak lagi pass errorMsg ke field individual
|
||||||
validator: (val) {
|
validator: (val) {
|
||||||
if (val!.isEmpty) {
|
if (val == null || val.isEmpty) {
|
||||||
return 'Please fill in this field';
|
return 'Mohon isi field ini';
|
||||||
|
}
|
||||||
|
// FIX: tambah validasi format email
|
||||||
|
if (!RegExp(r'^[\w.-]+@[\w.-]+\.\w{2,}$').hasMatch(val)) {
|
||||||
|
return 'Format email tidak valid';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})),
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
|
// Password field
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.of(context).size.width * 0.9,
|
width: MediaQuery.of(context).size.width * 0.9,
|
||||||
child: MyTextField(
|
child: MyTextField(
|
||||||
|
|
@ -70,10 +91,9 @@ class _SignInScreenState extends State<SignInScreen> {
|
||||||
obscureText: obscurePassword,
|
obscureText: obscurePassword,
|
||||||
keyboardType: TextInputType.visiblePassword,
|
keyboardType: TextInputType.visiblePassword,
|
||||||
prefixIcon: const Icon(CupertinoIcons.lock_fill),
|
prefixIcon: const Icon(CupertinoIcons.lock_fill),
|
||||||
errorMsg: _errorMsg,
|
|
||||||
validator: (val) {
|
validator: (val) {
|
||||||
if (val!.isEmpty) {
|
if (val == null || val.isEmpty) {
|
||||||
return 'Please fill in this field';
|
return 'Mohon isi field ini';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
@ -81,39 +101,51 @@ class _SignInScreenState extends State<SignInScreen> {
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
obscurePassword = !obscurePassword;
|
obscurePassword = !obscurePassword;
|
||||||
if (obscurePassword) {
|
iconPassword = obscurePassword
|
||||||
iconPassword = CupertinoIcons.eye_fill;
|
? CupertinoIcons.eye_fill
|
||||||
} else {
|
: CupertinoIcons.eye_slash_fill;
|
||||||
iconPassword = CupertinoIcons.eye_slash_fill;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
icon: Icon(iconPassword),
|
icon: Icon(iconPassword),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// FIX: Error message satu baris di bawah kedua field
|
||||||
|
if (_errorMsg != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(
|
||||||
|
width: MediaQuery.of(context).size.width * 0.9,
|
||||||
|
child: Text(
|
||||||
|
_errorMsg!,
|
||||||
|
style: const TextStyle(color: Colors.red, fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
!signInRequired
|
|
||||||
? SizedBox(
|
// FIX: Tombol Sign In dan Google dinonaktifkan saat loading
|
||||||
|
if (!signInRequired) ...[
|
||||||
|
SizedBox(
|
||||||
width: MediaQuery.of(context).size.width * 0.5,
|
width: MediaQuery.of(context).size.width * 0.5,
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (_formKey.currentState!.validate()) {
|
if (_formKey.currentState!.validate()) {
|
||||||
context.read<SignInBloc>().add(SignInRequired(
|
context.read<SignInBloc>().add(SignInRequired(
|
||||||
emailController.text,
|
emailController.text, passwordController.text));
|
||||||
passwordController.text));
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
elevation: 3.0,
|
elevation: 3.0,
|
||||||
backgroundColor:
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||||
Theme.of(context).colorScheme.primary,
|
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(60))),
|
borderRadius: BorderRadius.circular(60)),
|
||||||
|
),
|
||||||
child: const Padding(
|
child: const Padding(
|
||||||
padding: EdgeInsets.symmetric(
|
padding:
|
||||||
horizontal: 25, vertical: 5),
|
EdgeInsets.symmetric(horizontal: 25, vertical: 5),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Sign In',
|
'Sign In',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
|
|
@ -122,17 +154,16 @@ class _SignInScreenState extends State<SignInScreen> {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600),
|
fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
)),
|
),
|
||||||
)
|
),
|
||||||
: const CircularProgressIndicator(),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
/// ======= Tombol Login Google ======= ///
|
// Tombol Google — hanya muncul ketika tidak loading
|
||||||
const SizedBox(height: 20),
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.of(context).size.width * 0.5,
|
width: MediaQuery.of(context).size.width * 0.5,
|
||||||
child: TextButton.icon(
|
child: TextButton.icon(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// == Mengirim event login Google ke Bloc == //
|
|
||||||
context.read<SignInBloc>().add(GoogleSignInRequired());
|
context.read<SignInBloc>().add(GoogleSignInRequired());
|
||||||
},
|
},
|
||||||
icon: SizedBox(
|
icon: SizedBox(
|
||||||
|
|
@ -155,11 +186,19 @@ class _SignInScreenState extends State<SignInScreen> {
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(60),
|
borderRadius: BorderRadius.circular(60),
|
||||||
side: const BorderSide(color: Colors.grey))),
|
side: const BorderSide(color: Colors.grey),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
|
||||||
|
const SizedBox(height: 20),
|
||||||
],
|
],
|
||||||
)),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:user_repository/user_repository.dart';
|
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:user_repository/user_repository.dart';
|
||||||
|
|
||||||
import '../../../components/my_text_field.dart';
|
import '../../../components/my_text_field.dart';
|
||||||
import '../blocs/sign_up_bloc/sign_up_bloc.dart' as signUp;
|
import '../blocs/sign_up_bloc/sign_up_bloc.dart';
|
||||||
// import '../../../blocs/authentication_bloc/authentication_bloc.dart';
|
|
||||||
import '../blocs/sign_in_bloc/sign_in_bloc.dart' as signIn;
|
|
||||||
|
|
||||||
class SignUpScreen extends StatefulWidget {
|
class SignUpScreen extends StatefulWidget {
|
||||||
final VoidCallback? onSignInTap;
|
final VoidCallback? onSignInTap;
|
||||||
|
|
@ -24,86 +23,60 @@ class _SignUpScreenState extends State<SignUpScreen> {
|
||||||
IconData iconPassword = CupertinoIcons.eye_fill;
|
IconData iconPassword = CupertinoIcons.eye_fill;
|
||||||
bool obscurePassword = true;
|
bool obscurePassword = true;
|
||||||
bool signUpRequired = false;
|
bool signUpRequired = false;
|
||||||
|
// FIX: Hapus 5 variabel password strength yang tidak dipakai
|
||||||
|
|
||||||
bool containsUpperCase = false;
|
@override
|
||||||
bool containsLowerCase = false;
|
void dispose() {
|
||||||
bool containsNumber = false;
|
nameController.dispose();
|
||||||
bool containsSpecialChar = false;
|
emailController.dispose();
|
||||||
bool contains8Length = false;
|
passwordController.dispose();
|
||||||
|
confirmPasswordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocListener<signUp.SignUpBloc, signUp.SignUpState>(
|
return BlocListener<SignUpBloc, SignUpState>(
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
if (state is signUp.SignUpProcess) {
|
if (state is SignUpProcess) {
|
||||||
setState(() {
|
setState(() => signUpRequired = true);
|
||||||
signUpRequired = true;
|
} else if (state is SignUpFailure) {
|
||||||
});
|
setState(() => signUpRequired = false);
|
||||||
} else if (state is signUp.SignUpFailure) {
|
|
||||||
setState(() {
|
|
||||||
signUpRequired = false; // Matikan loading!
|
|
||||||
});
|
|
||||||
// Tampilkan pesan error lewat Snackbar biar user tahu kenapa gagal
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(state.message ?? 'An error ocurred'),
|
content: Text(state.message ?? 'Terjadi kesalahan'),
|
||||||
backgroundColor: Colors.red),
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else if (state is signUp.SignUpSuccess) {
|
} else if (state is SignUpSuccess) {
|
||||||
setState(() {
|
setState(() => signUpRequired = false);
|
||||||
signUpRequired = false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// FIX: Hapus Scaffold — WelcomeScreen sudah punya Scaffold
|
||||||
child: Form(
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: Scaffold(
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
body: Center(
|
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
||||||
children: [
|
|
||||||
const SizedBox(height: 30),
|
|
||||||
Container(
|
|
||||||
width: MediaQuery.of(context).size.width * 0.92,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 20, vertical: 22),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.04),
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: const Offset(0, 4),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 6),
|
|
||||||
const Center(
|
const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Buat Akun',
|
'Buat Akun',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
|
||||||
fontSize: 22, fontWeight: FontWeight.w700),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
const Center(
|
const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Daftar untuk mulai monitoring',
|
'Daftar untuk mulai monitoring',
|
||||||
style:
|
style: TextStyle(fontSize: 13, color: Colors.black54),
|
||||||
TextStyle(fontSize: 13, color: Colors.black54),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// Name
|
// Nama
|
||||||
SizedBox(
|
MyTextField(
|
||||||
width: MediaQuery.of(context).size.width * 0.9,
|
|
||||||
child: MyTextField(
|
|
||||||
controller: nameController,
|
controller: nameController,
|
||||||
hintText: 'Masukkan nama lengkap',
|
hintText: 'Masukkan nama lengkap',
|
||||||
obscureText: false,
|
obscureText: false,
|
||||||
|
|
@ -111,20 +84,16 @@ class _SignUpScreenState extends State<SignUpScreen> {
|
||||||
prefixIcon: const Icon(CupertinoIcons.person_fill),
|
prefixIcon: const Icon(CupertinoIcons.person_fill),
|
||||||
validator: (val) {
|
validator: (val) {
|
||||||
if (val == null || val.isEmpty) {
|
if (val == null || val.isEmpty) {
|
||||||
return 'Please fill in this field';
|
return 'Mohon isi field ini';
|
||||||
} else if (val.length > 50) {
|
|
||||||
return 'Name too long';
|
|
||||||
}
|
}
|
||||||
|
if (val.length > 50) return 'Nama terlalu panjang';
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
// Email
|
// Email
|
||||||
SizedBox(
|
MyTextField(
|
||||||
width: MediaQuery.of(context).size.width * 0.9,
|
|
||||||
child: MyTextField(
|
|
||||||
controller: emailController,
|
controller: emailController,
|
||||||
hintText: 'nama@email.com',
|
hintText: 'nama@email.com',
|
||||||
obscureText: false,
|
obscureText: false,
|
||||||
|
|
@ -132,18 +101,19 @@ class _SignUpScreenState extends State<SignUpScreen> {
|
||||||
prefixIcon: const Icon(CupertinoIcons.mail_solid),
|
prefixIcon: const Icon(CupertinoIcons.mail_solid),
|
||||||
validator: (val) {
|
validator: (val) {
|
||||||
if (val == null || val.isEmpty) {
|
if (val == null || val.isEmpty) {
|
||||||
return 'Please fill in this field';
|
return 'Mohon isi field ini';
|
||||||
|
}
|
||||||
|
// FIX: tambah validasi format email
|
||||||
|
if (!RegExp(r'^[\w.-]+@[\w.-]+\.\w{2,}$').hasMatch(val)) {
|
||||||
|
return 'Format email tidak valid';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
// Password
|
// Password
|
||||||
SizedBox(
|
MyTextField(
|
||||||
width: MediaQuery.of(context).size.width * 0.9,
|
|
||||||
child: MyTextField(
|
|
||||||
controller: passwordController,
|
controller: passwordController,
|
||||||
hintText: 'Minimal 6 karakter',
|
hintText: 'Minimal 6 karakter',
|
||||||
obscureText: obscurePassword,
|
obscureText: obscurePassword,
|
||||||
|
|
@ -162,18 +132,19 @@ class _SignUpScreenState extends State<SignUpScreen> {
|
||||||
),
|
),
|
||||||
validator: (val) {
|
validator: (val) {
|
||||||
if (val == null || val.isEmpty) {
|
if (val == null || val.isEmpty) {
|
||||||
return 'Please fill in this field';
|
return 'Mohon isi field ini';
|
||||||
|
}
|
||||||
|
// FIX: tambah validasi panjang minimum
|
||||||
|
if (val.length < 6) {
|
||||||
|
return 'Password minimal 6 karakter';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
// Confirm Password
|
// Confirm Password
|
||||||
SizedBox(
|
MyTextField(
|
||||||
width: MediaQuery.of(context).size.width * 0.9,
|
|
||||||
child: MyTextField(
|
|
||||||
controller: confirmPasswordController,
|
controller: confirmPasswordController,
|
||||||
hintText: 'Ulangi password',
|
hintText: 'Ulangi password',
|
||||||
obscureText: obscurePassword,
|
obscureText: obscurePassword,
|
||||||
|
|
@ -181,42 +152,41 @@ class _SignUpScreenState extends State<SignUpScreen> {
|
||||||
prefixIcon: const Icon(CupertinoIcons.lock_fill),
|
prefixIcon: const Icon(CupertinoIcons.lock_fill),
|
||||||
validator: (val) {
|
validator: (val) {
|
||||||
if (val == null || val.isEmpty) {
|
if (val == null || val.isEmpty) {
|
||||||
return 'Please fill in this field';
|
return 'Mohon isi field ini';
|
||||||
} else if (val != passwordController.text) {
|
}
|
||||||
|
if (val != passwordController.text) {
|
||||||
return 'Password tidak cocok';
|
return 'Password tidak cocok';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
/// == Sign Up == ///
|
// Tombol Sign Up
|
||||||
// Button
|
Center(
|
||||||
!signUpRequired
|
child: !signUpRequired
|
||||||
? Center(
|
? SizedBox(
|
||||||
child: SizedBox(
|
width: MediaQuery.of(context).size.width * 0.5,
|
||||||
width:
|
|
||||||
MediaQuery.of(context).size.width * 0.5,
|
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (_formKey.currentState!.validate()) {
|
if (_formKey.currentState!.validate()) {
|
||||||
MyUser myUser = MyUser.empty;
|
// FIX: buat MyUser langsung, jangan mutasi MyUser.empty
|
||||||
myUser.email = emailController.text;
|
final myUser = MyUser(
|
||||||
myUser.name = nameController.text;
|
userId: '',
|
||||||
|
email: emailController.text.trim(),
|
||||||
setState(() {
|
name: nameController.text.trim(),
|
||||||
context.read<signUp.SignUpBloc>().add(
|
hasActiveCart: false,
|
||||||
signUp.SignUpRequired(
|
);
|
||||||
myUser,
|
|
||||||
passwordController.text,
|
// FIX: add() dipanggil di luar setState
|
||||||
),
|
context.read<SignUpBloc>().add(
|
||||||
|
SignUpRequired(
|
||||||
|
myUser, passwordController.text),
|
||||||
);
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
style: TextButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
Theme.of(context).colorScheme.primary,
|
Theme.of(context).colorScheme.primary,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
|
|
@ -224,54 +194,39 @@ class _SignUpScreenState extends State<SignUpScreen> {
|
||||||
borderRadius: BorderRadius.circular(60),
|
borderRadius: BorderRadius.circular(60),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: const Padding(
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
horizontal: 25, vertical: 5),
|
horizontal: 25, vertical: 5),
|
||||||
child: const Text(
|
child: Text(
|
||||||
'Sign Up',
|
'Sign Up',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: const CircularProgressIndicator(),
|
: const CircularProgressIndicator(),
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Center(
|
|
||||||
child: Text('Atau'),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 16),
|
||||||
Center(
|
Row(
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Text('Sudah punya akun? '),
|
const Text('Sudah punya akun? '),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: widget.onSignInTap,
|
||||||
widget.onSignInTap?.call();
|
|
||||||
},
|
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'Login di sini',
|
'Login di sini',
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 30),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../../blocs/authentication_bloc/authentication_bloc.dart';
|
import 'package:user_repository/user_repository.dart';
|
||||||
|
|
||||||
import '../blocs/sign_in_bloc/sign_in_bloc.dart';
|
import '../blocs/sign_in_bloc/sign_in_bloc.dart';
|
||||||
import '../blocs/sign_up_bloc/sign_up_bloc.dart';
|
import '../blocs/sign_up_bloc/sign_up_bloc.dart';
|
||||||
import 'sign_in_screen.dart';
|
import 'sign_in_screen.dart';
|
||||||
|
|
@ -19,86 +20,68 @@ class _WelcomeScreenState extends State<WelcomeScreen>
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
tabController = TabController(initialIndex: 0, length: 2, vsync: this);
|
|
||||||
super.initState();
|
super.initState();
|
||||||
|
tabController = TabController(initialIndex: 0, length: 2, vsync: this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
tabController.dispose(); // FIX: dispose controller agar tidak memory leak
|
||||||
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
body: SingleChildScrollView(
|
body: SafeArea(
|
||||||
child: SizedBox(
|
|
||||||
height: MediaQuery.of(context).size.height,
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: SizedBox(
|
|
||||||
height: MediaQuery.of(context).size.height / 1.8,
|
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
const SizedBox(height: 24),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 50.0),
|
padding: const EdgeInsets.symmetric(horizontal: 50.0),
|
||||||
child: TabBar(
|
child: TabBar(
|
||||||
controller: tabController,
|
controller: tabController,
|
||||||
unselectedLabelColor: Theme.of(context)
|
unselectedLabelColor:
|
||||||
.colorScheme
|
Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
|
||||||
.onBackground
|
labelColor: Theme.of(context).colorScheme.onSurface,
|
||||||
.withOpacity(0.5),
|
|
||||||
labelColor:
|
|
||||||
Theme.of(context).colorScheme.onBackground,
|
|
||||||
tabs: const [
|
tabs: const [
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.all(12.0),
|
padding: EdgeInsets.all(12.0),
|
||||||
child: Text(
|
child: Text('Sign In', style: TextStyle(fontSize: 18)),
|
||||||
'Sign In',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.all(12.0),
|
padding: EdgeInsets.all(12.0),
|
||||||
child: Text(
|
child: Text('Sign Up', style: TextStyle(fontSize: 18)),
|
||||||
'Sign Up',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// FIX: Expanded supaya TabBarView mengisi sisa tinggi layar
|
||||||
|
// dengan ini scroll di dalam tab bisa berjalan normal
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
controller: tabController,
|
controller: tabController,
|
||||||
children: [
|
children: [
|
||||||
BlocProvider<SignInBloc>(
|
BlocProvider<SignInBloc>(
|
||||||
create: (context) => SignInBloc(context
|
create: (context) =>
|
||||||
.read<AuthenticationBloc>()
|
// FIX: akses UserRepository langsung, bukan via AuthBloc
|
||||||
.userRepository),
|
SignInBloc(context.read<UserRepository>()),
|
||||||
child: const SignInScreen(),
|
child: const SignInScreen(),
|
||||||
),
|
),
|
||||||
BlocProvider<SignUpBloc>(
|
BlocProvider<SignUpBloc>(
|
||||||
create: (context) => SignUpBloc(context
|
create: (context) =>
|
||||||
.read<AuthenticationBloc>()
|
SignUpBloc(context.read<UserRepository>()),
|
||||||
.userRepository),
|
child: SignUpScreen(
|
||||||
child: SignUpScreen(onSignInTap: () {
|
onSignInTap: () => tabController.animateTo(0),
|
||||||
tabController.animateTo(
|
),
|
||||||
0); // Ini yang bikin dia pindah ke tab Login
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
],
|
|
||||||
))
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,11 +79,16 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
||||||
);
|
);
|
||||||
|
|
||||||
final lastValue = history.isNotEmpty ? history.last.evaporasi : 0.0;
|
final lastValue = history.isNotEmpty ? history.last.evaporasi : 0.0;
|
||||||
|
final lastWaterLevel = history.isNotEmpty ? history.last.tinggiAir : 0.0;
|
||||||
|
final lastTemperature = history.isNotEmpty ? history.last.suhu : 0.0;
|
||||||
final (status, rain) = _computeWeatherStatus(lastValue);
|
final (status, rain) = _computeWeatherStatus(lastValue);
|
||||||
_emitEvaporasiAlert(status, rain, lastValue);
|
_emitEvaporasiAlert(status, rain, lastValue);
|
||||||
|
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
history: history,
|
history: history,
|
||||||
|
currentValue: lastValue,
|
||||||
|
waterLevel: lastWaterLevel,
|
||||||
|
temperature: lastTemperature,
|
||||||
dailyValues: dailyGraph,
|
dailyValues: dailyGraph,
|
||||||
dailyTemperatures: dailyTempGraph,
|
dailyTemperatures: dailyTempGraph,
|
||||||
weeklyValues: weeklyGraph,
|
weeklyValues: weeklyGraph,
|
||||||
|
|
@ -299,4 +304,3 @@ class _EvaporasiRealtimeUpdated extends EvaporasiEvent {
|
||||||
@override
|
@override
|
||||||
List<Object> get props => [data];
|
List<Object> get props => [data];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class EvaporasiChartWidget extends StatelessWidget {
|
||||||
|
final List<double> dailyValues;
|
||||||
|
final List<double> dailyTemperatures;
|
||||||
|
final String period;
|
||||||
|
final List<String> chartLabels;
|
||||||
|
|
||||||
|
const EvaporasiChartWidget({
|
||||||
|
super.key,
|
||||||
|
required this.dailyValues,
|
||||||
|
required this.dailyTemperatures,
|
||||||
|
required this.period,
|
||||||
|
required this.chartLabels,
|
||||||
|
});
|
||||||
|
|
||||||
|
double _safeValue(double value) {
|
||||||
|
if (value.isNaN || value.isInfinite) return 0.0;
|
||||||
|
return value < 0 ? 0.0 : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _maxY() {
|
||||||
|
final values = [
|
||||||
|
...dailyValues.map(_safeValue),
|
||||||
|
...dailyTemperatures.map(_safeValue),
|
||||||
|
];
|
||||||
|
if (values.isEmpty) return 10;
|
||||||
|
final maxValue = values.reduce((a, b) => a > b ? a : b);
|
||||||
|
return (maxValue * 1.4).clamp(10.0, 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<FlSpot> _lineSpots(List<double> series) {
|
||||||
|
return series.asMap().entries.map((entry) {
|
||||||
|
return FlSpot(entry.key.toDouble(), _safeValue(entry.value));
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getBottomLabel(int index) {
|
||||||
|
if (chartLabels.isEmpty || index < 0 || index >= chartLabels.length) {
|
||||||
|
return index.toString();
|
||||||
|
}
|
||||||
|
return chartLabels[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final values = _lineSpots(dailyValues);
|
||||||
|
final temperatures = _lineSpots(dailyTemperatures);
|
||||||
|
|
||||||
|
if (values.isEmpty && temperatures.isEmpty) {
|
||||||
|
return Container(
|
||||||
|
height: 240,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(25),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withAlpha(13),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 5),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Text('Tidak ada data chart'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: 300,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(25),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withAlpha(13),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 5),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(25),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: LineChart(
|
||||||
|
LineChartData(
|
||||||
|
minY: 0,
|
||||||
|
maxY: _maxY(),
|
||||||
|
gridData: FlGridData(show: false),
|
||||||
|
borderData: FlBorderData(show: false),
|
||||||
|
titlesData: FlTitlesData(
|
||||||
|
bottomTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
reservedSize: 32,
|
||||||
|
interval: 1,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
final index = value.toInt();
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Text(
|
||||||
|
_getBottomLabel(index),
|
||||||
|
style:
|
||||||
|
const TextStyle(color: Colors.grey, fontSize: 10),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
leftTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
topTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
rightTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
lineTouchData: LineTouchData(
|
||||||
|
handleBuiltInTouches: true,
|
||||||
|
touchTooltipData: LineTouchTooltipData(
|
||||||
|
getTooltipItems: (spots) {
|
||||||
|
return spots.map((spot) {
|
||||||
|
return LineTooltipItem(
|
||||||
|
spot.y.toStringAsFixed(1),
|
||||||
|
const TextStyle(color: Colors.white, fontSize: 12),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
lineBarsData: [
|
||||||
|
if (values.isNotEmpty)
|
||||||
|
LineChartBarData(
|
||||||
|
spots: values,
|
||||||
|
isCurved: true,
|
||||||
|
color: Colors.blue.shade700,
|
||||||
|
barWidth: 3,
|
||||||
|
dotData: FlDotData(show: false),
|
||||||
|
belowBarData: BarAreaData(
|
||||||
|
show: true,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Colors.blue.withAlpha(64),
|
||||||
|
Colors.blue.withAlpha(13),
|
||||||
|
],
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (temperatures.isNotEmpty)
|
||||||
|
LineChartBarData(
|
||||||
|
spots: temperatures,
|
||||||
|
isCurved: true,
|
||||||
|
color: Colors.orange.shade700,
|
||||||
|
barWidth: 3,
|
||||||
|
dotData: FlDotData(show: false),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -83,6 +83,7 @@ class _EvaporasiDatePickerState extends State<EvaporasiDatePicker> {
|
||||||
),
|
),
|
||||||
// Calendar
|
// Calendar
|
||||||
Expanded(
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
child: TableCalendar(
|
child: TableCalendar(
|
||||||
firstDay: DateTime.utc(2020, 1, 1),
|
firstDay: DateTime.utc(2020, 1, 1),
|
||||||
lastDay: DateTime.now(),
|
lastDay: DateTime.now(),
|
||||||
|
|
@ -174,6 +175,7 @@ class _EvaporasiDatePickerState extends State<EvaporasiDatePicker> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
// Selected date display
|
// Selected date display
|
||||||
if (_selectedDay != null)
|
if (_selectedDay != null)
|
||||||
Container(
|
Container(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
import 'package:monitoring_repository/monitoring_repository.dart';
|
|
||||||
|
|
||||||
class EvaporasiDateSearchBar extends StatefulWidget {
|
class EvaporasiDateSearchBar extends StatefulWidget {
|
||||||
final String initialQuery;
|
final String initialQuery;
|
||||||
|
|
@ -13,14 +10,12 @@ class EvaporasiDateSearchBar extends StatefulWidget {
|
||||||
required this.onQueryChanged,
|
required this.onQueryChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<EvaporasiDateSearchBar> createState() => _EvaporasiDateSearchBarState();
|
State<EvaporasiDateSearchBar> createState() => _EvaporasiDateSearchBarState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EvaporasiDateSearchBarState extends State<EvaporasiDateSearchBar> {
|
class _EvaporasiDateSearchBarState extends State<EvaporasiDateSearchBar> {
|
||||||
late final TextEditingController _controller;
|
late final TextEditingController _controller;
|
||||||
DateTime? _selected;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -34,21 +29,9 @@ class _EvaporasiDateSearchBarState extends State<EvaporasiDateSearchBar> {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _matches(Evaporasi item, String query) {
|
|
||||||
if (query.trim().isEmpty) return true;
|
|
||||||
|
|
||||||
final date = item.timestamp;
|
|
||||||
final ddMMyyyy = DateFormat('dd/MM/yyyy', 'id_ID').format(date);
|
|
||||||
final ddMMMYYYY = DateFormat('dd MMM yyyy', 'id_ID').format(date);
|
|
||||||
|
|
||||||
final q = query.trim().toLowerCase();
|
|
||||||
return ddMMyyyy.toLowerCase().contains(q) || ddMMMYYYY.toLowerCase().contains(q);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return TextField(
|
return TextField(
|
||||||
|
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Cari tanggal (dd/MM/yyyy)',
|
hintText: 'Cari tanggal (dd/MM/yyyy)',
|
||||||
|
|
@ -69,4 +52,3 @@ class _EvaporasiDateSearchBarState extends State<EvaporasiDateSearchBar> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,21 @@ class Evaporasi {
|
||||||
);
|
);
|
||||||
|
|
||||||
factory Evaporasi.fromJson(Map<dynamic, dynamic> json) {
|
factory Evaporasi.fromJson(Map<dynamic, dynamic> json) {
|
||||||
final now = DateTime.now();
|
double toDoubleSafe(dynamic v) {
|
||||||
DateTime timestamp = now;
|
if (v is num) return v.toDouble();
|
||||||
|
if (v is String) return double.tryParse(v) ?? 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ Prioritas waktu sesuai firmware ESP32:
|
final evaporasiVal = toDoubleSafe(
|
||||||
// 1) timestamp (Unix atau ISO string, kalau pernah dikirim)
|
json['evaporasi_mm'] ?? json['evaporasi'],
|
||||||
// 2) datetime ("YYYY-MM-DD HH:MM:SS")
|
);
|
||||||
// 3) waktu ("HH:MM:SS") fallback (pakai tanggal hari ini)
|
final suhuVal = toDoubleSafe(json['suhu'] ?? json['suhu_air']);
|
||||||
|
final tinggiVal = toDoubleSafe(json['tinggi_air_cm'] ?? json['tinggi_air']);
|
||||||
|
|
||||||
|
DateTime timestamp = DateTime.now();
|
||||||
final rawTimestamp = json['timestamp'];
|
final rawTimestamp = json['timestamp'];
|
||||||
|
|
||||||
if (rawTimestamp != null) {
|
if (rawTimestamp != null) {
|
||||||
if (rawTimestamp is int) {
|
if (rawTimestamp is int) {
|
||||||
timestamp = DateTime.fromMillisecondsSinceEpoch(rawTimestamp);
|
timestamp = DateTime.fromMillisecondsSinceEpoch(rawTimestamp);
|
||||||
|
|
@ -58,31 +65,24 @@ class Evaporasi {
|
||||||
final jam = int.tryParse(parts[0]) ?? 0;
|
final jam = int.tryParse(parts[0]) ?? 0;
|
||||||
final menit = int.tryParse(parts[1]) ?? 0;
|
final menit = int.tryParse(parts[1]) ?? 0;
|
||||||
final detik = parts.length >= 3 ? (int.tryParse(parts[2]) ?? 0) : 0;
|
final detik = parts.length >= 3 ? (int.tryParse(parts[2]) ?? 0) : 0;
|
||||||
timestamp = DateTime(now.year, now.month, now.day, jam, menit, detik);
|
final now = DateTime.now();
|
||||||
|
timestamp = DateTime(
|
||||||
|
now.year,
|
||||||
|
now.month,
|
||||||
|
now.day,
|
||||||
|
jam,
|
||||||
|
menit,
|
||||||
|
detik,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Field di DB (dari info Anda):
|
|
||||||
// - evaporasi_mm
|
|
||||||
// - suhu_air
|
|
||||||
// - tinggi_air_cm
|
|
||||||
// Namun tetap toleran jika key berubah.
|
|
||||||
final evaporasiVal = json['evaporasi_mm'] ?? json['evaporasi'] ?? 0;
|
|
||||||
final suhuVal = json['suhu_air'] ?? json['suhu'] ?? 0;
|
|
||||||
final tinggiVal = json['tinggi_air_cm'] ?? json['tinggi_air'] ?? 0;
|
|
||||||
|
|
||||||
double toDoubleSafe(dynamic v) {
|
|
||||||
if (v is num) return v.toDouble();
|
|
||||||
if (v is String) return double.tryParse(v) ?? 0;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Evaporasi(
|
return Evaporasi(
|
||||||
evaporasi: toDoubleSafe(evaporasiVal),
|
evaporasi: evaporasiVal,
|
||||||
suhu: toDoubleSafe(suhuVal),
|
suhu: suhuVal,
|
||||||
tinggiAir: toDoubleSafe(tinggiVal),
|
tinggiAir: tinggiVal,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,9 @@ class FirebaseUserRepo implements UserRepository {
|
||||||
final FirebaseAuth _firebaseAuth;
|
final FirebaseAuth _firebaseAuth;
|
||||||
final userCollection = FirebaseFirestore.instance.collection('users');
|
final userCollection = FirebaseFirestore.instance.collection('users');
|
||||||
|
|
||||||
FirebaseUserRepo({
|
FirebaseUserRepo({FirebaseAuth? firebaseAuth})
|
||||||
FirebaseAuth? firebaseAuth,
|
: _firebaseAuth = firebaseAuth ?? FirebaseAuth.instance;
|
||||||
}) : _firebaseAuth = firebaseAuth ?? FirebaseAuth.instance;
|
|
||||||
|
|
||||||
/// == Melakukan Implement User == ///
|
|
||||||
@override
|
@override
|
||||||
Stream<MyUser?> get user {
|
Stream<MyUser?> get user {
|
||||||
return _firebaseAuth.authStateChanges().flatMap((firebaseUser) async* {
|
return _firebaseAuth.authStateChanges().flatMap((firebaseUser) async* {
|
||||||
|
|
@ -27,16 +25,9 @@ class FirebaseUserRepo implements UserRepository {
|
||||||
yield MyUser.fromEntity(
|
yield MyUser.fromEntity(
|
||||||
MyUserEntity.fromDocument(userData.data()!));
|
MyUserEntity.fromDocument(userData.data()!));
|
||||||
} else {
|
} else {
|
||||||
// User baru yang belum ada data di Firestore, tunggu sebentar
|
// Data Firestore belum ada (seharusnya tidak terjadi dengan flow yang sudah diperbaiki)
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
final retryData = await userCollection.doc(firebaseUser.uid).get();
|
|
||||||
if (retryData.exists && retryData.data() != null) {
|
|
||||||
yield MyUser.fromEntity(
|
|
||||||
MyUserEntity.fromDocument(retryData.data()!));
|
|
||||||
} else {
|
|
||||||
yield MyUser.empty;
|
yield MyUser.empty;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log('Error fetching user data: $e');
|
log('Error fetching user data: $e');
|
||||||
yield MyUser.empty;
|
yield MyUser.empty;
|
||||||
|
|
@ -45,7 +36,6 @@ class FirebaseUserRepo implements UserRepository {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// == Melakukan Implement Sign in == ///
|
|
||||||
@override
|
@override
|
||||||
Future<void> signIn(String email, String password) async {
|
Future<void> signIn(String email, String password) async {
|
||||||
try {
|
try {
|
||||||
|
|
@ -57,7 +47,6 @@ class FirebaseUserRepo implements UserRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// == Melakukan Implement Sign Up == ///
|
|
||||||
@override
|
@override
|
||||||
Future<MyUser> signUp(MyUser myUser, String password) async {
|
Future<MyUser> signUp(MyUser myUser, String password) async {
|
||||||
try {
|
try {
|
||||||
|
|
@ -71,7 +60,6 @@ class FirebaseUserRepo implements UserRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// == Melakukan Implement Log Out == ///
|
|
||||||
@override
|
@override
|
||||||
Future<void> logOut() async {
|
Future<void> logOut() async {
|
||||||
final GoogleSignIn googleSignIn = GoogleSignIn();
|
final GoogleSignIn googleSignIn = GoogleSignIn();
|
||||||
|
|
@ -85,27 +73,26 @@ class FirebaseUserRepo implements UserRepository {
|
||||||
await userCollection
|
await userCollection
|
||||||
.doc(myUser.userId)
|
.doc(myUser.userId)
|
||||||
.set(myUser.toEntity().toDocument());
|
.set(myUser.toEntity().toDocument());
|
||||||
// Tunggu sebentar agar data tersimpan dengan baik sebelum stream update
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(e.toString());
|
log(e.toString());
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// == Sign in with Google == ///
|
|
||||||
@override
|
@override
|
||||||
Future<void> signInWithGoogle() async {
|
Future<void> signInWithGoogle() async {
|
||||||
try {
|
try {
|
||||||
|
UserCredential userCredential;
|
||||||
|
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
final googleProvider = GoogleAuthProvider();
|
final googleProvider = GoogleAuthProvider();
|
||||||
await _firebaseAuth.signInWithPopup(googleProvider);
|
userCredential = await _firebaseAuth.signInWithPopup(googleProvider);
|
||||||
} else {
|
} else {
|
||||||
final GoogleSignIn googleSignIn = GoogleSignIn();
|
final GoogleSignIn googleSignIn = GoogleSignIn();
|
||||||
final googleUser = await googleSignIn.signIn();
|
final googleUser = await googleSignIn.signIn();
|
||||||
|
|
||||||
if (googleUser == null) {
|
if (googleUser == null) {
|
||||||
throw Exception("cancelled");
|
throw Exception('cancelled');
|
||||||
}
|
}
|
||||||
|
|
||||||
final GoogleSignInAuthentication googleAuth =
|
final GoogleSignInAuthentication googleAuth =
|
||||||
|
|
@ -115,7 +102,22 @@ class FirebaseUserRepo implements UserRepository {
|
||||||
accessToken: googleAuth.accessToken,
|
accessToken: googleAuth.accessToken,
|
||||||
idToken: googleAuth.idToken,
|
idToken: googleAuth.idToken,
|
||||||
);
|
);
|
||||||
await _firebaseAuth.signInWithCredential(credential);
|
userCredential = await _firebaseAuth.signInWithCredential(credential);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIX: Cek apakah user sudah punya data Firestore
|
||||||
|
// Jika belum (user baru), buat dokumen sekarang juga
|
||||||
|
final firebaseUser = userCredential.user!;
|
||||||
|
final doc = await userCollection.doc(firebaseUser.uid).get();
|
||||||
|
|
||||||
|
if (!doc.exists) {
|
||||||
|
final newUser = MyUser(
|
||||||
|
userId: firebaseUser.uid,
|
||||||
|
email: firebaseUser.email ?? '',
|
||||||
|
name: firebaseUser.displayName ?? '',
|
||||||
|
hasActiveCart: false,
|
||||||
|
);
|
||||||
|
await setUserData(newUser);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log('Google sign-in error: $e');
|
log('Google sign-in error: $e');
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
import '../entities/entities.dart';
|
import '../entities/entities.dart';
|
||||||
|
|
||||||
class MyUser {
|
class MyUser {
|
||||||
|
|
@ -7,12 +6,11 @@ class MyUser {
|
||||||
String name;
|
String name;
|
||||||
bool hasActiveCart;
|
bool hasActiveCart;
|
||||||
|
|
||||||
MyUser ({
|
MyUser({
|
||||||
required this.userId,
|
required this.userId,
|
||||||
required this.email,
|
required this.email,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.hasActiveCart,
|
required this.hasActiveCart,
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
static final empty = MyUser(
|
static final empty = MyUser(
|
||||||
|
|
@ -40,9 +38,19 @@ class MyUser {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is MyUser &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
userId == other.userId &&
|
||||||
|
email == other.email;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(userId, email);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'MyUser: $userId, email: $email, name: $name, hasActiveCart: $hasActiveCart}';
|
return 'MyUser: $userId, email: $email, name: $name, hasActiveCart: $hasActiveCart}';
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
16
pubspec.lock
16
pubspec.lock
|
|
@ -695,6 +695,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.27.7"
|
version: "0.27.7"
|
||||||
|
simple_gesture_detector:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: simple_gesture_detector
|
||||||
|
sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.1"
|
||||||
sky_engine:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
@ -740,6 +748,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
table_calendar:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: table_calendar
|
||||||
|
sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.0"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,11 @@
|
||||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||||
// tree, read text, and verify that the values of widget properties are correct.
|
// tree, read text, and verify that the values of widget properties are correct.
|
||||||
|
|
||||||
import 'package:firebase_database/firebase_database.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:klimatologiot/app.dart';
|
||||||
import 'package:monitoring_repository/monitoring_repository.dart';
|
import 'package:monitoring_repository/monitoring_repository.dart';
|
||||||
import 'package:user_repository/user_repository.dart';
|
import 'package:user_repository/user_repository.dart';
|
||||||
import '../lib/app.dart';
|
|
||||||
|
|
||||||
// import 'package:klimatologiot/main.dart';
|
// import 'package:klimatologiot/main.dart';
|
||||||
|
|
||||||
|
|
@ -49,31 +48,38 @@ class FakeUserRepository implements UserRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeMonitoringRepository implements MonitoringRepository {
|
class FakeMonitoringRepository implements MonitoringRepository {
|
||||||
get _db => null;
|
|
||||||
|
|
||||||
// Satu fungsi untuk semua jenis sensor
|
|
||||||
// Kamu cukup masukkan "path" database-nya saja
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
// Jika ingin mengambil data sekali saja (bukan stream)
|
Future<T> getSensorSnapshot<T>(
|
||||||
Future<DataSnapshot> getSensorSnapshot(String path) async {
|
String path,
|
||||||
return await _db.ref(path).get();
|
T Function(Map<dynamic, dynamic>) mapper,
|
||||||
|
) async {
|
||||||
|
return mapper(<dynamic, dynamic>{});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<DatabaseEvent> getSensorStream(String path) {
|
Stream<T> getSensorStream<T>(
|
||||||
// TODO: implement getSensorStream
|
String path,
|
||||||
throw UnimplementedError();
|
T Function(Map<dynamic, dynamic>) mapper,
|
||||||
|
) {
|
||||||
|
return const Stream.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<T>> getSensorHistory<T>(
|
||||||
|
String path,
|
||||||
|
T Function(Map<dynamic, dynamic>) mapper,
|
||||||
|
) async {
|
||||||
|
return <T>[];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('App loads', (WidgetTester tester) async {
|
testWidgets('App loads', (WidgetTester tester) async {
|
||||||
final fakeRepo = FakeUserRepository();
|
final fakeRepo = FakeUserRepository();
|
||||||
final fakeMonitoring = FakeUserRepository();
|
final fakeMonitoring = FakeMonitoringRepository();
|
||||||
// Build our app and trigger a frame.
|
// Build our app and trigger a frame.
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MyApp(fakeRepo, fakeMonitoring as MonitoringRepository),
|
MyApp(fakeRepo, fakeMonitoring),
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue