diff --git a/back/microsservicos/catalogo/app/delete_catalogo/delete_catalogo_controller.ts b/back/microsservicos/catalogo/app/delete_catalogo/delete_catalogo_controller.ts index 20cb61b5..111c3ab7 100644 --- a/back/microsservicos/catalogo/app/delete_catalogo/delete_catalogo_controller.ts +++ b/back/microsservicos/catalogo/app/delete_catalogo/delete_catalogo_controller.ts @@ -15,16 +15,18 @@ export class DeleteCatalogoController { try { const id = req.params.id; + const userID = req.body?.userID as string | undefined; if (!id) throw new Error('ID não informado') if (id.length !== 36) throw new Error('ID inválido') + if (!userID) throw new Error('O campo userID deve ser informado') const deletedCatalogo = this.usecase.execute(id) const catalogoDeletedEvent: CatalogoEvent = { eventType: CatalogoEventNames.CatalogoDeleted, payload: { - userID: "", + userID, ...deletedCatalogo } } @@ -48,4 +50,4 @@ export class DeleteCatalogoController { } -} \ No newline at end of file +} diff --git a/back/microsservicos/property/app/deleteProperty/delete_property_presenter.ts b/back/microsservicos/property/app/deleteProperty/delete_property_presenter.ts new file mode 100644 index 00000000..689cdd3b --- /dev/null +++ b/back/microsservicos/property/app/deleteProperty/delete_property_presenter.ts @@ -0,0 +1,5 @@ +import { Environments } from "../../shared/environments"; +import { DeletePropertyUsecase } from "./delete_property_usecase"; + +const repo = Environments.getPropertyRepo(); +export const deletePropertyUsecase = new DeletePropertyUsecase(repo); diff --git a/back/microsservicos/property/app/deleteProperty/delete_property_usecase.ts b/back/microsservicos/property/app/deleteProperty/delete_property_usecase.ts new file mode 100644 index 00000000..18c6993e --- /dev/null +++ b/back/microsservicos/property/app/deleteProperty/delete_property_usecase.ts @@ -0,0 +1,12 @@ +import { PropertyRepository } from "../../shared/domain/repo/propertyRepository"; + +export class DeletePropertyUsecase { + + constructor(private repo: PropertyRepository) {} + + public execute(userID: string, catalogID: string) { + const userProperties = this.repo.deleteProperty(userID, catalogID); + return userProperties; + } + +} diff --git a/back/microsservicos/property/shared/domain/repo/propertyRepository.ts b/back/microsservicos/property/shared/domain/repo/propertyRepository.ts index 11c6e2ff..5dee8b77 100644 --- a/back/microsservicos/property/shared/domain/repo/propertyRepository.ts +++ b/back/microsservicos/property/shared/domain/repo/propertyRepository.ts @@ -13,4 +13,6 @@ export interface PropertyRepository { createPropertyManagement(userID: string): PropertyManagement; createProperty(userID: string, catalog: CatalogoType): PropertyManagement; + + deleteProperty(userID: string, catalogID: string): PropertyManagement; } diff --git a/back/microsservicos/property/shared/handlers/events/eventHandler.ts b/back/microsservicos/property/shared/handlers/events/eventHandler.ts index 8f9a35af..cf3bb794 100644 --- a/back/microsservicos/property/shared/handlers/events/eventHandler.ts +++ b/back/microsservicos/property/shared/handlers/events/eventHandler.ts @@ -1,5 +1,6 @@ import { createPropertyUsecase } from "../../../app/createProperty/create_property_presenter"; import { createPropertyManagementUsecase } from "../../../app/createPropertyManagement/create_property_management_presenter"; +import { deletePropertyUsecase } from "../../../app/deleteProperty/delete_property_presenter"; import { CatalogoEventNames, UserEventNames } from "../../infra/clients/rabbitmq/enums"; import { BaseEvent } from "../../infra/clients/rabbitmq/interfaces"; import { consumeEvents } from "../../infra/clients/rabbitmq/rabbitmq"; @@ -7,31 +8,35 @@ import { catalogo, userInformation } from "../../infra/clients/rabbitmq/types"; -type EventType = keyof typeof eventsFunctions; - -const eventsFunctions: { [event in UserEventNames.UserCreated | CatalogoEventNames.CatalogoCreated] : (payload: any) => void } = { - - UserCreated: async (userInfo: userInformation) => { - const created_user = createPropertyManagementUsecase.execute(userInfo.id) - console.log(created_user) +const eventsFunctions: Record Promise | void> = { + [UserEventNames.UserCreated]: async (userInfo: userInformation) => { + const created_user = createPropertyManagementUsecase.execute(userInfo.id); + console.log(created_user); }, - CatalogoCreated: async (catalogoInfo: catalogo) => { - - const { userID, ...catalog } = catalogoInfo - - const created_catalogo = createPropertyUsecase.execute(userID, catalog) - - console.log(created_catalogo) - } - -} + [CatalogoEventNames.CatalogoCreated]: async (catalogoInfo: catalogo) => { + const { userID, ...catalog } = catalogoInfo; + const created_catalogo = createPropertyUsecase.execute(userID, catalog); + console.log(created_catalogo); + }, + [CatalogoEventNames.CatalogoDeleted]: async (catalogoInfo: catalogo) => { + const { userID, id } = catalogoInfo; + if (!userID) { + console.warn('catalogo.deleted recebido sem userID, ignorando'); + return; + } + const deleted = deletePropertyUsecase.execute(userID, id); + console.log(deleted); + }, +}; export async function eventHandler(event: BaseEvent) { try { - const { eventType, payload } = event - eventsFunctions[eventType as EventType](payload); - } catch(err) { - console.log(err) + const handler = eventsFunctions[event.eventType]; + if (handler) { + await handler(event.payload); + } + } catch (err) { + console.log(err); } } @@ -39,10 +44,10 @@ export const startQueue = async () => { try { await consumeEvents("property_queue", "user.created", eventHandler) - await consumeEvents("property_queue", "catalogo.created", eventHandler) + await consumeEvents("property_queue", "catalogo.*", eventHandler) } catch(err){ console.error("Couldn't start the service queues"); process.exit(1); } -} \ No newline at end of file +} diff --git a/back/microsservicos/property/shared/infra/repo/propertyRepositoryMock.ts b/back/microsservicos/property/shared/infra/repo/propertyRepositoryMock.ts index 5e5968c2..f88040ca 100644 --- a/back/microsservicos/property/shared/infra/repo/propertyRepositoryMock.ts +++ b/back/microsservicos/property/shared/infra/repo/propertyRepositoryMock.ts @@ -63,4 +63,18 @@ export class PropertyRepositoryMock implements PropertyRepository{ } -} \ No newline at end of file + public deleteProperty(userID: string, catalogID: string): PropertyManagement { + + const userProperties = this.baseProperty[userID] + + if(!userProperties) throw new Error('Usuário não foi encontrado') + + if(!userProperties.properties[catalogID]) throw new Error('Não foi encontrado o catálogo para esse usuário') + + delete userProperties.properties[catalogID] + + return userProperties + + } + +} diff --git a/back/microsservicos/user/app/create_user/create_user_usecase.ts b/back/microsservicos/user/app/create_user/create_user_usecase.ts index 25688405..4594b8dc 100644 --- a/back/microsservicos/user/app/create_user/create_user_usecase.ts +++ b/back/microsservicos/user/app/create_user/create_user_usecase.ts @@ -7,16 +7,16 @@ export class CreateUserUsecase { public execute(props: createUserPropsType) { if (!userInfoValidation.validateEmail(props.auth.username)) - throw new Error("Field email is invalid"); + throw new Error("Campo e-mail inválido"); if (!userInfoValidation.validateCPF(props.information.cpf)) - throw new Error("Field cpf is invalid"); + throw new Error("Campo CPF inválido"); if (!userInfoValidation.validateBirth(props.information.birth)) - throw new Error("Field birth must be over 18 years old"); + throw new Error("Campo nascimento deve ter mais de 18 anos"); if (!userInfoValidation.validatePhone(props.information.phone)) - throw new Error("Field phone is invalid"); + throw new Error("Campo telefone inválido"); const user_info = this.repo.createUser(props); diff --git a/back/microsservicos/user/app/update_user/update_user_usecase.ts b/back/microsservicos/user/app/update_user/update_user_usecase.ts index 01ff2031..d8e80141 100644 --- a/back/microsservicos/user/app/update_user/update_user_usecase.ts +++ b/back/microsservicos/user/app/update_user/update_user_usecase.ts @@ -7,11 +7,11 @@ export class UpdateUserUsecase { public execute(props: updateUserPropsType) { - if(props.cpf && !userInfoValidation.validateCPF(props.cpf)) throw new Error("Field user CPF is invalid") + if(props.cpf && !userInfoValidation.validateCPF(props.cpf)) throw new Error("Campo CPF inválido") - if(props.birth && !userInfoValidation.validateBirth(props.birth)) throw new Error("Field user birth must be over 18 years old") + if(props.birth && !userInfoValidation.validateBirth(props.birth)) throw new Error("Campo nascimento deve ter mais de 18 anos") - if((props.birth) && !userInfoValidation.validateBirth(props.birth)) throw new Error("Field user phone is invalid") + if((props.phone) && !userInfoValidation.validatePhone(props.phone)) throw new Error("Campo telefone inválido") const user_info = this.repo.updateUser(props) diff --git a/mobile/.env.example b/mobile/.env.example new file mode 100644 index 00000000..9a6da535 --- /dev/null +++ b/mobile/.env.example @@ -0,0 +1,7 @@ +WORKUP_API_BASE=http://localhost +WORKUP_API_HOST=localhost +WORKUP_CATALOGO_PORT=4000 +WORKUP_USER_PORT=4001 +WORKUP_ALUGUEL_PORT=4002 +WORKUP_AVAILABILITY_PORT=4003 +WORKUP_PROPERTY_PORT=4004 diff --git a/mobile/lib/models/listing.dart b/mobile/lib/models/listing.dart new file mode 100644 index 00000000..84b45a71 --- /dev/null +++ b/mobile/lib/models/listing.dart @@ -0,0 +1,75 @@ +class Listing { + final String id; + final String name; + final String description; + final String address; + final List comodities; + final List pictures; + final double price; + final int capacity; + final String? doorSerial; + + const Listing({ + required this.id, + required this.name, + required this.description, + required this.address, + required this.comodities, + required this.pictures, + required this.price, + required this.capacity, + this.doorSerial, + }); + + factory Listing.fromJson(Map json) { + return Listing( + id: json['id']?.toString() ?? '', + name: json['name'] ?? '', + description: json['description'] ?? '', + address: json['address'] ?? '', + comodities: List.from(json['comodities'] ?? []), + pictures: List.from(json['pictures'] ?? []), + price: (json['price'] as num?)?.toDouble() ?? 0.0, + capacity: (json['capacity'] as num?)?.toInt() ?? 0, + doorSerial: json['doorSerial']?.toString(), + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'description': description, + 'address': address, + 'comodities': comodities, + 'pictures': pictures, + 'price': price, + 'capacity': capacity, + 'doorSerial': doorSerial, + }; + } + + Listing copyWith({ + String? id, + String? name, + String? description, + String? address, + List? comodities, + List? pictures, + double? price, + int? capacity, + String? doorSerial, + }) { + return Listing( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + address: address ?? this.address, + comodities: comodities ?? this.comodities, + pictures: pictures ?? this.pictures, + price: price ?? this.price, + capacity: capacity ?? this.capacity, + doorSerial: doorSerial ?? this.doorSerial, + ); + } +} diff --git a/mobile/lib/models/property.dart b/mobile/lib/models/property.dart index b15d727b..d0f7d777 100644 --- a/mobile/lib/models/property.dart +++ b/mobile/lib/models/property.dart @@ -26,12 +26,26 @@ class Property { id: json['id']?.toString() ?? '', name: json['name'] ?? '', price: (json['price'] is num) ? (json['price'] as num).toDouble() : 0.0, - images: (json['images'] as List?)?.map((e) => e.toString()).toList() ?? [], + images: + (json['images'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + [], address: json['address'] ?? '', description: json['description'] ?? '', - rating: json['rating'] != null ? (json['rating'] as num).toDouble() : null, - reviewCount: json['reviewCount'] is int ? json['reviewCount'] as int : (json['reviewCount'] is num ? (json['reviewCount'] as num).toInt() : null), - amenities: (json['amenities'] as List?)?.map((e) => e.toString()).toList() ?? [], + rating: json['rating'] != null + ? (json['rating'] as num).toDouble() + : null, + reviewCount: json['reviewCount'] is int + ? json['reviewCount'] as int + : (json['reviewCount'] is num + ? (json['reviewCount'] as num).toInt() + : null), + amenities: + (json['amenities'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + [], ); } } diff --git a/mobile/lib/models/reservation.dart b/mobile/lib/models/reservation.dart new file mode 100644 index 00000000..55f4b96e --- /dev/null +++ b/mobile/lib/models/reservation.dart @@ -0,0 +1,75 @@ +class Reservation { + static final Reservation empty = Reservation( + id: '', + userId: '', + workspaceId: '', + startDate: 0, + endDate: 0, + people: 0, + finalPrice: 0, + status: '', + createdAt: 0, + updatedAt: 0, + ); + final String id; + final String userId; + final String workspaceId; + final int startDate; + final int endDate; + final int people; + final double finalPrice; + final String status; + final String? doorCode; + final int createdAt; + final int updatedAt; + + const Reservation({ + required this.id, + required this.userId, + required this.workspaceId, + required this.startDate, + required this.endDate, + required this.people, + required this.finalPrice, + required this.status, + required this.createdAt, + required this.updatedAt, + this.doorCode, + }); + + factory Reservation.fromJson(Map json) { + return Reservation( + id: json['id']?.toString() ?? '', + userId: json['userId']?.toString() ?? '', + workspaceId: json['workspaceId']?.toString() ?? '', + startDate: (json['startDate'] as num?)?.toInt() ?? 0, + endDate: (json['endDate'] as num?)?.toInt() ?? 0, + people: (json['people'] as num?)?.toInt() ?? 0, + finalPrice: (json['finalPrice'] as num?)?.toDouble() ?? 0.0, + status: json['status']?.toString() ?? '', + doorCode: json['doorCode']?.toString(), + createdAt: (json['createdAt'] as num?)?.toInt() ?? 0, + updatedAt: (json['updatedAt'] as num?)?.toInt() ?? 0, + ); + } + + Map toJson() { + return { + 'id': id, + 'userId': userId, + 'workspaceId': workspaceId, + 'startDate': startDate, + 'endDate': endDate, + 'people': people, + 'finalPrice': finalPrice, + 'status': status, + 'doorCode': doorCode, + 'createdAt': createdAt, + 'updatedAt': updatedAt, + }; + } + + DateTime get startDateTime => DateTime.fromMillisecondsSinceEpoch(startDate); + + DateTime get endDateTime => DateTime.fromMillisecondsSinceEpoch(endDate); +} diff --git a/mobile/lib/models/user_profile.dart b/mobile/lib/models/user_profile.dart new file mode 100644 index 00000000..5839a92e --- /dev/null +++ b/mobile/lib/models/user_profile.dart @@ -0,0 +1,68 @@ +import 'package:intl/intl.dart'; + +class UserProfile { + final String id; + final String name; + final String email; + final String cpf; + final String phone; + final DateTime? birthDate; + + const UserProfile({ + required this.id, + required this.name, + required this.email, + required this.cpf, + required this.phone, + this.birthDate, + }); + + factory UserProfile.fromJson(Map json) { + DateTime? birthDate; + final rawBirth = json['birth']; + if (rawBirth is int) { + birthDate = DateTime.fromMillisecondsSinceEpoch(rawBirth); + } else if (rawBirth is String && rawBirth.isNotEmpty) { + final parsed = int.tryParse(rawBirth); + if (parsed != null) { + birthDate = DateTime.fromMillisecondsSinceEpoch(parsed); + } + } + + return UserProfile( + id: json['id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + email: json['email']?.toString() ?? '', + cpf: json['cpf']?.toString() ?? '', + phone: json['phone']?.toString() ?? '', + birthDate: birthDate, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'email': email, + 'cpf': cpf, + 'phone': phone, + 'birth': birthDate?.millisecondsSinceEpoch, + }; + } + + UserProfile copyWith({String? name, String? phone, DateTime? birthDate}) { + return UserProfile( + id: id, + name: name ?? this.name, + email: email, + cpf: cpf, + phone: phone ?? this.phone, + birthDate: birthDate ?? this.birthDate, + ); + } + + String formattedBirth({String pattern = 'dd/MM/yyyy'}) { + if (birthDate == null) return ''; + return DateFormat(pattern).format(birthDate!); + } +} diff --git a/mobile/lib/models/user_session.dart b/mobile/lib/models/user_session.dart new file mode 100644 index 00000000..71e8ff0a --- /dev/null +++ b/mobile/lib/models/user_session.dart @@ -0,0 +1,8 @@ +import 'user_profile.dart'; + +class UserSession { + final String token; + final UserProfile profile; + + const UserSession({required this.token, required this.profile}); +} diff --git a/mobile/lib/screens/create_propriedade_page.dart b/mobile/lib/screens/create_propriedade_page.dart index 635e9b22..40d643e9 100644 --- a/mobile/lib/screens/create_propriedade_page.dart +++ b/mobile/lib/screens/create_propriedade_page.dart @@ -1,11 +1,13 @@ -import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; -import 'package:image_picker/image_picker.dart'; import 'package:flutter/services.dart'; -import '../widgets/commodities_selector.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../services/workup_api.dart'; +import '../utils/user_storage.dart'; import '../widgets/alert_widget.dart'; +import '../widgets/commodities_selector.dart'; import '../widgets/side_bar.dart'; -import '../utils/user_storage.dart'; import 'properties_screen.dart'; class CreatePropriedadePage extends StatefulWidget { @@ -18,17 +20,19 @@ class CreatePropriedadePage extends StatefulWidget { class _CreatePropriedadePageState extends State { final _formKey = GlobalKey(); final _nameController = TextEditingController(); + final _descriptionController = TextEditingController(); final _priceController = TextEditingController(); final _addressController = TextEditingController(); final _capacityController = TextEditingController(); final _serialPortaController = TextEditingController(); List _comodidades = []; - final List _images = []; + final List<_SelectedImage> _images = []; bool _isSubmitting = false; String? _alertMessage; bool _isError = false; bool _sidebarActive = false; + final WorkupApi _api = WorkupApi(); final ImagePicker _picker = ImagePicker(); @@ -135,11 +139,16 @@ class _CreatePropriedadePageState extends State { Future _pickImages() async { try { - final List? picked = await _picker.pickMultiImage(); + final picked = await _picker.pickMultiImage(); if (picked != null && picked.isNotEmpty) { - setState(() { - _images.addAll(picked.map((x) => File(x.path)).toList()); - }); + final futures = picked.map((file) async { + final bytes = await file.readAsBytes(); + return _SelectedImage(file: file, bytes: bytes); + }).toList(); + final newImages = await Future.wait(futures); + if (mounted) { + setState(() => _images.addAll(newImages)); + } } } catch (e) { print('Erro ao selecionar imagens: $e'); @@ -156,11 +165,26 @@ class _CreatePropriedadePageState extends State { }); } + @override + void dispose() { + _nameController.dispose(); + _descriptionController.dispose(); + _priceController.dispose(); + _addressController.dispose(); + _capacityController.dispose(); + _serialPortaController.dispose(); + super.dispose(); + } + bool _validateForm() { if (_nameController.text.isEmpty) { _showAlert("Nome da sala é obrigatório", error: true); return false; } + if (_descriptionController.text.isEmpty) { + _showAlert("Descrição é obrigatória", error: true); + return false; + } if (_priceController.text.isEmpty || parseMoney(_priceController.text) <= 0) { _showAlert("Preço deve ser maior que zero", error: true); @@ -193,51 +217,48 @@ class _CreatePropriedadePageState extends State { setState(() => _isSubmitting = true); try { - await Future.delayed(const Duration(seconds: 1)); - - final imagePaths = _images.map((file) => file.path).toList(); - - final newProperty = { - 'id': DateTime.now().millisecondsSinceEpoch.toString(), - 'name': _nameController.text.trim(), - 'price': parseMoney(_priceController.text), - 'address': _addressController.text.trim(), - 'capacity': int.parse(_capacityController.text), - 'serialPorta': _serialPortaController.text.trim(), - 'comodities': _comodidades, - 'pictures': imagePaths, - 'createdAt': DateTime.now().toIso8601String(), - }; - - final success = UserStorage().addProperty(newProperty); - - if (success) { - setState(() { - _isSubmitting = false; - _alertMessage = "Sala criada com sucesso!"; - _isError = false; - }); - - // Aguarda um pouco para mostrar o alerta e então redireciona - // ALTERAÇÃO: Reduzido de 900ms para 600ms - await Future.delayed(const Duration(milliseconds: 600)); - - if (mounted) { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => const TelaPropriedadePage(), - ), - ); - } - } else { - throw Exception('Erro ao salvar propriedade'); + final userId = UserStorage().userId; + if (userId == null) { + _showAlert( + "Você precisa estar autenticado para cadastrar um espaço.", + error: true, + ); + return; } - } catch (e) { + + await _api.createCatalogo( + userId: userId, + name: _nameController.text.trim(), + description: _descriptionController.text.trim(), + address: _addressController.text.trim(), + comodities: _comodidades, + price: parseMoney(_priceController.text), + capacity: int.parse(_capacityController.text), + doorSerial: _serialPortaController.text.trim(), + pictures: _images.map((img) => img.file).toList(), + pictureBytes: _images.map((img) => img.bytes).toList(), + ); + + if (!mounted) return; setState(() { - _isSubmitting = false; + _alertMessage = "Sala criada com sucesso!"; + _isError = false; }); + + await Future.delayed(const Duration(milliseconds: 600)); + + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const TelaPropriedadePage()), + ); + } + } on ApiException catch (err) { + _showAlert(err.message, error: true); + } catch (e) { _showAlert("Erro ao criar sala: $e", error: true); + } finally { + if (mounted) setState(() => _isSubmitting = false); } } @@ -373,6 +394,14 @@ class _CreatePropriedadePageState extends State { ), const SizedBox(height: 16), + _buildTextField( + controller: _descriptionController, + labelText: "Descrição", + hintText: "Conte mais sobre o espaço", + keyboardType: TextInputType.multiline, + ), + const SizedBox(height: 16), + TextFormField( controller: _priceController, keyboardType: @@ -534,8 +563,8 @@ class _CreatePropriedadePageState extends State { ClipRRect( borderRadius: BorderRadius.circular(8), - child: Image.file( - _images[index], + child: Image.memory( + _images[index].bytes, fit: BoxFit.cover, width: double.infinity, height: double.infinity, @@ -729,3 +758,10 @@ class _CreatePropriedadePageState extends State { ); } } + +class _SelectedImage { + final XFile file; + final Uint8List bytes; + + const _SelectedImage({required this.file, required this.bytes}); +} diff --git a/mobile/lib/screens/home_page.dart b/mobile/lib/screens/home_page.dart index a6517533..093e662c 100644 --- a/mobile/lib/screens/home_page.dart +++ b/mobile/lib/screens/home_page.dart @@ -1,9 +1,10 @@ -import 'dart:io'; import 'package:flutter/material.dart'; -import '../widgets/side_bar.dart'; -import '../widgets/models_listing.dart'; -import '../widgets/header_bar.dart'; + +import '../models/listing.dart'; +import '../services/workup_api.dart'; import '../utils/user_storage.dart'; +import '../widgets/header_bar.dart'; +import '../widgets/side_bar.dart'; import 'workspace.dart'; class HomePage extends StatefulWidget { @@ -14,10 +15,13 @@ class HomePage extends StatefulWidget { } class _HomePageState extends State { + final WorkupApi _api = WorkupApi(); List filteredListings = []; + List _allListings = []; TextEditingController searchController = TextEditingController(); - bool isLoading = false; + bool isLoading = true; bool _sidebarActive = false; + String? _errorMessage; String normalizeText(String text) { return text @@ -42,46 +46,60 @@ class _HomePageState extends State { _loadAllProperties(); } - void _loadAllProperties() { - setState(() => isLoading = true); - - // Carrega APENAS propriedades criadas pelos usuários (sem mock) - List allListings = []; - - // Carrega propriedades disponíveis (não alugadas ou canceladas) - final availableProperties = UserStorage().getAvailableProperties(); - - for (var propData in availableProperties) { - allListings.add(Listing.fromJson(propData)); - } - + Future _loadAllProperties() async { setState(() { - filteredListings = allListings; - isLoading = false; + isLoading = true; + _errorMessage = null; }); + + try { + final rooms = await _api.fetchCatalogo(); + UserStorage().cacheCatalog(rooms); + if (!mounted) return; + setState(() { + _allListings = rooms; + filteredListings = rooms; + }); + } on ApiException catch (err) { + if (!mounted) return; + setState(() { + _errorMessage = err.message; + }); + } catch (err) { + if (!mounted) return; + setState(() { + _errorMessage = 'Erro ao carregar propriedades: $err'; + }); + } finally { + if (mounted) { + setState(() => isLoading = false); + } + } } void filterRooms(String query) { - _loadAllProperties(); // Recarrega todas as propriedades primeiro - final normalizedQuery = normalizeText(query.trim()); - if (normalizedQuery.isEmpty) { - return; // Já foi carregado tudo no _loadAllProperties - } - setState(() { - filteredListings = filteredListings.where((room) { - final normalizedName = normalizeText(room.name); - final normalizedAddress = normalizeText(room.address); + if (normalizedQuery.isEmpty) { + filteredListings = List.from(_allListings); + } else { + filteredListings = _allListings.where((room) { + final normalizedName = normalizeText(room.name); + final normalizedAddress = normalizeText(room.address); - return normalizedName.contains(normalizedQuery) || - normalizedAddress.contains(normalizedQuery); - }).toList(); + return normalizedName.contains(normalizedQuery) || + normalizedAddress.contains(normalizedQuery); + }).toList(); + } }); } Widget buildRoomCard(Listing room) { + final imageUrl = room.pictures + .firstWhere((pic) => pic.trim().isNotEmpty, orElse: () => '') + .trim(); + return GestureDetector( onTap: () { // Navega para a tela de detalhes do workspace @@ -105,12 +123,12 @@ class _HomePageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (room.pictures.isNotEmpty) + if (imageUrl.isNotEmpty) ClipRRect( borderRadius: const BorderRadius.vertical( top: Radius.circular(16), ), - child: _buildRoomImage(room.pictures.first), + child: _buildRoomImage(imageUrl), ), Padding( padding: const EdgeInsets.all(12), @@ -161,68 +179,36 @@ class _HomePageState extends State { ); } - // Método para construir a imagem (local ou da internet) Widget _buildRoomImage(String imagePath) { - // Verifica se é uma URL (começa com http) - if (imagePath.startsWith('http')) { - return Image.network( - imagePath, - width: double.infinity, - height: 180, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - height: 180, - color: Colors.grey[300], - child: const Icon( - Icons.photo_outlined, - size: 50, - color: Colors.grey, - ), - ); - }, - ); - } else { - // É um arquivo local - verifica se o arquivo existe - final file = File(imagePath); - return FutureBuilder( - future: file.exists(), - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data == true) { - return Image.file( - file, - width: double.infinity, - height: 180, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - height: 180, - color: Colors.grey[300], - child: const Icon( - Icons.photo_outlined, - size: 50, - color: Colors.grey, - ), - ); - }, - ); - } else { - // Arquivo não existe ou ainda carregando - return Container( - height: 180, - color: Colors.grey[300], - child: snapshot.connectionState == ConnectionState.waiting - ? const Center(child: CircularProgressIndicator()) - : const Icon( - Icons.photo_outlined, - size: 50, - color: Colors.grey, - ), - ); - } - }, - ); + final url = imagePath.trim(); + if (!url.startsWith('http')) { + return _imagePlaceholder(); } + + return Image.network( + url, + width: double.infinity, + height: 180, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( + height: 180, + color: Colors.grey[200], + alignment: Alignment.center, + child: const CircularProgressIndicator(strokeWidth: 2), + ); + }, + errorBuilder: (context, error, stackTrace) => _imagePlaceholder(), + ); + } + + Widget _imagePlaceholder() { + return Container( + height: 180, + color: Colors.grey[300], + child: const Icon(Icons.photo_outlined, size: 50, color: Colors.grey), + ); } @override @@ -238,114 +224,148 @@ class _HomePageState extends State { end: Alignment.bottomRight, ), ), - child: CustomScrollView( - slivers: [ - SliverAppBar( - pinned: true, - automaticallyImplyLeading: false, - flexibleSpace: HeaderBar( - onMenuClick: () => setState(() => _sidebarActive = true), + child: RefreshIndicator( + onRefresh: _loadAllProperties, + child: CustomScrollView( + slivers: [ + SliverAppBar( + pinned: true, + automaticallyImplyLeading: false, + flexibleSpace: HeaderBar( + onMenuClick: () => setState(() => _sidebarActive = true), + ), ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), - child: Column( - children: [ - const Text( - "Encontre o espaço perfeito para seu negócio", - style: TextStyle( - fontSize: 20, - color: Color(0xFF2C3E50), - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - const Text( - "Descubra escritórios e salas comerciais que combinam com sua necessidade", - textAlign: TextAlign.center, - style: TextStyle(fontSize: 14, color: Colors.grey), - ), - const SizedBox(height: 20), - TextField( - controller: searchController, - decoration: InputDecoration( - hintText: "Buscar sala...", - prefixIcon: const Icon(Icons.search), - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.symmetric( - vertical: 14, + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: Column( + children: [ + const Text( + "Encontre o espaço perfeito para seu negócio", + style: TextStyle( + fontSize: 20, + color: Color(0xFF2C3E50), + fontWeight: FontWeight.bold, ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide( - color: Colors.grey, - width: 0.5, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + const Text( + "Descubra escritórios e salas comerciais que combinam com sua necessidade", + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, color: Colors.grey), + ), + const SizedBox(height: 20), + TextField( + controller: searchController, + decoration: InputDecoration( + hintText: "Buscar sala...", + prefixIcon: const Icon(Icons.search), + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric( + vertical: 14, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide( + color: Colors.grey, + width: 0.5, + ), ), ), + onChanged: filterRooms, ), - onChanged: filterRooms, - ), - const SizedBox(height: 20), - ], + const SizedBox(height: 20), + ], + ), ), ), - ), - if (isLoading) - const SliverToBoxAdapter( - child: Center( - child: Padding( - padding: EdgeInsets.all(20.0), - child: CircularProgressIndicator(), + if (isLoading) + const SliverToBoxAdapter( + child: Center( + child: Padding( + padding: EdgeInsets.all(20.0), + child: CircularProgressIndicator(), + ), ), - ), - ) - else if (filteredListings.isEmpty) - SliverToBoxAdapter( - child: Center( + ) + else if (_errorMessage != null) + SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.all(48.0), + padding: const EdgeInsets.all(32.0), child: Column( children: [ Icon( - Icons.business_outlined, - size: 64, - color: Colors.grey[400], + Icons.warning_amber_rounded, + size: 56, + color: Colors.red[300], ), - const SizedBox(height: 16), - const Text( - "Nenhum espaço disponível no momento.", - style: TextStyle( - fontSize: 18, - color: Colors.grey, + const SizedBox(height: 12), + Text( + _errorMessage!, + style: const TextStyle( + fontSize: 16, + color: Colors.red, + fontWeight: FontWeight.w500, ), textAlign: TextAlign.center, ), - const SizedBox(height: 8), - const Text( - "Crie uma nova propriedade ou aguarde novos espaços!", - style: TextStyle( - fontSize: 14, - color: Colors.grey, - ), - textAlign: TextAlign.center, + const SizedBox(height: 12), + ElevatedButton.icon( + onPressed: _loadAllProperties, + icon: const Icon(Icons.refresh), + label: const Text('Tentar novamente'), ), ], ), ), + ) + else if (filteredListings.isEmpty) + SliverToBoxAdapter( + child: Center( + child: Padding( + padding: const EdgeInsets.all(48.0), + child: Column( + children: [ + Icon( + Icons.business_outlined, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + const Text( + "Nenhum espaço disponível no momento.", + style: TextStyle( + fontSize: 18, + color: Colors.grey, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + const Text( + "Crie uma nova propriedade ou aguarde novos espaços!", + style: TextStyle( + fontSize: 14, + color: Colors.grey, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => + buildRoomCard(filteredListings[index]), + childCount: filteredListings.length, + ), ), - ) - else - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) => - buildRoomCard(filteredListings[index]), - childCount: filteredListings.length, - ), - ), - ], + ], + ), ), ), ), diff --git a/mobile/lib/screens/login_screen.dart b/mobile/lib/screens/login_screen.dart index 1073e81e..33eb8aa6 100644 --- a/mobile/lib/screens/login_screen.dart +++ b/mobile/lib/screens/login_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../services/workup_api.dart'; import '../utils/user_storage.dart'; import 'home_page.dart'; import 'register_page.dart'; @@ -15,6 +16,8 @@ class _LoginScreenState extends State { final _emailController = TextEditingController(); final _passwordController = TextEditingController(); bool _obscurePassword = true; + bool _isSubmitting = false; + final WorkupApi _api = WorkupApi(); @override void dispose() { @@ -26,29 +29,37 @@ class _LoginScreenState extends State { Future _handleLogin() async { if (!_formKey.currentState!.validate()) return; - setState(() {}); + setState(() => _isSubmitting = true); - // small local check against UserStorage final email = _emailController.text.trim(); final password = _passwordController.text; - // simulate small delay - await Future.delayed(const Duration(milliseconds: 400)); + try { + final session = await _api.login(email: email, password: password); + UserStorage().saveSession(token: session.token, profile: session.profile); - final ok = UserStorage().validateCredentials(email, password); - if (!mounted) return; - if (ok) { + if (!mounted) return; Navigator.pushReplacement( context, MaterialPageRoute(builder: (context) => const HomePage()), ); - } else { + } on ApiException catch (err) { + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Usuário não encontrado ou senha incorreta'), + SnackBar(content: Text(err.message), backgroundColor: Colors.red), + ); + } catch (err) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erro ao fazer login: $err'), backgroundColor: Colors.red, ), ); + } finally { + if (mounted) { + setState(() => _isSubmitting = false); + } } } @@ -198,7 +209,7 @@ class _LoginScreenState extends State { height: 48, margin: const EdgeInsets.only(top: 16), child: ElevatedButton( - onPressed: _handleLogin, + onPressed: _isSubmitting ? null : _handleLogin, style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF34495e), foregroundColor: Colors.white, @@ -207,13 +218,24 @@ class _LoginScreenState extends State { ), elevation: 0, ), - child: const Text( - 'Entrar', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), + child: _isSubmitting + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : const Text( + 'Entrar', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), ), ), const SizedBox(height: 16), diff --git a/mobile/lib/screens/properties_screen.dart b/mobile/lib/screens/properties_screen.dart index dc491af8..3d0d7b16 100644 --- a/mobile/lib/screens/properties_screen.dart +++ b/mobile/lib/screens/properties_screen.dart @@ -1,7 +1,8 @@ -import 'dart:io'; import 'package:flutter/material.dart'; -import '../widgets/side_bar.dart'; +import '../models/listing.dart'; +import '../services/workup_api.dart'; import '../utils/user_storage.dart'; +import '../widgets/side_bar.dart'; class TelaPropriedadePage extends StatefulWidget { const TelaPropriedadePage({Key? key}) : super(key: key); @@ -10,39 +11,8 @@ class TelaPropriedadePage extends StatefulWidget { State createState() => _TelaPropriedadePageState(); } -class Listing { - final String id; - final String name; - final List pictures; - final double price; - final String address; - final List comodities; - final int capacity; - - Listing({ - required this.id, - required this.name, - required this.pictures, - required this.price, - required this.address, - required this.comodities, - required this.capacity, - }); - - factory Listing.fromJson(Map json) { - return Listing( - id: json['id']?.toString() ?? '', - name: json['name'] ?? '', - pictures: List.from(json['pictures'] ?? []), - price: (json['price'] as num?)?.toDouble() ?? 0.0, - address: json['address'] ?? '', - comodities: List.from(json['comodities'] ?? []), - capacity: (json['capacity'] as num?)?.toInt() ?? 0, - ); - } -} - class _TelaPropriedadePageState extends State { + final WorkupApi _api = WorkupApi(); List _properties = []; bool _isLoading = true; String? _errorMessage; @@ -54,25 +24,40 @@ class _TelaPropriedadePageState extends State { _loadUserProperties(); } - void _loadUserProperties() { - setState(() => _isLoading = true); + Future _loadUserProperties() async { + final userId = UserStorage().userId; + if (userId == null) { + setState(() { + _errorMessage = "Faça login para gerenciar suas propriedades."; + _isLoading = false; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); try { - // Carrega apenas as propriedades do usuário logado - final userPropertiesData = UserStorage().getUserProperties(); - + final properties = await _api.fetchUserProperties(userId); + UserStorage().cacheOwnedProperties(properties); + if (!mounted) return; setState(() { - _properties = userPropertiesData - .map((data) => Listing.fromJson(data)) - .toList(); - _isLoading = false; + _properties = properties; + }); + } on ApiException catch (err) { + if (!mounted) return; + setState(() { + _errorMessage = err.message; }); } catch (err) { + if (!mounted) return; setState(() { - _errorMessage = "Erro ao carregar as propriedades"; - _isLoading = false; + _errorMessage = "Erro ao carregar as propriedades: $err"; }); - print("Erro ao carregar propriedades do usuário: $err"); + } finally { + if (mounted) setState(() => _isLoading = false); } } @@ -91,14 +76,15 @@ class _TelaPropriedadePageState extends State { child: const Text('Cancelar', style: TextStyle(color: Colors.grey)), ), TextButton( - onPressed: () { + onPressed: () async { Navigator.of(context).pop(); - - final success = UserStorage().removeProperty(propertyId); - - if (success) { - _loadUserProperties(); // Recarrega a lista - + final userId = UserStorage().userId; + if (userId == null) return; + try { + await _api.deleteCatalogo(propertyId, userId); + if (!mounted) return; + await _loadUserProperties(); + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Propriedade removida com sucesso'), @@ -106,6 +92,22 @@ class _TelaPropriedadePageState extends State { duration: Duration(seconds: 2), ), ); + } on ApiException catch (err) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(err.message), + backgroundColor: Colors.red, + ), + ); + } catch (err) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erro ao remover propriedade: $err'), + backgroundColor: Colors.red, + ), + ); } }, child: const Text( @@ -274,66 +276,31 @@ class _TelaPropriedadePageState extends State { // Método para construir a imagem (local ou da internet) Widget _buildPropertyImage(String imagePath) { - // Verifica se é uma URL (começa com http) - if (imagePath.startsWith('http')) { - return Image.network( - imagePath, - width: double.infinity, - height: 180, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - height: 180, - color: Colors.grey[300], - child: const Icon( - Icons.photo_outlined, - size: 50, - color: Colors.grey, - ), - ); - }, - ); - } else { - // É um arquivo local - verifica se o arquivo existe - final file = File(imagePath); - return FutureBuilder( - future: file.exists(), - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data == true) { - return Image.file( - file, - width: double.infinity, - height: 180, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - height: 180, - color: Colors.grey[300], - child: const Icon( - Icons.photo_outlined, - size: 50, - color: Colors.grey, - ), - ); - }, - ); - } else { - // Arquivo não existe ou ainda carregando - return Container( - height: 180, - color: Colors.grey[300], - child: snapshot.connectionState == ConnectionState.waiting - ? const Center(child: CircularProgressIndicator()) - : const Icon( - Icons.photo_outlined, - size: 50, - color: Colors.grey, - ), - ); - } - }, - ); - } + if (!imagePath.startsWith('http')) return _imagePlaceholder(); + return Image.network( + imagePath, + width: double.infinity, + height: 180, + fit: BoxFit.cover, + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return Container( + height: 180, + color: Colors.grey[200], + alignment: Alignment.center, + child: const CircularProgressIndicator(strokeWidth: 2), + ); + }, + errorBuilder: (context, error, stackTrace) => _imagePlaceholder(), + ); + } + + Widget _imagePlaceholder() { + return Container( + height: 180, + color: Colors.grey[300], + child: const Icon(Icons.photo_outlined, size: 50, color: Colors.grey), + ); } @override diff --git a/mobile/lib/screens/register_page.dart b/mobile/lib/screens/register_page.dart index b06566b0..71a8ac6c 100644 --- a/mobile/lib/screens/register_page.dart +++ b/mobile/lib/screens/register_page.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:mask_text_input_formatter/mask_text_input_formatter.dart'; -import '../utils/user_storage.dart'; +import '../services/workup_api.dart'; import 'login_screen.dart'; class RegisterPage extends StatefulWidget { @@ -35,6 +35,19 @@ class _RegisterPageState extends State { bool _isError = false; bool _obscurePassword = true; // Adicionado para controlar visibilidade da senha + final WorkupApi _api = WorkupApi(); + + String _digitsOnly(String value) => value.replaceAll(RegExp(r'[^0-9]'), ''); + + DateTime? _parseBirthDate() { + final raw = _birthdateController.text.trim(); + if (raw.isEmpty) return null; + try { + return DateFormat('dd/MM/yyyy').parseStrict(raw); + } catch (_) { + return null; + } + } Future _handleSubmit() async { if (!_formKey.currentState!.validate()) return; @@ -45,51 +58,25 @@ class _RegisterPageState extends State { _isError = false; }); try { - final email = _emailController.text.trim(); - - // Checa se já existe localmente (email, cpf, telefone) - if (UserStorage().isEmailRegistered(email)) { - setState(() { - _apiMessage = 'Este e-mail já está registrado!'; - _isError = true; - _isSubmitting = false; - }); - return; - } - - final cpf = _cpfController.text; - if (UserStorage().isCpfRegistered(cpf)) { - setState(() { - _apiMessage = 'CPF já em uso!'; - _isError = true; - _isSubmitting = false; - }); - return; - } - - final phone = _phoneController.text; - if (UserStorage().isPhoneRegistered(phone)) { + final birthDate = _parseBirthDate(); + if (birthDate == null) { setState(() { - _apiMessage = 'Telefone já em uso!'; + _apiMessage = 'Data de nascimento inválida'; _isError = true; _isSubmitting = false; }); return; } - // Armazena usuário localmente (temporário) - final userData = { - 'name': _nameController.text.trim(), - 'email': email, - 'password': _passwordController.text, - 'cpf': _cpfController.text, - 'birthDate': _birthdateController.text, - 'phone': _phoneController.text, - }; - - UserStorage().addUser(userData); + await _api.registerUser( + name: _nameController.text.trim(), + email: _emailController.text.trim(), + password: _passwordController.text, + cpf: _digitsOnly(_cpfController.text), + phone: _digitsOnly(_phoneController.text), + birthDate: birthDate, + ); - // Feedback e navegação para login if (!mounted) return; setState(() { _apiMessage = 'Registro realizado com sucesso!'; @@ -98,15 +85,20 @@ class _RegisterPageState extends State { await Future.delayed(const Duration(milliseconds: 900)); if (!mounted) return; - setState(() => _isSubmitting = false); Navigator.pushReplacement( context, MaterialPageRoute(builder: (context) => const LoginScreen()), ); - } catch (e) { + } on ApiException catch (err) { + if (!mounted) return; + setState(() { + _apiMessage = err.message; + _isError = true; + }); + } catch (err) { if (!mounted) return; setState(() { - _apiMessage = 'Erro ao realizar cadastro: $e'; + _apiMessage = 'Erro ao realizar cadastro: $err'; _isError = true; }); } finally { @@ -197,9 +189,15 @@ class _RegisterPageState extends State { border: OutlineInputBorder(), hintText: "Seu nome completo", ), - validator: (value) => value == null || value.isEmpty - ? "Campo obrigatório" - : null, + validator: (value) { + if (value == null || value.isEmpty) { + return "Campo obrigatório"; + } + if (_parseBirthDate() == null) { + return "Data inválida"; + } + return null; + }, ), const SizedBox(height: 16), diff --git a/mobile/lib/screens/rent_screen.dart b/mobile/lib/screens/rent_screen.dart index af1e4d82..8170916f 100644 --- a/mobile/lib/screens/rent_screen.dart +++ b/mobile/lib/screens/rent_screen.dart @@ -1,7 +1,10 @@ -import 'dart:io'; import 'package:flutter/material.dart'; -import '../widgets/side_bar.dart'; + +import '../models/listing.dart'; +import '../models/reservation.dart'; +import '../services/workup_api.dart'; import '../utils/user_storage.dart'; +import '../widgets/side_bar.dart'; class TelaAluguelPage extends StatefulWidget { const TelaAluguelPage({Key? key}) : super(key: key); @@ -10,50 +13,13 @@ class TelaAluguelPage extends StatefulWidget { State createState() => _TelaAluguelPageState(); } -class Reservation { - final String id; - final String workspaceId; - final String workspaceName; - final String workspaceImage; - final String startDate; - final String endDate; - String status; - final String address; - final double price; - final int capacity; - - Reservation({ - required this.id, - required this.workspaceId, - required this.workspaceName, - required this.workspaceImage, - required this.startDate, - required this.endDate, - required this.status, - required this.address, - required this.price, - required this.capacity, - }); - - factory Reservation.fromJson(Map json) { - return Reservation( - id: json['id']?.toString() ?? '', - workspaceId: json['workspaceId']?.toString() ?? '', - workspaceName: json['workspaceName'] ?? '', - workspaceImage: json['workspaceImage'] ?? '', - startDate: json['startDate'] ?? '', - endDate: json['endDate'] ?? '', - status: json['status'] ?? '', - address: json['address'] ?? '', - price: (json['price'] as num?)?.toDouble() ?? 0.0, - capacity: json['capacity'] ?? 0, - ); - } -} - class _TelaAluguelPageState extends State { bool _sidebarActive = false; + final WorkupApi _api = WorkupApi(); List _reservations = []; + bool _isLoading = true; + String? _errorMessage; + final Set _doorCodeVisible = {}; @override void initState() { @@ -61,65 +27,98 @@ class _TelaAluguelPageState extends State { _loadReservations(); } - void _loadReservations() { - // Não inicializa mais reservas mockadas - final reservationsData = UserStorage().getReservations(); + Future _loadReservations() async { + final userId = UserStorage().userId; + if (userId == null) { + setState(() { + _errorMessage = 'Faça login para visualizar suas reservas.'; + _isLoading = false; + }); + return; + } setState(() { - _reservations = reservationsData - .map((data) => Reservation.fromJson(data)) - .toList(); + _isLoading = true; + _errorMessage = null; }); - } - String _formatDate(String dateString) { try { - final date = DateTime.parse(dateString); - return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}'; - } catch (e) { - return dateString; + final reservations = await _api.fetchUserReservations(userId); + UserStorage().cacheReservations(reservations); + if (!mounted) return; + setState(() { + _reservations = reservations; + }); + } on ApiException catch (err) { + if (!mounted) return; + setState(() { + _errorMessage = err.message; + }); + } catch (err) { + if (!mounted) return; + setState(() { + _errorMessage = 'Erro ao carregar reservas: $err'; + }); + } finally { + if (mounted) setState(() => _isLoading = false); } } + String _formatDate(int timestamp) { + final date = DateTime.fromMillisecondsSinceEpoch(timestamp); + return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}'; + } + Color _getStatusColor(String status) { - switch (status) { - case 'active': + switch (status.toUpperCase()) { + case 'PENDING': + return Colors.orange; + case 'CONFIRMED': return Colors.green; - case 'completed': - return Colors.grey; - case 'cancelled': + case 'CANCELLED': return Colors.red; + case 'EXPIRED': + return Colors.grey; default: return Colors.grey; } } Color _getStatusBackgroundColor(String status) { - switch (status) { - case 'active': + switch (status.toUpperCase()) { + case 'PENDING': + return Colors.orange.shade100; + case 'CONFIRMED': return Colors.green.shade100; - case 'completed': - return Colors.grey.shade100; - case 'cancelled': + case 'CANCELLED': return Colors.red.shade100; + case 'EXPIRED': + return Colors.grey.shade200; default: return Colors.grey.shade100; } } String _getStatusText(String status) { - switch (status) { - case 'active': - return 'Ativa'; - case 'completed': - return 'Concluída'; - case 'cancelled': + switch (status.toUpperCase()) { + case 'PENDING': + return 'Pendente'; + case 'CONFIRMED': + return 'Confirmada'; + case 'CANCELLED': return 'Cancelada'; + case 'EXPIRED': + return 'Expirada'; default: return status; } } + bool _canCancel(String status) { + final normalized = status.toUpperCase(); + return normalized == 'PENDING' || normalized == 'CONFIRMED'; + } + void _handleCancelReservation(String reservationId) { showDialog( context: context, @@ -133,24 +132,37 @@ class _TelaAluguelPageState extends State { child: const Text('Não', style: TextStyle(color: Colors.grey)), ), TextButton( - onPressed: () { + onPressed: () async { Navigator.of(context).pop(); - final success = UserStorage().updateReservationStatus( - reservationId, - 'cancelled', - ); - - if (success) { - _loadReservations(); - + try { + await _api.deleteReservation(reservationId); + if (!mounted) return; + await _loadReservations(); + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Reserva cancelada com sucesso'), + content: Text('Reserva removida com sucesso'), backgroundColor: Colors.green, duration: Duration(seconds: 2), ), ); + } on ApiException catch (err) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(err.message), + backgroundColor: Colors.red, + ), + ); + } catch (err) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erro ao cancelar reserva: $err'), + backgroundColor: Colors.red, + ), + ); } }, child: const Text( @@ -164,6 +176,8 @@ class _TelaAluguelPageState extends State { } Widget _buildReservationCard(Reservation reservation) { + final room = UserStorage().getCatalogRoom(reservation.workspaceId); + return Card( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), elevation: 5, @@ -171,16 +185,14 @@ class _TelaAluguelPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Imagem Stack( children: [ ClipRRect( borderRadius: const BorderRadius.vertical( top: Radius.circular(16), ), - child: _buildReservationImage(reservation.workspaceImage), + child: _buildReservationImage(room), ), - // Badge de status Positioned( top: 12, right: 12, @@ -209,31 +221,24 @@ class _TelaAluguelPageState extends State { ), ], ), - - // Conteúdo Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Nome Text( - reservation.workspaceName, + room?.name ?? 'Espaço ${reservation.workspaceId}', style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 6), - - // Endereço Text( - reservation.address, + room?.address ?? 'Endereço indisponível', style: const TextStyle(fontSize: 14, color: Colors.grey), ), - const SizedBox(height: 8), - - // Datas + const SizedBox(height: 12), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( @@ -291,14 +296,12 @@ class _TelaAluguelPageState extends State { ], ), ), - const SizedBox(height: 8), - - // Preço e capacidade + const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - "R\$ ${reservation.price.toStringAsFixed(2)} / hora", + "Total: R\$ ${reservation.finalPrice.toStringAsFixed(2)}", style: const TextStyle( fontSize: 16, color: Color(0xFF34495E), @@ -310,7 +313,7 @@ class _TelaAluguelPageState extends State { const Icon(Icons.people, size: 18, color: Colors.grey), const SizedBox(width: 4), Text( - "${reservation.capacity} pessoas", + "${room?.capacity ?? '-'} pessoas", style: const TextStyle( fontSize: 14, color: Colors.grey, @@ -320,9 +323,11 @@ class _TelaAluguelPageState extends State { ), ], ), - - // Botão de cancelar (apenas para reservas ativas) - if (reservation.status == 'active') ...[ + if (reservation.doorCode != null && + reservation.doorCode!.isNotEmpty && + reservation.status.toUpperCase() == 'CONFIRMED') + _buildDoorCode(reservation), + if (_canCancel(reservation.status)) ...[ const SizedBox(height: 12), SizedBox( width: double.infinity, @@ -348,72 +353,101 @@ class _TelaAluguelPageState extends State { ); } - Widget _buildReservationImage(String imagePath) { - if (imagePath.isEmpty) { - return Container( - height: 180, - color: Colors.grey[300], - child: const Icon(Icons.photo_outlined, size: 50, color: Colors.grey), - ); + Widget _buildReservationImage(Listing? room) { + final imagePath = (room?.pictures.isNotEmpty ?? false) + ? room!.pictures.first + : null; + + if (imagePath == null || !imagePath.startsWith('http')) { + return _reservationPlaceholder(); } - if (imagePath.startsWith('http')) { - return Image.network( - imagePath, - width: double.infinity, - height: 180, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - height: 180, - color: Colors.grey[300], - child: const Icon( - Icons.photo_outlined, - size: 50, - color: Colors.grey, - ), - ); - }, - ); - } else { - final file = File(imagePath); - return FutureBuilder( - future: file.exists(), - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data == true) { - return Image.file( - file, - width: double.infinity, - height: 180, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - height: 180, - color: Colors.grey[300], - child: const Icon( - Icons.photo_outlined, - size: 50, - color: Colors.grey, + return Image.network( + imagePath, + width: double.infinity, + height: 180, + fit: BoxFit.cover, + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return Container( + height: 180, + color: Colors.grey[200], + alignment: Alignment.center, + child: const CircularProgressIndicator(strokeWidth: 2), + ); + }, + errorBuilder: (context, error, stackTrace) => _reservationPlaceholder(), + ); + } + + Widget _reservationPlaceholder() { + return Container( + height: 180, + color: Colors.grey[300], + child: const Icon(Icons.photo_outlined, size: 50, color: Colors.grey), + ); + } + + Widget _buildDoorCode(Reservation reservation) { + final isVisible = _doorCodeVisible.contains(reservation.id); + final masked = '•' * reservation.doorCode!.length; + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.green.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.green.shade200), + ), + child: Row( + children: [ + const Icon(Icons.lock, color: Colors.green), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Código da Porta', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFF2C3E50), + ), ), - ); - }, - ); - } else { - return Container( - height: 180, - color: Colors.grey[300], - child: snapshot.connectionState == ConnectionState.waiting - ? const Center(child: CircularProgressIndicator()) - : const Icon( - Icons.photo_outlined, - size: 50, - color: Colors.grey, + const SizedBox(height: 4), + Text( + isVisible ? reservation.doorCode! : masked, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + letterSpacing: 4, + color: Colors.green[800], ), - ); - } - }, - ); - } + ), + ], + ), + ), + IconButton( + icon: Icon( + isVisible ? Icons.visibility_off : Icons.visibility, + color: Colors.green[700], + ), + onPressed: () { + setState(() { + if (isVisible) { + _doorCodeVisible.remove(reservation.id); + } else { + _doorCodeVisible.add(reservation.id); + } + }); + }, + ), + ], + ), + ), + ); } @override @@ -438,88 +472,129 @@ class _TelaAluguelPageState extends State { centerTitle: true, elevation: 3, ), - body: SingleChildScrollView( - child: Column( - children: [ - // Header com título - Container( - width: double.infinity, - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: Colors.white, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: const Column( - children: [ - Text( - "Minhas Reservas", - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - color: Color(0xFF2C3E50), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _errorMessage != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: Colors.red[300], ), - textAlign: TextAlign.center, - ), - SizedBox(height: 8), - Text( - "Gerencie suas reservas ativas e histórico", - style: TextStyle(fontSize: 14, color: Colors.grey), - textAlign: TextAlign.center, - ), - ], + const SizedBox(height: 16), + Text( + _errorMessage!, + style: const TextStyle( + fontSize: 16, + color: Colors.red, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _loadReservations, + icon: const Icon(Icons.refresh), + label: const Text('Tentar novamente'), + ), + ], + ), ), - ), - - // Lista de reservas - _reservations.isEmpty - ? Padding( - padding: const EdgeInsets.all(48), - child: Column( - children: [ - Icon( - Icons.calendar_today_outlined, - size: 64, - color: Colors.grey[400], - ), - const SizedBox(height: 16), - const Text( - "Você ainda não possui reservas.", - style: TextStyle( - fontSize: 18, - color: Colors.grey, + ) + : RefreshIndicator( + onRefresh: _loadReservations, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - const Text( - "Explore os espaços disponíveis e faça sua primeira reserva!", - style: TextStyle( - fontSize: 14, - color: Colors.grey, + ], + ), + child: const Column( + children: [ + Text( + "Minhas Reservas", + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Color(0xFF2C3E50), + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, - ), - ], + SizedBox(height: 8), + Text( + "Gerencie suas reservas ativas e histórico", + style: TextStyle( + fontSize: 14, + color: Colors.grey, + ), + textAlign: TextAlign.center, + ), + ], + ), ), - ) - : ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric(vertical: 16), - itemCount: _reservations.length, - itemBuilder: (context, index) { - return _buildReservationCard(_reservations[index]); - }, - ), - ], - ), - ), + _reservations.isEmpty + ? Padding( + padding: const EdgeInsets.all(48), + child: Column( + children: [ + Icon( + Icons.calendar_today_outlined, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + const Text( + "Você ainda não possui reservas.", + style: TextStyle( + fontSize: 18, + color: Colors.grey, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + const Text( + "Explore os espaços disponíveis e faça sua primeira reserva!", + style: TextStyle( + fontSize: 14, + color: Colors.grey, + ), + textAlign: TextAlign.center, + ), + ], + ), + ) + : ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric( + vertical: 16, + ), + itemCount: _reservations.length, + itemBuilder: (context, index) { + return _buildReservationCard( + _reservations[index], + ); + }, + ), + ], + ), + ), + ), ), SidebarMenu( active: _sidebarActive, diff --git a/mobile/lib/screens/user_profile_screen.dart b/mobile/lib/screens/user_profile_screen.dart index 94e4749f..c59c78a7 100644 --- a/mobile/lib/screens/user_profile_screen.dart +++ b/mobile/lib/screens/user_profile_screen.dart @@ -1,9 +1,15 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; -import 'dart:io'; -import '../widgets/custom_button.dart'; +import 'package:intl/intl.dart'; + +import '../models/user_profile.dart'; +import '../services/workup_api.dart'; import '../utils/profile_image_manager.dart'; import '../utils/user_storage.dart'; +import '../widgets/custom_button.dart'; class UserProfilePage extends StatefulWidget { const UserProfilePage({super.key}); @@ -15,15 +21,19 @@ class UserProfilePage extends StatefulWidget { class _UserProfilePageState extends State { bool _isEditing = false; final ImagePicker _picker = ImagePicker(); + final WorkupApi _api = WorkupApi(); // Controllers para os campos editáveis final _nameController = TextEditingController(); final _birthController = TextEditingController(); + final _phoneController = TextEditingController(); // Dados fixos do usuário String _email = ''; String _cpf = ''; - String _phone = ''; + bool _isSaving = false; + UserProfile? _profile; + final DateFormat _birthFormat = DateFormat('dd/MM/yyyy'); final Color primaryColor = const Color(0xFF34495E); @@ -37,20 +47,14 @@ class _UserProfilePageState extends State { void dispose() { _nameController.dispose(); _birthController.dispose(); + _phoneController.dispose(); super.dispose(); } void _loadUserData() { - final user = UserStorage().getLoggedUser(); - if (user != null) { - setState(() { - _nameController.text = user['name'] ?? ''; - _email = user['email'] ?? ''; - _cpf = user['cpf'] ?? ''; - _birthController.text = user['birthDate'] ?? ''; - _phone = user['phone'] ?? ''; - }); - } + final user = UserStorage().loggedUser; + if (user == null) return; + _applyProfile(user); } Future _pickImage() async { @@ -73,34 +77,101 @@ class _UserProfilePageState extends State { } } - void _handleSave() { - final updatedData = { - 'name': _nameController.text.trim(), - 'birthDate': _birthController.text, - }; + DateTime? _parseBirthInput() { + final raw = _birthController.text.trim(); + if (raw.isEmpty) return null; + try { + return _birthFormat.parseStrict(raw); + } catch (_) { + return null; + } + } - final success = UserStorage().updateLoggedUser(updatedData); + Future _handleSave() async { + final user = _profile; + if (user == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Nenhum usuário autenticado'), + backgroundColor: Colors.red, + ), + ); + return; + } - if (success) { - setState(() { - _isEditing = false; - }); + final birthDate = _parseBirthInput() ?? user.birthDate; + final phone = _phoneController.text.trim(); + + if (birthDate == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Informe uma data de nascimento válida.'), + backgroundColor: Colors.red, + ), + ); + return; + } + + if (phone.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Informe um telefone válido.'), + backgroundColor: Colors.red, + ), + ); + return; + } + + setState(() => _isSaving = true); + try { + final updated = await _api.updateUserProfile( + id: user.id, + name: _nameController.text.trim(), + birthDate: birthDate, + phone: phone, + cpf: user.cpf, + ); + UserStorage().updateLoggedUser(updated); + if (!mounted) return; + _applyProfile(updated, exitEditMode: true); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text("Informações atualizadas com sucesso!"), backgroundColor: Colors.green, ), ); - } else { + } on ApiException catch (err) { + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Erro ao atualizar informações"), + SnackBar(content: Text(err.message), backgroundColor: Colors.red), + ); + } catch (err) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erro ao atualizar informações: $err'), backgroundColor: Colors.red, ), ); + } finally { + if (mounted) setState(() => _isSaving = false); } } + void _applyProfile(UserProfile profile, {bool exitEditMode = false}) { + setState(() { + _profile = profile; + _nameController.text = profile.name; + _email = profile.email; + _cpf = profile.cpf; + _phoneController.text = profile.phone; + _birthController.text = profile.formattedBirth(); + if (exitEditMode) { + _isEditing = false; + } + }); + } + void _handleCancel() { setState(() { _isEditing = false; @@ -241,8 +312,30 @@ class _UserProfilePageState extends State { Icons.cake, _birthController, canEdit: true, + readOnly: true, + onTap: () async { + final current = _parseBirthInput() ?? DateTime(2000); + final selected = await showDatePicker( + context: context, + initialDate: current, + firstDate: DateTime(1900), + lastDate: DateTime.now(), + ); + if (selected != null) { + _birthController.text = _birthFormat.format(selected); + } + }, + ), + _buildEditableTextField( + "Telefone", + Icons.phone, + _phoneController, + canEdit: true, + keyboardType: TextInputType.phone, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9()+\-\s]')), + ], ), - _buildFixedTextField("Telefone", Icons.phone, _phone), const SizedBox(height: 30), @@ -250,8 +343,9 @@ class _UserProfilePageState extends State { if (_isEditing) CustomButton( text: "Salvar Alterações", - onPressed: _handleSave, + onPressed: _isSaving ? null : _handleSave, backgroundColor: primaryColor, + isLoading: _isSaving, ), ], ), @@ -265,12 +359,20 @@ class _UserProfilePageState extends State { IconData icon, TextEditingController controller, { required bool canEdit, + VoidCallback? onTap, + bool readOnly = false, + TextInputType keyboardType = TextInputType.text, + List? inputFormatters, }) { return Padding( padding: const EdgeInsets.only(bottom: 16), child: TextFormField( + readOnly: readOnly || (onTap != null && !_isEditing), enabled: _isEditing && canEdit, + onTap: (_isEditing && canEdit && onTap != null) ? onTap : null, controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, decoration: InputDecoration( labelText: label, prefixIcon: Icon(icon), @@ -298,6 +400,7 @@ class _UserProfilePageState extends State { return Padding( padding: const EdgeInsets.only(bottom: 16), child: TextFormField( + key: ValueKey('$label-$value'), enabled: false, initialValue: value, decoration: InputDecoration( diff --git a/mobile/lib/screens/workspace.dart b/mobile/lib/screens/workspace.dart index 39c69bf7..c1a63d98 100644 --- a/mobile/lib/screens/workspace.dart +++ b/mobile/lib/screens/workspace.dart @@ -1,5 +1,8 @@ -import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../models/listing.dart'; +import '../services/workup_api.dart'; import '../utils/user_storage.dart'; import 'rent_screen.dart'; @@ -14,40 +17,9 @@ class WorkSpacePage extends StatefulWidget { State createState() => _WorkSpacePageState(); } -class RoomDetails { - final String id; - final String name; - final String address; - final List comodities; - final List pictures; - final double price; - final int capacity; - - RoomDetails({ - required this.id, - required this.name, - required this.address, - required this.comodities, - required this.pictures, - required this.price, - required this.capacity, - }); - - factory RoomDetails.fromJson(Map json) { - return RoomDetails( - id: json['id']?.toString() ?? '', - name: json['name'] ?? '', - address: json['address'] ?? '', - comodities: List.from(json['comodities'] ?? []), - pictures: List.from(json['pictures'] ?? []), - price: (json['price'] as num?)?.toDouble() ?? 0.0, - capacity: (json['capacity'] as num?)?.toInt() ?? 0, - ); - } -} - class _WorkSpacePageState extends State { - RoomDetails? _room; + final WorkupApi _api = WorkupApi(); + Listing? _room; bool _loading = true; String? _alertMessage; bool _isError = false; @@ -55,6 +27,7 @@ class _WorkSpacePageState extends State { DateTime? _endDate; int _selectedImageIndex = 0; final PageController _pageController = PageController(); + bool _isReserving = false; final Color primaryColor = const Color(0xFF34495E); final Color backgroundColor = const Color(0xFFF4F6FA); @@ -62,6 +35,11 @@ class _WorkSpacePageState extends State { @override void initState() { super.initState(); + final cached = UserStorage().getCatalogRoom(widget.propertyId); + if (cached != null) { + _room = cached; + _loading = false; + } _fetchRoom(); } @@ -71,29 +49,35 @@ class _WorkSpacePageState extends State { super.dispose(); } - int _getDaysBetween(DateTime start, DateTime end) { - return end.difference(start).inDays; + double _calculateTotalPrice() { + if (_room == null || _startDate == null || _endDate == null) return 0; + final minutes = _endDate!.difference(_startDate!).inMinutes; + final totalHours = minutes <= 0 ? 1 : (minutes / 60); + return _room!.price * totalHours; } Future _fetchRoom() async { - try { - await Future.delayed(const Duration(milliseconds: 500)); - - final property = UserStorage().getPropertyById(widget.propertyId); + if (_room == null) { + setState(() => _loading = true); + } - if (property != null) { - setState(() { - _room = RoomDetails.fromJson(property); - _loading = false; - }); - } else { - setState(() { - _alertMessage = 'Propriedade não encontrada.'; - _isError = true; - _loading = false; - }); - } + try { + final room = await _api.fetchCatalogoById(widget.propertyId); + if (!mounted) return; + setState(() { + _room = room; + _loading = false; + }); + UserStorage().upsertCatalogRoom(room); + } on ApiException catch (err) { + if (!mounted) return; + setState(() { + _alertMessage = err.message; + _isError = true; + _loading = false; + }); } catch (err) { + if (!mounted) return; setState(() { _alertMessage = 'ERRO: Não foi possível carregar detalhes da sala.'; _isError = true; @@ -103,32 +87,68 @@ class _WorkSpacePageState extends State { } Future _selectDateRange() async { - // Seleciona data de check-in - final DateTime? checkIn = await _showCustomDatePicker( - context: context, - initialDate: _startDate ?? DateTime.now(), - firstDate: DateTime.now(), - lastDate: DateTime(DateTime.now().year + 2), + final DateTime now = DateTime.now(); + final start = await _selectDateTime( + initialDate: _startDate ?? now, + firstDate: now, helpText: 'Selecione a data de Check-in', ); + if (start == null) return; - if (checkIn == null) return; - - // Seleciona data de check-out (deve ser após check-in) - final DateTime? checkOut = await _showCustomDatePicker( - context: context, - initialDate: checkIn.add(const Duration(days: 1)), - firstDate: checkIn.add(const Duration(days: 1)), - lastDate: DateTime(DateTime.now().year + 2), + final end = await _selectDateTime( + initialDate: (_endDate != null && _endDate!.isAfter(start)) + ? _endDate! + : start.add(const Duration(hours: 1)), + firstDate: start.add(const Duration(hours: 1)), helpText: 'Selecione a data de Check-out', ); + if (end == null) return; - if (checkOut != null) { - setState(() { - _startDate = checkIn; - _endDate = checkOut; - }); + if (!end.isAfter(start)) { + _showAlert( + 'A data/horário de check-out deve ser maior que o check-in.', + true, + ); + return; } + + setState(() { + _startDate = start; + _endDate = end; + }); + } + + Future _selectDateTime({ + required DateTime initialDate, + required DateTime firstDate, + DateTime? lastDate, + String? helpText, + }) async { + final pickedDate = await _showCustomDatePicker( + context: context, + initialDate: initialDate, + firstDate: DateTime(firstDate.year, firstDate.month, firstDate.day), + lastDate: lastDate ?? DateTime(DateTime.now().year + 2), + helpText: helpText, + ); + + if (pickedDate == null) return null; + + final initialTime = TimeOfDay.fromDateTime(initialDate); + final pickedTime = await showTimePicker( + context: context, + initialTime: initialTime, + ); + + if (pickedTime == null) return null; + + return DateTime( + pickedDate.year, + pickedDate.month, + pickedDate.day, + pickedTime.hour, + pickedTime.minute, + ); } Future _showCustomDatePicker({ @@ -423,160 +443,154 @@ class _WorkSpacePageState extends State { return months[month - 1]; } - void _handleReserve() async { + Future _handleReserve() async { if (_startDate == null || _endDate == null) { - setState(() { - _alertMessage = - 'Por favor, selecione as datas de check-in e check-out.'; - _isError = true; - }); - Future.delayed(const Duration(seconds: 3), () { - if (mounted) setState(() => _alertMessage = null); - }); + _showAlert( + 'Por favor, selecione as datas de check-in e check-out.', + true, + ); return; } - if (_room == null) return; + final room = _room; + final userId = UserStorage().userId; - final reservation = { - 'id': DateTime.now().millisecondsSinceEpoch.toString(), - 'workspaceId': _room!.id, - 'workspaceName': _room!.name, - 'workspaceImage': _room!.pictures.isNotEmpty ? _room!.pictures.first : '', - 'startDate': _startDate!.toIso8601String(), - 'endDate': _endDate!.toIso8601String(), - 'status': 'active', - 'address': _room!.address, - 'price': _room!.price, - 'capacity': _room!.capacity, - }; + if (room == null) return; + if (userId == null) { + _showAlert('Faça login para reservar este espaço.', true); + return; + } - final success = UserStorage().addReservation(reservation); + setState(() { + _isReserving = true; + _alertMessage = null; + }); - if (success) { - UserStorage().markPropertyAsRented(widget.propertyId); + final start = _startDate!.millisecondsSinceEpoch; + final end = _endDate!.millisecondsSinceEpoch; + final totalPrice = _calculateTotalPrice(); - setState(() { - _alertMessage = 'Reserva realizada com sucesso!'; - _isError = false; - }); + try { + final availableSpots = await _api.checkAvailability( + workspaceId: room.id, + startDate: start, + endDate: end, + ); + + if (availableSpots <= 0) { + _showAlert( + 'Este espaço já está reservado para o período informado.', + true, + ); + return; + } + + await _api.createReservation( + userId: userId, + workspaceId: room.id, + startDate: start, + endDate: end, + people: room.capacity, + finalPrice: totalPrice, + ); + + _showAlert('Reserva realizada com sucesso!', false); + + if (widget.onReserve != null) { + widget.onReserve!(); + } await Future.delayed(const Duration(milliseconds: 600)); if (mounted) { - if (widget.onReserve != null) { - widget.onReserve!(); - } - Navigator.pushReplacement( context, MaterialPageRoute(builder: (context) => const TelaAluguelPage()), ); } - } else { - setState(() { - _alertMessage = 'Erro ao realizar reserva.'; - _isError = true; - }); + } on ApiException catch (err) { + _showAlert(err.message, true); + } catch (err) { + _showAlert('Erro ao realizar reserva: $err', true); + } finally { + if (mounted) setState(() => _isReserving = false); } } + void _showAlert(String message, bool isError) { + setState(() { + _alertMessage = message; + _isError = isError; + }); + Future.delayed(const Duration(seconds: 3), () { + if (mounted) setState(() => _alertMessage = null); + }); + } + + String _formatDateTime(DateTime date) { + return DateFormat('dd/MM/yyyy HH:mm').format(date); + } + + String _formatDuration() { + if (_startDate == null || _endDate == null) return ''; + final duration = _endDate!.difference(_startDate!); + if (duration.inMinutes <= 0) return ''; + final days = duration.inDays; + final hours = duration.inHours; + final minutes = duration.inMinutes % 60; + if (hours < 24) { + if (minutes == 0) return '$hours horas'; + return '${hours}h ${minutes}min'; + } + final remainingHours = hours - days * 24; + if (remainingHours == 0 && minutes == 0) { + return '$days dias'; + } + final buffer = StringBuffer('$days dias'); + if (remainingHours > 0) buffer.write(' e $remainingHours h'); + if (minutes > 0) buffer.write(' ${minutes}min'); + return buffer.toString(); + } + Widget _buildPropertyImage(String imagePath) { - if (imagePath.startsWith('http')) { - return Image.network( - imagePath, - width: double.infinity, - height: 320, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - height: 320, - color: Colors.grey[300], - child: const Icon( - Icons.photo_outlined, - size: 50, - color: Colors.grey, - ), - ); - }, - ); - } else { - final file = File(imagePath); - return FutureBuilder( - future: file.exists(), - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data == true) { - return Image.file( - file, - width: double.infinity, - height: 320, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - height: 320, - color: Colors.grey[300], - child: const Icon( - Icons.photo_outlined, - size: 50, - color: Colors.grey, - ), - ); - }, - ); - } else { - return Container( - height: 320, - color: Colors.grey[300], - child: snapshot.connectionState == ConnectionState.waiting - ? const Center(child: CircularProgressIndicator()) - : const Icon( - Icons.photo_outlined, - size: 50, - color: Colors.grey, - ), - ); - } - }, - ); + if (!imagePath.startsWith('http')) { + return _imagePlaceholder(height: 320); } + return Image.network( + imagePath, + width: double.infinity, + height: 320, + fit: BoxFit.cover, + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return Container( + height: 320, + color: Colors.grey[200], + alignment: Alignment.center, + child: const CircularProgressIndicator(strokeWidth: 2), + ); + }, + errorBuilder: (context, error, stackTrace) => + _imagePlaceholder(height: 320), + ); } Widget _buildThumbnail(String imagePath) { - if (imagePath.startsWith('http')) { - return Image.network( - imagePath, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - color: Colors.grey[300], - child: const Icon( - Icons.photo_outlined, - size: 30, - color: Colors.grey, - ), - ); - }, - ); - } else { - final file = File(imagePath); - return FutureBuilder( - future: file.exists(), - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data == true) { - return Image.file(file, fit: BoxFit.cover); - } else { - return Container( - color: Colors.grey[300], - child: const Icon( - Icons.photo_outlined, - size: 30, - color: Colors.grey, - ), - ); - } - }, - ); - } + if (!imagePath.startsWith('http')) return _imagePlaceholder(size: 30); + return Image.network( + imagePath, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => _imagePlaceholder(size: 30), + ); + } + + Widget _imagePlaceholder({double height = 180, double size = 50}) { + return Container( + height: height, + color: Colors.grey[300], + alignment: Alignment.center, + child: Icon(Icons.photo_outlined, size: size, color: Colors.grey), + ); } Widget _buildPhotoCarousel() { @@ -890,8 +904,8 @@ class _WorkSpacePageState extends State { children: [ Text( _startDate != null && _endDate != null - ? 'Check-in: ${_startDate!.day.toString().padLeft(2, '0')}/${_startDate!.month.toString().padLeft(2, '0')}/${_startDate!.year}' - : 'Selecione as datas', + ? 'Check-in: ${_formatDateTime(_startDate!)}' + : 'Selecione data e horário', style: TextStyle( fontSize: 14, color: _startDate != null @@ -904,7 +918,7 @@ class _WorkSpacePageState extends State { _endDate != null) ...[ const SizedBox(height: 4), Text( - 'Check-out: ${_endDate!.day.toString().padLeft(2, '0')}/${_endDate!.month.toString().padLeft(2, '0')}/${_endDate!.year}', + 'Check-out: ${_formatDateTime(_endDate!)}', style: const TextStyle( fontSize: 14, color: Color(0xFF2C3E50), @@ -913,7 +927,7 @@ class _WorkSpacePageState extends State { ), const SizedBox(height: 4), Text( - '${_getDaysBetween(_startDate!, _endDate!)} dias', + _formatDuration(), style: TextStyle( fontSize: 12, color: Colors.blue[700], @@ -996,10 +1010,22 @@ class _WorkSpacePageState extends State { color: Colors.green, ), ), + if (_startDate != null && _endDate != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'Total estimado: R\$ ${_calculateTotalPrice().toStringAsFixed(2)}', + style: const TextStyle( + fontSize: 14, + color: Color(0xFF2C3E50), + fontWeight: FontWeight.w500, + ), + ), + ), ], ), ElevatedButton( - onPressed: _handleReserve, + onPressed: _isReserving ? null : _handleReserve, style: ElevatedButton.styleFrom( backgroundColor: primaryColor, padding: const EdgeInsets.symmetric( @@ -1011,25 +1037,36 @@ class _WorkSpacePageState extends State { ), elevation: 2, ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.check_circle_outline, - color: Colors.white, - size: 20, - ), - SizedBox(width: 8), - Text( - 'Reservar Agora', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.white, + child: _isReserving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle_outline, + color: Colors.white, + size: 20, + ), + SizedBox(width: 8), + Text( + 'Reservar Agora', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], ), - ), - ], - ), ), ], ), diff --git a/mobile/lib/services/api_config.dart b/mobile/lib/services/api_config.dart new file mode 100644 index 00000000..4323b6d1 --- /dev/null +++ b/mobile/lib/services/api_config.dart @@ -0,0 +1,144 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +/// Identifica os microsserviços disponíveis no backend. +enum WorkupService { catalogo, user, property, availability, aluguel } + +/// Classe utilitária responsável por montar os endpoints baseados nas +/// variáveis de ambiente fornecidas via `--dart-define`. +class ApiConfig { + ApiConfig._internal(); + static final ApiConfig instance = ApiConfig._internal(); + + static const String _baseOverride = + String.fromEnvironment('WORKUP_API_BASE', defaultValue: ''); + static const String _defaultScheme = String.fromEnvironment( + 'WORKUP_API_SCHEME', + defaultValue: 'http', + ); + static const String _defaultHost = String.fromEnvironment( + 'WORKUP_API_HOST', + defaultValue: 'localhost', + ); + static const int _catalogoPort = int.fromEnvironment( + 'WORKUP_CATALOGO_PORT', + defaultValue: 4000, + ); + static const int _userPort = int.fromEnvironment( + 'WORKUP_USER_PORT', + defaultValue: 4001, + ); + static const int _aluguelPort = int.fromEnvironment( + 'WORKUP_ALUGUEL_PORT', + defaultValue: 4002, + ); + static const int _availabilityPort = int.fromEnvironment( + 'WORKUP_AVAILABILITY_PORT', + defaultValue: 4003, + ); + static const int _propertyPort = int.fromEnvironment( + 'WORKUP_PROPERTY_PORT', + defaultValue: 4004, + ); + + String get _host => _resolveHost(_defaultHost); + + String get scheme => _defaultScheme; + + /// Monta uma [Uri] com base no microsserviço solicitado. + Uri uri( + WorkupService service, { + String path = '/', + Map? queryParameters, + }) { + final sanitizedPath = path.startsWith('/') ? path : '/$path'; + + if (_baseOverride.isNotEmpty) { + final base = Uri.parse(_baseOverride); + final combinedPath = _combinePaths(base.path, sanitizedPath); + return base.replace( + path: combinedPath, + queryParameters: _mergeQueries( + base.queryParameters, + _stringifyQuery(queryParameters), + ), + ); + } + + return Uri( + scheme: scheme, + host: _host, + port: _portFor(service), + path: sanitizedPath, + queryParameters: _stringifyQuery(queryParameters), + ); + } + + int _portFor(WorkupService service) { + switch (service) { + case WorkupService.catalogo: + return _catalogoPort; + case WorkupService.user: + return _userPort; + case WorkupService.property: + return _propertyPort; + case WorkupService.availability: + return _availabilityPort; + case WorkupService.aluguel: + return _aluguelPort; + } + } + + Map? _stringifyQuery(Map? query) { + if (query == null || query.isEmpty) return null; + final cleaned = {}; + query.forEach((key, value) { + if (value == null) return; + cleaned[key] = value.toString(); + }); + return cleaned; + } + + String _resolveHost(String value) { + if (value.toLowerCase() != 'localhost') { + return value; + } + + if (kIsWeb) { + return value; + } + + try { + if (Platform.isAndroid) { + // Loopback padrão para emulador Android falar com localhost do host. + return '10.0.2.2'; + } + } catch (_) { + // Em plataformas onde Platform não está disponível, mantém padrão. + } + + return value; + } + + String _combinePaths(String basePath, String extraPath) { + if (basePath.endsWith('/')) { + basePath = basePath.substring(0, basePath.length - 1); + } + return '$basePath$extraPath'; + } + + Map? _mergeQueries( + Map? base, + Map? extra, + ) { + if ((base == null || base.isEmpty) && + (extra == null || extra.isEmpty)) { + return null; + } + final merged = {}; + if (base != null) merged.addAll(base); + if (extra != null) merged.addAll(extra); + return merged; + } +} diff --git a/mobile/lib/services/workup_api.dart b/mobile/lib/services/workup_api.dart new file mode 100644 index 00000000..fc5cbd5b --- /dev/null +++ b/mobile/lib/services/workup_api.dart @@ -0,0 +1,446 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../models/listing.dart'; +import '../models/reservation.dart'; +import '../models/user_profile.dart'; +import '../models/user_session.dart'; +import 'api_config.dart'; + +class ApiException implements Exception { + final String message; + final int? statusCode; + final dynamic details; + + const ApiException({required this.message, this.statusCode, this.details}); + + @override + String toString() => + 'ApiException(statusCode: \$statusCode, message: $message, details: $details)'; +} + +class WorkupApi { + factory WorkupApi({http.Client? client}) { + if (client != null) { + return WorkupApi._(client: client); + } + return _instance; + } + + WorkupApi._({http.Client? client}) : _client = client ?? http.Client(); + + static final WorkupApi _instance = WorkupApi._(); + + final http.Client _client; + final ApiConfig _config = ApiConfig.instance; + + static const Map _jsonHeaders = { + 'Content-Type': 'application/json', + }; + + Future registerUser({ + required String name, + required String email, + required String password, + required String cpf, + required String phone, + required DateTime? birthDate, + }) async { + final uri = _config.uri(WorkupService.user, path: '/user'); + final payload = { + 'username': email, + 'password': password, + 'name': name, + 'cpf': cpf, + 'birth': birthDate?.millisecondsSinceEpoch ?? 0, + 'phone': phone, + }; + + final data = await _sendJson( + () => _client.post(uri, headers: _jsonHeaders, body: jsonEncode(payload)), + ); + + final user = data['user'] ?? data['information']; + if (user is! Map) { + throw const ApiException( + message: 'Resposta inesperada do serviço de usuário', + ); + } + + return UserProfile.fromJson(user); + } + + Future login({ + required String email, + required String password, + }) async { + final uri = _config.uri(WorkupService.user, path: '/login'); + final data = await _sendJson( + () => _client.post( + uri, + headers: _jsonHeaders, + body: jsonEncode({'username': email, 'password': password}), + ), + ); + + final token = data['token']?.toString(); + if (token == null || token.isEmpty) { + throw const ApiException( + message: 'Token não retornado pelo serviço de login', + ); + } + + final profile = await fetchUserProfile(token); + return UserSession(token: token, profile: profile); + } + + Future fetchUserProfile(String id) async { + final uri = _config.uri(WorkupService.user, path: '/user/$id'); + final data = await _sendJson(() => _client.get(uri)); + final user = data['user'] ?? data; + if (user is! Map) { + throw const ApiException( + message: 'Resposta inesperada ao buscar usuário', + ); + } + return UserProfile.fromJson(user); + } + + Future updateUserProfile({ + required String id, + String? name, + String? phone, + DateTime? birthDate, + String? cpf, + }) async { + final uri = _config.uri(WorkupService.user, path: '/user/$id'); + final payload = {}; + if (name != null) payload['name'] = name; + if (phone != null) payload['phone'] = phone; + if (birthDate != null) { + payload['birth'] = birthDate.millisecondsSinceEpoch; + } + if (cpf != null) payload['cpf'] = cpf; + + final data = await _sendJson( + () => + _client.patch(uri, headers: _jsonHeaders, body: jsonEncode(payload)), + ); + + final user = data['user'] ?? data['updatedUser'] ?? data; + if (user is! Map) { + throw const ApiException( + message: 'Resposta inesperada ao atualizar usuário', + ); + } + + return UserProfile.fromJson(user); + } + + Future> fetchCatalogo() async { + final uri = _config.uri(WorkupService.catalogo, path: '/catalogo'); + final data = await _sendJson(() => _client.get(uri)); + final rooms = data['rooms']; + + if (rooms is Map) { + return rooms.values + .map((raw) => Listing.fromJson(Map.from(raw))) + .toList(); + } + + if (rooms is List) { + return rooms + .map((raw) => Listing.fromJson(Map.from(raw))) + .toList(); + } + + // Pode ser que o serviço retorne um único objeto como fallback + return [Listing.fromJson(data)]; + } + + Future fetchCatalogoById(String id) async { + final uri = _config.uri(WorkupService.catalogo, path: '/catalogo/$id'); + final data = await _sendJson(() => _client.get(uri)); + return Listing.fromJson(data); + } + + Future createCatalogo({ + required String userId, + required String name, + required String description, + required String address, + required List comodities, + required double price, + required int capacity, + required String doorSerial, + required List pictures, + List? pictureBytes, + }) async { + final uri = _config.uri(WorkupService.catalogo, path: '/catalogo'); + final request = http.MultipartRequest('POST', uri) + ..fields['userID'] = userId + ..fields['name'] = name + ..fields['description'] = description + ..fields['address'] = address + ..fields['price'] = price.toString() + ..fields['capacity'] = capacity.toString() + ..fields['doorSerial'] = doorSerial; + + for (var i = 0; i < comodities.length; i++) { + request.fields['comodities[$i]'] = comodities[i]; + } + if (comodities.isEmpty) { + request.fields['comodities'] = ''; + } + + for (var i = 0; i < pictures.length; i++) { + final file = pictures[i]; + final filename = _resolveFilename(file); + + if (kIsWeb) { + final bytes = pictureBytes != null && i < pictureBytes.length + ? pictureBytes[i] + : await file.readAsBytes(); + request.files.add( + http.MultipartFile.fromBytes( + 'pictures', + bytes, + filename: filename, + contentType: MediaType('image', _guessMime(filename)), + ), + ); + } else { + request.files.add( + await http.MultipartFile.fromPath( + 'pictures', + file.path, + filename: filename, + contentType: MediaType('image', _guessMime(filename)), + ), + ); + } + } + + final data = await _sendMultipart(request); + final room = data['room'] ?? data; + if (room is! Map) { + throw const ApiException( + message: 'Resposta inesperada ao criar catálogo', + ); + } + return Listing.fromJson(room); + } + + Future deleteCatalogo(String roomId, String userId) async { + final uri = _config.uri(WorkupService.catalogo, path: '/catalogo/$roomId'); + await _sendJson( + () => _client.delete( + uri, + headers: _jsonHeaders, + body: jsonEncode({'userID': userId}), + ), + ); + } + + Future> fetchUserProperties(String userId) async { + final uri = _config.uri(WorkupService.property, path: '/property/$userId'); + final data = await _sendJson(() => _client.get(uri)); + final userProps = data['userProperties']; + if (userProps is! Map) return const []; + final properties = userProps['properties']; + if (properties is! Map) return const []; + return properties.values + .map((raw) => Listing.fromJson(Map.from(raw))) + .toList(); + } + + Future checkAvailability({ + required String workspaceId, + required int startDate, + required int endDate, + }) async { + final uri = _config.uri( + WorkupService.availability, + path: '/availability/$workspaceId', + queryParameters: {'startDate': startDate, 'endDate': endDate}, + ); + + final data = await _sendJson(() => _client.get(uri)); + final spots = data['availableSpots']; + if (spots is num) return spots.toInt(); + return 0; + } + + Future> fetchUserReservations(String userId) async { + final uri = _config.uri(WorkupService.aluguel, path: '/all-aluguel'); + final data = await _sendJson(() => _client.get(uri)); + final rentals = data['alugueis']; + if (rentals is! Map) return const []; + return rentals.values + .map((raw) => Reservation.fromJson(Map.from(raw))) + .where((reservation) => reservation.userId == userId) + .toList(); + } + + Future createReservation({ + required String userId, + required String workspaceId, + required int startDate, + required int endDate, + required int people, + required double finalPrice, + }) async { + final uri = _config.uri(WorkupService.aluguel, path: '/aluguel'); + final payload = { + 'userId': userId, + 'workspaceId': workspaceId, + 'startDate': startDate, + 'endDate': endDate, + 'people': people, + 'finalPrice': finalPrice, + }; + + final data = await _sendJson( + () => _client.post(uri, headers: _jsonHeaders, body: jsonEncode(payload)), + ); + + return Reservation.fromJson(data); + } + + Future updateReservationStatus({ + required String reservationId, + required String status, + }) async { + final uri = _config.uri(WorkupService.aluguel, path: '/aluguel'); + final payload = {'id': reservationId, 'status': status}; + final data = await _sendJson( + () => + _client.patch(uri, headers: _jsonHeaders, body: jsonEncode(payload)), + ); + return Reservation.fromJson(data); + } + + Future deleteReservation(String reservationId) async { + final uri = _config.uri(WorkupService.aluguel, path: '/aluguel'); + await _sendJson( + () => _client.delete( + uri, + headers: _jsonHeaders, + body: jsonEncode({'id': reservationId}), + ), + ); + } + + Future> _sendMultipart( + http.MultipartRequest request, + ) async { + try { + final response = await request.send(); + final body = await response.stream.bytesToString(); + final data = _decodeBody(body); + if (response.statusCode >= 400) { + throw ApiException( + message: _extractMessage(data) ?? 'Erro ${response.statusCode}', + statusCode: response.statusCode, + details: data, + ); + } + return data; + } on ApiException { + rethrow; + } on http.ClientException catch (e) { + throw ApiException( + message: 'Não foi possível conectar ao servidor', + details: e.message, + ); + } on FormatException catch (e) { + throw ApiException( + message: 'Resposta inválida do servidor', + details: e.message, + ); + } catch (e) { + throw ApiException( + message: 'Não foi possível conectar ao servidor', + details: e.toString(), + ); + } + } + + Future> _sendJson( + Future Function() request, + ) async { + try { + final response = await request(); + return _parseResponse(response); + } on ApiException { + rethrow; + } on http.ClientException catch (e) { + throw ApiException( + message: 'Não foi possível conectar ao servidor', + details: e.message, + ); + } on FormatException catch (e) { + throw ApiException( + message: 'Resposta inválida do servidor', + details: e.message, + ); + } catch (e) { + throw ApiException( + message: 'Não foi possível conectar ao servidor', + details: e.toString(), + ); + } + } + + Map _parseResponse(http.Response response) { + final data = _decodeBody(response.body); + if (response.statusCode >= 400) { + throw ApiException( + message: _extractMessage(data) ?? 'Erro ${response.statusCode}', + statusCode: response.statusCode, + details: data, + ); + } + return data; + } + + Map _decodeBody(String body) { + if (body.isEmpty) return {}; + final decoded = jsonDecode(body); + if (decoded is Map) return decoded; + return {'data': decoded}; + } + + String? _extractMessage(Map data) { + if (data['message'] is String) return data['message'] as String; + if (data['error'] is String) return data['error'] as String; + return null; + } + + String _resolveFilename(XFile file) { + if (file.name.isNotEmpty) return file.name; + return _basename(file.path); + } + + String _basename(String filePath) { + final normalized = filePath.replaceAll('\\', '/'); + final segments = normalized.split('/'); + if (segments.isEmpty || segments.last.isEmpty) { + return 'picture.jpg'; + } + return segments.last; + } + + String _guessMime(String filename) { + final lower = filename.toLowerCase(); + if (lower.endsWith('.png')) return 'png'; + if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'jpeg'; + if (lower.endsWith('.gif')) return 'gif'; + return 'jpeg'; + } +} diff --git a/mobile/lib/utils/user_storage.dart b/mobile/lib/utils/user_storage.dart index 8590ffa5..32922496 100644 --- a/mobile/lib/utils/user_storage.dart +++ b/mobile/lib/utils/user_storage.dart @@ -1,269 +1,64 @@ +import '../models/listing.dart'; +import '../models/reservation.dart'; +import '../models/user_profile.dart'; + +/// Mantém os dados da sessão atual na memória durante o ciclo de vida do app. class UserStorage { static final UserStorage _instance = UserStorage._internal(); factory UserStorage() => _instance; UserStorage._internal(); - final Map> _users = {}; - String? _loggedUserEmail; - - final Set _usersWithInitializedReservations = {}; - - void addUser(Map user) { - final userCopy = Map.from(user); - - if (!userCopy.containsKey('reservations')) { - userCopy['reservations'] = >[]; - } else { - userCopy['reservations'] = List>.from( - userCopy['reservations'] ?? [], - ); - } - - _users[userCopy['email']] = userCopy; - } - - Map? getUser(String email) { - return _users[email]; - } - - bool validateCredentials(String email, String password) { - final user = _users[email]; - if (user == null) return false; - if (user['password'] == password) { - _loggedUserEmail = email; - return true; - } - return false; - } - - Map? getLoggedUser() { - if (_loggedUserEmail == null) return null; - return _users[_loggedUserEmail]; - } - - bool updateLoggedUser(Map updatedData) { - if (_loggedUserEmail == null) return false; - - final currentUser = _users[_loggedUserEmail]; - if (currentUser == null) return false; - - currentUser['name'] = updatedData['name']; - currentUser['birthDate'] = updatedData['birthDate']; - - return true; - } - - // ========== GERENCIAMENTO DE RESERVAS ========== - - List> getReservations() { - if (_loggedUserEmail == null) return []; - - final user = _users[_loggedUserEmail]; - if (user == null) return []; - - final reservations = user['reservations']; - if (reservations == null) { - user['reservations'] = []; - return []; - } - - return List>.from(reservations); - } - - bool addReservation(Map reservation) { - if (_loggedUserEmail == null) return false; - - final user = _users[_loggedUserEmail]; - if (user == null) return false; - - if (user['reservations'] == null) { - user['reservations'] = []; - } - - (user['reservations'] as List).add(reservation); - return true; - } - - bool updateReservationStatus(String reservationId, String newStatus) { - if (_loggedUserEmail == null) return false; - - final user = _users[_loggedUserEmail]; - if (user == null) return false; - - final reservations = user['reservations'] as List?; - if (reservations == null) return false; - - for (var reservation in reservations) { - if (reservation['id'] == reservationId) { - reservation['status'] = newStatus; + String? _token; + UserProfile? _loggedUser; + final Map _catalogCache = {}; + List _ownedProperties = []; + List _reservations = []; - // Se foi cancelada, libera a propriedade - if (newStatus == 'cancelled') { - final workspaceId = reservation['workspaceId']; - if (workspaceId != null) { - markPropertyAsAvailable(workspaceId.toString()); - } - } + bool get isLogged => _loggedUser != null; + String? get token => _token; + String? get userId => _loggedUser?.id; + UserProfile? get loggedUser => _loggedUser; - return true; - } - } - - return false; - } - - void initializeMockReservations() { - if (_loggedUserEmail == null) return; - - if (_usersWithInitializedReservations.contains(_loggedUserEmail)) { - return; - } - - final user = _users[_loggedUserEmail]; - if (user == null) return; - - if (user['reservations'] != null && - (user['reservations'] as List).isNotEmpty) { - _usersWithInitializedReservations.add(_loggedUserEmail!); - return; - } - - // Não inicializa mais reservas mockadas - user['reservations'] = []; - _usersWithInitializedReservations.add(_loggedUserEmail!); - } - - bool isEmailRegistered(String email) { - return _users.containsKey(email); - } - - String _onlyDigits(String value) => value.replaceAll(RegExp(r'[^0-9]'), ''); - - bool isCpfRegistered(String cpf) { - final norm = _onlyDigits(cpf); - for (final user in _users.values) { - final userCpf = user['cpf']?.toString() ?? ''; - if (_onlyDigits(userCpf) == norm && norm.isNotEmpty) return true; - } - return false; + void saveSession({required String token, required UserProfile profile}) { + _token = token; + _loggedUser = profile; } - bool isPhoneRegistered(String phone) { - final norm = _onlyDigits(phone); - for (final user in _users.values) { - final userPhone = user['phone']?.toString() ?? ''; - if (_onlyDigits(userPhone) == norm && norm.isNotEmpty) return true; - } - return false; + void updateLoggedUser(UserProfile profile) { + _loggedUser = profile; } void clearLoggedUser() { - _loggedUserEmail = null; - } - - // ========== GERENCIAMENTO DE PROPRIEDADES ========== - - final List> _allProperties = []; - - bool addProperty(Map property) { - if (_loggedUserEmail == null) return false; - - property['ownerEmail'] = _loggedUserEmail; - property['isRented'] = false; // Adiciona flag de disponibilidade - - _allProperties.add(property); - - return true; - } - - List> getAllProperties() { - return List>.from(_allProperties); - } - - // Retorna apenas propriedades disponíveis (não alugadas) - List> getAvailableProperties() { - return _allProperties - .where((property) => property['isRented'] != true) - .toList(); - } - - List> getUserProperties() { - if (_loggedUserEmail == null) return []; - - return _allProperties - .where((property) => property['ownerEmail'] == _loggedUserEmail) - .toList(); + _token = null; + _loggedUser = null; + _catalogCache.clear(); + _ownedProperties = []; + _reservations = []; } - Map? getPropertyById(String propertyId) { - try { - return _allProperties.firstWhere((prop) => prop['id'] == propertyId); - } catch (e) { - return null; - } + void cacheCatalog(List rooms) { + _catalogCache + ..clear() + ..addEntries(rooms.map((room) => MapEntry(room.id, room))); } - bool removeProperty(String propertyId) { - if (_loggedUserEmail == null) return false; - - final index = _allProperties.indexWhere( - (prop) => - prop['id'] == propertyId && prop['ownerEmail'] == _loggedUserEmail, - ); - - if (index != -1) { - _allProperties.removeAt(index); - return true; - } - - return false; + void upsertCatalogRoom(Listing room) { + _catalogCache[room.id] = room; } - bool updateProperty(String propertyId, Map updatedData) { - if (_loggedUserEmail == null) return false; - - final index = _allProperties.indexWhere( - (prop) => - prop['id'] == propertyId && prop['ownerEmail'] == _loggedUserEmail, - ); + Listing? getCatalogRoom(String id) => _catalogCache[id]; - if (index != -1) { - updatedData['ownerEmail'] = _allProperties[index]['ownerEmail']; - updatedData['id'] = _allProperties[index]['id']; - _allProperties[index] = updatedData; - return true; - } + List get catalog => List.unmodifiable(_catalogCache.values); - return false; + void cacheOwnedProperties(List properties) { + _ownedProperties = List.from(properties); } - // Marca propriedade como alugada - void markPropertyAsRented(String propertyId) { - final index = _allProperties.indexWhere((prop) => prop['id'] == propertyId); - if (index != -1) { - _allProperties[index]['isRented'] = true; - } - } + List get ownedProperties => List.unmodifiable(_ownedProperties); - // Marca propriedade como disponível - void markPropertyAsAvailable(String propertyId) { - final index = _allProperties.indexWhere((prop) => prop['id'] == propertyId); - if (index != -1) { - _allProperties[index]['isRented'] = false; - } + void cacheReservations(List reservations) { + _reservations = List.from(reservations); } - void debugPrintUsers() { - print('=== USERS DEBUG ==='); - _users.forEach((email, userData) { - print('Email: $email'); - print('Reservations: ${userData['reservations']}'); - }); - print('Logged user: $_loggedUserEmail'); - print( - 'Users with initialized reservations: $_usersWithInitializedReservations', - ); - print('Total properties: ${_allProperties.length}'); - print('Available properties: ${getAvailableProperties().length}'); - } + List get reservations => List.unmodifiable(_reservations); } diff --git a/mobile/lib/widgets/header_bar.dart b/mobile/lib/widgets/header_bar.dart index 2555587e..dbad325c 100644 --- a/mobile/lib/widgets/header_bar.dart +++ b/mobile/lib/widgets/header_bar.dart @@ -49,36 +49,6 @@ class _HeaderBarState extends State { centerTitle: true, elevation: 3, actions: [ - Stack( - children: [ - IconButton( - icon: const Icon( - Icons.notifications_outlined, - color: Colors.white, - ), - onPressed: () {}, - ), - Positioned( - right: 8, - top: 8, - child: Container( - padding: const EdgeInsets.all(4), - decoration: const BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - ), - child: const Text( - '3', - style: TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ], - ), Padding( padding: const EdgeInsets.only(right: 8.0), child: GestureDetector( diff --git a/mobile/lib/widgets/models_listing.dart b/mobile/lib/widgets/models_listing.dart deleted file mode 100644 index 019e7d7c..00000000 --- a/mobile/lib/widgets/models_listing.dart +++ /dev/null @@ -1,31 +0,0 @@ -class Listing { - final String id; - final String name; - final String address; - final List comodities; - final List pictures; - final double price; - final int capacity; - - Listing({ - required this.id, - required this.name, - required this.address, - required this.comodities, - required this.pictures, - required this.price, - required this.capacity, - }); - - factory Listing.fromJson(Map json) { - return Listing( - id: json['id']?.toString() ?? '', - name: json['name'] ?? '', - address: json['address'] ?? '', - comodities: List.from(json['comodities'] ?? []), - pictures: List.from(json['pictures'] ?? []), - price: (json['price'] as num?)?.toDouble() ?? 0.0, - capacity: json['capacity'] ?? 0, - ); - } -} \ No newline at end of file diff --git a/mobile/web/favicon.png b/mobile/web/favicon.png index 8aaa46ac..0fa1cc34 100644 Binary files a/mobile/web/favicon.png and b/mobile/web/favicon.png differ