diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..1901bb2 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,167 @@ +from flask import Flask, request, jsonify +from models import db, User, Device, SensorLog +import secrets + +app = Flask(__name__) +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///smart_workspace.db' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +db.init_app(app) + +with app.app_context(): + db.create_all() + + +@app.route('/auth/register', methods=['POST']) +def register(): + data = request.json + if User.query.filter_by(email=data.get('email')).first(): + return jsonify({"error": "User already exists"}), 400 + + new_user = User( + full_name=data.get('fullName'), + email=data.get('email'), + password=data.get('password'), + department=data.get('department', 'Unknown') + ) + db.session.add(new_user) + db.session.commit() + return jsonify({"message": "User registered successfully"}), 201 + +@app.route('/auth/login', methods=['POST']) +def login(): + data = request.json + user = User.query.filter_by(email=data.get('email'), password=data.get('password')).first() + + if user: + token = secrets.token_hex(20) + user.access_token = token + db.session.commit() + return jsonify({"token": token, "user": user.to_dict()}), 200 + + return jsonify({"error": "Invalid email or password"}), 401 + +@app.route('/auth/account', methods=['DELETE']) +def delete_account(): + token = request.headers.get('Authorization') + user = User.query.filter_by(access_token=token).first() + if not user: + return jsonify({"error": "Unauthorized"}), 401 + + Device.query.filter_by(user_id=user.id).delete() + SensorLog.query.filter_by(user_id=user.id).delete() + + db.session.delete(user) + db.session.commit() + + return jsonify({"message": "Account, devices and logs deleted"}), 200 + + +@app.route('/devices', methods=['GET']) +def get_devices(): + token = request.headers.get('Authorization') + user = User.query.filter_by(access_token=token).first() + if not user: + return jsonify({"error": "Unauthorized"}), 401 + + devices = Device.query.filter_by(user_id=user.id).all() + return jsonify([device.to_dict() for device in devices]), 200 + +@app.route('/devices', methods=['POST']) +def add_device(): + token = request.headers.get('Authorization') + user = User.query.filter_by(access_token=token).first() + if not user: + return jsonify({"error": "Unauthorized"}), 401 + + data = request.json + new_device = Device( + id=data.get('id'), + title=data.get('title'), + value=data.get('value', '--'), + status=data.get('status', 'Stable'), + icon_code=data.get('icon_code'), + color_hex=data.get('color_hex'), + user_id=user.id + ) + db.session.add(new_device) + db.session.commit() + return jsonify(new_device.to_dict()), 201 + +@app.route('/devices/', methods=['PUT']) +def update_device(device_id): + token = request.headers.get('Authorization') + user = User.query.filter_by(access_token=token).first() + if not user: + return jsonify({"error": "Unauthorized"}), 401 + + device = Device.query.filter_by(id=device_id, user_id=user.id).first() + if not device: + return jsonify({"error": "Device not found"}), 404 + + data = request.json + device.title = data.get('title', device.title) + device.value = data.get('value', device.value) + device.status = data.get('status', device.status) + device.icon_code = data.get('icon_code', device.icon_code) + device.color_hex = data.get('color_hex', device.color_hex) + + db.session.commit() + return jsonify(device.to_dict()), 200 + +@app.route('/devices/', methods=['DELETE']) +def delete_device(device_id): + token = request.headers.get('Authorization') + user = User.query.filter_by(access_token=token).first() + if not user: + return jsonify({"error": "Unauthorized"}), 401 + + device = Device.query.filter_by(id=device_id, user_id=user.id).first() + if not device: + return jsonify({"error": "Device not found"}), 404 + + db.session.delete(device) + db.session.commit() + return jsonify({"message": "Device deleted"}), 200 + + +@app.route('/logs', methods=['POST']) +def save_log(): + token = request.headers.get('Authorization') + user = User.query.filter_by(access_token=token).first() + if not user: + return jsonify({"error": "Unauthorized"}), 401 + + data = request.json + new_log = SensorLog( + sensor_id=data.get('sensor_id'), + value=str(data.get('value')), + user_id=user.id + ) + db.session.add(new_log) + db.session.commit() + + return jsonify(new_log.to_dict()), 201 + +@app.route('/auth/profile', methods=['PUT']) +def update_profile(): + token = request.headers.get('Authorization') + user = User.query.filter_by(access_token=token).first() + if not user: + return jsonify({"error": "Unauthorized"}), 401 + + data = request.json + + if 'email' in data and data['email'] != user.email: + if User.query.filter_by(email=data['email']).first(): + return jsonify({"error": "Email already in use"}), 400 + + user.full_name = data.get('fullName', user.full_name) + user.email = data.get('email', user.email) + user.department = data.get('department', user.department) + + db.session.commit() + return jsonify(user.to_dict()), 200 + + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..93f9964 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,55 @@ +from flask_sqlalchemy import SQLAlchemy +from datetime import datetime +import uuid + +db = SQLAlchemy() + +class User(db.Model): + id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + full_name = db.Column(db.String(100), nullable=False) + email = db.Column(db.String(100), unique=True, nullable=False) + password = db.Column(db.String(100), nullable=False) + department = db.Column(db.String(100)) + access_token = db.Column(db.String(100), unique=True) + + def to_dict(self): + return { + "id": self.id, + "fullName": self.full_name, + "email": self.email, + "department": self.department + } + +class Device(db.Model): + id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + title = db.Column(db.String(100), nullable=False) + value = db.Column(db.String(50)) + status = db.Column(db.String(50), default="Stable") + icon_code = db.Column(db.Integer) + color_hex = db.Column(db.Integer) + user_id = db.Column(db.String(36), db.ForeignKey('user.id')) + + def to_dict(self): + return { + "id": self.id, + "title": self.title, + "value": self.value, + "status": self.status, + "icon_code": self.icon_code, + "color_hex": self.color_hex + } + +class SensorLog(db.Model): + id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + sensor_id = db.Column(db.String(100), nullable=False) + value = db.Column(db.String(50), nullable=False) + timestamp = db.Column(db.DateTime, default=datetime.utcnow) + user_id = db.Column(db.String(36), db.ForeignKey('user.id')) + + def to_dict(self): + return { + "id": self.id, + "sensor_id": self.sensor_id, + "value": self.value, + "timestamp": self.timestamp.isoformat() + } \ No newline at end of file diff --git a/instance/smart_workspace.db b/instance/smart_workspace.db new file mode 100644 index 0000000..abd146d Binary files /dev/null and b/instance/smart_workspace.db differ diff --git a/lib/models/device_model.dart b/lib/models/device_model.dart index 1bf9c5c..593639c 100644 --- a/lib/models/device_model.dart +++ b/lib/models/device_model.dart @@ -38,4 +38,29 @@ class DeviceModel { color: Color(map['color'] as int), ); } + + factory DeviceModel.fromJson(Map json) { + return DeviceModel( + id: json['id']?.toString() ?? '', + title: json['title']?.toString() ?? 'Unknown Node', + value: json['value']?.toString() ?? '--', + status: json['status']?.toString() ?? 'Stable', + icon: IconData( + json['icon_code'] as int? ?? Icons.device_hub.codePoint, + fontFamily: 'MaterialIcons', + ), + color: Color(json['color_hex'] as int? ?? 0xFF38BDF8), + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'value': value, + 'status': status, + 'icon_code': icon.codePoint, + 'color_hex': color.toARGB32(), + }; + } } diff --git a/lib/models/user_model.dart b/lib/models/user_model.dart index c9ec7fa..3af4aef 100644 --- a/lib/models/user_model.dart +++ b/lib/models/user_model.dart @@ -13,10 +13,10 @@ class UserModel { factory UserModel.fromJson(Map json) { return UserModel( - fullName: json['fullName'] as String, - email: json['email'] as String, - password: json['password'] as String, - department: json['department'] as String, + fullName: json['fullName']?.toString() ?? 'Unknown User', + email: json['email']?.toString() ?? 'no-email@system.io', + password: json['password']?.toString() ?? '', + department: json['department']?.toString() ?? 'KSA', ); } diff --git a/lib/profile/profile_screen.dart b/lib/profile/profile_screen.dart index f39209d..3159ee1 100644 --- a/lib/profile/profile_screen.dart +++ b/lib/profile/profile_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:mobile_flutter_iot/models/user_model.dart'; import 'package:mobile_flutter_iot/providers/auth_provider.dart'; import 'package:mobile_flutter_iot/repository/local_user_repository.dart'; +import 'package:mobile_flutter_iot/services/api_service.dart'; import 'package:mobile_flutter_iot/widgets/blur_blob.dart'; import 'package:mobile_flutter_iot/widgets/glass_card.dart'; import 'package:mobile_flutter_iot/widgets/profile_item.dart'; @@ -20,6 +21,7 @@ class _ProfileScreenState extends State { UserModel? _currentUser; final _userRepository = LocalUserRepository(); + final _apiService = ApiService(); @override void initState() { @@ -57,9 +59,7 @@ class _ProfileScreenState extends State { TextButton( onPressed: () async { final auth = Provider.of(context, listen: false); - Navigator.pop(dialogContext); - await auth.logout(); if (mounted) { @@ -72,7 +72,9 @@ class _ProfileScreenState extends State { }, child: const Text( 'LOGOUT', - style: TextStyle(color: Colors.redAccent), + style: TextStyle( + color: Colors.redAccent, + ), ), ), ], @@ -82,6 +84,8 @@ class _ProfileScreenState extends State { } Future _deleteAccount() async { + final authProvider = context.read(); + final confirm = await showDialog( context: context, builder: (context) => AlertDialog( @@ -95,7 +99,7 @@ class _ProfileScreenState extends State { ], ), content: const Text( - 'This will permanently erase your encryption keys ' + 'This will permanently erase your encryption keys, cloud data, ' 'and local profile. Continue?', style: TextStyle(color: Colors.white70), ), @@ -119,16 +123,36 @@ class _ProfileScreenState extends State { ); if (confirm == true) { - await _userRepository.deleteUser(); - if (mounted) { - await context.read().logout(); - if (mounted) { - Navigator.pushNamedAndRemoveUntil( - context, - '/login', - (route) => false, - ); - } + final success = await _apiService.deleteAccount(); + + if (!mounted) return; + + if (success) { + await _userRepository.deleteUser(); + + await authProvider.logout(); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Cloud Account Destroyed.'), + backgroundColor: Colors.redAccent, + ), + ); + Navigator.pushNamedAndRemoveUntil( + context, + '/login', + (route) => false, + ); + } else { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to delete cloud account. Check connection.'), + backgroundColor: Colors.orange, + ), + ); } } } @@ -136,12 +160,13 @@ class _ProfileScreenState extends State { Future _editProfileField( String title, String currentValue, - void Function(String) onSave, + Future Function(String) onSave, ) async { final controller = TextEditingController(text: currentValue); + return showDialog( context: context, - builder: (context) => AlertDialog( + builder: (dialogContext) => AlertDialog( backgroundColor: const Color(0xFF1E293B), title: Text('Update $title', style: const TextStyle(color: Colors.white)), @@ -157,13 +182,15 @@ class _ProfileScreenState extends State { ), actions: [ TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () => Navigator.pop(dialogContext), child: const Text('Cancel'), ), TextButton( - onPressed: () { - onSave(controller.text); - Navigator.pop(context); + onPressed: () async { + await onSave(controller.text); + + if (!dialogContext.mounted) return; + Navigator.pop(dialogContext); _loadUserData(); }, child: @@ -293,12 +320,25 @@ class _ProfileScreenState extends State { password: _currentUser!.password, department: _currentUser!.department, ); - await _userRepository.saveUser(updated); + final success = await _apiService.updateUserProfile(updated); + if (success) { + await _userRepository.saveUser(updated); + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to sync with cloud'), + backgroundColor: Colors.orange, + ), + ); + } } }), child: Text( name, - style: const TextStyle(fontSize: 26, fontWeight: FontWeight.bold), + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + ), ), ), const SizedBox(height: 4), @@ -320,7 +360,17 @@ class _ProfileScreenState extends State { password: _currentUser!.password, department: _currentUser!.department, ); - await _userRepository.saveUser(updated); + final success = await _apiService.updateUserProfile(updated); + if (success) { + await _userRepository.saveUser(updated); + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Email might be taken or offline'), + backgroundColor: Colors.orange, + ), + ); + } } }), child: Container( @@ -403,7 +453,17 @@ class _ProfileScreenState extends State { password: _currentUser!.password, department: val, ); - await _userRepository.saveUser(updated); + final success = await _apiService.updateUserProfile(updated); + if (success) { + await _userRepository.saveUser(updated); + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to sync with cloud'), + backgroundColor: Colors.orange, + ), + ); + } } }), ), diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index df059b5..20cf315 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -1,41 +1,75 @@ import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:mobile_flutter_iot/services/api_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; class AuthProvider with ChangeNotifier { final _storage = const FlutterSecureStorage(); + final _apiService = ApiService(); + bool _isLoggedIn = false; String? _userEmail; + String? _userName; bool get isLoggedIn => _isLoggedIn; String? get userEmail => _userEmail; + String? get userName => _userName; Future checkAuth() async { final prefs = await SharedPreferences.getInstance(); - _isLoggedIn = prefs.getBool('isLoggedIn') ?? false; - _userEmail = await _storage.read(key: 'user_email'); + + final token = await _storage.read(key: 'access_token'); + + _isLoggedIn = (prefs.getBool('isLoggedIn') ?? false) && (token != null); + + if (_isLoggedIn) { + _userEmail = await _storage.read(key: 'user_email'); + _userName = await _storage.read(key: 'user_name'); + } + notifyListeners(); } - Future login(String email, String password) async { - final prefs = await SharedPreferences.getInstance(); + Future login(String email, String password) async { + try { + final responseData = await _apiService.login(email, password); - await prefs.setBool('isLoggedIn', true); - await _storage.write(key: 'user_email', value: email); - await _storage.write(key: 'user_password', value: password); + if (responseData != null && responseData.containsKey('token')) { + final prefs = await SharedPreferences.getInstance(); - _isLoggedIn = true; - _userEmail = email; - notifyListeners(); + final token = responseData['token'].toString(); + final userJson = responseData['user'] as Map; + final userName = userJson['fullName'].toString(); + final userEmail = userJson['email'].toString(); + + await prefs.setBool('isLoggedIn', true); + await _storage.write(key: 'access_token', value: token); + await _storage.write(key: 'user_email', value: userEmail); + await _storage.write(key: 'user_name', value: userName); + + _isLoggedIn = true; + _userEmail = userEmail; + _userName = userName; + + notifyListeners(); + return true; + } + } catch (e) { + debugPrint('Auth Error: $e'); + } + return false; } Future logout() async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool('isLoggedIn', false); + await _storage.deleteAll(); _isLoggedIn = false; _userEmail = null; + _userName = null; + notifyListeners(); } } diff --git a/lib/providers/mqtt_provider.dart b/lib/providers/mqtt_provider.dart index 158ac9d..00930a4 100644 --- a/lib/providers/mqtt_provider.dart +++ b/lib/providers/mqtt_provider.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:mobile_flutter_iot/services/api_service.dart'; import 'package:mqtt_client/mqtt_client.dart'; import 'package:mqtt_client/mqtt_server_client.dart'; +import 'package:shared_preferences/shared_preferences.dart'; enum MqttStatus { disconnected, connecting, connected, error } @@ -9,12 +11,49 @@ class MqttProvider with ChangeNotifier { MqttStatus _status = MqttStatus.disconnected; String _airQuality = '0'; bool _isLedOn = false; + final ApiService _apiService = ApiService(); + + int startLockHour = 22; + int endLockHour = 6; + + bool _hasLoggedReadViolation = false; MqttStatus get status => _status; String get airQuality => _airQuality; bool get isLedOn => _isLedOn; + Future loadLockPolicy() async { + final prefs = await SharedPreferences.getInstance(); + startLockHour = prefs.getInt('start_lock') ?? 22; + endLockHour = prefs.getInt('end_lock') ?? 6; + notifyListeners(); + } + + Future updateLockHours(int start, int end) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('start_lock', start); + await prefs.setInt('end_lock', end); + startLockHour = start; + endLockHour = end; + + _hasLoggedReadViolation = false; + notifyListeners(); + } + + bool isTimeRestricted() { + final now = DateTime.now(); + final hour = now.hour; + + if (startLockHour > endLockHour) { + return hour >= startLockHour || hour < endLockHour; + } else { + return hour >= startLockHour && hour < endLockHour; + } + } + void initMqtt(String broker, String clientId) { + loadLockPolicy(); + client = MqttServerClient(broker, clientId); client!.port = 1883; client!.logging(on: false); @@ -63,7 +102,25 @@ class MqttProvider with ChangeNotifier { client!.subscribe('sensors/air', MqttQos.atMostOnce); client!.updates!.listen( - (List> messages) { + (List> messages) async { + if (isTimeRestricted()) { + _airQuality = '0'; + notifyListeners(); + + if (!_hasLoggedReadViolation) { + await _apiService.saveLog( + 'SECURITY_POLICY', + 'VIOLATION: Blocked incoming telemetry stream' + 'at (${DateTime.now().hour}:00)', + ); + _hasLoggedReadViolation = true; + debugPrint('Read blocked by time policy'); + } + return; + } + + _hasLoggedReadViolation = false; + final recMess = messages[0].payload as MqttPublishMessage; final payload = MqttPublishPayload.bytesToStringAsString(recMess.payload.message); @@ -77,8 +134,19 @@ class MqttProvider with ChangeNotifier { ); } - void toggleLed() { + Future toggleLed() async { if (client == null || _status != MqttStatus.connected) return; + + if (isTimeRestricted()) { + await _apiService.saveLog( + 'SECURITY_POLICY', + 'VIOLATION: Attempted to control LED' + 'at restricted time (${DateTime.now().hour}:00)', + ); + debugPrint('Action blocked by time policy'); + return; + } + try { _isLedOn = !_isLedOn; final builder = MqttClientPayloadBuilder(); diff --git a/lib/repository/local_user_repository.dart b/lib/repository/local_user_repository.dart index 7a807c6..c32c452 100644 --- a/lib/repository/local_user_repository.dart +++ b/lib/repository/local_user_repository.dart @@ -1,5 +1,4 @@ import 'dart:convert'; - import 'package:mobile_flutter_iot/models/device_model.dart'; import 'package:mobile_flutter_iot/models/user_model.dart'; import 'package:mobile_flutter_iot/repository/user_repository.dart'; @@ -21,6 +20,7 @@ class LocalUserRepository implements UserRepository { final prefs = await SharedPreferences.getInstance(); final String? userJson = prefs.getString(_userKey); if (userJson == null) return null; + final Map userMap = jsonDecode(userJson) as Map; return UserModel.fromJson(userMap); @@ -35,12 +35,6 @@ class LocalUserRepository implements UserRepository { } @override - Future validateCredentials(String email, String password) async { - final user = await getUser(); - if (user == null) return false; - return user.email == email && user.password == password; - } - Future saveDevices(List devices) async { final prefs = await SharedPreferences.getInstance(); final String devicesJson = jsonEncode( @@ -49,6 +43,7 @@ class LocalUserRepository implements UserRepository { await prefs.setString(_devicesKey, devicesJson); } + @override Future> getDevices() async { final prefs = await SharedPreferences.getInstance(); final String? devicesJson = prefs.getString(_devicesKey); diff --git a/lib/repository/user_repository.dart b/lib/repository/user_repository.dart index f89c6a2..16e8da9 100644 --- a/lib/repository/user_repository.dart +++ b/lib/repository/user_repository.dart @@ -1,8 +1,11 @@ +import 'package:mobile_flutter_iot/models/device_model.dart'; import 'package:mobile_flutter_iot/models/user_model.dart'; abstract class UserRepository { Future saveUser(UserModel user); Future getUser(); Future deleteUser(); - Future validateCredentials(String email, String password); + + Future saveDevices(List devices); + Future> getDevices(); } diff --git a/lib/screens/auth/login_screen.dart b/lib/screens/auth/login_screen.dart index 27bf1b8..ebb882e 100644 --- a/lib/screens/auth/login_screen.dart +++ b/lib/screens/auth/login_screen.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:mobile_flutter_iot/providers/auth_provider.dart'; -import 'package:mobile_flutter_iot/repository/local_user_repository.dart'; import 'package:mobile_flutter_iot/services/connectivity_service.dart'; import 'package:mobile_flutter_iot/widgets/blur_blob.dart'; import 'package:mobile_flutter_iot/widgets/glass_card.dart'; @@ -19,7 +18,7 @@ class _LoginScreenState extends State { final _emailController = TextEditingController(); final _passwordController = TextEditingController(); final _connectivity = ConnectivityService(); - final _userRepository = LocalUserRepository(); + bool _isLoading = false; void _handleLogin() async { final email = _emailController.text.trim(); @@ -38,21 +37,21 @@ class _LoginScreenState extends State { return; } - final isValid = await _userRepository.validateCredentials(email, password); + setState(() => _isLoading = true); + + final isValid = await context.read().login(email, password); if (!mounted) return; - if (isValid) { - if (mounted) { - await context.read().login(email, password); - if (!mounted) return; + setState(() => _isLoading = false); - _showStatusMessage('Access Granted. Welcome back!'); - Navigator.pushReplacementNamed(context, '/main'); - } + if (isValid) { + _showStatusMessage('Access Granted. Welcome back!'); + Navigator.pushReplacementNamed(context, '/main'); } else { - if (mounted) { - _showStatusMessage('Invalid email or password', isError: true); - } + _showStatusMessage( + 'Invalid email or password (API Error)', + isError: true, + ); } } @@ -154,7 +153,13 @@ class _LoginScreenState extends State { controller: _passwordController, ), const SizedBox(height: 24), - PrimaryButton(text: 'INITIALIZE LOGIN', onPressed: _handleLogin), + if (_isLoading) + const CircularProgressIndicator(color: Color(0xFF38BDF8)) + else + PrimaryButton( + text: 'INITIALIZE LOGIN', + onPressed: _handleLogin, + ), const SizedBox(height: 12), TextButton( onPressed: () => Navigator.pushNamed(context, '/register'), diff --git a/lib/screens/auth/register_screen.dart b/lib/screens/auth/register_screen.dart index 390a0b7..c74f1c8 100644 --- a/lib/screens/auth/register_screen.dart +++ b/lib/screens/auth/register_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:mobile_flutter_iot/models/user_model.dart'; import 'package:mobile_flutter_iot/repository/local_user_repository.dart'; +import 'package:mobile_flutter_iot/services/api_service.dart'; import 'package:mobile_flutter_iot/services/connectivity_service.dart'; import 'package:mobile_flutter_iot/widgets/blur_blob.dart'; import 'package:mobile_flutter_iot/widgets/glass_card.dart'; @@ -19,17 +20,19 @@ class _RegisterScreenState extends State { final _emailController = TextEditingController(); final _deptController = TextEditingController(); final _passwordController = TextEditingController(); - final _connectivity = ConnectivityService(); + final _connectivity = ConnectivityService(); + final _apiService = ApiService(); // Підключаємо API final _userRepository = LocalUserRepository(); - String? _nameError; - String? _emailError; - String? _deptError; - String? _passwordError; + String? _nameError, _emailError, _deptError, _passwordError; + bool _isLoading = false; void _handleRegister() async { final bool isOnline = await _connectivity.hasConnection(); + + if (!mounted) return; + if (!isOnline) { _showStatusMessage( 'No Internet connection to sync account', @@ -39,44 +42,36 @@ class _RegisterScreenState extends State { } setState(() { - _nameError = null; - _emailError = null; - _deptError = null; - _passwordError = null; + _nameError = _emailError = _deptError = _passwordError = null; }); final name = _nameController.text.trim(); final email = _emailController.text.trim(); final dept = _deptController.text.trim(); final password = _passwordController.text.trim(); - bool hasError = false; if (name.isEmpty) { - setState(() => _nameError = 'Please enter your full name'); + setState(() => _nameError = 'Required'); hasError = true; } - if (email.isEmpty) { - setState(() => _emailError = 'Email address is required'); - hasError = true; - } else if (!email.contains('@') || !email.contains('.')) { - setState(() => _emailError = 'Invalid email format (missing @ or .)'); + if (email.isEmpty || !email.contains('@')) { + setState(() => _emailError = 'Invalid email'); hasError = true; } if (dept.isEmpty) { - setState(() => _deptError = 'Specify your department'); + setState(() => _deptError = 'Required'); hasError = true; } - if (password.isEmpty) { - setState(() => _passwordError = 'Security password is required'); - hasError = true; - } else if (password.length < 6) { - setState(() => _passwordError = 'Password must be at least 6 characters'); + if (password.length < 6) { + setState(() => _passwordError = 'Min 6 chars'); hasError = true; } if (hasError) return; + setState(() => _isLoading = true); + final newUser = UserModel( fullName: name, email: email, @@ -84,11 +79,23 @@ class _RegisterScreenState extends State { department: dept, ); - await _userRepository.saveUser(newUser); + final success = await _apiService.register(newUser); - if (mounted) { - _showStatusMessage('Access Key Created! You can now log in.'); + if (!mounted) return; + setState(() => _isLoading = false); + + if (success) { + await _userRepository.saveUser(newUser); + + if (!mounted) return; + + _showStatusMessage('Access Key Created in Cloud! You can now log in.'); Navigator.pop(context); + } else { + _showStatusMessage( + 'Server error or email already exists.', + isError: true, + ); } } @@ -201,7 +208,7 @@ class _RegisterScreenState extends State { ), const SizedBox(height: 16), GlassInput( - hintText: 'Department (e.g., KSA, IoT)', + hintText: 'Department (e.g., KSA)', icon: Icons.business_center_outlined, controller: _deptController, errorText: _deptError, @@ -215,7 +222,13 @@ class _RegisterScreenState extends State { errorText: _passwordError, ), const SizedBox(height: 32), - PrimaryButton(text: 'INITIALIZE ACCOUNT', onPressed: _handleRegister), + if (_isLoading) + const CircularProgressIndicator(color: Color(0xFF4ADE80)) + else + PrimaryButton( + text: 'INITIALIZE ACCOUNT', + onPressed: _handleRegister, + ), const SizedBox(height: 16), TextButton( onPressed: () => Navigator.pop(context), diff --git a/lib/screens/home/add_device_screen.dart b/lib/screens/home/add_device_screen.dart index cbeed93..bd7c60d 100644 --- a/lib/screens/home/add_device_screen.dart +++ b/lib/screens/home/add_device_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:mobile_flutter_iot/models/device_model.dart'; +import 'package:mobile_flutter_iot/services/api_service.dart'; import 'package:mobile_flutter_iot/widgets/glass_input.dart'; import 'package:mobile_flutter_iot/widgets/primary_button.dart'; @@ -17,6 +18,9 @@ class _AddDeviceScreenState extends State { late Color _selectedColor; late IconData _selectedIcon; + bool _isLoading = false; + final ApiService _apiService = ApiService(); + final List _colors = [ const Color(0xFF4ADE80), const Color(0xFF38BDF8), @@ -42,6 +46,46 @@ class _AddDeviceScreenState extends State { _selectedIcon = widget.device?.icon ?? _icons[0]; } + Future _handleSave() async { + if (_titleController.text.trim().isEmpty) return; + + setState(() => _isLoading = true); + + final deviceToSave = DeviceModel( + id: widget.device?.id ?? DateTime.now().millisecondsSinceEpoch.toString(), + title: _titleController.text.trim(), + value: widget.device?.value ?? '0 units', + status: widget.device?.status ?? 'INITIALIZING', + icon: _selectedIcon, + color: _selectedColor, + ); + + bool success; + if (widget.device == null) { + success = await _apiService.addDevice(deviceToSave); + } else { + success = await _apiService.updateDevice(deviceToSave); + } + + if (!mounted) return; + setState(() => _isLoading = false); + + if (success) { + Navigator.pop(context, deviceToSave); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('API Error: Saved to local cache only.'), + backgroundColor: Colors.orange, + ), + ); + Navigator.pop( + context, + deviceToSave, + ); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -68,22 +112,15 @@ class _AddDeviceScreenState extends State { const SizedBox(height: 12), _buildIconPicker(), const SizedBox(height: 48), - PrimaryButton( - text: widget.device == null ? 'CREATE DEVICE' : 'SAVE CHANGES', - onPressed: () { - if (_titleController.text.isEmpty) return; - - final result = DeviceModel( - id: widget.device?.id ?? DateTime.now().toString(), - title: _titleController.text, - value: widget.device?.value ?? '0 units', - status: widget.device?.status ?? 'INITIALIZING', - icon: _selectedIcon, - color: _selectedColor, - ); - Navigator.pop(context, result); - }, - ), + if (_isLoading) + const Center( + child: CircularProgressIndicator(color: Color(0xFF38BDF8)), + ) + else + PrimaryButton( + text: widget.device == null ? 'CREATE DEVICE' : 'SAVE CHANGES', + onPressed: _handleSave, + ), ], ), ), diff --git a/lib/screens/home/dashboard_screen.dart b/lib/screens/home/dashboard_screen.dart index 1a92b35..cff7497 100644 --- a/lib/screens/home/dashboard_screen.dart +++ b/lib/screens/home/dashboard_screen.dart @@ -7,12 +7,11 @@ import 'package:mobile_flutter_iot/models/device_model.dart'; import 'package:mobile_flutter_iot/providers/mqtt_provider.dart'; import 'package:mobile_flutter_iot/repository/local_user_repository.dart'; import 'package:mobile_flutter_iot/screens/home/add_device_screen.dart'; -import 'package:mobile_flutter_iot/screens/home/details_screen.dart'; -import 'package:mobile_flutter_iot/widgets/glass_card.dart'; -import 'package:mobile_flutter_iot/widgets/indicator.dart'; -import 'package:mobile_flutter_iot/widgets/workspace_card.dart'; +import 'package:mobile_flutter_iot/widgets/api_device_list.dart'; +import 'package:mobile_flutter_iot/widgets/mqtt_section.dart'; import 'package:provider/provider.dart'; import 'package:shake/shake.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @@ -22,17 +21,14 @@ class DashboardScreen extends StatefulWidget { } class _DashboardScreenState extends State { - final LocalUserRepository _repository = LocalUserRepository(); - List _devices = []; - bool _isLoading = true; - late ShakeDetector _shakeDetector; - StreamSubscription>? _connectivitySubscription; + late ShakeDetector _shakeDetector; + bool _isFirstCheck = true; + int _listKey = 0; @override void initState() { super.initState(); - _loadDevices(); _initConnectivityMonitoring(); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -45,8 +41,6 @@ class _DashboardScreenState extends State { ); } - bool _isFirstCheck = true; - void _initConnectivityMonitoring() { _connectivitySubscription = Connectivity().onConnectivityChanged.listen( (List results) { @@ -57,18 +51,15 @@ class _DashboardScreenState extends State { if (!hasNet) { _isFirstCheck = false; - _showStatusBanner( - 'OFFLINE: Check your Wi-Fi connection', - Colors.redAccent, - ); + _showBanner('OFFLINE: Check your Wi-Fi connection', Colors.redAccent); mqtt.disconnect(); } else { if (!_isFirstCheck) { - _showStatusBanner('ONLINE: Connection restored!', Colors.green); + _showBanner('ONLINE: Connection restored!', Colors.green); + setState(() => _listKey++); } _isFirstCheck = false; - debugPrint('Network is back! Attempting MQTT Sync...'); Future.delayed(const Duration(seconds: 2), () { if (mounted) _safelyConnectMQTT(); }); @@ -77,20 +68,11 @@ class _DashboardScreenState extends State { ); } - void _showStatusBanner(String message, Color color) { + void _showBanner(String msg, Color color) { ScaffoldMessenger.of(context).hideCurrentSnackBar(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Row( - children: [ - Icon( - color == Colors.green ? Icons.wifi : Icons.wifi_off, - color: Colors.white, - ), - const SizedBox(width: 12), - Text(message), - ], - ), + content: Text(msg, style: const TextStyle(color: Colors.white)), backgroundColor: color.withValues(alpha: 0.9), behavior: SnackBarBehavior.floating, ), @@ -100,15 +82,24 @@ class _DashboardScreenState extends State { void _safelyConnectMQTT() async { final mqtt = Provider.of(context, listen: false); if (mqtt.client == null) { - mqtt.initMqtt('192.168.1.XXX', 'flutter_client_${Random().nextInt(100)}'); - } + // Читаємо збережену IP-адресу + final prefs = await SharedPreferences.getInstance(); + final savedIp = prefs.getString('mqtt_ip') ?? '192.168.1.XXX'; + mqtt.initMqtt(savedIp, 'flutter_client_${Random().nextInt(100)}'); + } final connectivity = await Connectivity().checkConnectivity(); if (!connectivity.contains(ConnectivityResult.none)) { mqtt.connect(); } } + void _handleShake() { + if (!mounted) return; + _showBanner('SHAKE: Data refreshed', const Color(0xFF38BDF8)); + setState(() => _listKey++); + } + @override void dispose() { _shakeDetector.stopListening(); @@ -116,65 +107,54 @@ class _DashboardScreenState extends State { super.dispose(); } - void _handleShake() async { - if (!mounted || _devices.isEmpty) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('SHAKE: Local nodes refreshed'), - duration: Duration(seconds: 1), - ), - ); - final random = Random(); - for (var device in _devices) { - device.value = device.title.toLowerCase().contains('temp') - ? '${20 + random.nextInt(10)}°C' - : '${random.nextInt(100)} units'; - } - await _syncData(); - _loadDevices(); - } - - Future _loadDevices() async { - final savedDevices = await _repository.getDevices(); - if (!mounted) return; - setState(() { - _devices = savedDevices; - _isLoading = false; - }); - } - - Future _syncData() async { - await _repository.saveDevices(_devices); - } - @override Widget build(BuildContext context) { final mqtt = context.watch(); - final bool isMqttLive = mqtt.status == MqttStatus.connected; return Scaffold( backgroundColor: Colors.transparent, body: SafeArea( - child: Column( - children: [ - _buildAppBar(), - _buildStatusCard(mqtt), - if (isMqttLive) ...[ - _buildMqttLiveNode(mqtt), - _buildMqttControlNode(mqtt), - ], - Expanded( - child: _isLoading - ? const Center(child: CircularProgressIndicator()) - : (_devices.isEmpty && !isMqttLive) - ? _buildEmptyState() - : _buildDeviceList(mqtt), + child: RefreshIndicator( + onRefresh: () async => setState(() => _listKey++), + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildAppBar(), + MqttSection(mqtt: mqtt), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Text( + 'CLOUD & LOCAL NODES', + style: TextStyle( + color: Colors.white30, + fontSize: 11, + letterSpacing: 1.5, + ), + ), + ), + ApiDeviceList( + key: ValueKey(_listKey), + mqttIp: mqtt.client?.server, + ), + const SizedBox(height: 80), + ], ), - ], + ), ), ), floatingActionButton: FloatingActionButton( - onPressed: _onAddPressed, + onPressed: () async { + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const AddDeviceScreen()), + ); + if (result != null && mounted) { + await LocalUserRepository().saveDevices([result]); + setState(() => _listKey++); + } + }, backgroundColor: const Color(0xFF38BDF8), child: const Icon(Icons.add, color: Colors.white), ), @@ -192,186 +172,4 @@ class _DashboardScreenState extends State { ), ); } - - Widget _buildStatusCard(MqttProvider mqtt) { - final bool isConnected = mqtt.status == MqttStatus.connected; - - final Color statusColor = mqtt.status == MqttStatus.connected - ? const Color(0xFF4ADE80) - : const Color(0xFFF87171); - return Padding( - padding: const EdgeInsets.all(16), - child: GlassCard( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), - child: Row( - children: [ - SystemPulseIndicator(color: statusColor), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'SYSTEM ENGINE', - style: TextStyle(fontSize: 10, color: Colors.white38), - ), - Text( - isConnected ? 'ONLINE' : 'MQTT DISCONNECTED', - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const Spacer(), - Text( - mqtt.client?.server ?? 'No IP', - style: const TextStyle(fontSize: 10, color: Colors.white24), - ), - ], - ), - ), - ); - } - - Widget _buildMqttLiveNode(MqttProvider mqtt) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: GestureDetector( - onTap: () { - Navigator.pushNamed( - context, - '/details', - arguments: SensorArguments( - id: 'ESP_AIR_01', - title: 'Air Quality', - value: '${mqtt.airQuality} AQI', - icon: Icons.air_rounded, - color: const Color(0xFF38BDF8), - ipAddress: mqtt.client?.server ?? '192.168.1.XXX', - ), - ); - }, - child: WorkspaceCard( - id: 'ESP_AIR_01', - title: 'Air Quality (ESP8266)', - value: '${mqtt.airQuality} AQI', - status: 'LIVE', - subtitle: 'Real-time MQTT data', - icon: Icons.air_rounded, - accentColor: const Color(0xFF38BDF8), - ), - ), - ); - } - - Widget _buildMqttControlNode(MqttProvider mqtt) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: GlassCard( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: mqtt.isLedOn - ? Colors.yellow.withValues(alpha: 0.1) - : Colors.white.withValues(alpha: 0.05), - shape: BoxShape.circle, - ), - child: Icon( - mqtt.isLedOn ? Icons.lightbulb : Icons.lightbulb_outline, - color: mqtt.isLedOn ? Colors.yellow : Colors.white24, - ), - ), - title: const Text( - 'Smart LED System', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), - ), - subtitle: Text( - mqtt.isLedOn ? 'Active (ON)' : 'Inactive (OFF)', - style: const TextStyle(fontSize: 11, color: Colors.white38), - ), - trailing: Switch( - value: mqtt.isLedOn, - activeThumbColor: Colors.yellow, - onChanged: (_) => mqtt.toggleLed(), - ), - ), - ), - ); - } - - Widget _buildEmptyState() { - return const Center( - child: Text( - 'No active nodes found.\nConnect ESP8266 or add manually.', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.white30), - ), - ); - } - - Widget _buildDeviceList(MqttProvider mqtt) { - return ListView.separated( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - itemCount: _devices.length, - separatorBuilder: (context, index) => const SizedBox(height: 16), - itemBuilder: (context, index) { - final device = _devices[index]; - return GestureDetector( - onTap: () async { - await Navigator.pushNamed( - context, - '/details', - arguments: SensorArguments( - id: device.id, - title: device.title, - value: device.value, - icon: device.icon, - color: device.color, - ipAddress: mqtt.client?.server ?? '192.168.1.XXX', - ), - ); - if (mounted) _loadDevices(); - }, - onLongPress: () => _onEditDevice(device, index), - child: WorkspaceCard( - id: device.id, - title: device.title, - value: device.value, - status: device.status, - subtitle: 'Local simulation', - icon: device.icon, - accentColor: device.color, - ), - ); - }, - ); - } - - void _onAddPressed() async { - final result = await Navigator.push( - context, - MaterialPageRoute(builder: (context) => const AddDeviceScreen()), - ); - if (mounted && result != null) { - setState(() => _devices.add(result)); - _syncData(); - } - } - - void _onEditDevice(DeviceModel device, int index) async { - final result = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => AddDeviceScreen(device: device), - ), - ); - if (mounted && result != null) { - setState(() => _devices[index] = result); - _syncData(); - } - } } diff --git a/lib/screens/home/details_screen.dart b/lib/screens/home/details_screen.dart index 091d406..6858c13 100644 --- a/lib/screens/home/details_screen.dart +++ b/lib/screens/home/details_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:mobile_flutter_iot/providers/mqtt_provider.dart'; import 'package:mobile_flutter_iot/repository/local_user_repository.dart'; +import 'package:mobile_flutter_iot/services/api_service.dart'; import 'package:mobile_flutter_iot/widgets/glass_card.dart'; import 'package:mobile_flutter_iot/widgets/sensor_chart.dart'; import 'package:provider/provider.dart'; @@ -36,7 +37,92 @@ class _DetailsScreenState extends State { bool _isManualControlOn = true; String? _currentValue; String? _customIp; + final _userRepository = LocalUserRepository(); + final _apiService = ApiService(); + bool _isSavingSnapshot = false; + + Future _saveSnapshot(SensorArguments args) async { + setState(() => _isSavingSnapshot = true); + + final valueToSave = _currentValue ?? args.value; + final success = await _apiService.saveLog(args.id, valueToSave); + + if (!mounted) return; + + setState(() => _isSavingSnapshot = false); + + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Snapshot saved to cloud database! 📸'), + backgroundColor: Color(0xFF4ADE80), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to save snapshot. Check connection.'), + backgroundColor: Colors.orange, + ), + ); + } + } + + Future _deleteDevice(String deviceId) async { + final confirm = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + backgroundColor: const Color(0xFF1E293B), + title: + const Text('Delete Sensor?', style: TextStyle(color: Colors.white)), + content: const Text( + 'This will permanently remove the device from the cloud.', + style: TextStyle(color: Colors.white70), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: + const Text('DELETE', style: TextStyle(color: Colors.redAccent)), + ), + ], + ), + ); + + if (confirm != true || !mounted) return; + + final success = await _apiService.deleteDevice(deviceId); + + if (!mounted) return; + + if (success) { + final devices = await _userRepository.getDevices(); + devices.removeWhere((d) => d.id == deviceId); + await _userRepository.saveDevices(devices); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Device deleted from cloud!'), + backgroundColor: Colors.redAccent, + ), + ); + Navigator.pop(context); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to delete (Check connection)'), + backgroundColor: Colors.orange, + ), + ); + } + } Future _editIpAddress(SensorArguments args) async { final mqtt = Provider.of(context, listen: false); @@ -44,7 +130,7 @@ class _DetailsScreenState extends State { final newIp = await showDialog( context: context, - builder: (context) => AlertDialog( + builder: (dialogContext) => AlertDialog( backgroundColor: const Color(0xFF1E293B), title: const Text('Edit Device IP / Broker'), content: TextField( @@ -62,11 +148,11 @@ class _DetailsScreenState extends State { ), actions: [ TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () => Navigator.pop(dialogContext), child: const Text('Cancel'), ), TextButton( - onPressed: () => Navigator.pop(context, controller.text), + onPressed: () => Navigator.pop(dialogContext, controller.text), child: const Text( 'Update & Reconnect', style: TextStyle(color: Color(0xFF4ADE80)), @@ -80,7 +166,6 @@ class _DetailsScreenState extends State { if (newIp != null && newIp.isNotEmpty) { setState(() => _customIp = newIp); - mqtt.disconnect(); mqtt.initMqtt(newIp, 'flutter_client_reconnect'); mqtt.connect(); @@ -96,9 +181,10 @@ class _DetailsScreenState extends State { Future _editValue(SensorArguments args) async { final controller = TextEditingController(text: _currentValue ?? args.value); + final newValue = await showDialog( context: context, - builder: (context) => AlertDialog( + builder: (dialogContext) => AlertDialog( backgroundColor: const Color(0xFF1E293B), title: Text('Edit ${args.title} Value'), content: TextField( @@ -110,11 +196,11 @@ class _DetailsScreenState extends State { ), actions: [ TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () => Navigator.pop(dialogContext), child: const Text('Cancel'), ), TextButton( - onPressed: () => Navigator.pop(context, controller.text), + onPressed: () => Navigator.pop(dialogContext, controller.text), child: const Text('Save', style: TextStyle(color: Color(0xFF38BDF8))), ), @@ -122,12 +208,19 @@ class _DetailsScreenState extends State { ), ); + if (!mounted) return; + if (newValue != null && newValue.isNotEmpty) { final devices = await _userRepository.getDevices(); final index = devices.indexWhere((d) => d.id == args.id); + if (index != -1) { devices[index].value = newValue; + + await _apiService.updateDevice(devices[index]); await _userRepository.saveDevices(devices); + + if (!mounted) return; setState(() => _currentValue = newValue); } } @@ -144,14 +237,17 @@ class _DetailsScreenState extends State { return Scaffold( appBar: AppBar( backgroundColor: Colors.transparent, - title: Text('${args.title.toUpperCase()} ANALYSIS'), + title: Text( + '${args.title.toUpperCase()} ANALYSIS', + style: const TextStyle(fontSize: 16), + ), actions: [ IconButton( icon: const Icon( Icons.delete_sweep_outlined, color: Colors.redAccent, ), - onPressed: () => Navigator.pop(context, {'deleteId': args.id}), + onPressed: () => _deleteDevice(args.id), ), ], ), @@ -163,11 +259,36 @@ class _DetailsScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '${args.title} History', - style: const TextStyle(color: Colors.white70), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${args.title} History', + style: const TextStyle(color: Colors.white70), + ), + // НОВЕ: Кнопка Snapshot + if (_isSavingSnapshot) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Color(0xFF38BDF8), + ), + ) + else + IconButton( + icon: const Icon( + Icons.camera_alt_outlined, + color: Color(0xFF38BDF8), + size: 20, + ), + tooltip: 'Save Snapshot to Database', + onPressed: () => _saveSnapshot(args), + ), + ], ), - const SizedBox(height: 20), + const SizedBox(height: 10), const SensorChart(), ], ), diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart new file mode 100644 index 0000000..ae886a3 --- /dev/null +++ b/lib/services/api_service.dart @@ -0,0 +1,171 @@ +import 'dart:developer'; +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:mobile_flutter_iot/models/device_model.dart'; +import 'package:mobile_flutter_iot/models/user_model.dart'; + +class ApiService { + static const String baseUrl = 'http://10.0.2.2:5000'; + + final Dio _dio = Dio( + BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 5), + ), + ); + + final _storage = const FlutterSecureStorage(); + + Future register(UserModel user) async { + try { + final response = + await _dio.post('/auth/register', data: user.toJson()); + return response.statusCode == 201; + } catch (e) { + return false; + } + } + + Future?> login(String email, String password) async { + try { + final response = await _dio.post( + '/auth/login', + data: { + 'email': email, + 'password': password, + }, + ); + if (response.statusCode == 200 && response.data != null) { + return response.data as Map; + } + } catch (e) { + return null; + } + return null; + } + + Future updateUserProfile(UserModel user) async { + try { + final token = await _storage.read(key: 'access_token'); + if (token == null) return false; + + final response = await _dio.put( + '/auth/profile', + data: user.toJson(), + options: Options(headers: {'Authorization': token}), + ); + return response.statusCode == 200; + } catch (e) { + log('Update profile error: $e'); + return false; + } + } + + Future deleteAccount() async { + try { + final token = await _storage.read(key: 'access_token'); + if (token == null) return false; + + final response = await _dio.delete( + '/auth/account', + options: Options(headers: {'Authorization': token}), + ); + return response.statusCode == 200; + } catch (e) { + return false; + } + } + + Future?> fetchDevices() async { + try { + final token = await _storage.read(key: 'access_token'); + if (token == null) return null; + + final response = await _dio.get( + '/devices', + options: Options(headers: {'Authorization': token}), + ); + + if (response.statusCode == 200 && response.data != null) { + final data = response.data as List; + return data + .map((json) => DeviceModel.fromJson(json as Map)) + .toList(); + } + } catch (e) { + log('Fetch error: $e'); + return null; + } + return null; + } + + Future addDevice(DeviceModel device) async { + try { + final token = await _storage.read(key: 'access_token'); + if (token == null) return false; + + final response = await _dio.post( + '/devices', + data: device.toJson(), + options: Options(headers: {'Authorization': token}), + ); + return response.statusCode == 201; + } catch (e) { + log('Add device error: $e'); + return false; + } + } + + Future updateDevice(DeviceModel device) async { + try { + final token = await _storage.read(key: 'access_token'); + if (token == null) return false; + + final response = await _dio.put( + '/devices/${device.id}', + data: device.toJson(), + options: Options(headers: {'Authorization': token}), + ); + return response.statusCode == 200; + } catch (e) { + log('Update device error: $e'); + return false; + } + } + + Future deleteDevice(String deviceId) async { + try { + final token = await _storage.read(key: 'access_token'); + if (token == null) return false; + + final response = await _dio.delete( + '/devices/$deviceId', + options: Options(headers: {'Authorization': token}), + ); + return response.statusCode == 200; + } catch (e) { + log('Delete device error: $e'); + return false; + } + } + + Future saveLog(String sensorId, String value) async { + try { + final token = await _storage.read(key: 'access_token'); + if (token == null) return false; + + final response = await _dio.post( + '/logs', + data: { + 'sensor_id': sensorId, + 'value': value, + }, + options: Options(headers: {'Authorization': token}), + ); + return response.statusCode == 201; + } catch (e) { + log('Save log error: $e'); + return false; + } + } +} diff --git a/lib/widgets/api_device_list.dart b/lib/widgets/api_device_list.dart new file mode 100644 index 0000000..d1d2abe --- /dev/null +++ b/lib/widgets/api_device_list.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:mobile_flutter_iot/models/device_model.dart'; +import 'package:mobile_flutter_iot/repository/local_user_repository.dart'; +import 'package:mobile_flutter_iot/screens/home/add_device_screen.dart'; +import 'package:mobile_flutter_iot/screens/home/details_screen.dart'; +import 'package:mobile_flutter_iot/services/api_service.dart'; +import 'package:mobile_flutter_iot/widgets/workspace_card.dart'; + +class ApiDeviceList extends StatefulWidget { + final String? mqttIp; + const ApiDeviceList({this.mqttIp, super.key}); + + @override + State createState() => _ApiDeviceListState(); +} + +class _ApiDeviceListState extends State { + final ApiService _apiService = ApiService(); + final LocalUserRepository _cache = LocalUserRepository(); + + late Future> _devicesFuture; + + @override + void initState() { + super.initState(); + _refreshData(); + } + + void _refreshData() { + setState(() { + _devicesFuture = _fetchAndSyncDevices(); + }); + } + + Future> _fetchAndSyncDevices() async { + try { + final cloudDevices = await _apiService.fetchDevices(); + if (cloudDevices != null && cloudDevices.isNotEmpty) { + await _cache.saveDevices(cloudDevices); + return cloudDevices; + } + } catch (e) { + debugPrint('API Error, falling back to cache: $e'); + } + return await _cache.getDevices(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: _devicesFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Padding( + padding: EdgeInsets.all(32), + child: Center( + child: CircularProgressIndicator(color: Color(0xFF38BDF8)), + ), + ); + } + + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: Text( + 'No devices found.\nAdd one manually or connect ESP.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white30), + ), + ), + ); + } + + final devices = snapshot.data!; + return ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: devices.length, + separatorBuilder: (_, __) => const SizedBox(height: 16), + itemBuilder: (context, index) { + final device = devices[index]; + return GestureDetector( + onTap: () async { + await Navigator.pushNamed( + context, + '/details', + arguments: SensorArguments( + id: device.id, + title: device.title, + value: device.value, + icon: device.icon, + color: device.color, + ipAddress: widget.mqttIp ?? 'No IP', + ), + ); + _refreshData(); + }, + onLongPress: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AddDeviceScreen(device: device), + ), + ); + _refreshData(); + }, + child: WorkspaceCard( + id: device.id, + title: device.title, + value: device.value, + status: device.status, + subtitle: 'Cloud / Local', + icon: device.icon, + accentColor: device.color, + ), + ); + }, + ); + }, + ); + } +} diff --git a/lib/widgets/mqtt_section.dart b/lib/widgets/mqtt_section.dart new file mode 100644 index 0000000..0a2615f --- /dev/null +++ b/lib/widgets/mqtt_section.dart @@ -0,0 +1,335 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:mobile_flutter_iot/providers/mqtt_provider.dart'; +import 'package:mobile_flutter_iot/screens/home/details_screen.dart'; +import 'package:mobile_flutter_iot/widgets/glass_card.dart'; +import 'package:mobile_flutter_iot/widgets/indicator.dart'; +import 'package:mobile_flutter_iot/widgets/workspace_card.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class MqttSection extends StatelessWidget { + final MqttProvider mqtt; + const MqttSection({required this.mqtt, super.key}); + + Future _editIpAddress(BuildContext context) async { + final prefs = await SharedPreferences.getInstance(); + if (!context.mounted) return; + + final currentIp = + mqtt.client?.server ?? prefs.getString('mqtt_ip') ?? '192.168.1.XXX'; + final controller = TextEditingController(text: currentIp); + + final newIp = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: const Color(0xFF1E293B), + title: + const Text('Set Broker IP', style: TextStyle(color: Colors.white)), + content: TextField( + controller: controller, + autofocus: true, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + hintText: 'e.g. 192.168.1.100', + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Colors.white24), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + child: const Text( + 'Save & Reconnect', + style: TextStyle(color: Color(0xFF4ADE80)), + ), + ), + ], + ), + ); + + if (newIp != null && newIp.isNotEmpty && context.mounted) { + await prefs.setString('mqtt_ip', newIp); + if (!context.mounted) return; + + mqtt.disconnect(); + mqtt.initMqtt(newIp, 'flutter_client_${Random().nextInt(100)}'); + mqtt.connect(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Reconnecting to $newIp...'), + backgroundColor: Colors.blueGrey, + ), + ); + } + } + + Future _setLockPolicy(BuildContext context) async { + final startController = + TextEditingController(text: mqtt.startLockHour.toString()); + final endController = + TextEditingController(text: mqtt.endLockHour.toString()); + + await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: const Color(0xFF1E293B), + title: const Text( + 'Admin Security Policy', + style: TextStyle(color: Colors.white), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Set hours when LED control is disabled.' + 'Actions will be logged to the server.', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextField( + controller: startController, + keyboardType: TextInputType.number, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + labelText: 'Lock From (Hour)', + labelStyle: TextStyle(color: Colors.white54), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextField( + controller: endController, + keyboardType: TextInputType.number, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + labelText: 'Unlock At (Hour)', + labelStyle: TextStyle(color: Colors.white54), + ), + ), + ), + ], + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + final start = int.tryParse(startController.text) ?? 22; + final end = int.tryParse(endController.text) ?? 6; + mqtt.updateLockHours(start, end); + Navigator.pop(context); + }, + child: const Text( + 'Save Policy', + style: TextStyle(color: Colors.redAccent), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final bool isConnected = mqtt.status == MqttStatus.connected; + + return Column( + children: [ + _buildStatusCard(context, isConnected), + if (isConnected) ...[ + _buildMqttLiveNode(context), + _buildMqttControlNode(context), + ], + ], + ); + } + + Widget _buildStatusCard(BuildContext context, bool isConnected) { + final statusColor = + isConnected ? const Color(0xFF4ADE80) : const Color(0xFFF87171); + + return Padding( + padding: const EdgeInsets.all(16), + child: GlassCard( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), + child: Row( + children: [ + SystemPulseIndicator(color: statusColor), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'SYSTEM ENGINE', + style: TextStyle(fontSize: 10, color: Colors.white38), + ), + Text( + isConnected ? 'ONLINE' : 'MQTT DISCONNECTED', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const Spacer(), + GestureDetector( + onTap: () => _editIpAddress(context), + child: Row( + children: [ + Text( + mqtt.client?.server ?? 'No IP', + style: const TextStyle(fontSize: 10, color: Colors.white24), + ), + const SizedBox(width: 4), + const Icon(Icons.edit, size: 10, color: Colors.white24), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildMqttLiveNode(BuildContext context) { + final double currentAqi = + double.tryParse(mqtt.airQuality.toString()) ?? 0.0; + final bool isSensorOffline = currentAqi == 0.0; + + final String displayValue = + isSensorOffline ? 'No Data' : '${mqtt.airQuality} AQI'; + final String displayStatus = isSensorOffline ? 'SENSOR OFFLINE' : 'LIVE'; + final Color displayColor = + isSensorOffline ? Colors.white24 : const Color(0xFF38BDF8); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: GestureDetector( + onTap: () => Navigator.pushNamed( + context, + '/details', + arguments: SensorArguments( + id: 'ESP_AIR_01', + title: 'Air Quality', + value: displayValue, + icon: Icons.air_rounded, + color: displayColor, + status: displayStatus, + ipAddress: mqtt.client?.server ?? '192.168.1.XXX', + ), + ), + child: WorkspaceCard( + id: 'ESP_AIR_01', + title: 'Air Quality (ESP8266)', + value: displayValue, + status: displayStatus, + subtitle: + isSensorOffline ? 'Check hardware power' : 'Real-time MQTT data', + icon: Icons.air_rounded, + accentColor: displayColor, + ), + ), + ); + } + + Widget _buildMqttControlNode(BuildContext context) { + final bool isLocked = mqtt.isTimeRestricted(); + + final String lockTimeStr = + '${mqtt.startLockHour.toString().padLeft(2, '0')}:00 - ' + '${mqtt.endLockHour.toString().padLeft(2, '0')}:00'; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: GlassCard( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isLocked + ? Colors.redAccent.withValues(alpha: 0.1) + : mqtt.isLedOn + ? Colors.yellow.withValues(alpha: 0.1) + : Colors.white.withValues(alpha: 0.05), + shape: BoxShape.circle, + ), + child: Icon( + isLocked + ? Icons.lock_clock + : mqtt.isLedOn + ? Icons.lightbulb + : Icons.lightbulb_outline, + color: isLocked + ? Colors.redAccent + : mqtt.isLedOn + ? Colors.yellow + : Colors.white24, + ), + ), + title: Row( + children: [ + Text( + isLocked ? 'LED System (Locked)' : 'Smart LED System', + style: + const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + const Spacer(), + IconButton( + icon: + const Icon(Icons.security, size: 16, color: Colors.white38), + onPressed: () => _setLockPolicy(context), + ), + ], + ), + subtitle: Text( + isLocked + ? 'Restricted hours ($lockTimeStr)' + : mqtt.isLedOn + ? 'Active (ON)' + : 'Inactive (OFF)', + style: TextStyle( + fontSize: 11, + color: isLocked + ? Colors.redAccent.withValues(alpha: 0.7) + : Colors.white38, + ), + ), + trailing: Switch( + value: !isLocked && mqtt.isLedOn, + activeThumbColor: Colors.yellow, + onChanged: (bool value) { + if (isLocked) { + mqtt.toggleLed(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('ACCESS DENIED: System lock policy active'), + backgroundColor: Colors.redAccent, + ), + ); + } else { + mqtt.toggleLed(); + } + }, + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 4157023..660e5f2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -97,6 +97,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" + dio: + dependency: "direct main" + description: + name: dio + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.dev" + source: hosted + version: "5.9.2" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.dev" + source: hosted + version: "2.1.2" event_bus: dependency: transitive description: @@ -216,6 +232,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" js: dependency: transitive description: @@ -288,6 +312,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" mqtt_client: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index b67e1ea..424f4ca 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,8 @@ dependencies: flutter_secure_storage: ^9.0.0 provider: ^6.1.1 + dio: ^5.4.1 + dev_dependencies: flutter_test: sdk: flutter