New feature add devices
Menambah fitur tambah perangkat, disuruh pak beni
This commit is contained in:
parent
cdb1a4c860
commit
f217d040b0
|
@ -1,31 +1,156 @@
|
|||
import 'package:CCTV_App/login_page.dart';
|
||||
import 'package:CCTV_App/main.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
// =================================================================
|
||||
// AUTH & ROUTING
|
||||
// =================================================================
|
||||
|
||||
import 'package:CCTV_App/device_management_page.dart';
|
||||
import 'package:CCTV_App/main.dart';
|
||||
import 'package:CCTV_App/provider.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
class AuthWrapper extends StatelessWidget {
|
||||
const AuthWrapper({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<AuthState>(
|
||||
stream: supabase.auth.onAuthStateChange,
|
||||
builder: (context, snapshot) {
|
||||
// Tampilkan loading spinner selagi menunggu event auth pertama.
|
||||
if (!snapshot.hasData) {
|
||||
return Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
if (appState.isLoading && appState.currentUser == null) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
// Sesuai alur aplikasi, selalu mulai dari LoginPage untuk meminta Device ID,
|
||||
// terlepas dari status sesi sebelumnya.
|
||||
return const LoginPage();
|
||||
if (appState.currentUser == null) {
|
||||
return const AuthPage();
|
||||
} else {
|
||||
return const DeviceManagementPage();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AuthPage extends StatefulWidget {
|
||||
const AuthPage({super.key});
|
||||
@override
|
||||
State<AuthPage> createState() => _AuthPageState();
|
||||
}
|
||||
|
||||
class _AuthPageState extends State<AuthPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _fullNameController = TextEditingController();
|
||||
bool _isLogin = true;
|
||||
bool _isLoading = false;
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
if (_isLogin) {
|
||||
await supabase.auth.signInWithPassword(
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text.trim(),
|
||||
);
|
||||
} else {
|
||||
await supabase.auth.signUp(
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text.trim(),
|
||||
data: {'full_name': _fullNameController.text.trim()});
|
||||
}
|
||||
} on AuthException catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(e.message),
|
||||
backgroundColor: Theme.of(context).colorScheme.error));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: const Text('An unexpected error occurred.'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error));
|
||||
}
|
||||
}
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Icon(CupertinoIcons.shield_lefthalf_fill,
|
||||
size: 80, color: Theme.of(context).primaryColor),
|
||||
const SizedBox(height: 24),
|
||||
Text(_isLogin ? 'Smart Anti Theft' : 'Buat Akun',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: Colors.white, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
Text(_isLogin ? 'Sign In untuk melanjutkan' : 'Daftar Sekarang',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge),
|
||||
const SizedBox(height: 32),
|
||||
if (!_isLogin)
|
||||
TextFormField(
|
||||
controller: _fullNameController,
|
||||
decoration: const InputDecoration(labelText: 'Nama Lengkap'),
|
||||
validator: (value) =>
|
||||
value!.isEmpty ? 'Masukkan nama lengkap anda' : null,
|
||||
),
|
||||
if (!_isLogin) const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(labelText: 'Email'),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (value) =>
|
||||
value!.isEmpty ? 'Masukkan email google anda' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(labelText: 'Password'),
|
||||
obscureText: true,
|
||||
validator: (value) => value!.length < 6
|
||||
? 'Password harus setidaknya 6 karakter'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ElevatedButton(
|
||||
onPressed: _submit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
foregroundColor: Colors.black),
|
||||
child: Text(_isLogin ? 'SIGN IN' : 'SIGN UP',
|
||||
style:
|
||||
const TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _isLogin = !_isLogin),
|
||||
child: Text(
|
||||
_isLogin
|
||||
? 'Belum memiliki akun? Daftar disini'
|
||||
: 'Sudah memiliki akun? Sign In disini',
|
||||
style: const TextStyle(color: Colors.white70)),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,473 +0,0 @@
|
|||
// =================================================================
|
||||
// DASHBOARD PAGE & COMPONENTS
|
||||
// =================================================================
|
||||
|
||||
import 'package:CCTV_App/login_page.dart';
|
||||
import 'package:CCTV_App/provider.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
// =================================================================
|
||||
// DASHBOARD PAGE & COMPONENTS
|
||||
// =================================================================
|
||||
|
||||
class DashboardPage extends StatelessWidget {
|
||||
const DashboardPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
if (appState.isLoading && appState.events.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: Provider.of<AppState>(context, listen: false)
|
||||
.fetchInitialData,
|
||||
color: Colors.tealAccent,
|
||||
backgroundColor: const Color(0xFF2c2c2c),
|
||||
child: const CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: ProfileBar()),
|
||||
SliverToBoxAdapter(child: SettingsBar()),
|
||||
SliverToBoxAdapter(child: SizedBox(height: 20)),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Text("Footage Gallery",
|
||||
style: TextStyle(
|
||||
fontSize: 22, fontWeight: FontWeight.bold)),
|
||||
)),
|
||||
SliverToBoxAdapter(child: SizedBox(height: 10)),
|
||||
FootageGallery(),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
return Visibility(
|
||||
visible: appState.isLoading,
|
||||
child: const LinearProgressIndicator(
|
||||
color: Colors.tealAccent,
|
||||
backgroundColor: Colors.transparent),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProfileBar extends StatelessWidget {
|
||||
const ProfileBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = Provider.of<AppState>(context, listen: false).currentUser;
|
||||
final fullName = user?.userMetadata?['full_name'] ?? 'No Name';
|
||||
final email = user?.email ?? 'No Email';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: Colors.teal,
|
||||
child: Text(fullName.isNotEmpty ? fullName[0].toUpperCase() : 'U',
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(fullName,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white)),
|
||||
Text(email,
|
||||
style:
|
||||
const TextStyle(fontSize: 14, color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: "Logout",
|
||||
icon: const Icon(Icons.logout, color: Colors.white70),
|
||||
onPressed: () async {
|
||||
final bool? confirmLogout = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
backgroundColor: const Color(0xFF2c2c2c),
|
||||
title: const Text('Konfirmasi Logout',
|
||||
style: TextStyle(color: Colors.white)),
|
||||
content: const Text('Apakah Anda yakin ingin keluar?',
|
||||
style: TextStyle(color: Colors.white70)),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Batal',
|
||||
style: TextStyle(color: Colors.white70)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Logout',
|
||||
style: TextStyle(color: Colors.redAccent)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (confirmLogout == true) {
|
||||
// PERBAIKAN: Navigasi dulu, baru proses logout di latar belakang.
|
||||
final appState = Provider.of<AppState>(context, listen: false);
|
||||
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
const LoginPage(showLogoutSuccess: true)),
|
||||
(route) => false,
|
||||
);
|
||||
|
||||
await appState.signOut();
|
||||
}
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SettingsBar extends StatelessWidget {
|
||||
const SettingsBar({super.key});
|
||||
|
||||
void _showSetTimerDialog(BuildContext context, AppState appState) async {
|
||||
final now = DateTime.now();
|
||||
final TimeOfDay? pickedTime = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(now.add(const Duration(hours: 1))),
|
||||
);
|
||||
|
||||
if (pickedTime != null) {
|
||||
DateTime scheduledTime = DateTime(
|
||||
now.year, now.month, now.day, pickedTime.hour, pickedTime.minute);
|
||||
if (scheduledTime.isBefore(now)) {
|
||||
scheduledTime = scheduledTime.add(const Duration(days: 1));
|
||||
}
|
||||
|
||||
final duration = scheduledTime.difference(now);
|
||||
final durationMicroseconds = duration.inMicroseconds;
|
||||
|
||||
final error = await appState.setSleepSchedule(durationMicroseconds);
|
||||
|
||||
if (context.mounted) {
|
||||
if (error == null) {
|
||||
final formattedTime = DateFormat.jm('id_ID').format(scheduledTime);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Jadwal deep sleep diatur hingga $formattedTime'),
|
||||
backgroundColor: Colors.green,
|
||||
));
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Gagal mengatur jadwal: $error'),
|
||||
backgroundColor: Colors.redAccent,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
final statusData = appState.deviceStatus;
|
||||
|
||||
if (statusData == null) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Center(
|
||||
child: Text("Mencari status perangkat...",
|
||||
style: TextStyle(color: Colors.white24))),
|
||||
);
|
||||
}
|
||||
|
||||
final deviceId = statusData['device_id'] ?? 'N/A';
|
||||
final deviceStatus = statusData['status'] ?? 'unknown';
|
||||
final isOnline = deviceStatus == 'active';
|
||||
final lastUpdateString =
|
||||
statusData['last_update'] ?? DateTime.now().toIso8601String();
|
||||
final lastUpdate = DateTime.parse(lastUpdateString).toLocal();
|
||||
final formattedLastUpdate =
|
||||
DateFormat('dd MMM, HH:mm:ss', 'id_ID').format(lastUpdate);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Device: $deviceId',
|
||||
style: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.circle,
|
||||
color: isOnline
|
||||
? Colors.greenAccent
|
||||
: Colors.redAccent,
|
||||
size: 12),
|
||||
const SizedBox(width: 8),
|
||||
Text(isOnline ? 'Active' : 'Inactive',
|
||||
style: const TextStyle(color: Colors.white)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Last update: $formattedLastUpdate',
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: Colors.white54),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: isOnline
|
||||
? () => _showSetTimerDialog(context, appState)
|
||||
: null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.teal,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: Colors.grey.withOpacity(0.3)),
|
||||
child: const Text('Set Timer'),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FootageGallery extends StatelessWidget {
|
||||
const FootageGallery({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
final events = appState.events;
|
||||
if (events.isEmpty) {
|
||||
return const SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(48.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(CupertinoIcons.photo_on_rectangle,
|
||||
size: 60, color: Colors.white24),
|
||||
SizedBox(height: 16),
|
||||
Text('Belum ada tangkapan',
|
||||
style: TextStyle(color: Colors.white24, fontSize: 16)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final event = events[index];
|
||||
return FootageCard(event: event);
|
||||
},
|
||||
childCount: events.length,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FootageCard extends StatelessWidget {
|
||||
final Map<String, dynamic> event;
|
||||
const FootageCard({super.key, required this.event});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final imageUrl = event['image_ref'] as String?;
|
||||
final location = event['location'] ?? 'Unknown Location';
|
||||
final eventType = event['event_type'] ?? 'Unknown Event';
|
||||
final timestampString =
|
||||
event['timestamp'] ?? DateTime.now().toIso8601String();
|
||||
final timestamp = DateTime.parse(timestampString);
|
||||
|
||||
final formattedTime = DateFormat('EEEE, dd MMMM yyyy, HH:mm:ss', 'id_ID')
|
||||
.format(timestamp.toLocal());
|
||||
|
||||
String displayEventType = 'Unknown';
|
||||
Color eventColor = Colors.grey;
|
||||
if (eventType == 'motion') {
|
||||
displayEventType = 'Motion Detected';
|
||||
eventColor = Colors.orange.shade800;
|
||||
} else if (eventType == 'vibration') {
|
||||
displayEventType = 'Vibration Detected';
|
||||
eventColor = Colors.purple.shade800;
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
spreadRadius: 2,
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (imageUrl != null && imageUrl.isNotEmpty)
|
||||
Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: 250,
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Container(
|
||||
height: 250,
|
||||
color: Colors.black12,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.tealAccent,
|
||||
value: loadingProgress.expectedTotalBytes != null
|
||||
? loadingProgress.cumulativeBytesLoaded /
|
||||
loadingProgress.expectedTotalBytes!
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
height: 250,
|
||||
color: Colors.black12,
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(CupertinoIcons.exclamationmark_triangle,
|
||||
color: Colors.redAccent, size: 50),
|
||||
SizedBox(height: 8),
|
||||
Text("Gagal memuat gambar",
|
||||
style: TextStyle(color: Colors.white70))
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
height: 100,
|
||||
color: Colors.black12,
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(CupertinoIcons.videocam,
|
||||
size: 30, color: Colors.white24),
|
||||
SizedBox(height: 8),
|
||||
Text("No image captured",
|
||||
style: TextStyle(color: Colors.white24))
|
||||
],
|
||||
)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: eventColor,
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
child: Text(
|
||||
displayEventType,
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(CupertinoIcons.location_solid,
|
||||
size: 16, color: Colors.white70),
|
||||
const SizedBox(width: 8),
|
||||
Text(location,
|
||||
style: const TextStyle(
|
||||
fontSize: 16, color: Colors.white)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(CupertinoIcons.time_solid,
|
||||
size: 16, color: Colors.white70),
|
||||
const SizedBox(width: 8),
|
||||
Text(formattedTime,
|
||||
style: const TextStyle(
|
||||
fontSize: 14, color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,194 @@
|
|||
// =================================================================
|
||||
// HALAMAN BARU: DEVICE MANAGEMENT
|
||||
// =================================================================
|
||||
|
||||
import 'package:CCTV_App/home_page.dart';
|
||||
import 'package:CCTV_App/provider.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class DeviceManagementPage extends StatefulWidget {
|
||||
const DeviceManagementPage({super.key});
|
||||
@override
|
||||
State<DeviceManagementPage> createState() => _DeviceManagementPageState();
|
||||
}
|
||||
|
||||
class _DeviceManagementPageState extends State<DeviceManagementPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() => context.read<AppState>().fetchUserDevices());
|
||||
}
|
||||
|
||||
void _showAddDeviceDialog() {
|
||||
final formKey = GlobalKey<FormState>();
|
||||
final deviceIdController = TextEditingController();
|
||||
final deviceNameController = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
backgroundColor: Theme.of(context).cardColor,
|
||||
title: const Text('Tambah perangkat baru'),
|
||||
content: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: deviceIdController,
|
||||
decoration: const InputDecoration(labelText: 'Device ID'),
|
||||
validator: (value) =>
|
||||
value!.isEmpty ? 'Device ID wajib diisi' : null),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: deviceNameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nama perangkat (e.g., Teras Rumah)'),
|
||||
validator: (value) =>
|
||||
value!.isEmpty ? 'Perangkat harus diberi nama' : null),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Batal')),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (formKey.currentState!.validate()) {
|
||||
final appState = context.read<AppState>();
|
||||
final result = await appState.addUserDevice(
|
||||
deviceIdController.text.trim(),
|
||||
deviceNameController.text.trim());
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content:
|
||||
Text(result ?? 'Perangkat berhasil ditambahkan!'),
|
||||
backgroundColor: result == null
|
||||
? Colors.green
|
||||
: Theme.of(context).colorScheme.error));
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Tambah')),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Daftar Perangkat Tertaut'),
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: () async => await context.read<AppState>().signOut())
|
||||
],
|
||||
),
|
||||
body: Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
if (appState.isLoading && appState.userDevices.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (appState.userDevices.isEmpty) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(CupertinoIcons.camera_viewfinder,
|
||||
size: 60, color: Colors.white38),
|
||||
SizedBox(height: 16),
|
||||
Text('Perangkat tidak ditermukan',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white)),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Klik [+] dipojok kanan bawah untuk menambahkan perangkat.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => appState.fetchUserDevices(),
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
itemCount: appState.userDevices.length,
|
||||
itemBuilder: (context, index) {
|
||||
final device = appState.userDevices[index];
|
||||
return Card(
|
||||
margin:
|
||||
const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 10, horizontal: 20),
|
||||
leading: const Icon(CupertinoIcons.camera_fill,
|
||||
color: Colors.tealAccent),
|
||||
title: Text(device['device_name'],
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold, color: Colors.white)),
|
||||
subtitle: Text(device['device_id'],
|
||||
style: const TextStyle(color: Colors.white70)),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline,
|
||||
color: Colors.redAccent),
|
||||
onPressed: () async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Confirm Deletion'),
|
||||
content: Text(
|
||||
'Apakah anda yakin untuk menghapus perangkat ${device['device_name']}?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(false),
|
||||
child: const Text('Batal')),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(true),
|
||||
child: const Text('Hapus',
|
||||
style: TextStyle(
|
||||
color: Colors.redAccent))),
|
||||
],
|
||||
));
|
||||
if (confirm == true) {
|
||||
await appState.removeUserDevice(device['id']);
|
||||
}
|
||||
},
|
||||
),
|
||||
onTap: () async {
|
||||
await appState.selectDevice(device);
|
||||
if (mounted) {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => const HomePage()));
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _showAddDeviceDialog,
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
child: const Icon(Icons.add, color: Colors.black)),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,388 @@
|
|||
// =================================================================
|
||||
// HALAMAN UTAMA (MONITORING) - DENGAN PENYESUAIAN
|
||||
// =================================================================
|
||||
|
||||
import 'package:CCTV_App/provider.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class HomePage extends StatelessWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = Provider.of<AppState>(context);
|
||||
final deviceName = appState.selectedDevice?['device_name'] ?? 'Perangkat';
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Sedang memantau perangkat: $deviceName'),
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
context.read<AppState>().deselectDevice();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () => appState.fetchInitialDataForSelectedDevice(),
|
||||
child: appState.isLoading && appState.events.isEmpty
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView(
|
||||
children: [
|
||||
const DeviceStatusCard(),
|
||||
if (appState.events.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 50.0),
|
||||
child: Center(
|
||||
child: Text('Belum ada ancaman yang direkam.',
|
||||
style: TextStyle(color: Colors.white70))),
|
||||
),
|
||||
...appState.events.map((event) => EventCard(event: event)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DeviceStatusCard extends StatelessWidget {
|
||||
const DeviceStatusCard({super.key});
|
||||
|
||||
void _showSleepScheduleDialog(BuildContext context) {
|
||||
TimeOfDay? selectedTime;
|
||||
String? durationText;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (BuildContext context, StateSetter setState) {
|
||||
return AlertDialog(
|
||||
backgroundColor: Theme.of(context).cardColor,
|
||||
title: const Text('Set Timer Deep Sleep'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
"Perangkat akan deepsleep hingga waktu yang dipilih."),
|
||||
const SizedBox(height: 20),
|
||||
ListTile(
|
||||
onTap: () async {
|
||||
final time = await showTimePicker(
|
||||
context: context, initialTime: TimeOfDay.now());
|
||||
if (time != null) {
|
||||
setState(() {
|
||||
selectedTime = time;
|
||||
final now = DateTime.now();
|
||||
var scheduledTime = DateTime(now.year, now.month,
|
||||
now.day, time.hour, time.minute);
|
||||
if (scheduledTime.isBefore(now) ||
|
||||
scheduledTime.isAtSameMomentAs(now)) {
|
||||
scheduledTime =
|
||||
scheduledTime.add(const Duration(days: 1));
|
||||
}
|
||||
final duration = scheduledTime.difference(now);
|
||||
durationText = _formatDuration(duration);
|
||||
});
|
||||
}
|
||||
},
|
||||
leading: const Icon(CupertinoIcons.clock_fill),
|
||||
title: Text(
|
||||
selectedTime?.format(context) ?? 'Pilih waktu selesai',
|
||||
style: const TextStyle(
|
||||
color: Colors.tealAccent,
|
||||
fontWeight: FontWeight.bold)),
|
||||
subtitle: Text(durationText ?? "Klik untuk set timer"),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel')),
|
||||
ElevatedButton(
|
||||
onPressed: selectedTime == null
|
||||
? null
|
||||
: () async {
|
||||
final now = DateTime.now();
|
||||
var scheduledTime = DateTime(
|
||||
now.year,
|
||||
now.month,
|
||||
now.day,
|
||||
selectedTime!.hour,
|
||||
selectedTime!.minute);
|
||||
if (scheduledTime.isBefore(now) ||
|
||||
scheduledTime.isAtSameMomentAs(now)) {
|
||||
scheduledTime =
|
||||
scheduledTime.add(const Duration(days: 1));
|
||||
}
|
||||
final durationInMicroseconds =
|
||||
scheduledTime.difference(now).inMicroseconds;
|
||||
final appState = context.read<AppState>();
|
||||
final result = await appState
|
||||
.setSleepSchedule(durationInMicroseconds);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content:
|
||||
Text(result ?? 'Deepsleep sukses terjadwal!'),
|
||||
backgroundColor: result == null
|
||||
? Colors.green
|
||||
: Theme.of(context).colorScheme.error,
|
||||
));
|
||||
}
|
||||
},
|
||||
child: const Text('Set'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
String twoDigits(int n) => n.toString().padLeft(2, "0");
|
||||
final hours = twoDigits(duration.inHours);
|
||||
final minutes = twoDigits(duration.inMinutes.remainder(60));
|
||||
return "Durasi: $hours jam, $minutes menit";
|
||||
}
|
||||
|
||||
String _formatSleepDuration(int microseconds) {
|
||||
if (microseconds <= 0) return 'Timer belum di set';
|
||||
final duration = Duration(microseconds: microseconds);
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes.remainder(60);
|
||||
return '${hours}h ${minutes}m tersisa';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
final status =
|
||||
appState.deviceStatus?['status'] as String? ?? 'Tidak diketahui';
|
||||
final lastUpdateStr = appState.deviceStatus?['last_update'] as String?;
|
||||
final lastUpdate = lastUpdateStr != null
|
||||
? DateTime.parse(lastUpdateStr).toLocal()
|
||||
: null;
|
||||
final formattedTime = lastUpdate != null
|
||||
? DateFormat('d MMM, HH:mm:ss', 'id_ID').format(lastUpdate)
|
||||
: 'N/A';
|
||||
final scheduleMicroseconds =
|
||||
appState.deviceStatus?['schedule_duration'] as int? ?? 0;
|
||||
|
||||
final isSleeping = status == 'sleeping';
|
||||
final sleepDurationText = _formatSleepDuration(scheduleMicroseconds);
|
||||
final hasActiveTimer = scheduleMicroseconds > 0;
|
||||
|
||||
Color statusColor;
|
||||
IconData statusIcon;
|
||||
String statusText;
|
||||
|
||||
if (isSleeping) {
|
||||
statusColor = Colors.lightBlueAccent;
|
||||
statusIcon = CupertinoIcons.zzz;
|
||||
statusText = 'DEEPSLEEP';
|
||||
} else if (status == 'active') {
|
||||
statusColor = Colors.greenAccent;
|
||||
statusIcon = CupertinoIcons.checkmark_shield_fill;
|
||||
statusText = 'ONLINE';
|
||||
} else {
|
||||
statusColor = Colors.redAccent;
|
||||
statusIcon = CupertinoIcons.xmark_shield_fill;
|
||||
statusText = 'OFFLINE';
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(statusIcon, color: statusColor, size: 28),
|
||||
const SizedBox(width: 12),
|
||||
Text(statusText,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: statusColor)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(color: Colors.white24),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Terakhir aktif: $formattedTime',
|
||||
style: const TextStyle(color: Colors.white70)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
hasActiveTimer
|
||||
? sleepDurationText
|
||||
: 'Timer Deepsleep belum di set',
|
||||
style: TextStyle(
|
||||
color: hasActiveTimer
|
||||
? Colors.lightBlueAccent
|
||||
: Colors.white70)),
|
||||
],
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(CupertinoIcons.timer, size: 16),
|
||||
label: const Text('Set Timer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isSleeping
|
||||
? Colors.grey.shade800
|
||||
: Theme.of(context).primaryColor,
|
||||
foregroundColor:
|
||||
isSleeping ? Colors.grey.shade400 : Colors.black,
|
||||
),
|
||||
onPressed: isSleeping
|
||||
? null
|
||||
: () => _showSleepScheduleDialog(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// === WIDGET DENGAN PERBAIKAN UTAMA ===
|
||||
class EventCard extends StatelessWidget {
|
||||
final Map<String, dynamic> event;
|
||||
const EventCard({super.key, required this.event});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final eventType = event['event_type'] as String?;
|
||||
final location = event['location'] as String? ?? 'Lokasi tidak diketahui';
|
||||
final timestampStr = event['timestamp'] as String?;
|
||||
final timestamp = timestampStr != null
|
||||
? DateTime.parse(timestampStr).toLocal()
|
||||
: DateTime.now();
|
||||
final formattedTime =
|
||||
DateFormat('EEEE, d MMMM yyyy, HH:mm:ss', 'id_ID').format(timestamp);
|
||||
|
||||
// PERBAIKAN #1 (Lanjutan): Langsung gunakan 'image_ref' sebagai imageUrl.
|
||||
final imageRef = event['image_ref'] as String?;
|
||||
final imageUrl = imageRef; // Tidak perlu pemrosesan lagi
|
||||
|
||||
final String displayEventType;
|
||||
final Color eventColor;
|
||||
|
||||
// PERBAIKAN #2: Mencocokkan nilai 'event_type' dengan yang dikirim oleh ESP32.
|
||||
// ESP32 mengirim "motion" dan "vibration". Kode sebelumnya memeriksa "motion_detected"
|
||||
// dan "vibration_detected", yang menyebabkan selalu jatuh ke 'Unknown Event'.
|
||||
if (eventType == 'motion') {
|
||||
displayEventType = 'Gerakan Terdeteksi';
|
||||
eventColor = Colors.orange;
|
||||
} else if (eventType == 'vibration') {
|
||||
displayEventType = 'Getaran Terdeteksi';
|
||||
eventColor = Colors.purpleAccent;
|
||||
} else {
|
||||
displayEventType = 'Event tidak diketahui';
|
||||
eventColor = Colors.grey;
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (imageUrl != null && imageUrl.isNotEmpty)
|
||||
InkWell(
|
||||
onTap: () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: InteractiveViewer(
|
||||
child: Image.network(imageUrl,
|
||||
loadingBuilder: (context, child, progress) =>
|
||||
progress == null
|
||||
? child
|
||||
: const Center(
|
||||
child: CircularProgressIndicator()),
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
const Icon(Icons.error,
|
||||
color: Colors.red, size: 50))))),
|
||||
child: Image.network(
|
||||
imageUrl,
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
// Tambahkan print untuk debugging jika gambar masih gagal dimuat
|
||||
print("Gagal memuat image: $error");
|
||||
print("URL Gambar: $imageUrl");
|
||||
return Container(
|
||||
height: 200,
|
||||
color: Colors.black26,
|
||||
child: const Center(
|
||||
child: Icon(Icons.broken_image,
|
||||
color: Colors.white30, size: 40)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: eventColor,
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
child: Text(displayEventType,
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(children: [
|
||||
const Icon(CupertinoIcons.location_solid,
|
||||
size: 16, color: Colors.white70),
|
||||
const SizedBox(width: 8),
|
||||
Text(location,
|
||||
style:
|
||||
const TextStyle(fontSize: 16, color: Colors.white)),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
Row(children: [
|
||||
const Icon(CupertinoIcons.time_solid,
|
||||
size: 16, color: Colors.white70),
|
||||
const SizedBox(width: 8),
|
||||
Text(formattedTime,
|
||||
style:
|
||||
const TextStyle(fontSize: 14, color: Colors.white70)),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,175 +0,0 @@
|
|||
// import 'package:CCTV_App/dashboard_page.dart';
|
||||
import 'package:CCTV_App/dashboard_page.dart';
|
||||
import 'package:CCTV_App/main.dart';
|
||||
import 'package:CCTV_App/provider.dart';
|
||||
import 'package:CCTV_App/register_page.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
class LoginPage extends StatefulWidget {
|
||||
final bool showLogoutSuccess;
|
||||
|
||||
const LoginPage({super.key, this.showLogoutSuccess = false});
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _deviceIdController = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isLoading = false;
|
||||
|
||||
// PERBAIKAN: Menambahkan initState untuk menampilkan snackbar jika diperlukan.
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.showLogoutSuccess) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Anda telah berhasil logout.'),
|
||||
backgroundColor: Colors.green,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _signIn() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
final appState = Provider.of<AppState>(context, listen: false);
|
||||
|
||||
try {
|
||||
final authResponse = await supabase.auth.signInWithPassword(
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text.trim(),
|
||||
);
|
||||
|
||||
if (authResponse.user == null) {
|
||||
throw 'Login gagal, pengguna tidak ditemukan.';
|
||||
}
|
||||
|
||||
final deviceIdIsValid = await appState
|
||||
.setDeviceIdAndFetchData(_deviceIdController.text.trim());
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (deviceIdIsValid) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const DashboardPage()),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('Login berhasil, tetapi Device ID tidak terdaftar.'),
|
||||
backgroundColor: Colors.orange,
|
||||
));
|
||||
await appState.signOut();
|
||||
}
|
||||
} on AuthException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(e.message),
|
||||
backgroundColor: Colors.redAccent,
|
||||
));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Terjadi kesalahan: ${e.toString()}'),
|
||||
backgroundColor: Colors.redAccent,
|
||||
));
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userEmail = supabase.auth.currentUser?.email;
|
||||
if (userEmail != null && _emailController.text.isEmpty) {
|
||||
_emailController.text = userEmail;
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(CupertinoIcons.shield_lefthalf_fill,
|
||||
size: 80, color: Colors.tealAccent),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Security Monitor',
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 48),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(labelText: 'Email'),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (value) =>
|
||||
value!.isEmpty ? 'Email tidak boleh kosong' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(labelText: 'Password'),
|
||||
obscureText: true,
|
||||
validator: (value) =>
|
||||
value!.isEmpty ? 'Password tidak boleh kosong' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _deviceIdController,
|
||||
decoration: const InputDecoration(labelText: 'Device ID'),
|
||||
validator: (value) =>
|
||||
value!.isEmpty ? 'Device ID tidak boleh kosong' : null,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _signIn,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
backgroundColor: Colors.tealAccent,
|
||||
foregroundColor: Colors.black,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.black))
|
||||
: const Text('Login',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => const RegisterPage())),
|
||||
child: const Text('Belum punya akun? Daftar sekarang',
|
||||
style: TextStyle(color: Colors.white70)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -256,7 +256,7 @@ class AppState extends ChangeNotifier {
|
|||
|
||||
try {
|
||||
await supabase.from('device_status').update({
|
||||
'schedule_duration_microseconds': durationMicroseconds,
|
||||
'schedule_duration': durationMicroseconds,
|
||||
'last_update': DateTime.now().toIso8601String(),
|
||||
}).eq('device_id', _deviceId!);
|
||||
|
|
@ -48,6 +48,7 @@ class MyApp extends StatelessWidget {
|
|||
secondary: Colors.teal,
|
||||
surface: Color(0xFF2c2c2c),
|
||||
onSurface: Colors.white,
|
||||
error: Colors.redAccent,
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
bodyLarge: TextStyle(color: Colors.white70),
|
||||
|
@ -65,13 +66,8 @@ class MyApp extends StatelessWidget {
|
|||
labelStyle: const TextStyle(color: Colors.white54)),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const AuthWrapper(), // Menggunakan AuthWrapper untuk logika awal
|
||||
home: const AuthWrapper(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// SUPABASE_URL=https://ihuetazxejsioxcjejpj.supabase.co
|
||||
// SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImlodWV0YXp4ZWpzaW94Y2planBqIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTExMDEyMTgsImV4cCI6MjA2NjY3NzIxOH0.IKFyrKioiScfAS_9UouPEcesHHoas0SDZH0mZBCA1ro
|
||||
|
||||
|
|
|
@ -2,91 +2,161 @@
|
|||
// STATE MANAGEMENT (PROVIDER)
|
||||
// =================================================================
|
||||
|
||||
// =================================================================
|
||||
// STATE MANAGEMENT (PROVIDER)
|
||||
// =================================================================
|
||||
|
||||
import 'package:CCTV_App/main.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
class AppState extends ChangeNotifier {
|
||||
bool _isLoading = false;
|
||||
String? _deviceId;
|
||||
User? _currentUser;
|
||||
|
||||
List<Map<String, dynamic>> _userDevices = [];
|
||||
Map<String, dynamic>? _selectedDevice;
|
||||
|
||||
List<Map<String, dynamic>> _events = [];
|
||||
Map<String, dynamic>? _deviceStatus;
|
||||
User? _currentUser;
|
||||
|
||||
RealtimeChannel? _eventsChannel;
|
||||
RealtimeChannel? _deviceStatusChannel;
|
||||
|
||||
bool get isLoading => _isLoading;
|
||||
String? get deviceId => _deviceId;
|
||||
User? get currentUser => _currentUser;
|
||||
List<Map<String, dynamic>> get userDevices => _userDevices;
|
||||
Map<String, dynamic>? get selectedDevice => _selectedDevice;
|
||||
List<Map<String, dynamic>> get events => _events;
|
||||
Map<String, dynamic>? get deviceStatus => _deviceStatus;
|
||||
User? get currentUser => _currentUser;
|
||||
|
||||
String? get selectedDeviceId => _selectedDevice?['device_id'];
|
||||
|
||||
AppState() {
|
||||
_currentUser = supabase.auth.currentUser;
|
||||
if (_currentUser != null) {
|
||||
fetchUserDevices();
|
||||
}
|
||||
|
||||
supabase.auth.onAuthStateChange.listen((data) {
|
||||
final session = data.session;
|
||||
_currentUser = session?.user;
|
||||
if (_currentUser == null) {
|
||||
if (_currentUser != null) {
|
||||
fetchUserDevices();
|
||||
} else {
|
||||
clearState();
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
void _unsubscribeFromChannels() {
|
||||
if (_eventsChannel != null) {
|
||||
supabase.removeChannel(_eventsChannel!);
|
||||
_eventsChannel = null;
|
||||
}
|
||||
if (_deviceStatusChannel != null) {
|
||||
supabase.removeChannel(_deviceStatusChannel!);
|
||||
_deviceStatusChannel = null;
|
||||
void _setLoading(bool value) {
|
||||
if (_isLoading != value) {
|
||||
_isLoading = value;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _unsubscribeFromChannels() {
|
||||
_eventsChannel?.unsubscribe();
|
||||
_deviceStatusChannel?.unsubscribe();
|
||||
_eventsChannel = null;
|
||||
_deviceStatusChannel = null;
|
||||
}
|
||||
|
||||
void clearState() {
|
||||
_deviceId = null;
|
||||
_userDevices.clear();
|
||||
_selectedDevice = null;
|
||||
_events.clear();
|
||||
_deviceStatus = null;
|
||||
_unsubscribeFromChannels();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _setLoading(bool value) {
|
||||
_isLoading = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> setDeviceIdAndFetchData(String deviceId) async {
|
||||
Future<void> fetchUserDevices() async {
|
||||
if (_currentUser == null) return;
|
||||
_setLoading(true);
|
||||
try {
|
||||
final response = await supabase
|
||||
.from('user_devices')
|
||||
.select()
|
||||
.eq('user_id', _currentUser!.id)
|
||||
.order('created_at', ascending: true);
|
||||
_userDevices = List<Map<String, dynamic>>.from(response);
|
||||
} catch (e) {
|
||||
print('Error fetching user devices: $e');
|
||||
_userDevices = [];
|
||||
}
|
||||
_setLoading(false);
|
||||
}
|
||||
|
||||
Future<String?> addUserDevice(String deviceId, String deviceName) async {
|
||||
if (_currentUser == null) return "User not logged in.";
|
||||
_setLoading(true);
|
||||
try {
|
||||
final deviceExists = await supabase
|
||||
.from('device_status')
|
||||
.select('device_id')
|
||||
.eq('device_id', deviceId)
|
||||
.maybeSingle();
|
||||
|
||||
if (response == null) {
|
||||
if (deviceExists == null) {
|
||||
_setLoading(false);
|
||||
return false;
|
||||
return "Device ID tidak ditemukan. Pastikan perangkat Anda sudah online setidaknya sekali.";
|
||||
}
|
||||
|
||||
_deviceId = deviceId;
|
||||
await fetchInitialData();
|
||||
_listenToRealtimeChanges();
|
||||
notifyListeners();
|
||||
_setLoading(false);
|
||||
return true;
|
||||
await supabase.from('user_devices').insert({
|
||||
'user_id': _currentUser!.id,
|
||||
'device_id': deviceId,
|
||||
'device_name': deviceName,
|
||||
});
|
||||
|
||||
await fetchUserDevices();
|
||||
return null;
|
||||
} on PostgrestException catch (e) {
|
||||
if (e.code == '23505') {
|
||||
return "Perangkat ini sudah ada di dalam daftar Anda.";
|
||||
}
|
||||
return e.message;
|
||||
} catch (e) {
|
||||
print('Error validating device ID: $e');
|
||||
return 'Terjadi kesalahan tidak terduga.';
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchInitialData() async {
|
||||
if (_deviceId == null) return;
|
||||
Future<String?> removeUserDevice(int id) async {
|
||||
if (_currentUser == null) return "User not logged in.";
|
||||
_setLoading(true);
|
||||
try {
|
||||
await supabase.from('user_devices').delete().eq('id', id);
|
||||
await fetchUserDevices();
|
||||
return null;
|
||||
} catch (e) {
|
||||
return "Gagal menghapus perangkat.";
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> selectDevice(Map<String, dynamic> device) async {
|
||||
_setLoading(true);
|
||||
_selectedDevice = device;
|
||||
await fetchInitialDataForSelectedDevice();
|
||||
_listenToRealtimeChanges();
|
||||
_setLoading(false);
|
||||
}
|
||||
|
||||
void deselectDevice() {
|
||||
_selectedDevice = null;
|
||||
_events.clear();
|
||||
_deviceStatus = null;
|
||||
_unsubscribeFromChannels();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> fetchInitialDataForSelectedDevice() async {
|
||||
if (selectedDeviceId == null) return;
|
||||
_setLoading(true);
|
||||
await Future.wait([
|
||||
fetchEvents(),
|
||||
|
@ -96,13 +166,14 @@ class AppState extends ChangeNotifier {
|
|||
}
|
||||
|
||||
Future<void> fetchEvents() async {
|
||||
if (_deviceId == null) return;
|
||||
if (selectedDeviceId == null) return;
|
||||
try {
|
||||
final response = await supabase
|
||||
.from('events')
|
||||
.select()
|
||||
.eq('device_id', _deviceId!)
|
||||
.order('timestamp', ascending: false);
|
||||
.eq('device_id', selectedDeviceId!)
|
||||
.order('timestamp', ascending: false)
|
||||
.limit(50); // Batasi jumlah event awal
|
||||
_events = List<Map<String, dynamic>>.from(response);
|
||||
} catch (e) {
|
||||
print('Error fetching events: $e');
|
||||
|
@ -112,12 +183,12 @@ class AppState extends ChangeNotifier {
|
|||
}
|
||||
|
||||
Future<void> fetchDeviceStatus() async {
|
||||
if (_deviceId == null) return;
|
||||
if (selectedDeviceId == null) return;
|
||||
try {
|
||||
final response = await supabase
|
||||
.from('device_status')
|
||||
.select()
|
||||
.eq('device_id', _deviceId!)
|
||||
.eq('device_id', selectedDeviceId!)
|
||||
.single();
|
||||
_deviceStatus = response;
|
||||
} catch (e) {
|
||||
|
@ -128,10 +199,11 @@ class AppState extends ChangeNotifier {
|
|||
}
|
||||
|
||||
void _listenToRealtimeChanges() {
|
||||
if (_deviceId == null) return;
|
||||
if (selectedDeviceId == null) return;
|
||||
_unsubscribeFromChannels();
|
||||
|
||||
_eventsChannel = supabase.channel('public:events:device_id=$_deviceId');
|
||||
_eventsChannel =
|
||||
supabase.channel('public:events:device_id=$selectedDeviceId');
|
||||
_eventsChannel!
|
||||
.onPostgresChanges(
|
||||
event: PostgresChangeEvent.insert,
|
||||
|
@ -140,7 +212,7 @@ class AppState extends ChangeNotifier {
|
|||
filter: PostgresChangeFilter(
|
||||
type: PostgresChangeFilterType.eq,
|
||||
column: 'device_id',
|
||||
value: _deviceId!,
|
||||
value: selectedDeviceId!,
|
||||
),
|
||||
callback: (payload) {
|
||||
final newEvent = payload.newRecord;
|
||||
|
@ -153,16 +225,17 @@ class AppState extends ChangeNotifier {
|
|||
.subscribe();
|
||||
|
||||
_deviceStatusChannel =
|
||||
supabase.channel('public:device_status:device_id=$_deviceId');
|
||||
supabase.channel('public:device_status:device_id=$selectedDeviceId');
|
||||
_deviceStatusChannel!
|
||||
.onPostgresChanges(
|
||||
event: PostgresChangeEvent.update,
|
||||
event: PostgresChangeEvent
|
||||
.all, // Dengarkan semua perubahan (insert/update)
|
||||
schema: 'public',
|
||||
table: 'device_status',
|
||||
filter: PostgresChangeFilter(
|
||||
type: PostgresChangeFilterType.eq,
|
||||
column: 'device_id',
|
||||
value: _deviceId!,
|
||||
value: selectedDeviceId!,
|
||||
),
|
||||
callback: (payload) {
|
||||
_deviceStatus = payload.newRecord;
|
||||
|
@ -175,32 +248,25 @@ class AppState extends ChangeNotifier {
|
|||
Future<void> signOut() async {
|
||||
_setLoading(true);
|
||||
await supabase.auth.signOut();
|
||||
// clearState() akan dipanggil oleh listener onAuthStateChange
|
||||
_setLoading(false);
|
||||
}
|
||||
|
||||
Future<String?> setSleepSchedule(int durationMicroseconds) async {
|
||||
if (_deviceId == null) {
|
||||
return "Device not identified.";
|
||||
}
|
||||
if (selectedDeviceId == null) return "Device not identified.";
|
||||
_setLoading(true);
|
||||
|
||||
try {
|
||||
await supabase.from('device_status').update({
|
||||
'schedule_duration_microseconds': durationMicroseconds,
|
||||
'schedule_duration': durationMicroseconds,
|
||||
'last_update': DateTime.now().toIso8601String(),
|
||||
}).eq('device_id', _deviceId!);
|
||||
|
||||
_setLoading(false);
|
||||
}).eq('device_id', selectedDeviceId!);
|
||||
await fetchDeviceStatus();
|
||||
return null;
|
||||
} on PostgrestException catch (e) {
|
||||
_setLoading(false);
|
||||
print('Error setting sleep schedule: ${e.message}');
|
||||
return e.message;
|
||||
} catch (e) {
|
||||
_setLoading(false);
|
||||
print('Generic error setting sleep schedule: $e');
|
||||
return 'An unexpected error occurred.';
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,124 +0,0 @@
|
|||
import 'package:CCTV_App/main.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
class RegisterPage extends StatefulWidget {
|
||||
const RegisterPage({super.key});
|
||||
@override
|
||||
State<RegisterPage> createState() => _RegisterPageState();
|
||||
}
|
||||
|
||||
class _RegisterPageState extends State<RegisterPage> {
|
||||
final _fullNameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isLoading = false;
|
||||
|
||||
Future<void> _signUp() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
await supabase.auth.signUp(
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text.trim(),
|
||||
data: {'full_name': _fullNameController.text.trim()},
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text(
|
||||
'Registrasi berhasil! Silakan periksa email untuk verifikasi & login.'),
|
||||
backgroundColor: Colors.green,
|
||||
));
|
||||
Navigator.pop(context);
|
||||
} on AuthException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(e.message),
|
||||
backgroundColor: Colors.redAccent,
|
||||
));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text("Error: ${e.toString()}"),
|
||||
backgroundColor: Colors.redAccent,
|
||||
));
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(backgroundColor: Colors.transparent, elevation: 0),
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(CupertinoIcons.person_add_solid,
|
||||
size: 80, color: Colors.tealAccent),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Buat Akun Baru',
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 48),
|
||||
TextFormField(
|
||||
controller: _fullNameController,
|
||||
decoration: const InputDecoration(labelText: 'Nama Lengkap'),
|
||||
validator: (value) =>
|
||||
value!.isEmpty ? 'Nama tidak boleh kosong' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(labelText: 'Email'),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (value) =>
|
||||
value!.isEmpty ? 'Email tidak boleh kosong' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(labelText: 'Password'),
|
||||
obscureText: true,
|
||||
validator: (value) =>
|
||||
value!.length < 6 ? 'Password minimal 6 karakter' : null,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _signUp,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
backgroundColor: Colors.tealAccent,
|
||||
foregroundColor: Colors.black,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.black))
|
||||
: const Text('Register',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
// =================================================================
|
||||
// SPLASH SCREEN PAGE
|
||||
// =================================================================
|
||||
|
||||
import 'package:CCTV_App/auth_wrapper.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends State<SplashScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.delayed(const Duration(seconds: 3), () {
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const AuthWrapper()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(CupertinoIcons.lock_shield_fill,
|
||||
size: 120, color: Colors.tealAccent),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Smart Anti Theft',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(fontSize: 28),
|
||||
),
|
||||
const SizedBox(height: 80),
|
||||
const CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white54),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue