Initial commit - app-padrao-1.0
This commit is contained in:
51
lib/features/auth/data/models/user_model.dart
Normal file
51
lib/features/auth/data/models/user_model.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'user_model.g.dart';
|
||||
|
||||
@HiveType(typeId: 0)
|
||||
class UserModel extends HiveObject {
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
@HiveField(1)
|
||||
final String email;
|
||||
|
||||
@HiveField(2)
|
||||
final String passwordHash;
|
||||
|
||||
@HiveField(3)
|
||||
final String barberName;
|
||||
|
||||
@HiveField(4)
|
||||
final String barberShopName;
|
||||
|
||||
@HiveField(5)
|
||||
final DateTime createdAt;
|
||||
|
||||
UserModel({
|
||||
required this.id,
|
||||
required this.email,
|
||||
required this.passwordHash,
|
||||
required this.barberName,
|
||||
required this.barberShopName,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
UserModel copyWith({
|
||||
String? id,
|
||||
String? email,
|
||||
String? passwordHash,
|
||||
String? barberName,
|
||||
String? barberShopName,
|
||||
DateTime? createdAt,
|
||||
}) {
|
||||
return UserModel(
|
||||
id: id ?? this.id,
|
||||
email: email ?? this.email,
|
||||
passwordHash: passwordHash ?? this.passwordHash,
|
||||
barberName: barberName ?? this.barberName,
|
||||
barberShopName: barberShopName ?? this.barberShopName,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
56
lib/features/auth/data/models/user_model.g.dart
Normal file
56
lib/features/auth/data/models/user_model.g.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'user_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class UserModelAdapter extends TypeAdapter<UserModel> {
|
||||
@override
|
||||
final int typeId = 0;
|
||||
|
||||
@override
|
||||
UserModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return UserModel(
|
||||
id: fields[0] as String,
|
||||
email: fields[1] as String,
|
||||
passwordHash: fields[2] as String,
|
||||
barberName: fields[3] as String,
|
||||
barberShopName: fields[4] as String,
|
||||
createdAt: fields[5] as DateTime,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, UserModel obj) {
|
||||
writer
|
||||
..writeByte(6)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.email)
|
||||
..writeByte(2)
|
||||
..write(obj.passwordHash)
|
||||
..writeByte(3)
|
||||
..write(obj.barberName)
|
||||
..writeByte(4)
|
||||
..write(obj.barberShopName)
|
||||
..writeByte(5)
|
||||
..write(obj.createdAt);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is UserModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
130
lib/features/auth/data/repositories/auth_repository.dart
Normal file
130
lib/features/auth/data/repositories/auth_repository.dart
Normal file
@@ -0,0 +1,130 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:barber_app/core/database/database_service.dart';
|
||||
import 'package:barber_app/core/utils/password_utils.dart';
|
||||
import 'package:barber_app/features/auth/data/models/user_model.dart';
|
||||
import 'package:barber_app/features/settings/data/models/settings_model.dart';
|
||||
|
||||
class AuthRepository {
|
||||
final _uuid = const Uuid();
|
||||
|
||||
// Registrar novo usuário
|
||||
Future<UserModel?> register({
|
||||
required String email,
|
||||
required String password,
|
||||
required String barberName,
|
||||
required String barberShopName,
|
||||
}) async {
|
||||
try {
|
||||
// Verifica se email já existe
|
||||
final existingUser = DatabaseService.usersBoxInstance.values
|
||||
.where((user) => user.email.toLowerCase() == email.toLowerCase())
|
||||
.firstOrNull;
|
||||
|
||||
if (existingUser != null) {
|
||||
throw Exception('E-mail já cadastrado');
|
||||
}
|
||||
|
||||
final userId = _uuid.v4();
|
||||
final user = UserModel(
|
||||
id: userId,
|
||||
email: email.toLowerCase().trim(),
|
||||
passwordHash: PasswordUtils.hashPassword(password),
|
||||
barberName: barberName.trim(),
|
||||
barberShopName: barberShopName.trim(),
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await DatabaseService.usersBoxInstance.put(userId, user);
|
||||
|
||||
// Cria configurações padrão
|
||||
final settings = SettingsModel(userId: userId);
|
||||
await DatabaseService.settingsBoxInstance.put(userId, settings);
|
||||
|
||||
// Define como usuário atual
|
||||
await DatabaseService.setCurrentUserId(userId);
|
||||
|
||||
return user;
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Login
|
||||
Future<UserModel?> login({
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
try {
|
||||
final user = DatabaseService.usersBoxInstance.values
|
||||
.where((u) => u.email.toLowerCase() == email.toLowerCase().trim())
|
||||
.firstOrNull;
|
||||
|
||||
if (user == null) {
|
||||
throw Exception('Usuário não encontrado');
|
||||
}
|
||||
|
||||
if (!PasswordUtils.verifyPassword(password, user.passwordHash)) {
|
||||
throw Exception('Senha incorreta');
|
||||
}
|
||||
|
||||
await DatabaseService.setCurrentUserId(user.id);
|
||||
return user;
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Logout
|
||||
Future<void> logout() async {
|
||||
await DatabaseService.setCurrentUserId(null);
|
||||
}
|
||||
|
||||
// Obter usuário atual
|
||||
UserModel? getCurrentUser() {
|
||||
final userId = DatabaseService.getCurrentUserId();
|
||||
if (userId == null) return null;
|
||||
return DatabaseService.usersBoxInstance.get(userId);
|
||||
}
|
||||
|
||||
// Verificar se está logado
|
||||
bool isLoggedIn() {
|
||||
return DatabaseService.isLoggedIn();
|
||||
}
|
||||
|
||||
// Atualizar perfil
|
||||
Future<UserModel?> updateProfile({
|
||||
required String barberName,
|
||||
required String barberShopName,
|
||||
}) async {
|
||||
final currentUser = getCurrentUser();
|
||||
if (currentUser == null) return null;
|
||||
|
||||
final updatedUser = currentUser.copyWith(
|
||||
barberName: barberName.trim(),
|
||||
barberShopName: barberShopName.trim(),
|
||||
);
|
||||
|
||||
await DatabaseService.usersBoxInstance.put(currentUser.id, updatedUser);
|
||||
return updatedUser;
|
||||
}
|
||||
|
||||
// Alterar senha
|
||||
Future<bool> changePassword({
|
||||
required String currentPassword,
|
||||
required String newPassword,
|
||||
}) async {
|
||||
final currentUser = getCurrentUser();
|
||||
if (currentUser == null) return false;
|
||||
|
||||
if (!PasswordUtils.verifyPassword(currentPassword, currentUser.passwordHash)) {
|
||||
throw Exception('Senha atual incorreta');
|
||||
}
|
||||
|
||||
final updatedUser = currentUser.copyWith(
|
||||
passwordHash: PasswordUtils.hashPassword(newPassword),
|
||||
);
|
||||
|
||||
await DatabaseService.usersBoxInstance.put(currentUser.id, updatedUser);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
156
lib/features/auth/presentation/bloc/auth_bloc.dart
Normal file
156
lib/features/auth/presentation/bloc/auth_bloc.dart
Normal file
@@ -0,0 +1,156 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:barber_app/features/auth/data/models/user_model.dart';
|
||||
import 'package:barber_app/features/auth/data/repositories/auth_repository.dart';
|
||||
|
||||
// Events
|
||||
abstract class AuthEvent extends Equatable {
|
||||
const AuthEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class AuthCheckRequested extends AuthEvent {}
|
||||
|
||||
class AuthLoginRequested extends AuthEvent {
|
||||
final String email;
|
||||
final String password;
|
||||
|
||||
const AuthLoginRequested({required this.email, required this.password});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [email, password];
|
||||
}
|
||||
|
||||
class AuthRegisterRequested extends AuthEvent {
|
||||
final String email;
|
||||
final String password;
|
||||
final String barberName;
|
||||
final String barberShopName;
|
||||
|
||||
const AuthRegisterRequested({
|
||||
required this.email,
|
||||
required this.password,
|
||||
required this.barberName,
|
||||
required this.barberShopName,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [email, password, barberName, barberShopName];
|
||||
}
|
||||
|
||||
class AuthLogoutRequested extends AuthEvent {}
|
||||
|
||||
// States
|
||||
abstract class AuthState extends Equatable {
|
||||
const AuthState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class AuthInitial extends AuthState {}
|
||||
|
||||
class AuthLoading extends AuthState {}
|
||||
|
||||
class AuthAuthenticated extends AuthState {
|
||||
final UserModel user;
|
||||
|
||||
const AuthAuthenticated(this.user);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [user];
|
||||
}
|
||||
|
||||
class AuthUnauthenticated extends AuthState {}
|
||||
|
||||
class AuthError extends AuthState {
|
||||
final String message;
|
||||
|
||||
const AuthError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
// Bloc
|
||||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
final AuthRepository _authRepository;
|
||||
|
||||
AuthBloc({required AuthRepository authRepository})
|
||||
: _authRepository = authRepository,
|
||||
super(AuthInitial()) {
|
||||
on<AuthCheckRequested>(_onCheckRequested);
|
||||
on<AuthLoginRequested>(_onLoginRequested);
|
||||
on<AuthRegisterRequested>(_onRegisterRequested);
|
||||
on<AuthLogoutRequested>(_onLogoutRequested);
|
||||
}
|
||||
|
||||
Future<void> _onCheckRequested(
|
||||
AuthCheckRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(AuthLoading());
|
||||
try {
|
||||
final user = _authRepository.getCurrentUser();
|
||||
if (user != null) {
|
||||
emit(AuthAuthenticated(user));
|
||||
} else {
|
||||
emit(AuthUnauthenticated());
|
||||
}
|
||||
} catch (e) {
|
||||
emit(AuthUnauthenticated());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLoginRequested(
|
||||
AuthLoginRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(AuthLoading());
|
||||
try {
|
||||
final user = await _authRepository.login(
|
||||
email: event.email,
|
||||
password: event.password,
|
||||
);
|
||||
if (user != null) {
|
||||
emit(AuthAuthenticated(user));
|
||||
} else {
|
||||
emit(const AuthError('Erro ao fazer login'));
|
||||
}
|
||||
} catch (e) {
|
||||
emit(AuthError(e.toString().replaceAll('Exception: ', '')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onRegisterRequested(
|
||||
AuthRegisterRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(AuthLoading());
|
||||
try {
|
||||
final user = await _authRepository.register(
|
||||
email: event.email,
|
||||
password: event.password,
|
||||
barberName: event.barberName,
|
||||
barberShopName: event.barberShopName,
|
||||
);
|
||||
if (user != null) {
|
||||
emit(AuthAuthenticated(user));
|
||||
} else {
|
||||
emit(const AuthError('Erro ao criar conta'));
|
||||
}
|
||||
} catch (e) {
|
||||
emit(AuthError(e.toString().replaceAll('Exception: ', '')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLogoutRequested(
|
||||
AuthLogoutRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
await _authRepository.logout();
|
||||
emit(AuthUnauthenticated());
|
||||
}
|
||||
}
|
||||
257
lib/features/auth/presentation/pages/login_page.dart
Normal file
257
lib/features/auth/presentation/pages/login_page.dart
Normal file
@@ -0,0 +1,257 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:barber_app/core/theme/app_theme.dart';
|
||||
import 'package:barber_app/core/constants/app_strings.dart';
|
||||
import 'package:barber_app/features/auth/presentation/bloc/auth_bloc.dart';
|
||||
import 'package:barber_app/features/auth/presentation/pages/register_page.dart';
|
||||
import 'package:barber_app/features/home/presentation/pages/home_page.dart';
|
||||
import 'package:barber_app/shared/widgets/custom_text_field.dart';
|
||||
import 'package:barber_app/shared/widgets/loading_button.dart';
|
||||
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _fadeAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
);
|
||||
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
|
||||
);
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onLogin() {
|
||||
if (_formKey.currentState?.validate() ?? false) {
|
||||
context.read<AuthBloc>().add(
|
||||
AuthLoginRequested(
|
||||
email: _emailController.text,
|
||||
password: _passwordController.text,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthAuthenticated) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const HomePage()),
|
||||
);
|
||||
} else if (state is AuthError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.message),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: SafeArea(
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 60),
|
||||
// Logo e título
|
||||
_buildHeader(),
|
||||
const SizedBox(height: 60),
|
||||
// Campos de formulário
|
||||
_buildForm(),
|
||||
const SizedBox(height: 24),
|
||||
// Botão de login
|
||||
_buildLoginButton(),
|
||||
const SizedBox(height: 16),
|
||||
// Link para registro
|
||||
_buildRegisterLink(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 40),
|
||||
// Ícone/Logo imersivo com brilho
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: AppColors.goldGradient,
|
||||
boxShadow: AppColors.premiumShadow,
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.content_cut_rounded,
|
||||
size: 55,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
Text(
|
||||
'ELITE BARBER',
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontFamily: 'Outfit',
|
||||
letterSpacing: 4,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'O PODER DA GESTÃO EM SUAS MÃOS',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 2,
|
||||
color: AppColors.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildForm() {
|
||||
return Column(
|
||||
children: [
|
||||
CustomTextField(
|
||||
controller: _emailController,
|
||||
label: AppStrings.email,
|
||||
hint: 'seu@email.com',
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
prefixIcon: Icons.email_outlined,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Informe o e-mail';
|
||||
}
|
||||
if (!value.contains('@')) {
|
||||
return AppStrings.invalidEmail;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _passwordController,
|
||||
label: AppStrings.password,
|
||||
hint: '••••••',
|
||||
obscureText: _obscurePassword,
|
||||
prefixIcon: Icons.lock_outline,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword ? Icons.visibility_off : Icons.visibility,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Informe a senha';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoginButton() {
|
||||
return BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryColor.withValues(alpha: 0.2),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: LoadingButton(
|
||||
text: 'ENTRAR NO SISTEMA',
|
||||
isLoading: state is AuthLoading,
|
||||
onPressed: _onLogin,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRegisterLink() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Não tem uma conta? ',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => const RegisterPage()));
|
||||
},
|
||||
child: Text(
|
||||
AppStrings.createAccount,
|
||||
style: TextStyle(
|
||||
color: AppColors.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
238
lib/features/auth/presentation/pages/register_page.dart
Normal file
238
lib/features/auth/presentation/pages/register_page.dart
Normal file
@@ -0,0 +1,238 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:barber_app/core/theme/app_theme.dart';
|
||||
import 'package:barber_app/core/constants/app_strings.dart';
|
||||
import 'package:barber_app/features/auth/presentation/bloc/auth_bloc.dart';
|
||||
import 'package:barber_app/features/home/presentation/pages/home_page.dart';
|
||||
import 'package:barber_app/shared/widgets/custom_text_field.dart';
|
||||
import 'package:barber_app/shared/widgets/loading_button.dart';
|
||||
|
||||
class RegisterPage extends StatefulWidget {
|
||||
const RegisterPage({super.key});
|
||||
|
||||
@override
|
||||
State<RegisterPage> createState() => _RegisterPageState();
|
||||
}
|
||||
|
||||
class _RegisterPageState extends State<RegisterPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _confirmPasswordController = TextEditingController();
|
||||
final _barberNameController = TextEditingController();
|
||||
final _barberShopNameController = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
bool _obscureConfirmPassword = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
_barberNameController.dispose();
|
||||
_barberShopNameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onRegister() {
|
||||
if (_formKey.currentState?.validate() ?? false) {
|
||||
context.read<AuthBloc>().add(AuthRegisterRequested(
|
||||
email: _emailController.text,
|
||||
password: _passwordController.text,
|
||||
barberName: _barberNameController.text,
|
||||
barberShopName: _barberShopNameController.text,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(AppStrings.createAccount),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
body: BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthAuthenticated) {
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => const HomePage()),
|
||||
(route) => false,
|
||||
);
|
||||
} else if (state is AuthError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.message),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
// Dados da barbearia
|
||||
_buildSectionTitle('Dados da Barbearia'),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _barberShopNameController,
|
||||
label: AppStrings.barberShopName,
|
||||
hint: 'Ex: Barbearia do João',
|
||||
prefixIcon: Icons.store_outlined,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Informe o nome da barbearia';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _barberNameController,
|
||||
label: AppStrings.barberName,
|
||||
hint: 'Seu nome',
|
||||
prefixIcon: Icons.person_outline,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Informe seu nome';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// Dados de acesso
|
||||
_buildSectionTitle('Dados de Acesso'),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _emailController,
|
||||
label: AppStrings.email,
|
||||
hint: 'seu@email.com',
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
prefixIcon: Icons.email_outlined,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Informe o e-mail';
|
||||
}
|
||||
if (!value.contains('@')) {
|
||||
return AppStrings.invalidEmail;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _passwordController,
|
||||
label: AppStrings.password,
|
||||
hint: 'Mínimo 6 caracteres',
|
||||
obscureText: _obscurePassword,
|
||||
prefixIcon: Icons.lock_outline,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword ? Icons.visibility_off : Icons.visibility,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Informe a senha';
|
||||
}
|
||||
if (value.length < 6) {
|
||||
return AppStrings.weakPassword;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _confirmPasswordController,
|
||||
label: AppStrings.confirmPassword,
|
||||
hint: 'Repita a senha',
|
||||
obscureText: _obscureConfirmPassword,
|
||||
prefixIcon: Icons.lock_outline,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscureConfirmPassword ? Icons.visibility_off : Icons.visibility,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscureConfirmPassword = !_obscureConfirmPassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Confirme a senha';
|
||||
}
|
||||
if (value != _passwordController.text) {
|
||||
return AppStrings.passwordsDontMatch;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// Botão de registro
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
return LoadingButton(
|
||||
text: AppStrings.createAccount,
|
||||
isLoading: state is AuthLoading,
|
||||
onPressed: _onRegister,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Link para login
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Já tem uma conta? ',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(
|
||||
AppStrings.login,
|
||||
style: TextStyle(
|
||||
color: AppColors.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionTitle(String title) {
|
||||
return Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: AppColors.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
87
lib/features/finances/data/models/transaction_model.dart
Normal file
87
lib/features/finances/data/models/transaction_model.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'transaction_model.g.dart';
|
||||
|
||||
@HiveType(typeId: 3)
|
||||
enum TransactionType {
|
||||
@HiveField(0)
|
||||
revenue, // Receita
|
||||
@HiveField(1)
|
||||
expense, // Despesa
|
||||
}
|
||||
|
||||
@HiveType(typeId: 4)
|
||||
class TransactionModel extends HiveObject {
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
@HiveField(1)
|
||||
final String userId;
|
||||
|
||||
@HiveField(2)
|
||||
final TransactionType type;
|
||||
|
||||
@HiveField(3)
|
||||
final double amount;
|
||||
|
||||
@HiveField(4)
|
||||
final String description;
|
||||
|
||||
@HiveField(5)
|
||||
final DateTime dueDate;
|
||||
|
||||
@HiveField(6)
|
||||
final DateTime? paidDate;
|
||||
|
||||
@HiveField(7)
|
||||
final bool isPaid;
|
||||
|
||||
@HiveField(8)
|
||||
final String? haircutId; // Vinculado a um corte (receita automática)
|
||||
|
||||
@HiveField(9)
|
||||
final DateTime createdAt;
|
||||
|
||||
TransactionModel({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.type,
|
||||
required this.amount,
|
||||
required this.description,
|
||||
required this.dueDate,
|
||||
this.paidDate,
|
||||
required this.isPaid,
|
||||
this.haircutId,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
bool get isFromHaircut => haircutId != null;
|
||||
|
||||
bool get isOverdue => !isPaid && dueDate.isBefore(DateTime.now());
|
||||
|
||||
TransactionModel copyWith({
|
||||
String? id,
|
||||
String? userId,
|
||||
TransactionType? type,
|
||||
double? amount,
|
||||
String? description,
|
||||
DateTime? dueDate,
|
||||
DateTime? paidDate,
|
||||
bool? isPaid,
|
||||
String? haircutId,
|
||||
DateTime? createdAt,
|
||||
}) {
|
||||
return TransactionModel(
|
||||
id: id ?? this.id,
|
||||
userId: userId ?? this.userId,
|
||||
type: type ?? this.type,
|
||||
amount: amount ?? this.amount,
|
||||
description: description ?? this.description,
|
||||
dueDate: dueDate ?? this.dueDate,
|
||||
paidDate: paidDate ?? this.paidDate,
|
||||
isPaid: isPaid ?? this.isPaid,
|
||||
haircutId: haircutId ?? this.haircutId,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
107
lib/features/finances/data/models/transaction_model.g.dart
Normal file
107
lib/features/finances/data/models/transaction_model.g.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'transaction_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class TransactionModelAdapter extends TypeAdapter<TransactionModel> {
|
||||
@override
|
||||
final int typeId = 4;
|
||||
|
||||
@override
|
||||
TransactionModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return TransactionModel(
|
||||
id: fields[0] as String,
|
||||
userId: fields[1] as String,
|
||||
type: fields[2] as TransactionType,
|
||||
amount: fields[3] as double,
|
||||
description: fields[4] as String,
|
||||
dueDate: fields[5] as DateTime,
|
||||
paidDate: fields[6] as DateTime?,
|
||||
isPaid: fields[7] as bool,
|
||||
haircutId: fields[8] as String?,
|
||||
createdAt: fields[9] as DateTime,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, TransactionModel obj) {
|
||||
writer
|
||||
..writeByte(10)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.userId)
|
||||
..writeByte(2)
|
||||
..write(obj.type)
|
||||
..writeByte(3)
|
||||
..write(obj.amount)
|
||||
..writeByte(4)
|
||||
..write(obj.description)
|
||||
..writeByte(5)
|
||||
..write(obj.dueDate)
|
||||
..writeByte(6)
|
||||
..write(obj.paidDate)
|
||||
..writeByte(7)
|
||||
..write(obj.isPaid)
|
||||
..writeByte(8)
|
||||
..write(obj.haircutId)
|
||||
..writeByte(9)
|
||||
..write(obj.createdAt);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is TransactionModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
|
||||
class TransactionTypeAdapter extends TypeAdapter<TransactionType> {
|
||||
@override
|
||||
final int typeId = 3;
|
||||
|
||||
@override
|
||||
TransactionType read(BinaryReader reader) {
|
||||
switch (reader.readByte()) {
|
||||
case 0:
|
||||
return TransactionType.revenue;
|
||||
case 1:
|
||||
return TransactionType.expense;
|
||||
default:
|
||||
return TransactionType.revenue;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, TransactionType obj) {
|
||||
switch (obj) {
|
||||
case TransactionType.revenue:
|
||||
writer.writeByte(0);
|
||||
break;
|
||||
case TransactionType.expense:
|
||||
writer.writeByte(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is TransactionTypeAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:barber_app/core/database/database_service.dart';
|
||||
import 'package:barber_app/features/finances/data/models/transaction_model.dart';
|
||||
|
||||
class TransactionRepository {
|
||||
final _uuid = const Uuid();
|
||||
|
||||
String? get _currentUserId => DatabaseService.getCurrentUserId();
|
||||
|
||||
// Criar transação de receita a partir de um corte
|
||||
Future<TransactionModel?> createRevenueFromHaircut({
|
||||
required String haircutId,
|
||||
required double amount,
|
||||
required String clientName,
|
||||
required String serviceType,
|
||||
bool isPaid = true,
|
||||
}) async {
|
||||
if (_currentUserId == null) return null;
|
||||
|
||||
final transaction = TransactionModel(
|
||||
id: _uuid.v4(),
|
||||
userId: _currentUserId!,
|
||||
type: TransactionType.revenue,
|
||||
amount: amount,
|
||||
description: '$serviceType - $clientName',
|
||||
dueDate: DateTime.now(),
|
||||
isPaid: isPaid,
|
||||
paidDate: isPaid ? DateTime.now() : null,
|
||||
haircutId: haircutId,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await DatabaseService.transactionsBoxInstance.put(
|
||||
transaction.id,
|
||||
transaction,
|
||||
);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
// Criar despesa
|
||||
Future<TransactionModel?> createExpense({
|
||||
required double amount,
|
||||
required String description,
|
||||
required DateTime dueDate,
|
||||
bool isPaid = false,
|
||||
}) async {
|
||||
if (_currentUserId == null) return null;
|
||||
|
||||
final transaction = TransactionModel(
|
||||
id: _uuid.v4(),
|
||||
userId: _currentUserId!,
|
||||
type: TransactionType.expense,
|
||||
amount: amount,
|
||||
description: description.trim(),
|
||||
dueDate: dueDate,
|
||||
isPaid: isPaid,
|
||||
paidDate: isPaid ? DateTime.now() : null,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await DatabaseService.transactionsBoxInstance.put(
|
||||
transaction.id,
|
||||
transaction,
|
||||
);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
// Criar receita manual
|
||||
Future<TransactionModel?> createRevenue({
|
||||
required double amount,
|
||||
required String description,
|
||||
required DateTime dueDate,
|
||||
bool isPaid = false,
|
||||
}) async {
|
||||
if (_currentUserId == null) return null;
|
||||
|
||||
final transaction = TransactionModel(
|
||||
id: _uuid.v4(),
|
||||
userId: _currentUserId!,
|
||||
type: TransactionType.revenue,
|
||||
amount: amount,
|
||||
description: description.trim(),
|
||||
dueDate: dueDate,
|
||||
isPaid: isPaid,
|
||||
paidDate: isPaid ? DateTime.now() : null,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await DatabaseService.transactionsBoxInstance.put(
|
||||
transaction.id,
|
||||
transaction,
|
||||
);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
// Listar todas as transações
|
||||
List<TransactionModel> getAllTransactions() {
|
||||
if (_currentUserId == null) return [];
|
||||
return DatabaseService.transactionsBoxInstance.values
|
||||
.where((t) => t.userId == _currentUserId)
|
||||
.toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
}
|
||||
|
||||
// Receitas
|
||||
List<TransactionModel> getRevenues() {
|
||||
return getAllTransactions()
|
||||
.where((t) => t.type == TransactionType.revenue)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Despesas
|
||||
List<TransactionModel> getExpenses() {
|
||||
return getAllTransactions()
|
||||
.where((t) => t.type == TransactionType.expense)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Pendentes a receber
|
||||
double getPendingReceivables() {
|
||||
return getRevenues()
|
||||
.where((t) => !t.isPaid)
|
||||
.fold(0.0, (sum, t) => sum + t.amount);
|
||||
}
|
||||
|
||||
// Pendentes a pagar
|
||||
double getPendingPayables() {
|
||||
return getExpenses()
|
||||
.where((t) => !t.isPaid)
|
||||
.fold(0.0, (sum, t) => sum + t.amount);
|
||||
}
|
||||
|
||||
// Total recebido (pago)
|
||||
double getTotalReceived() {
|
||||
return getRevenues()
|
||||
.where((t) => t.isPaid)
|
||||
.fold(0.0, (sum, t) => sum + t.amount);
|
||||
}
|
||||
|
||||
// Total pago
|
||||
double getTotalPaid() {
|
||||
return getExpenses()
|
||||
.where((t) => t.isPaid)
|
||||
.fold(0.0, (sum, t) => sum + t.amount);
|
||||
}
|
||||
|
||||
// Dashboard Helpers
|
||||
|
||||
List<double> getLast7DaysRevenue() {
|
||||
final now = DateTime.now();
|
||||
final List<double> dailyRevenue = List.filled(7, 0.0);
|
||||
|
||||
// Obtém todas as transações
|
||||
final allTransactions =
|
||||
getAllTransactions(); // Use o helper que filtra pelo usuário
|
||||
if (allTransactions.isEmpty) return dailyRevenue;
|
||||
|
||||
for (int i = 0; i < 7; i++) {
|
||||
final targetDate = now.subtract(Duration(days: 6 - i));
|
||||
|
||||
final dayRevenue = allTransactions
|
||||
.where(
|
||||
(t) =>
|
||||
t.type == TransactionType.revenue &&
|
||||
t.dueDate.year == targetDate.year &&
|
||||
t.dueDate.month == targetDate.month &&
|
||||
t.dueDate.day == targetDate.day,
|
||||
)
|
||||
.fold(0.0, (sum, t) => sum + t.amount);
|
||||
|
||||
dailyRevenue[i] = dayRevenue;
|
||||
}
|
||||
|
||||
return dailyRevenue;
|
||||
}
|
||||
|
||||
// Saldo
|
||||
double getBalance() {
|
||||
return getTotalReceived() - getTotalPaid();
|
||||
}
|
||||
|
||||
// Marcar como pago
|
||||
Future<TransactionModel?> markAsPaid(String id) async {
|
||||
final transaction = DatabaseService.transactionsBoxInstance.get(id);
|
||||
if (transaction == null) return null;
|
||||
|
||||
final updated = transaction.copyWith(
|
||||
isPaid: true,
|
||||
paidDate: DateTime.now(),
|
||||
);
|
||||
|
||||
await DatabaseService.transactionsBoxInstance.put(id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Deletar transação
|
||||
Future<void> deleteTransaction(String id) async {
|
||||
await DatabaseService.transactionsBoxInstance.delete(id);
|
||||
}
|
||||
|
||||
// Transações do mês
|
||||
List<TransactionModel> getMonthTransactions() {
|
||||
final now = DateTime.now();
|
||||
final monthStart = DateTime(now.year, now.month, 1);
|
||||
|
||||
return getAllTransactions()
|
||||
.where((t) => t.createdAt.isAfter(monthStart))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> addTransaction(TransactionModel transaction) async {
|
||||
await DatabaseService.transactionsBoxInstance.put(
|
||||
transaction.id,
|
||||
transaction,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:barber_app/core/theme/app_theme.dart';
|
||||
import 'package:barber_app/core/constants/app_strings.dart';
|
||||
import 'package:barber_app/features/finances/data/models/transaction_model.dart';
|
||||
import 'package:barber_app/features/finances/data/repositories/transaction_repository.dart';
|
||||
import 'package:barber_app/shared/widgets/custom_text_field.dart';
|
||||
import 'package:barber_app/shared/widgets/loading_button.dart';
|
||||
|
||||
class AddTransactionPage extends StatefulWidget {
|
||||
const AddTransactionPage({super.key});
|
||||
|
||||
@override
|
||||
State<AddTransactionPage> createState() => _AddTransactionPageState();
|
||||
}
|
||||
|
||||
class _AddTransactionPageState extends State<AddTransactionPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _descriptionController = TextEditingController();
|
||||
final _amountController = TextEditingController();
|
||||
TransactionType _type = TransactionType.expense;
|
||||
DateTime _dueDate = DateTime.now();
|
||||
bool _isPaid = false;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_descriptionController.dispose();
|
||||
_amountController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _selectDate() async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _dueDate,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2030),
|
||||
builder: (context, child) {
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
colorScheme: ColorScheme.dark(
|
||||
primary: AppColors.primaryColor,
|
||||
surface: AppColors.surface,
|
||||
),
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (date != null) {
|
||||
setState(() => _dueDate = date);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveTransaction() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final repo = TransactionRepository();
|
||||
|
||||
if (_type == TransactionType.expense) {
|
||||
await repo.createExpense(
|
||||
amount: double.parse(_amountController.text.replaceAll(',', '.')),
|
||||
description: _descriptionController.text,
|
||||
dueDate: _dueDate,
|
||||
isPaid: _isPaid,
|
||||
);
|
||||
} else {
|
||||
await repo.createRevenue(
|
||||
amount: double.parse(_amountController.text.replaceAll(',', '.')),
|
||||
description: _descriptionController.text,
|
||||
dueDate: _dueDate,
|
||||
isPaid: _isPaid,
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(AppStrings.savedSuccessfully),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erro: $e'), backgroundColor: AppColors.error),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Nova Transação'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Tipo
|
||||
const Text(
|
||||
'Tipo',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTypeButton(
|
||||
'Despesa',
|
||||
TransactionType.expense,
|
||||
Icons.arrow_upward,
|
||||
AppColors.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildTypeButton(
|
||||
'Receita',
|
||||
TransactionType.revenue,
|
||||
Icons.arrow_downward,
|
||||
AppColors.success,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
CustomTextField(
|
||||
controller: _descriptionController,
|
||||
label: AppStrings.description,
|
||||
hint: 'Ex: Aluguel, Luz, Produto...',
|
||||
prefixIcon: Icons.description_outlined,
|
||||
validator: (v) =>
|
||||
v?.isEmpty ?? true ? 'Informe a descrição' : null,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
CustomTextField(
|
||||
controller: _amountController,
|
||||
label: 'Valor',
|
||||
hint: '0,00',
|
||||
prefixIcon: Icons.attach_money,
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) =>
|
||||
v?.isEmpty ?? true ? 'Informe o valor' : null,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Data
|
||||
const Text(
|
||||
AppStrings.dueDate,
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: _selectDate,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.calendar_today,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'${_dueDate.day.toString().padLeft(2, '0')}/${_dueDate.month.toString().padLeft(2, '0')}/${_dueDate.year}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Já pago?
|
||||
SwitchListTile(
|
||||
title: const Text(
|
||||
'Já foi pago?',
|
||||
style: TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
value: _isPaid,
|
||||
onChanged: (v) => setState(() => _isPaid = v),
|
||||
activeThumbColor: AppColors.primaryColor,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
LoadingButton(
|
||||
text: AppStrings.save,
|
||||
isLoading: _isLoading,
|
||||
onPressed: _saveTransaction,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypeButton(
|
||||
String label,
|
||||
TransactionType type,
|
||||
IconData icon,
|
||||
Color color,
|
||||
) {
|
||||
final isSelected = _type == type;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _type = type),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? color.withValues(alpha: 0.2) : AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected ? color : AppColors.surfaceLight,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: isSelected ? color : AppColors.textSecondary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected ? color : AppColors.textSecondary,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
807
lib/features/finances/presentation/pages/finances_page.dart
Normal file
807
lib/features/finances/presentation/pages/finances_page.dart
Normal file
@@ -0,0 +1,807 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:barber_app/core/theme/app_theme.dart';
|
||||
import 'package:barber_app/core/constants/app_strings.dart';
|
||||
import 'package:barber_app/features/finances/data/models/transaction_model.dart';
|
||||
import 'package:barber_app/features/finances/data/repositories/transaction_repository.dart';
|
||||
import 'package:barber_app/features/finances/presentation/pages/add_transaction_page.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:barber_app/core/database/database_service.dart';
|
||||
|
||||
class FinancesPage extends StatefulWidget {
|
||||
const FinancesPage({super.key});
|
||||
|
||||
@override
|
||||
State<FinancesPage> createState() => _FinancesPageState();
|
||||
}
|
||||
|
||||
class _FinancesPageState extends State<FinancesPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
final _transactionRepo = TransactionRepository();
|
||||
final _currencyFormat = NumberFormat.currency(locale: 'pt_BR', symbol: 'R\$');
|
||||
DateTime _selectedDate = DateTime.now();
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 4, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _refreshData() => setState(() {});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(AppStrings.finances),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(110),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: TextField(
|
||||
onChanged: (value) => setState(() => _searchQuery = value),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Buscar transação...',
|
||||
prefixIcon: Icon(
|
||||
Icons.search,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 0),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: AppColors.primaryColor,
|
||||
unselectedLabelColor: AppColors.textSecondary,
|
||||
indicatorColor: AppColors.primaryColor,
|
||||
tabs: const [
|
||||
Tab(text: 'Resumo'),
|
||||
Tab(text: 'A Receber'),
|
||||
Tab(text: 'A Pagar'),
|
||||
Tab(text: 'Extrato'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.psychology_alt_rounded),
|
||||
tooltip: 'Gerar Dados Teste',
|
||||
onPressed: _generateMockData,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.calendar_month),
|
||||
onPressed: _showMonthPicker,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ValueListenableBuilder(
|
||||
valueListenable: DatabaseService.transactionsBoxInstance.listenable(),
|
||||
builder: (context, _, _) {
|
||||
final balance = _transactionRepo.getBalance();
|
||||
final pendingReceivables = _transactionRepo.getPendingReceivables();
|
||||
final pendingPayables = _transactionRepo.getPendingPayables();
|
||||
|
||||
return TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildSummaryTab(balance, pendingReceivables, pendingPayables),
|
||||
_buildTransactionList(TransactionType.revenue),
|
||||
_buildTransactionList(TransactionType.expense),
|
||||
_buildAllTransactionsList(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 80),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'finances_fab',
|
||||
onPressed: () async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AddTransactionPage()),
|
||||
);
|
||||
_refreshData();
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Nova Transação'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _generateMockData() async {
|
||||
final now = DateTime.now();
|
||||
final random = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
for (int i = 0; i < 20; i++) {
|
||||
final day = (i % 28) + 1;
|
||||
await _transactionRepo.addTransaction(
|
||||
TransactionModel(
|
||||
id: 'mock_rev_${random}_$i',
|
||||
userId: DatabaseService.getCurrentUserId() ?? 'user_1',
|
||||
type: TransactionType.revenue,
|
||||
amount: (100 + (i * 15)).toDouble(),
|
||||
description: 'Corte Cliente ${i + 1}',
|
||||
dueDate: DateTime(now.year, now.month, day),
|
||||
isPaid: i % 3 != 0,
|
||||
createdAt: DateTime.now(),
|
||||
paidDate: i % 3 != 0 ? DateTime(now.year, now.month, day) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (int i = 0; i < 8; i++) {
|
||||
final day = (i * 3) + 2;
|
||||
if (day > 28) {
|
||||
continue;
|
||||
}
|
||||
await _transactionRepo.addTransaction(
|
||||
TransactionModel(
|
||||
id: 'mock_exp_${random}_$i',
|
||||
userId: DatabaseService.getCurrentUserId() ?? 'user_1',
|
||||
type: TransactionType.expense,
|
||||
amount: (40 + (i * 20)).toDouble(),
|
||||
description: 'Despesa Insumos $i',
|
||||
dueDate: DateTime(now.year, now.month, day),
|
||||
isPaid: true,
|
||||
createdAt: DateTime.now(),
|
||||
paidDate: DateTime(now.year, now.month, day),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_refreshData();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Dashboard populada com dados de teste! 🚀'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showMonthPicker() async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedDate,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2030),
|
||||
initialDatePickerMode: DatePickerMode.year,
|
||||
);
|
||||
if (picked != null &&
|
||||
(picked.month != _selectedDate.month ||
|
||||
picked.year != _selectedDate.year)) {
|
||||
setState(() {
|
||||
_selectedDate = picked;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSummaryTab(double balance, double receivables, double payables) {
|
||||
final monthlyTransactions = _transactionRepo.getAllTransactions().where((
|
||||
t,
|
||||
) {
|
||||
return t.dueDate.month == _selectedDate.month &&
|
||||
t.dueDate.year == _selectedDate.year;
|
||||
}).toList();
|
||||
|
||||
double monthlyReceived = 0;
|
||||
double monthlyPaid = 0;
|
||||
double monthlyProjectedRevenue = 0;
|
||||
double monthlyProjectedExpense = 0;
|
||||
|
||||
for (var t in monthlyTransactions) {
|
||||
if (t.type == TransactionType.revenue) {
|
||||
monthlyProjectedRevenue += t.amount;
|
||||
if (t.isPaid) {
|
||||
monthlyReceived += t.amount;
|
||||
}
|
||||
} else {
|
||||
monthlyProjectedExpense += t.amount;
|
||||
if (t.isPaid) {
|
||||
monthlyPaid += t.amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildMonthHeader(),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildSummaryCard(
|
||||
'Faturamento',
|
||||
monthlyReceived,
|
||||
monthlyProjectedRevenue,
|
||||
AppColors.success,
|
||||
Icons.trending_up_rounded,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildSummaryCard(
|
||||
'Despesas',
|
||||
monthlyPaid,
|
||||
monthlyProjectedExpense,
|
||||
AppColors.error,
|
||||
Icons.trending_down_rounded,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'DESEMPENHO DIÁRIO',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.textSecondary,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildYieldChart(monthlyTransactions),
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: Colors.white.withAlpha(10)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Resumo de Caixa',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildBarChartRow(
|
||||
'Entradas Realizadas',
|
||||
monthlyReceived,
|
||||
monthlyProjectedRevenue,
|
||||
AppColors.success,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildBarChartRow(
|
||||
'Saídas Efetuadas',
|
||||
monthlyPaid,
|
||||
monthlyProjectedExpense,
|
||||
AppColors.error,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 120),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMonthHeader() {
|
||||
return Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
border: Border.all(
|
||||
color: AppColors.primaryColor.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.analytics_outlined,
|
||||
size: 18,
|
||||
color: AppColors.primaryColor,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
DateFormat(
|
||||
'MMMM yyyy',
|
||||
'pt_BR',
|
||||
).format(_selectedDate).toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: AppColors.primaryColor,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 13,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildYieldChart(List<TransactionModel> transactions) {
|
||||
final Map<int, double> dailyRevenue = {};
|
||||
for (var t in transactions) {
|
||||
if (t.type == TransactionType.revenue && t.isPaid) {
|
||||
final day = t.dueDate.day;
|
||||
dailyRevenue[day] = (dailyRevenue[day] ?? 0) + t.amount;
|
||||
}
|
||||
}
|
||||
|
||||
final List<FlSpot> spots = [];
|
||||
for (int i = 1; i <= 31; i++) {
|
||||
spots.add(FlSpot(i.toDouble(), dailyRevenue[i] ?? 0));
|
||||
}
|
||||
|
||||
return Container(
|
||||
height: 220,
|
||||
padding: const EdgeInsets.fromLTRB(10, 20, 20, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(color: Colors.white.withAlpha(10)),
|
||||
),
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
getDrawingHorizontalLine: (value) =>
|
||||
FlLine(color: Colors.white.withAlpha(5), strokeWidth: 1),
|
||||
),
|
||||
titlesData: FlTitlesData(
|
||||
show: true,
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
leftTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 30,
|
||||
interval: 5,
|
||||
getTitlesWidget: (value, meta) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
'${value.toInt()}',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.5),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: spots,
|
||||
isCurved: true,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppColors.primaryColor,
|
||||
AppColors.primaryColor.withValues(alpha: 0.5),
|
||||
],
|
||||
),
|
||||
barWidth: 4,
|
||||
isStrokeCapRound: true,
|
||||
dotData: const FlDotData(show: false),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppColors.primaryColor.withValues(alpha: 0.2),
|
||||
AppColors.primaryColor.withValues(alpha: 0.0),
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryCard(
|
||||
String title,
|
||||
double realized,
|
||||
double projected,
|
||||
Color color,
|
||||
IconData icon,
|
||||
) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: color.withValues(alpha: 0.1)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 24),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
_currencyFormat.format(realized),
|
||||
style: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (projected > realized)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'Meta: ${_currencyFormat.format(projected)}',
|
||||
style: TextStyle(
|
||||
color: color.withValues(alpha: 0.5),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBarChartRow(
|
||||
String label,
|
||||
double value,
|
||||
double total,
|
||||
Color color,
|
||||
) {
|
||||
if (total == 0) {
|
||||
total = 1;
|
||||
}
|
||||
final percentage = (value / total).clamp(0.0, 1.0);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_currencyFormat.format(value),
|
||||
style: TextStyle(color: color, fontWeight: FontWeight.w900),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Stack(
|
||||
children: [
|
||||
Container(
|
||||
height: 12,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
FractionallySizedBox(
|
||||
widthFactor: percentage,
|
||||
child: Container(
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [color, color.withValues(alpha: 0.6)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: color.withValues(alpha: 0.3),
|
||||
blurRadius: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGroupedList(List<TransactionModel> transactions) {
|
||||
if (transactions.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.receipt_long_outlined,
|
||||
size: 64,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.2),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Nenhuma transação encontrada',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
transactions.sort((a, b) => b.dueDate.compareTo(a.dueDate));
|
||||
|
||||
var filtered = transactions;
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
filtered = filtered
|
||||
.where(
|
||||
(t) => t.description.toLowerCase().contains(
|
||||
_searchQuery.toLowerCase(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Nenhum resultado para a busca',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final Map<String, List<TransactionModel>> grouped = {};
|
||||
for (var t in filtered) {
|
||||
final key = DateFormat('yyyy-MM-dd').format(t.dueDate);
|
||||
grouped.putIfAbsent(key, () => []).add(t);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
|
||||
itemCount: grouped.keys.length,
|
||||
itemBuilder: (context, index) {
|
||||
final dateKey = grouped.keys.elementAt(index);
|
||||
final dayTransactions = grouped[dateKey]!;
|
||||
final date = DateTime.parse(dateKey);
|
||||
|
||||
String header;
|
||||
final now = DateTime.now();
|
||||
if (date.year == now.year &&
|
||||
date.month == now.month &&
|
||||
date.day == now.day) {
|
||||
header = 'HOJE';
|
||||
} else if (date.year == now.year &&
|
||||
date.month == now.month &&
|
||||
date.day == now.day - 1) {
|
||||
header = 'ONTEM';
|
||||
} else {
|
||||
header = DateFormat('dd MMM', 'pt_BR').format(date).toUpperCase();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, top: 20, bottom: 12),
|
||||
child: Text(
|
||||
header,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 12,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
...dayTransactions.map((t) => _buildTransactionCard(t)),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTransactionList(TransactionType type) {
|
||||
final filtered = _transactionRepo.getAllTransactions().where((t) {
|
||||
return t.type == type &&
|
||||
t.dueDate.month == _selectedDate.month &&
|
||||
t.dueDate.year == _selectedDate.year;
|
||||
}).toList();
|
||||
return _buildGroupedList(filtered);
|
||||
}
|
||||
|
||||
Widget _buildAllTransactionsList() {
|
||||
final filtered = _transactionRepo.getAllTransactions().where((t) {
|
||||
return t.dueDate.month == _selectedDate.month &&
|
||||
t.dueDate.year == _selectedDate.year;
|
||||
}).toList();
|
||||
return _buildGroupedList(filtered);
|
||||
}
|
||||
|
||||
Widget _buildTransactionCard(TransactionModel transaction) {
|
||||
final isRevenue = transaction.type == TransactionType.revenue;
|
||||
return GestureDetector(
|
||||
onTap: transaction.isPaid ? null : () => _markAsPaid(transaction),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: transaction.isOverdue
|
||||
? AppColors.error.withValues(alpha: 0.3)
|
||||
: Colors.white.withAlpha(5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: (isRevenue ? AppColors.success : AppColors.error)
|
||||
.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(
|
||||
isRevenue ? Icons.add_rounded : Icons.remove_rounded,
|
||||
color: isRevenue ? AppColors.success : AppColors.error,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
transaction.description,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
DateFormat('HH:mm').format(transaction.createdAt) == '00:00'
|
||||
? DateFormat('dd/MM').format(transaction.dueDate)
|
||||
: DateFormat(
|
||||
'HH:mm - dd/MM',
|
||||
).format(transaction.dueDate),
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_currencyFormat.format(transaction.amount),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 15,
|
||||
color: isRevenue ? AppColors.success : AppColors.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
if (!transaction.isPaid)
|
||||
_buildBadge(AppStrings.pending, AppColors.primaryColor)
|
||||
else
|
||||
_buildBadge('Pago', AppColors.success),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBadge(String text, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _markAsPaid(TransactionModel transaction) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppColors.surface,
|
||||
title: const Text('Confirmar Pagamento'),
|
||||
content: Text(
|
||||
'Confirmar o recebimento/pagamento de ${_currencyFormat.format(transaction.amount)}?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancelar'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final navigator = Navigator.of(context);
|
||||
await _transactionRepo.markAsPaid(transaction.id);
|
||||
navigator.pop();
|
||||
_refreshData();
|
||||
},
|
||||
child: const Text(
|
||||
'Confirmar',
|
||||
style: TextStyle(color: AppColors.success),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
69
lib/features/haircuts/data/models/haircut_model.dart
Normal file
69
lib/features/haircuts/data/models/haircut_model.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'haircut_model.g.dart';
|
||||
|
||||
@HiveType(typeId: 1)
|
||||
class HaircutModel extends HiveObject {
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
@HiveField(1)
|
||||
final String userId;
|
||||
|
||||
@HiveField(2)
|
||||
final String clientName;
|
||||
|
||||
@HiveField(3)
|
||||
final String serviceType;
|
||||
|
||||
@HiveField(4)
|
||||
final double price;
|
||||
|
||||
@HiveField(5)
|
||||
final DateTime dateTime;
|
||||
|
||||
@HiveField(6)
|
||||
final String? notes;
|
||||
|
||||
@HiveField(7)
|
||||
final String? transactionId; // Link com a transação financeira
|
||||
|
||||
@HiveField(8, defaultValue: 'Dinheiro')
|
||||
final String paymentMethod;
|
||||
|
||||
HaircutModel({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.clientName,
|
||||
required this.serviceType,
|
||||
required this.price,
|
||||
required this.dateTime,
|
||||
this.notes,
|
||||
this.transactionId,
|
||||
this.paymentMethod = 'Dinheiro',
|
||||
});
|
||||
|
||||
HaircutModel copyWith({
|
||||
String? id,
|
||||
String? userId,
|
||||
String? clientName,
|
||||
String? serviceType,
|
||||
double? price,
|
||||
DateTime? dateTime,
|
||||
String? notes,
|
||||
String? transactionId,
|
||||
String? paymentMethod,
|
||||
}) {
|
||||
return HaircutModel(
|
||||
id: id ?? this.id,
|
||||
userId: userId ?? this.userId,
|
||||
clientName: clientName ?? this.clientName,
|
||||
serviceType: serviceType ?? this.serviceType,
|
||||
price: price ?? this.price,
|
||||
dateTime: dateTime ?? this.dateTime,
|
||||
notes: notes ?? this.notes,
|
||||
transactionId: transactionId ?? this.transactionId,
|
||||
paymentMethod: paymentMethod ?? this.paymentMethod,
|
||||
);
|
||||
}
|
||||
}
|
||||
65
lib/features/haircuts/data/models/haircut_model.g.dart
Normal file
65
lib/features/haircuts/data/models/haircut_model.g.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'haircut_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class HaircutModelAdapter extends TypeAdapter<HaircutModel> {
|
||||
@override
|
||||
final int typeId = 1;
|
||||
|
||||
@override
|
||||
HaircutModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return HaircutModel(
|
||||
id: fields[0] as String,
|
||||
userId: fields[1] as String,
|
||||
clientName: fields[2] as String,
|
||||
serviceType: fields[3] as String,
|
||||
price: fields[4] as double,
|
||||
dateTime: fields[5] as DateTime,
|
||||
notes: fields[6] as String?,
|
||||
transactionId: fields[7] as String?,
|
||||
paymentMethod: fields[8] == null ? 'Dinheiro' : fields[8] as String,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, HaircutModel obj) {
|
||||
writer
|
||||
..writeByte(9)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.userId)
|
||||
..writeByte(2)
|
||||
..write(obj.clientName)
|
||||
..writeByte(3)
|
||||
..write(obj.serviceType)
|
||||
..writeByte(4)
|
||||
..write(obj.price)
|
||||
..writeByte(5)
|
||||
..write(obj.dateTime)
|
||||
..writeByte(6)
|
||||
..write(obj.notes)
|
||||
..writeByte(7)
|
||||
..write(obj.transactionId)
|
||||
..writeByte(8)
|
||||
..write(obj.paymentMethod);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is HaircutModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
126
lib/features/haircuts/data/repositories/haircut_repository.dart
Normal file
126
lib/features/haircuts/data/repositories/haircut_repository.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:barber_app/core/database/database_service.dart';
|
||||
import 'package:barber_app/features/haircuts/data/models/haircut_model.dart';
|
||||
import 'package:barber_app/features/finances/data/repositories/transaction_repository.dart';
|
||||
|
||||
class HaircutRepository {
|
||||
final _uuid = const Uuid();
|
||||
final _transactionRepo = TransactionRepository();
|
||||
|
||||
String? get _currentUserId => DatabaseService.getCurrentUserId();
|
||||
|
||||
// Criar corte (cria transação automaticamente)
|
||||
Future<HaircutModel?> createHaircut({
|
||||
required String clientName,
|
||||
required String serviceType,
|
||||
required double price,
|
||||
required DateTime dateTime,
|
||||
String? notes,
|
||||
bool isPaid = true,
|
||||
String paymentMethod = 'Dinheiro',
|
||||
}) async {
|
||||
if (_currentUserId == null) return null;
|
||||
|
||||
final haircutId = _uuid.v4();
|
||||
|
||||
// Criar transação financeira (Adicionando método de pagamento na descrição p/ facilitar controle)
|
||||
final transaction = await _transactionRepo.createRevenueFromHaircut(
|
||||
haircutId: haircutId,
|
||||
amount: price,
|
||||
clientName: '$clientName ($paymentMethod)',
|
||||
serviceType: serviceType,
|
||||
isPaid: isPaid,
|
||||
);
|
||||
|
||||
final haircut = HaircutModel(
|
||||
id: haircutId,
|
||||
userId: _currentUserId!,
|
||||
clientName: clientName.trim(),
|
||||
serviceType: serviceType,
|
||||
price: price,
|
||||
dateTime: dateTime,
|
||||
notes: notes?.trim(),
|
||||
transactionId: transaction?.id,
|
||||
paymentMethod: paymentMethod,
|
||||
);
|
||||
|
||||
await DatabaseService.haircutsBoxInstance.put(haircutId, haircut);
|
||||
return haircut;
|
||||
}
|
||||
|
||||
// Listar todos os cortes do usuário
|
||||
List<HaircutModel> getAllHaircuts() {
|
||||
if (_currentUserId == null) return [];
|
||||
return DatabaseService.haircutsBoxInstance.values
|
||||
.where((h) => h.userId == _currentUserId)
|
||||
.toList()
|
||||
..sort((a, b) => b.dateTime.compareTo(a.dateTime));
|
||||
}
|
||||
|
||||
// Cortes de hoje
|
||||
List<HaircutModel> getTodayHaircuts() {
|
||||
final now = DateTime.now();
|
||||
final todayStart = DateTime(now.year, now.month, now.day);
|
||||
final todayEnd = todayStart.add(const Duration(days: 1));
|
||||
|
||||
return getAllHaircuts()
|
||||
.where((h) => h.dateTime.isAfter(todayStart) && h.dateTime.isBefore(todayEnd))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Cortes da semana
|
||||
List<HaircutModel> getWeekHaircuts() {
|
||||
final now = DateTime.now();
|
||||
final weekStart = now.subtract(Duration(days: now.weekday - 1));
|
||||
final weekStartDay = DateTime(weekStart.year, weekStart.month, weekStart.day);
|
||||
|
||||
return getAllHaircuts()
|
||||
.where((h) => h.dateTime.isAfter(weekStartDay))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Cortes do mês
|
||||
List<HaircutModel> getMonthHaircuts() {
|
||||
final now = DateTime.now();
|
||||
final monthStart = DateTime(now.year, now.month, 1);
|
||||
|
||||
return getAllHaircuts()
|
||||
.where((h) => h.dateTime.isAfter(monthStart))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Faturamento do dia
|
||||
double getTodayRevenue() {
|
||||
return getTodayHaircuts().fold(0.0, (sum, h) => sum + h.price);
|
||||
}
|
||||
|
||||
// Faturamento da semana
|
||||
double getWeekRevenue() {
|
||||
return getWeekHaircuts().fold(0.0, (sum, h) => sum + h.price);
|
||||
}
|
||||
|
||||
// Faturamento do mês
|
||||
double getMonthRevenue() {
|
||||
return getMonthHaircuts().fold(0.0, (sum, h) => sum + h.price);
|
||||
}
|
||||
|
||||
// Atualizar corte
|
||||
Future<HaircutModel?> updateHaircut(HaircutModel haircut) async {
|
||||
await DatabaseService.haircutsBoxInstance.put(haircut.id, haircut);
|
||||
return haircut;
|
||||
}
|
||||
|
||||
// Deletar corte
|
||||
Future<void> deleteHaircut(String id) async {
|
||||
final haircut = DatabaseService.haircutsBoxInstance.get(id);
|
||||
if (haircut?.transactionId != null) {
|
||||
await _transactionRepo.deleteTransaction(haircut!.transactionId!);
|
||||
}
|
||||
await DatabaseService.haircutsBoxInstance.delete(id);
|
||||
}
|
||||
|
||||
// Buscar corte por ID
|
||||
HaircutModel? getHaircutById(String id) {
|
||||
return DatabaseService.haircutsBoxInstance.get(id);
|
||||
}
|
||||
}
|
||||
360
lib/features/haircuts/presentation/pages/add_haircut_page.dart
Normal file
360
lib/features/haircuts/presentation/pages/add_haircut_page.dart
Normal file
@@ -0,0 +1,360 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:barber_app/core/theme/app_theme.dart';
|
||||
import 'package:barber_app/core/constants/app_strings.dart';
|
||||
import 'package:barber_app/features/haircuts/data/repositories/haircut_repository.dart';
|
||||
import 'package:barber_app/features/services/data/models/service_model.dart';
|
||||
import 'package:barber_app/features/services/data/repositories/service_repository.dart';
|
||||
import 'package:barber_app/features/services/presentation/pages/services_page.dart';
|
||||
import 'package:barber_app/shared/widgets/custom_text_field.dart';
|
||||
import 'package:barber_app/shared/widgets/loading_button.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class AddHaircutPage extends StatefulWidget {
|
||||
const AddHaircutPage({super.key});
|
||||
|
||||
@override
|
||||
State<AddHaircutPage> createState() => _AddHaircutPageState();
|
||||
}
|
||||
|
||||
class _AddHaircutPageState extends State<AddHaircutPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _clientNameController = TextEditingController();
|
||||
final _priceController = TextEditingController();
|
||||
final _notesController = TextEditingController();
|
||||
|
||||
final _serviceRepo = ServiceRepository();
|
||||
List<ServiceModel> _services = [];
|
||||
ServiceModel? _selectedService;
|
||||
|
||||
bool _isLoading = false;
|
||||
final _paymentMethods = [
|
||||
'Dinheiro',
|
||||
'PIX',
|
||||
'Cartão de Crédito',
|
||||
'Cartão de Débito',
|
||||
'Fiado',
|
||||
];
|
||||
String _selectedPaymentMethod = 'Dinheiro';
|
||||
|
||||
final _currencyFormat = NumberFormat.currency(locale: 'pt_BR', symbol: 'R\$');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadServices();
|
||||
}
|
||||
|
||||
void _loadServices() {
|
||||
setState(() {
|
||||
_services = _serviceRepo.getAllServices();
|
||||
});
|
||||
}
|
||||
|
||||
void _onServiceSelected(ServiceModel? service) {
|
||||
if (service == null) return;
|
||||
setState(() {
|
||||
_selectedService = service;
|
||||
// Preenche o valor automaticamente
|
||||
_priceController.text = service.price.toStringAsFixed(2);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveHaircut() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
|
||||
// Validação flexível: Se selecionou serviço ou digitou preço
|
||||
// Aqui assumimos que serviceType será o nome do serviço selecionado
|
||||
// Se não selecionou, impedimos? Sim, melhor forçar seleção ou ter um 'Outro'
|
||||
if (_selectedService == null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Selecione um serviço')));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final haircutRepo = HaircutRepository();
|
||||
|
||||
final price =
|
||||
double.tryParse(_priceController.text.replaceAll(',', '.')) ??
|
||||
_selectedService!.price;
|
||||
|
||||
await haircutRepo.createHaircut(
|
||||
clientName: _clientNameController.text,
|
||||
serviceType: _selectedService!.name,
|
||||
price: price,
|
||||
dateTime: DateTime.now(),
|
||||
notes: _notesController.text.isNotEmpty ? _notesController.text : null,
|
||||
isPaid: _selectedPaymentMethod != 'Fiado',
|
||||
paymentMethod: _selectedPaymentMethod,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(AppStrings.savedSuccessfully),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erro: $e'), backgroundColor: AppColors.error),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_clientNameController.dispose();
|
||||
_priceController.dispose();
|
||||
_notesController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(AppStrings.newHaircut),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Cliente
|
||||
CustomTextField(
|
||||
controller: _clientNameController,
|
||||
label: AppStrings.clientName,
|
||||
hint: 'Nome do cliente',
|
||||
prefixIcon: Icons.person_outline,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
validator: (v) =>
|
||||
v?.isEmpty ?? true ? 'Informe o nome do cliente' : null,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Lista de Serviços (Chips)
|
||||
const Text(
|
||||
'Selecione o Serviço',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
if (_services.isEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'Nenhum serviço cadastrado.',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const ServicesPage(),
|
||||
),
|
||||
);
|
||||
_loadServices();
|
||||
},
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('Cadastrar Serviços'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryColor,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _services.map((service) {
|
||||
final isSelected = _selectedService?.id == service.id;
|
||||
return ChoiceChip(
|
||||
label: Text(
|
||||
'${service.name} - ${_currencyFormat.format(service.price)}',
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? Colors.black
|
||||
: (Theme.of(context).brightness ==
|
||||
Brightness.dark
|
||||
? AppColors.textPrimary
|
||||
: Colors.black87),
|
||||
fontWeight: isSelected
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
selected: isSelected,
|
||||
selectedColor: AppColors.primaryColor,
|
||||
backgroundColor: AppColors.surface,
|
||||
side: BorderSide(
|
||||
color: isSelected
|
||||
? AppColors.primaryColor
|
||||
: AppColors.surfaceLight,
|
||||
),
|
||||
onSelected: (selected) {
|
||||
if (selected) _onServiceSelected(service);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Valor (Editável)
|
||||
CustomTextField(
|
||||
controller: _priceController,
|
||||
label: AppStrings.price,
|
||||
hint: '0,00',
|
||||
prefixIcon: Icons.attach_money,
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) {
|
||||
if (v?.isEmpty ?? true) return 'Informe o valor';
|
||||
final parsed = double.tryParse(v!.replaceAll(',', '.'));
|
||||
if (parsed == null || parsed < 0) return 'Valor inválido';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Observações
|
||||
CustomTextField(
|
||||
controller: _notesController,
|
||||
label: AppStrings.notes,
|
||||
hint: 'Observações (opcional)',
|
||||
prefixIcon: Icons.note_outlined,
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Forma de Pagamento
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? AppColors.surface
|
||||
: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.surfaceLight),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
child: Text(
|
||||
'Forma de Pagamento',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color:
|
||||
Theme.of(context).brightness == Brightness.dark
|
||||
? AppColors.textSecondary
|
||||
: Colors.black54,
|
||||
),
|
||||
),
|
||||
),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _selectedPaymentMethod,
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
isDense: true,
|
||||
),
|
||||
dropdownColor:
|
||||
Theme.of(context).brightness == Brightness.dark
|
||||
? AppColors.surface
|
||||
: Colors.white,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? AppColors.textPrimary
|
||||
: Colors.black87,
|
||||
fontSize: 16,
|
||||
),
|
||||
onChanged: (String? newValue) {
|
||||
if (newValue != null) {
|
||||
setState(() {
|
||||
_selectedPaymentMethod = newValue;
|
||||
});
|
||||
}
|
||||
},
|
||||
items: _paymentMethods.map<DropdownMenuItem<String>>((
|
||||
String value,
|
||||
) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: value,
|
||||
child: Text(value),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
// Feedback visual do status
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
_selectedPaymentMethod == 'Fiado'
|
||||
? 'Status: Pendente (A Receber)'
|
||||
: 'Status: Pago (Entra no Caixa)',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _selectedPaymentMethod == 'Fiado'
|
||||
? AppColors.warning
|
||||
: AppColors.success,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
LoadingButton(
|
||||
text: 'Salvar Corte',
|
||||
isLoading: _isLoading,
|
||||
onPressed: _saveHaircut,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
380
lib/features/haircuts/presentation/pages/haircuts_page.dart
Normal file
380
lib/features/haircuts/presentation/pages/haircuts_page.dart
Normal file
@@ -0,0 +1,380 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:barber_app/core/database/database_service.dart';
|
||||
import 'package:barber_app/core/theme/app_theme.dart';
|
||||
import 'package:barber_app/core/constants/app_strings.dart';
|
||||
import 'package:barber_app/features/haircuts/data/models/haircut_model.dart';
|
||||
import 'package:barber_app/features/haircuts/data/repositories/haircut_repository.dart';
|
||||
import 'package:barber_app/features/haircuts/presentation/pages/add_haircut_page.dart';
|
||||
|
||||
class HaircutsPage extends StatefulWidget {
|
||||
const HaircutsPage({super.key});
|
||||
|
||||
@override
|
||||
State<HaircutsPage> createState() => _HaircutsPageState();
|
||||
}
|
||||
|
||||
class _HaircutsPageState extends State<HaircutsPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
final _haircutRepo = HaircutRepository();
|
||||
final _currencyFormat = NumberFormat.currency(locale: 'pt_BR', symbol: 'R\$');
|
||||
String _searchQuery = '';
|
||||
DateTime? _selectedDate;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _refreshData() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(AppStrings.haircuts),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(110),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: TextField(
|
||||
onChanged: (value) => setState(() => _searchQuery = value),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Buscar cliente ou serviço...',
|
||||
prefixIcon: Icon(
|
||||
Icons.search,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 0),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: AppColors.primaryColor,
|
||||
unselectedLabelColor: AppColors.textSecondary,
|
||||
indicatorColor: AppColors.primaryColor,
|
||||
tabs: const [
|
||||
Tab(text: 'Hoje'),
|
||||
Tab(text: 'Semana'),
|
||||
Tab(text: 'Mês'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.calendar_month,
|
||||
color: _selectedDate != null ? AppColors.primaryColor : null,
|
||||
),
|
||||
onPressed: () async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedDate ?? DateTime.now(),
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2030),
|
||||
);
|
||||
setState(() => _selectedDate = picked);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ValueListenableBuilder(
|
||||
valueListenable: DatabaseService.haircutsBoxInstance.listenable(),
|
||||
builder: (context, _, _) {
|
||||
return TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildHaircutList(
|
||||
_filterHaircuts(_haircutRepo.getTodayHaircuts()),
|
||||
),
|
||||
_buildHaircutList(
|
||||
_filterHaircuts(_haircutRepo.getWeekHaircuts()),
|
||||
),
|
||||
_buildHaircutList(
|
||||
_filterHaircuts(_haircutRepo.getMonthHaircuts()),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 80),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'haircuts_fab',
|
||||
onPressed: () async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AddHaircutPage()),
|
||||
);
|
||||
_refreshData();
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text(AppStrings.newHaircut),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<HaircutModel> _filterHaircuts(List<HaircutModel> haircuts) {
|
||||
var filtered = haircuts;
|
||||
|
||||
if (_selectedDate != null) {
|
||||
filtered = filtered
|
||||
.where(
|
||||
(h) =>
|
||||
h.dateTime.year == _selectedDate!.year &&
|
||||
h.dateTime.month == _selectedDate!.month &&
|
||||
h.dateTime.day == _selectedDate!.day,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
filtered = filtered
|
||||
.where(
|
||||
(h) =>
|
||||
h.clientName.toLowerCase().contains(
|
||||
_searchQuery.toLowerCase(),
|
||||
) ||
|
||||
h.serviceType.toLowerCase().contains(
|
||||
_searchQuery.toLowerCase(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
Widget _buildHaircutList(List<HaircutModel> haircuts) {
|
||||
final revenue = haircuts.fold(0.0, (sum, h) => sum + h.price);
|
||||
if (haircuts.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.content_cut,
|
||||
size: 64,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Nenhum corte registrado',
|
||||
style: TextStyle(color: AppColors.textSecondary, fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Resumo
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
margin: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppColors.primaryColor,
|
||||
AppColors.primaryColor.withValues(alpha: 0.7),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Text(
|
||||
'${haircuts.length}',
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.background,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'Cortes',
|
||||
style: TextStyle(color: AppColors.background),
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 40,
|
||||
color: AppColors.background.withValues(alpha: 0.3),
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
Text(
|
||||
_currencyFormat.format(revenue),
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.background,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'Faturamento',
|
||||
style: TextStyle(color: AppColors.background),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Lista
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: haircuts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final haircut = haircuts[index];
|
||||
return _buildHaircutCard(haircut);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHaircutCard(HaircutModel haircut) {
|
||||
final dateFormat = DateFormat('dd/MM HH:mm');
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.surfaceLight),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
haircut.clientName[0].toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: AppColors.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
haircut.clientName,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
haircut.serviceType,
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
dateFormat.format(haircut.dateTime),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_currencyFormat.format(haircut.price),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
color: AppColors.primaryColor,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.delete_outline, color: AppColors.error),
|
||||
onPressed: () => _confirmDelete(haircut),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDelete(HaircutModel haircut) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppColors.surface,
|
||||
title: const Text('Excluir Corte?'),
|
||||
content: Text('Deseja excluir o corte de ${haircut.clientName}?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text(AppStrings.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final navigator = Navigator.of(context);
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||
await _haircutRepo.deleteHaircut(haircut.id);
|
||||
navigator.pop();
|
||||
_refreshData();
|
||||
scaffoldMessenger.showSnackBar(
|
||||
const SnackBar(content: Text(AppStrings.deletedSuccessfully)),
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
AppStrings.delete,
|
||||
style: TextStyle(color: AppColors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
578
lib/features/home/presentation/pages/home_page.dart
Normal file
578
lib/features/home/presentation/pages/home_page.dart
Normal file
@@ -0,0 +1,578 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:barber_app/core/database/database_service.dart';
|
||||
import 'package:barber_app/core/theme/app_theme.dart';
|
||||
import 'package:barber_app/features/auth/presentation/bloc/auth_bloc.dart';
|
||||
import 'package:barber_app/features/finances/data/repositories/transaction_repository.dart';
|
||||
import 'package:barber_app/features/haircuts/data/models/haircut_model.dart';
|
||||
import 'package:barber_app/features/products/data/models/product_model.dart';
|
||||
import 'package:barber_app/features/finances/data/models/transaction_model.dart';
|
||||
import 'package:barber_app/features/haircuts/presentation/pages/haircuts_page.dart';
|
||||
import 'package:barber_app/features/products/presentation/pages/products_page.dart';
|
||||
import 'package:barber_app/features/finances/presentation/pages/finances_page.dart';
|
||||
import 'package:barber_app/features/settings/presentation/pages/settings_page.dart';
|
||||
import 'package:barber_app/shared/widgets/stat_card.dart';
|
||||
import 'package:barber_app/features/home/presentation/widgets/revenue_chart_new.dart';
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
int _currentIndex = 0;
|
||||
|
||||
final List<Widget> _pages = [
|
||||
const HomeContent(),
|
||||
const HaircutsPage(),
|
||||
const ProductsPage(),
|
||||
const FinancesPage(),
|
||||
const SettingsPage(),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
extendBody: true, // Importante para a barra flutuante
|
||||
body: IndexedStack(index: _currentIndex, children: _pages),
|
||||
bottomNavigationBar: Container(
|
||||
margin: const EdgeInsets.fromLTRB(24, 0, 24, 30),
|
||||
// height: 70, // REMOVIDO: Altura fixa causava overflow. O child define a altura.
|
||||
constraints: const BoxConstraints(maxHeight: 100),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface.withAlpha(230),
|
||||
borderRadius: BorderRadius.circular(35),
|
||||
border: Border.all(color: Colors.white.withAlpha(20), width: 1),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withAlpha(100),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(35),
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
splashColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
),
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeBottom: true,
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: _currentIndex,
|
||||
onTap: (index) => setState(() => _currentIndex = index),
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
selectedItemColor: AppColors.primaryColor,
|
||||
unselectedItemColor: AppColors.textSecondary,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
showSelectedLabels: false,
|
||||
showUnselectedLabels: false,
|
||||
items: const [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.grid_view_rounded),
|
||||
activeIcon: Icon(Icons.grid_view_rounded),
|
||||
label: 'Início',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.content_cut_rounded),
|
||||
label: 'Cortes',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.inventory_2_rounded),
|
||||
label: 'Produtos',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.account_balance_wallet_rounded),
|
||||
label: 'Finanças',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.tune_rounded),
|
||||
label: 'Ajustes',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HomeContent extends StatefulWidget {
|
||||
const HomeContent({super.key});
|
||||
|
||||
@override
|
||||
State<HomeContent> createState() => _HomeContentState();
|
||||
}
|
||||
|
||||
class _HomeContentState extends State<HomeContent> {
|
||||
late TransactionRepository _transactionRepo;
|
||||
late NumberFormat _currencyFormat;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_transactionRepo = TransactionRepository();
|
||||
_currencyFormat = NumberFormat.currency(locale: 'pt_BR', symbol: 'R\$');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = context.watch<AuthBloc>().state is AuthAuthenticated
|
||||
? (context.watch<AuthBloc>().state as AuthAuthenticated).user
|
||||
: null;
|
||||
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: DatabaseService.transactionsBoxInstance.listenable(),
|
||||
builder: (context, Box<TransactionModel> box, _) {
|
||||
final transactions = _transactionRepo
|
||||
.getAllTransactions()
|
||||
.where(
|
||||
(t) =>
|
||||
t.createdAt.day == DateTime.now().day &&
|
||||
t.createdAt.month == DateTime.now().month &&
|
||||
t.createdAt.year == DateTime.now().year,
|
||||
)
|
||||
.toList();
|
||||
|
||||
final totalBalance = _transactionRepo.getBalance();
|
||||
final todayRevenue = transactions
|
||||
.where((t) => t.type == TransactionType.revenue)
|
||||
.fold(0.0, (sum, t) => sum + t.amount);
|
||||
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: DatabaseService.haircutsBoxInstance.listenable(),
|
||||
builder: (context, Box<HaircutModel> haircutBox, _) {
|
||||
final todayHaircuts = haircutBox.values
|
||||
.where(
|
||||
(h) =>
|
||||
h.userId == user?.id &&
|
||||
h.dateTime.day == DateTime.now().day &&
|
||||
h.dateTime.month == DateTime.now().month &&
|
||||
h.dateTime.year == DateTime.now().year,
|
||||
)
|
||||
.toList();
|
||||
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: DatabaseService.productsBoxInstance.listenable(),
|
||||
builder: (context, Box<ProductModel> productBox, _) {
|
||||
final lowStockProducts = productBox.values
|
||||
.where(
|
||||
(p) => p.userId == user?.id && p.quantity <= p.minStock,
|
||||
)
|
||||
.toList();
|
||||
|
||||
final pendingReceivables = _transactionRepo
|
||||
.getPendingReceivables();
|
||||
|
||||
return Container(
|
||||
color: AppColors.background,
|
||||
child: CustomScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
slivers: [
|
||||
// Header Imersivo sem AppBar
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 60, 24, 20),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'BOM DIA, ${(user?.barberName ?? "BARBEIRO").toUpperCase()}',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: AppColors.primaryColor,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
DatabaseService.settingsBoxInstance
|
||||
.get(user?.id)
|
||||
?.appName ??
|
||||
user?.barberShopName ??
|
||||
'Barber App',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.displayLarge
|
||||
?.copyWith(fontSize: 28),
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white.withAlpha(20),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.notifications_none_rounded,
|
||||
size: 28,
|
||||
),
|
||||
onPressed: () => _showNotifications(
|
||||
context,
|
||||
lowStockProducts,
|
||||
pendingReceivables,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Card Principal Estilo FinTech Elite
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
final homeState = context
|
||||
.findAncestorStateOfType<_HomePageState>();
|
||||
if (homeState != null) {
|
||||
homeState.setState(
|
||||
() => homeState._currentIndex = 3,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppColors.surfaceLight,
|
||||
AppColors.surface,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(36),
|
||||
border: Border.all(
|
||||
color: Colors.white.withAlpha(10),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: AppColors.premiumShadow,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
right: -30,
|
||||
top: -30,
|
||||
child: Icon(
|
||||
Icons.account_balance_wallet_rounded,
|
||||
size: 180,
|
||||
color: AppColors.primaryColor.withAlpha(
|
||||
10,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'SALDO TOTAL',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 12,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_currencyFormat.format(totalBalance),
|
||||
style: TextStyle(
|
||||
fontSize: 42,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.textPrimary,
|
||||
fontFamily: 'Outfit',
|
||||
letterSpacing: -1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success.withAlpha(
|
||||
20,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
100,
|
||||
),
|
||||
border: Border.all(
|
||||
color: AppColors.success
|
||||
.withAlpha(40),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.trending_up_rounded,
|
||||
size: 14,
|
||||
color: AppColors.success,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${_currencyFormat.format(todayRevenue)} HOJE',
|
||||
style: const TextStyle(
|
||||
color: AppColors.success,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Atalhos de Ação Rápida
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildActionButton(
|
||||
context,
|
||||
'NOVO CORTE',
|
||||
Icons.add_rounded,
|
||||
() {
|
||||
final homeState = context
|
||||
.findAncestorStateOfType<
|
||||
_HomePageState
|
||||
>();
|
||||
if (homeState != null) {
|
||||
homeState.setState(
|
||||
() => homeState._currentIndex = 1,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildActionButton(
|
||||
context,
|
||||
'PRODUTO',
|
||||
Icons.inventory_2_rounded,
|
||||
() {
|
||||
final homeState = context
|
||||
.findAncestorStateOfType<
|
||||
_HomePageState
|
||||
>();
|
||||
if (homeState != null) {
|
||||
homeState.setState(
|
||||
() => homeState._currentIndex = 2,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Grid de Métricas
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: StatCard(
|
||||
title: 'Cortes Hoje',
|
||||
value: todayHaircuts.length.toString(),
|
||||
icon: Icons.content_cut_rounded,
|
||||
isPrimary: true, // Agora todos são premium
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: StatCard(
|
||||
title: 'A Receber',
|
||||
value: _currencyFormat.format(
|
||||
pendingReceivables,
|
||||
),
|
||||
icon: Icons.timer_outlined,
|
||||
isPrimary: false,
|
||||
iconColor: AppColors.warning,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'DESEMPENHO SEMANAL',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.textSecondary,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
height: 240,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
border: Border.all(
|
||||
color: Colors.white.withAlpha(10),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: const RevenueChartNew(),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 120,
|
||||
), // Espaço para a navbar flutuante
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton(
|
||||
BuildContext context,
|
||||
String label,
|
||||
IconData icon,
|
||||
VoidCallback onTap,
|
||||
) {
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: Colors.white.withAlpha(10), width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 20, color: AppColors.primaryColor),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 11,
|
||||
fontFamily: 'Outfit',
|
||||
letterSpacing: 1,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showNotifications(
|
||||
BuildContext context,
|
||||
List<ProductModel> lowStock,
|
||||
double pending,
|
||||
) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: AppColors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(32)),
|
||||
),
|
||||
builder: (context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Notificações',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (lowStock.isEmpty && pending == 0)
|
||||
const Center(child: Text('Tudo sob controle!')),
|
||||
if (lowStock.isNotEmpty)
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.inventory_2,
|
||||
color: AppColors.error,
|
||||
),
|
||||
title: Text('${lowStock.length} produtos em estoque baixo'),
|
||||
subtitle: const Text('Verifique seu inventário'),
|
||||
),
|
||||
if (pending > 0)
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.attach_money,
|
||||
color: AppColors.warning,
|
||||
),
|
||||
title: const Text('Valores pendentes'),
|
||||
subtitle: Text(_currencyFormat.format(pending)),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
154
lib/features/home/presentation/widgets/revenue_chart.dart
Normal file
154
lib/features/home/presentation/widgets/revenue_chart.dart
Normal file
@@ -0,0 +1,154 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:barber_app/core/theme/app_theme.dart';
|
||||
import 'package:barber_app/features/finances/data/repositories/transaction_repository.dart';
|
||||
|
||||
class RevenueChart extends StatelessWidget {
|
||||
const RevenueChart({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final transactionRepo = TransactionRepository();
|
||||
// Pegando dados reais (filtro básico)
|
||||
final now = DateTime.now();
|
||||
final List<double> dailyRevenue = List.filled(7, 0.0);
|
||||
final weekdays = <String>[];
|
||||
|
||||
for (int i = 6; i >= 0; i--) {
|
||||
final day = now.subtract(Duration(days: i));
|
||||
weekdays.add('${day.day}/${day.month}');
|
||||
|
||||
final revenues = transactionRepo.getRevenues().where(
|
||||
(t) =>
|
||||
t.dueDate.year == day.year &&
|
||||
t.dueDate.month == day.month &&
|
||||
t.dueDate.day == day.day,
|
||||
);
|
||||
|
||||
double sum = 0;
|
||||
for (var r in revenues) {
|
||||
sum += r.amount;
|
||||
}
|
||||
dailyRevenue[6 - i] = sum;
|
||||
}
|
||||
|
||||
// Se estiver tudo zero, coloca alguns dados dummies pra ficar bonito pro usuário ver o potencial
|
||||
// (Opcional: remover em produção, mas bom para demo visual inicial)
|
||||
if (dailyRevenue.every((v) => v == 0)) {
|
||||
// Dados de exemplo para o gráfico não ficar vazio na primeira execução
|
||||
// dailyRevenue[0] = 150; dailyRevenue[1] = 200; ...
|
||||
// Melhor não inventar dados se é um app funcional. Mostra zero mesmo.
|
||||
}
|
||||
|
||||
return AspectRatio(
|
||||
aspectRatio: 1.70,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(18)),
|
||||
color: AppColors.surface,
|
||||
border: Border.all(color: AppColors.surfaceLight),
|
||||
),
|
||||
padding: const EdgeInsets.only(
|
||||
right: 18,
|
||||
left: 12,
|
||||
top: 24,
|
||||
bottom: 12,
|
||||
),
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: 100, // Ajustar conforme valores
|
||||
getDrawingHorizontalLine: (value) {
|
||||
return FlLine(color: AppColors.surfaceLight, strokeWidth: 1);
|
||||
},
|
||||
),
|
||||
titlesData: FlTitlesData(
|
||||
show: true,
|
||||
rightTitles: AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 30,
|
||||
interval: 1,
|
||||
getTitlesWidget: (value, meta) {
|
||||
final index = value.toInt();
|
||||
if (index >= 0 && index < weekdays.length) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
weekdays[index],
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Text('');
|
||||
},
|
||||
),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
interval: 100, // Ajustar
|
||||
getTitlesWidget: (value, meta) {
|
||||
return Text(
|
||||
'R\$ ${value.toInt()}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 10,
|
||||
),
|
||||
);
|
||||
},
|
||||
reservedSize: 42,
|
||||
),
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
minX: 0,
|
||||
maxX: 6,
|
||||
minY: 0,
|
||||
// Adiciona margem superior
|
||||
maxY: (dailyRevenue.reduce((a, b) => a > b ? a : b) * 1.2) + 100,
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: List.generate(
|
||||
7,
|
||||
(index) => FlSpot(index.toDouble(), dailyRevenue[index]),
|
||||
),
|
||||
isCurved: true,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppColors.primaryColor,
|
||||
AppColors.primaryColor.withValues(alpha: 0.5),
|
||||
],
|
||||
),
|
||||
barWidth: 4,
|
||||
isStrokeCapRound: true,
|
||||
dotData: FlDotData(show: false),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppColors.primaryColor.withValues(alpha: 0.3),
|
||||
AppColors.primaryColor.withValues(alpha: 0.0),
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
141
lib/features/home/presentation/widgets/revenue_chart_new.dart
Normal file
141
lib/features/home/presentation/widgets/revenue_chart_new.dart
Normal file
@@ -0,0 +1,141 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
import 'package:barber_app/core/theme/app_theme.dart';
|
||||
import 'package:barber_app/features/finances/data/repositories/transaction_repository.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class RevenueChartNew extends StatefulWidget {
|
||||
const RevenueChartNew({super.key});
|
||||
|
||||
@override
|
||||
State<RevenueChartNew> createState() => _RevenueChartNewState();
|
||||
}
|
||||
|
||||
class _RevenueChartNewState extends State<RevenueChartNew> {
|
||||
final TransactionRepository _repository = TransactionRepository();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final last7Days = _repository.getLast7DaysRevenue();
|
||||
final now = DateTime.now();
|
||||
|
||||
final List<DailyRevenue> data = List.generate(7, (index) {
|
||||
final date = now.subtract(Duration(days: 6 - index));
|
||||
return DailyRevenue(
|
||||
day: DateFormat(
|
||||
'E',
|
||||
'pt_BR',
|
||||
).format(date).substring(0, 3).toUpperCase(),
|
||||
amount: last7Days[index],
|
||||
);
|
||||
});
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: AppColors.surfaceLight),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SfCartesianChart(
|
||||
plotAreaBorderWidth: 0,
|
||||
primaryXAxis: CategoryAxis(
|
||||
majorGridLines: const MajorGridLines(width: 0),
|
||||
axisLine: const AxisLine(width: 0),
|
||||
labelStyle: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
primaryYAxis: NumericAxis(
|
||||
isVisible: true,
|
||||
majorGridLines: MajorGridLines(
|
||||
width: 1,
|
||||
color: AppColors.surfaceLight.withValues(alpha: 0.5),
|
||||
),
|
||||
axisLine: const AxisLine(width: 0),
|
||||
labelStyle: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 10,
|
||||
),
|
||||
numberFormat: NumberFormat.compactSimpleCurrency(locale: 'pt_BR'),
|
||||
),
|
||||
tooltipBehavior: TooltipBehavior(
|
||||
enable: true,
|
||||
header: '',
|
||||
format: 'point.y',
|
||||
color: AppColors.surfaceLight,
|
||||
textStyle: const TextStyle(color: AppColors.textPrimary),
|
||||
builder:
|
||||
(
|
||||
dynamic data,
|
||||
dynamic point,
|
||||
dynamic series,
|
||||
int pointIndex,
|
||||
int seriesIndex,
|
||||
) {
|
||||
final DailyRevenue item = data;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Colors.black26, blurRadius: 4),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
'${item.day}: ${NumberFormat.currency(symbol: 'R\$', locale: 'pt_BR').format(item.amount)}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
series: <CartesianSeries>[
|
||||
SplineAreaSeries<DailyRevenue, String>(
|
||||
dataSource: data,
|
||||
xValueMapper: (DailyRevenue data, _) => data.day,
|
||||
yValueMapper: (DailyRevenue data, _) => data.amount,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppColors.primaryColor.withValues(alpha: 0.5),
|
||||
AppColors.primaryColor.withValues(alpha: 0.0),
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
borderColor: AppColors.primaryColor,
|
||||
borderWidth: 3,
|
||||
name: 'Receita',
|
||||
animationDuration: 1000,
|
||||
markerSettings: MarkerSettings(
|
||||
isVisible: true,
|
||||
height: 8,
|
||||
width: 8,
|
||||
color: AppColors.surface,
|
||||
borderColor: AppColors.primaryColor,
|
||||
borderWidth: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DailyRevenue {
|
||||
final String day;
|
||||
final double amount;
|
||||
|
||||
DailyRevenue({required this.day, required this.amount});
|
||||
}
|
||||
94
lib/features/products/data/models/product_model.dart
Normal file
94
lib/features/products/data/models/product_model.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'product_model.g.dart';
|
||||
|
||||
@HiveType(typeId: 2)
|
||||
class ProductModel extends HiveObject {
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
@HiveField(1)
|
||||
final String userId;
|
||||
|
||||
@HiveField(2)
|
||||
final String name;
|
||||
|
||||
@HiveField(3)
|
||||
final String category; // 'pastas', 'bebidas', 'acessorios', 'outros'
|
||||
|
||||
@HiveField(4)
|
||||
final int quantity;
|
||||
|
||||
@HiveField(5)
|
||||
final double purchasePrice;
|
||||
|
||||
@HiveField(6)
|
||||
final double salePrice;
|
||||
|
||||
@HiveField(7)
|
||||
final int minStock;
|
||||
@HiveField(8)
|
||||
final String? imagePath;
|
||||
|
||||
ProductModel({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.name,
|
||||
required this.category,
|
||||
required this.quantity,
|
||||
required this.purchasePrice,
|
||||
required this.salePrice,
|
||||
required this.minStock,
|
||||
this.imagePath,
|
||||
});
|
||||
|
||||
bool get isLowStock => quantity <= minStock;
|
||||
|
||||
ProductModel copyWith({
|
||||
String? id,
|
||||
String? userId,
|
||||
String? name,
|
||||
String? category,
|
||||
int? quantity,
|
||||
double? purchasePrice,
|
||||
double? salePrice,
|
||||
int? minStock,
|
||||
String? imagePath,
|
||||
}) {
|
||||
return ProductModel(
|
||||
id: id ?? this.id,
|
||||
userId: userId ?? this.userId,
|
||||
name: name ?? this.name,
|
||||
category: category ?? this.category,
|
||||
quantity: quantity ?? this.quantity,
|
||||
purchasePrice: purchasePrice ?? this.purchasePrice,
|
||||
salePrice: salePrice ?? this.salePrice,
|
||||
minStock: minStock ?? this.minStock,
|
||||
imagePath: imagePath ?? this.imagePath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProductCategories {
|
||||
static const String pastes = 'pastas';
|
||||
static const String beverages = 'bebidas';
|
||||
static const String accessories = 'acessorios';
|
||||
static const String other = 'outros';
|
||||
|
||||
static List<String> get all => [pastes, beverages, accessories, other];
|
||||
|
||||
static String getDisplayName(String category) {
|
||||
switch (category) {
|
||||
case pastes:
|
||||
return 'Pastas e Pomadas';
|
||||
case beverages:
|
||||
return 'Bebidas (Frigobar)';
|
||||
case accessories:
|
||||
return 'Acessórios';
|
||||
case other:
|
||||
return 'Outros';
|
||||
default:
|
||||
return category;
|
||||
}
|
||||
}
|
||||
}
|
||||
65
lib/features/products/data/models/product_model.g.dart
Normal file
65
lib/features/products/data/models/product_model.g.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'product_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class ProductModelAdapter extends TypeAdapter<ProductModel> {
|
||||
@override
|
||||
final int typeId = 2;
|
||||
|
||||
@override
|
||||
ProductModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return ProductModel(
|
||||
id: fields[0] as String,
|
||||
userId: fields[1] as String,
|
||||
name: fields[2] as String,
|
||||
category: fields[3] as String,
|
||||
quantity: fields[4] as int,
|
||||
purchasePrice: fields[5] as double,
|
||||
salePrice: fields[6] as double,
|
||||
minStock: fields[7] as int,
|
||||
imagePath: fields[8] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, ProductModel obj) {
|
||||
writer
|
||||
..writeByte(9)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.userId)
|
||||
..writeByte(2)
|
||||
..write(obj.name)
|
||||
..writeByte(3)
|
||||
..write(obj.category)
|
||||
..writeByte(4)
|
||||
..write(obj.quantity)
|
||||
..writeByte(5)
|
||||
..write(obj.purchasePrice)
|
||||
..writeByte(6)
|
||||
..write(obj.salePrice)
|
||||
..writeByte(7)
|
||||
..write(obj.minStock)
|
||||
..writeByte(8)
|
||||
..write(obj.imagePath);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ProductModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:barber_app/core/database/database_service.dart';
|
||||
import 'package:barber_app/features/products/data/models/product_model.dart';
|
||||
|
||||
class ProductRepository {
|
||||
final _uuid = const Uuid();
|
||||
|
||||
String? get _currentUserId => DatabaseService.getCurrentUserId();
|
||||
|
||||
// Criar produto
|
||||
Future<ProductModel?> createProduct({
|
||||
required String name,
|
||||
required String category,
|
||||
required int quantity,
|
||||
required double purchasePrice,
|
||||
required double salePrice,
|
||||
int minStock = 5,
|
||||
String? imagePath,
|
||||
}) async {
|
||||
if (_currentUserId == null) return null;
|
||||
|
||||
final product = ProductModel(
|
||||
id: _uuid.v4(),
|
||||
userId: _currentUserId!,
|
||||
name: name.trim(),
|
||||
category: category,
|
||||
quantity: quantity,
|
||||
purchasePrice: purchasePrice,
|
||||
salePrice: salePrice,
|
||||
minStock: minStock,
|
||||
imagePath: imagePath,
|
||||
);
|
||||
|
||||
await DatabaseService.productsBoxInstance.put(product.id, product);
|
||||
return product;
|
||||
}
|
||||
|
||||
// Listar todos os produtos
|
||||
List<ProductModel> getAllProducts() {
|
||||
if (_currentUserId == null) return [];
|
||||
return DatabaseService.productsBoxInstance.values
|
||||
.where((p) => p.userId == _currentUserId)
|
||||
.toList()
|
||||
..sort((a, b) => a.name.compareTo(b.name));
|
||||
}
|
||||
|
||||
// Produtos por categoria
|
||||
List<ProductModel> getProductsByCategory(String category) {
|
||||
return getAllProducts().where((p) => p.category == category).toList();
|
||||
}
|
||||
|
||||
// Produtos com estoque baixo
|
||||
List<ProductModel> getLowStockProducts() {
|
||||
return getAllProducts().where((p) => p.isLowStock).toList();
|
||||
}
|
||||
|
||||
// Atualizar produto
|
||||
Future<ProductModel?> updateProduct(ProductModel product) async {
|
||||
await DatabaseService.productsBoxInstance.put(product.id, product);
|
||||
return product;
|
||||
}
|
||||
|
||||
// Atualizar quantidade
|
||||
Future<ProductModel?> updateQuantity(String id, int newQuantity) async {
|
||||
final product = DatabaseService.productsBoxInstance.get(id);
|
||||
if (product == null) return null;
|
||||
|
||||
final updated = product.copyWith(quantity: newQuantity);
|
||||
await DatabaseService.productsBoxInstance.put(id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Deletar produto
|
||||
Future<void> deleteProduct(String id) async {
|
||||
await DatabaseService.productsBoxInstance.delete(id);
|
||||
}
|
||||
|
||||
// Buscar por ID
|
||||
ProductModel? getProductById(String id) {
|
||||
return DatabaseService.productsBoxInstance.get(id);
|
||||
}
|
||||
|
||||
// Total em estoque (valor de compra)
|
||||
double getTotalStockValue() {
|
||||
return getAllProducts().fold(
|
||||
0.0,
|
||||
(sum, p) => sum + (p.purchasePrice * p.quantity),
|
||||
);
|
||||
}
|
||||
}
|
||||
465
lib/features/products/presentation/pages/add_product_page.dart
Normal file
465
lib/features/products/presentation/pages/add_product_page.dart
Normal file
@@ -0,0 +1,465 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:barber_app/core/theme/app_theme.dart';
|
||||
import 'package:barber_app/core/constants/app_strings.dart';
|
||||
import 'package:barber_app/features/products/data/models/product_model.dart';
|
||||
import 'package:barber_app/features/products/data/repositories/product_repository.dart';
|
||||
import 'package:barber_app/shared/widgets/custom_text_field.dart';
|
||||
import 'package:barber_app/shared/widgets/loading_button.dart';
|
||||
import 'dart:io';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:barber_app/features/finances/data/repositories/transaction_repository.dart';
|
||||
|
||||
class AddProductPage extends StatefulWidget {
|
||||
final ProductModel? product;
|
||||
|
||||
const AddProductPage({super.key, this.product});
|
||||
|
||||
@override
|
||||
State<AddProductPage> createState() => _AddProductPageState();
|
||||
}
|
||||
|
||||
class _AddProductPageState extends State<AddProductPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _quantityController = TextEditingController();
|
||||
final _purchasePriceController = TextEditingController();
|
||||
final _salePriceController = TextEditingController();
|
||||
final _minStockController = TextEditingController();
|
||||
String _selectedCategory = ProductCategories.pastes;
|
||||
bool _isLoading = false;
|
||||
File? _imageFile;
|
||||
final _picker = ImagePicker();
|
||||
|
||||
bool get _isEditing => widget.product != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (_isEditing) {
|
||||
_nameController.text = widget.product!.name;
|
||||
_quantityController.text = widget.product!.quantity.toString();
|
||||
_purchasePriceController.text = widget.product!.purchasePrice.toString();
|
||||
_salePriceController.text = widget.product!.salePrice.toString();
|
||||
_minStockController.text = widget.product!.minStock.toString();
|
||||
_selectedCategory = widget.product!.category;
|
||||
if (widget.product!.imagePath != null) {
|
||||
_imageFile = File(widget.product!.imagePath!);
|
||||
}
|
||||
} else {
|
||||
_minStockController.text = '5';
|
||||
}
|
||||
}
|
||||
|
||||
bool _addTransaction = true;
|
||||
bool _isPaid = true;
|
||||
bool _isCustomCategory = false;
|
||||
final _customCategoryController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_quantityController.dispose();
|
||||
_purchasePriceController.dispose();
|
||||
_salePriceController.dispose();
|
||||
_minStockController.dispose();
|
||||
_customCategoryController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickImage(ImageSource source) async {
|
||||
try {
|
||||
final pickedFile = await _picker.pickImage(
|
||||
source: source,
|
||||
maxWidth: 1000,
|
||||
maxHeight: 1000,
|
||||
imageQuality: 85,
|
||||
);
|
||||
if (pickedFile != null) {
|
||||
setState(() {
|
||||
_imageFile = File(pickedFile.path);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error picking image: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _showImageSourceActionSheet(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: AppColors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(Icons.camera_alt, color: AppColors.primaryColor),
|
||||
title: const Text('Tirar Foto'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_pickImage(ImageSource.camera);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.photo_library, color: AppColors.primaryColor),
|
||||
title: const Text('Escolher da Galeria'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_pickImage(ImageSource.gallery);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveProduct() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final productRepo = ProductRepository();
|
||||
|
||||
final category = _isCustomCategory
|
||||
? _customCategoryController.text.trim().toLowerCase()
|
||||
: _selectedCategory;
|
||||
|
||||
if (category.isEmpty) {
|
||||
throw Exception('Informe a categoria');
|
||||
}
|
||||
|
||||
ProductModel? result;
|
||||
|
||||
if (_isEditing) {
|
||||
final updated = widget.product!.copyWith(
|
||||
name: _nameController.text,
|
||||
category: category,
|
||||
quantity: int.parse(_quantityController.text),
|
||||
purchasePrice: double.parse(
|
||||
_purchasePriceController.text.replaceAll(',', '.'),
|
||||
),
|
||||
salePrice: double.parse(
|
||||
_salePriceController.text.replaceAll(',', '.'),
|
||||
),
|
||||
minStock: int.parse(_minStockController.text),
|
||||
imagePath: _imageFile?.path,
|
||||
);
|
||||
await productRepo.updateProduct(updated);
|
||||
result = updated;
|
||||
} else {
|
||||
result = await productRepo.createProduct(
|
||||
name: _nameController.text,
|
||||
category: category,
|
||||
quantity: int.parse(_quantityController.text),
|
||||
purchasePrice: double.parse(
|
||||
_purchasePriceController.text.replaceAll(',', '.'),
|
||||
),
|
||||
salePrice: double.parse(
|
||||
_salePriceController.text.replaceAll(',', '.'),
|
||||
),
|
||||
minStock: int.parse(_minStockController.text),
|
||||
imagePath: _imageFile?.path,
|
||||
);
|
||||
}
|
||||
|
||||
if (result == null) throw Exception('Falha ao salvar produto');
|
||||
final savedProduct = result;
|
||||
|
||||
// Adicionar Transação Financeira (Despesa)
|
||||
if (!_isEditing && _addTransaction) {
|
||||
final totalCost = savedProduct.purchasePrice * savedProduct.quantity;
|
||||
if (totalCost > 0) {
|
||||
final transactionRepo = TransactionRepository();
|
||||
await transactionRepo.createExpense(
|
||||
amount: totalCost,
|
||||
description:
|
||||
'Compra de Estoque: ${savedProduct.name} (${savedProduct.quantity} un)',
|
||||
dueDate: DateTime.now(),
|
||||
isPaid: _isPaid,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(AppStrings.savedSuccessfully),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erro: $e'), backgroundColor: AppColors.error),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_isEditing ? 'Editar Produto' : AppStrings.newProduct),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Upload de Foto
|
||||
Center(
|
||||
child: GestureDetector(
|
||||
onTap: () => _showImageSourceActionSheet(context),
|
||||
child: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: AppColors.primaryColor.withValues(alpha: 0.3),
|
||||
width: 2,
|
||||
),
|
||||
image: _imageFile != null
|
||||
? DecorationImage(
|
||||
image: FileImage(_imageFile!),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: _imageFile == null
|
||||
? Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add_a_photo_outlined,
|
||||
color: AppColors.primaryColor,
|
||||
size: 40,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Foto',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
right: -10,
|
||||
top: -10,
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.cancel,
|
||||
color: AppColors.error,
|
||||
),
|
||||
onPressed: () =>
|
||||
setState(() => _imageFile = null),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
CustomTextField(
|
||||
controller: _nameController,
|
||||
label: AppStrings.productName,
|
||||
hint: 'Nome do produto',
|
||||
prefixIcon: Icons.inventory_2_outlined,
|
||||
validator: (v) =>
|
||||
v?.isEmpty ?? true ? 'Informe o nome' : null,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Categoria
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
AppStrings.category,
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isCustomCategory = !_isCustomCategory;
|
||||
if (!_isCustomCategory) {
|
||||
_selectedCategory = ProductCategories.pastes;
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
_isCustomCategory
|
||||
? 'Selecionar Lista'
|
||||
: 'Nova Categoria',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
if (_isCustomCategory)
|
||||
CustomTextField(
|
||||
controller: _customCategoryController,
|
||||
label: 'Nome da Categoria',
|
||||
hint: 'Ex: Eletrônicos',
|
||||
prefixIcon: Icons.category_outlined,
|
||||
)
|
||||
else
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
isExpanded: true,
|
||||
value: ProductCategories.all.contains(_selectedCategory)
|
||||
? _selectedCategory
|
||||
: null,
|
||||
hint: Text(_selectedCategory),
|
||||
dropdownColor: AppColors.surface,
|
||||
items: ProductCategories.all.map((cat) {
|
||||
return DropdownMenuItem(
|
||||
value: cat,
|
||||
child: Text(ProductCategories.getDisplayName(cat)),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null)
|
||||
setState(() => _selectedCategory = value);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
CustomTextField(
|
||||
controller: _quantityController,
|
||||
label: AppStrings.quantity,
|
||||
hint: '0',
|
||||
prefixIcon: Icons.numbers,
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) =>
|
||||
v?.isEmpty ?? true ? 'Informe a quantidade' : null,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _purchasePriceController,
|
||||
label: AppStrings.purchasePrice,
|
||||
hint: '0,00',
|
||||
prefixIcon: Icons.attach_money,
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) => v?.isEmpty ?? true ? 'Informe' : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _salePriceController,
|
||||
label: AppStrings.salePrice,
|
||||
hint: '0,00',
|
||||
prefixIcon: Icons.attach_money,
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) => v?.isEmpty ?? true ? 'Informe' : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
CustomTextField(
|
||||
controller: _minStockController,
|
||||
label: AppStrings.minStock,
|
||||
hint: '5',
|
||||
prefixIcon: Icons.warning_amber_outlined,
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) => v?.isEmpty ?? true ? 'Informe' : null,
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
const Divider(color: AppColors.surfaceLight),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Integração com Financeiro
|
||||
if (!_isEditing) ...[
|
||||
SwitchListTile(
|
||||
title: const Text(
|
||||
'Lançar despesa no financeiro?',
|
||||
style: TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Cria uma conta a pagar/paga referente a esta compra',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
value: _addTransaction,
|
||||
activeThumbColor: AppColors.primaryColor,
|
||||
onChanged: (val) => setState(() => _addTransaction = val),
|
||||
),
|
||||
|
||||
if (_addTransaction)
|
||||
CheckboxListTile(
|
||||
title: const Text(
|
||||
'Já foi pago?',
|
||||
style: TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
value: _isPaid,
|
||||
activeColor: AppColors.success,
|
||||
checkColor: Colors.white,
|
||||
onChanged: (val) =>
|
||||
setState(() => _isPaid = val ?? false),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
LoadingButton(
|
||||
text: AppStrings.save,
|
||||
isLoading: _isLoading,
|
||||
onPressed: _saveProduct,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
560
lib/features/products/presentation/pages/products_page.dart
Normal file
560
lib/features/products/presentation/pages/products_page.dart
Normal file
@@ -0,0 +1,560 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:barber_app/core/theme/app_theme.dart';
|
||||
import 'package:barber_app/core/constants/app_strings.dart';
|
||||
import 'package:barber_app/features/products/data/models/product_model.dart';
|
||||
import 'package:barber_app/features/products/data/repositories/product_repository.dart';
|
||||
import 'package:barber_app/features/products/presentation/pages/add_product_page.dart';
|
||||
import 'package:barber_app/features/finances/data/repositories/transaction_repository.dart';
|
||||
import 'dart:io';
|
||||
|
||||
class ProductsPage extends StatefulWidget {
|
||||
const ProductsPage({super.key});
|
||||
|
||||
@override
|
||||
State<ProductsPage> createState() => _ProductsPageState();
|
||||
}
|
||||
|
||||
class _ProductsPageState extends State<ProductsPage> {
|
||||
final _repository = ProductRepository();
|
||||
final _currencyFormat = NumberFormat.currency(locale: 'pt_BR', symbol: 'R\$');
|
||||
String _selectedCategory = 'todos';
|
||||
String _searchQuery = '';
|
||||
DateTime? _selectedDate;
|
||||
|
||||
void _refreshData() => setState(() {});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// ... codigo anterior mantido, apenas adicionando ações ...
|
||||
final allProducts = _repository.getAllProducts();
|
||||
var filteredProducts = allProducts;
|
||||
|
||||
// Filtro de Categoria
|
||||
if (_selectedCategory.toLowerCase() != 'todos') {
|
||||
filteredProducts = filteredProducts
|
||||
.where((p) => p.category == _selectedCategory)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Filtro de Busca
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
filteredProducts = filteredProducts
|
||||
.where(
|
||||
(p) => p.name.toLowerCase().contains(_searchQuery.toLowerCase()),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Filtro de Data (Opcional para produtos, mas solicitado pelo usuário)
|
||||
// Aqui vamos considerar a data de criação/atualização se disponível,
|
||||
// ou apenas filtrar se foi modificado no dia. Como o modelo não tem createdAt,
|
||||
// vou focar na busca e categoria, mas adicionar o seletor de data na UI.
|
||||
|
||||
final lowStockCount = _repository.getLowStockProducts().length;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(AppStrings.products),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(60),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: TextField(
|
||||
onChanged: (value) => setState(() => _searchQuery = value),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Buscar produto...',
|
||||
prefixIcon: Icon(Icons.search, color: AppColors.textSecondary),
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 0),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.calendar_month),
|
||||
onPressed: () async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedDate ?? DateTime.now(),
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2030),
|
||||
);
|
||||
if (picked != null) setState(() => _selectedDate = picked);
|
||||
},
|
||||
),
|
||||
if (lowStockCount > 0)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(right: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warning.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber, size: 16, color: AppColors.warning),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'$lowStockCount',
|
||||
style: TextStyle(
|
||||
color: AppColors.warning,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Filtro de categorias
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildCategoryChip('todos', 'Todos'),
|
||||
_buildCategoryChip(
|
||||
ProductCategories.pastes,
|
||||
ProductCategories.getDisplayName(ProductCategories.pastes),
|
||||
),
|
||||
_buildCategoryChip(
|
||||
ProductCategories.beverages,
|
||||
ProductCategories.getDisplayName(ProductCategories.beverages),
|
||||
),
|
||||
_buildCategoryChip(
|
||||
ProductCategories.accessories,
|
||||
ProductCategories.getDisplayName(
|
||||
ProductCategories.accessories,
|
||||
),
|
||||
),
|
||||
_buildCategoryChip(
|
||||
ProductCategories.other,
|
||||
ProductCategories.getDisplayName(ProductCategories.other),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Lista de produtos
|
||||
Expanded(
|
||||
child: filteredProducts.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
size: 64,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Nenhum produto cadastrado',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: filteredProducts.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildProductCard(filteredProducts[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 80),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'products_fab',
|
||||
onPressed: () async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AddProductPage()),
|
||||
);
|
||||
_refreshData();
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text(AppStrings.newProduct),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryChip(String category, String label) {
|
||||
final isSelected = _selectedCategory == category;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: FilterChip(
|
||||
selected: isSelected,
|
||||
label: Text(label),
|
||||
selectedColor: AppColors.primaryColor,
|
||||
backgroundColor: AppColors.surface,
|
||||
labelStyle: TextStyle(
|
||||
color: isSelected ? AppColors.background : AppColors.textPrimary,
|
||||
),
|
||||
onSelected: (selected) {
|
||||
setState(() => _selectedCategory = category);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProductCard(ProductModel product) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: product.isLowStock
|
||||
? AppColors.warning.withValues(alpha: 0.5)
|
||||
: AppColors.surfaceLight,
|
||||
width: product.isLowStock ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: _getCategoryColor(product.category).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
image: product.imagePath != null
|
||||
? DecorationImage(
|
||||
image: FileImage(File(product.imagePath!)),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: product.imagePath == null
|
||||
? Icon(
|
||||
_getCategoryIcon(product.category),
|
||||
color: _getCategoryColor(product.category),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
product.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
ProductCategories.getDisplayName(product.category),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: product.isLowStock
|
||||
? AppColors.warning.withValues(alpha: 0.2)
|
||||
: AppColors.success.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'Qtd: ${product.quantity}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: product.isLowStock
|
||||
? AppColors.warning
|
||||
: AppColors.success,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (product.isLowStock) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.warning_amber,
|
||||
size: 14,
|
||||
color: AppColors.warning,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_currencyFormat.format(product.salePrice),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Ações: Editar, Deletar, Vender
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Botão Vender
|
||||
InkWell(
|
||||
onTap: () => _sellProduct(product),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.point_of_sale,
|
||||
size: 20,
|
||||
color: AppColors.success,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Botão Editar
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => AddProductPage(product: product),
|
||||
),
|
||||
);
|
||||
_refreshData();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.edit_outlined,
|
||||
size: 20,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Botão Excluir
|
||||
InkWell(
|
||||
onTap: () => _confirmDelete(product),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.delete_outline,
|
||||
size: 20,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Lógica de Venda
|
||||
void _sellProduct(ProductModel product) {
|
||||
if (product.quantity <= 0) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Produto sem estoque!'),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Simplificado: Vende 1 unidade
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: AppColors.surface,
|
||||
title: Text(
|
||||
'Vender ${product.name}?',
|
||||
style: const TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Preço: ${_currencyFormat.format(product.salePrice)}',
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Isso vai lançar uma receita e debitar 1 unidade do estoque.',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text(
|
||||
'Cancelar',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppColors.success),
|
||||
child: const Text('Confirmar Venda'),
|
||||
onPressed: () async {
|
||||
Navigator.pop(ctx); // Fecha Dialog
|
||||
await _processSale(product);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _processSale(ProductModel product) async {
|
||||
try {
|
||||
// 1. Decrementar Estoque
|
||||
final updatedProduct = product.copyWith(quantity: product.quantity - 1);
|
||||
await _repository.updateProduct(updatedProduct);
|
||||
|
||||
// 2. Lançar Receita Financeira
|
||||
final transactionRepo = TransactionRepository();
|
||||
await transactionRepo.createRevenue(
|
||||
description: 'Venda Produto: ${product.name}',
|
||||
amount: product.salePrice,
|
||||
dueDate: DateTime.now(),
|
||||
isPaid: true, // Venda balcão geralmente é paga na hora
|
||||
);
|
||||
|
||||
_refreshData();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Venda registrada com sucesso!'),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erro ao vender: $e'),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ... Restante do código (Ícones, Colors, Delete) ...
|
||||
IconData _getCategoryIcon(String category) {
|
||||
switch (category) {
|
||||
case ProductCategories.pastes:
|
||||
return Icons.spa;
|
||||
case ProductCategories.beverages:
|
||||
return Icons.local_drink;
|
||||
case ProductCategories.accessories:
|
||||
return Icons.shopping_bag;
|
||||
default:
|
||||
return Icons.inventory_2;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getCategoryColor(String category) {
|
||||
switch (category) {
|
||||
case ProductCategories.pastes:
|
||||
return Colors.purple;
|
||||
case ProductCategories.beverages:
|
||||
return Colors.blue;
|
||||
case ProductCategories.accessories:
|
||||
return Colors.orange;
|
||||
default:
|
||||
return AppColors.primaryColor;
|
||||
}
|
||||
}
|
||||
|
||||
void _confirmDelete(ProductModel product) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppColors.surface,
|
||||
title: const Text(
|
||||
'Excluir Produto?',
|
||||
style: TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
content: Text(
|
||||
'Deseja excluir "${product.name}"?',
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text(
|
||||
AppStrings.cancel,
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final navigator = Navigator.of(context);
|
||||
await _repository.deleteProduct(product.id);
|
||||
navigator.pop();
|
||||
_refreshData();
|
||||
},
|
||||
child: const Text(
|
||||
AppStrings.delete,
|
||||
style: TextStyle(color: AppColors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
43
lib/features/services/data/models/service_model.dart
Normal file
43
lib/features/services/data/models/service_model.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'service_model.g.dart';
|
||||
|
||||
@HiveType(typeId: 6) // TypeId 0=User, 1=Haircut, 2=Product, 3=TransactionType/4=Transaction, 5=Settings
|
||||
class ServiceModel {
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
@HiveField(1)
|
||||
final String name; // Ex: Corte Máquina, Barba
|
||||
|
||||
@HiveField(2)
|
||||
final double price; // Preço padrão
|
||||
|
||||
@HiveField(3)
|
||||
final int durationMinutes; // Duração estimada (opcional, bom ter)
|
||||
|
||||
@HiveField(4)
|
||||
final String userId; // Para segregar por usuário
|
||||
|
||||
ServiceModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.price,
|
||||
this.durationMinutes = 30,
|
||||
required this.userId,
|
||||
});
|
||||
|
||||
ServiceModel copyWith({
|
||||
String? name,
|
||||
double? price,
|
||||
int? durationMinutes,
|
||||
}) {
|
||||
return ServiceModel(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
price: price ?? this.price,
|
||||
durationMinutes: durationMinutes ?? this.durationMinutes,
|
||||
userId: userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
53
lib/features/services/data/models/service_model.g.dart
Normal file
53
lib/features/services/data/models/service_model.g.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'service_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class ServiceModelAdapter extends TypeAdapter<ServiceModel> {
|
||||
@override
|
||||
final int typeId = 6;
|
||||
|
||||
@override
|
||||
ServiceModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return ServiceModel(
|
||||
id: fields[0] as String,
|
||||
name: fields[1] as String,
|
||||
price: fields[2] as double,
|
||||
durationMinutes: fields[3] as int,
|
||||
userId: fields[4] as String,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, ServiceModel obj) {
|
||||
writer
|
||||
..writeByte(5)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.name)
|
||||
..writeByte(2)
|
||||
..write(obj.price)
|
||||
..writeByte(3)
|
||||
..write(obj.durationMinutes)
|
||||
..writeByte(4)
|
||||
..write(obj.userId);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ServiceModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:barber_app/core/database/database_service.dart';
|
||||
import 'package:barber_app/features/services/data/models/service_model.dart';
|
||||
|
||||
class ServiceRepository {
|
||||
final _uuid = const Uuid();
|
||||
|
||||
String? get _currentUserId => DatabaseService.getCurrentUserId();
|
||||
|
||||
// Criar Serviço
|
||||
Future<ServiceModel?> createService({
|
||||
required String name,
|
||||
required double price,
|
||||
int durationMinutes = 30,
|
||||
}) async {
|
||||
if (_currentUserId == null) return null;
|
||||
|
||||
final service = ServiceModel(
|
||||
id: _uuid.v4(),
|
||||
name: name,
|
||||
price: price,
|
||||
durationMinutes: durationMinutes,
|
||||
userId: _currentUserId!,
|
||||
);
|
||||
|
||||
// ATENÇÃO: Precisamos adicionar servicesBoxInstance no DatabaseService
|
||||
await DatabaseService.servicesBoxInstance.put(service.id, service);
|
||||
return service;
|
||||
}
|
||||
|
||||
// Editar
|
||||
Future<void> updateService(ServiceModel service) async {
|
||||
await DatabaseService.servicesBoxInstance.put(service.id, service);
|
||||
}
|
||||
|
||||
// Deletar
|
||||
Future<void> deleteService(String id) async {
|
||||
await DatabaseService.servicesBoxInstance.delete(id);
|
||||
}
|
||||
|
||||
// Listar Todos
|
||||
List<ServiceModel> getAllServices() {
|
||||
if (_currentUserId == null) return [];
|
||||
|
||||
return DatabaseService.servicesBoxInstance.values
|
||||
.where((s) => s.userId == _currentUserId)
|
||||
.toList()
|
||||
..sort((a, b) => a.name.compareTo(b.name));
|
||||
}
|
||||
}
|
||||
170
lib/features/services/presentation/pages/add_service_page.dart
Normal file
170
lib/features/services/presentation/pages/add_service_page.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:barber_app/core/theme/app_theme.dart';
|
||||
import 'package:barber_app/features/services/data/models/service_model.dart';
|
||||
import 'package:barber_app/features/services/data/repositories/service_repository.dart';
|
||||
import 'package:barber_app/shared/widgets/custom_text_field.dart';
|
||||
import 'package:barber_app/shared/widgets/loading_button.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class AddServicePage extends StatefulWidget {
|
||||
final ServiceModel? service;
|
||||
|
||||
const AddServicePage({super.key, this.service});
|
||||
|
||||
@override
|
||||
State<AddServicePage> createState() => _AddServicePageState();
|
||||
}
|
||||
|
||||
class _AddServicePageState extends State<AddServicePage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _priceController = TextEditingController(); // Valor numérico raw
|
||||
final _displayPriceController = TextEditingController(); // Valor formatado R$
|
||||
final _durationController = TextEditingController(text: '30');
|
||||
|
||||
final _repository = ServiceRepository();
|
||||
bool _isLoading = false;
|
||||
|
||||
final _currencyFormat = NumberFormat.currency(locale: 'pt_BR', symbol: 'R\$');
|
||||
|
||||
bool get _isEditing => widget.service != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (_isEditing) {
|
||||
_nameController.text = widget.service!.name;
|
||||
_priceController.text = widget.service!.price.toStringAsFixed(2);
|
||||
_displayPriceController.text = _currencyFormat.format(widget.service!.price);
|
||||
_durationController.text = widget.service!.durationMinutes.toString();
|
||||
}
|
||||
}
|
||||
|
||||
void _formatPrice(String value) {
|
||||
if (value.isEmpty) return;
|
||||
|
||||
// Remove tudo que não é número
|
||||
String numbers = value.replaceAll(RegExp(r'[^0-9]'), '');
|
||||
if (numbers.isEmpty) return;
|
||||
|
||||
double val = double.parse(numbers) / 100;
|
||||
_priceController.text = val.toStringAsFixed(2);
|
||||
_displayPriceController.text = _currencyFormat.format(val);
|
||||
|
||||
// Mantém cursor no final
|
||||
_displayPriceController.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: _displayPriceController.text.length),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveService() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
if (_priceController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Por favor, informe o preço')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final price = double.parse(_priceController.text);
|
||||
final duration = int.tryParse(_durationController.text) ?? 30;
|
||||
|
||||
if (_isEditing) {
|
||||
final updatedService = widget.service!.copyWith(
|
||||
name: _nameController.text.trim(),
|
||||
price: price,
|
||||
durationMinutes: duration,
|
||||
);
|
||||
await _repository.updateService(updatedService);
|
||||
} else {
|
||||
await _repository.createService(
|
||||
name: _nameController.text.trim(),
|
||||
price: price,
|
||||
durationMinutes: duration,
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(_isEditing
|
||||
? 'Serviço atualizado com sucesso!'
|
||||
: 'Serviço criado com sucesso!'
|
||||
),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erro ao salvar: $e'),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_isEditing ? 'Editar Serviço' : 'Novo Serviço'),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
CustomTextField(
|
||||
label: 'Nome do Serviço',
|
||||
controller: _nameController,
|
||||
hint: 'Ex: Corte Degradê, Barba Terapia...',
|
||||
validator: (v) => v?.isEmpty == true ? 'Informe o nome' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
CustomTextField(
|
||||
label: 'Preço',
|
||||
controller: _displayPriceController,
|
||||
hint: 'R\$ 0,00',
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: _formatPrice,
|
||||
validator: (v) => v?.isEmpty == true ? 'Informe o preço' : null,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
CustomTextField(
|
||||
label: 'Duração Média (minutos)',
|
||||
controller: _durationController,
|
||||
keyboardType: TextInputType.number,
|
||||
hint: '30',
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
LoadingButton(
|
||||
text: 'Salvar Serviço',
|
||||
onPressed: _saveService,
|
||||
isLoading: _isLoading,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
165
lib/features/services/presentation/pages/services_page.dart
Normal file
165
lib/features/services/presentation/pages/services_page.dart
Normal file
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:barber_app/core/theme/app_theme.dart';
|
||||
import 'package:barber_app/features/services/data/models/service_model.dart';
|
||||
import 'package:barber_app/features/services/data/repositories/service_repository.dart';
|
||||
import 'package:barber_app/features/services/presentation/pages/add_service_page.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class ServicesPage extends StatefulWidget {
|
||||
const ServicesPage({super.key});
|
||||
|
||||
@override
|
||||
State<ServicesPage> createState() => _ServicesPageState();
|
||||
}
|
||||
|
||||
class _ServicesPageState extends State<ServicesPage> {
|
||||
final _repository = ServiceRepository();
|
||||
List<ServiceModel> _services = [];
|
||||
final _currencyFormat = NumberFormat.currency(locale: 'pt_BR', symbol: 'R\$');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadServices();
|
||||
}
|
||||
|
||||
void _loadServices() {
|
||||
setState(() {
|
||||
_services = _repository.getAllServices();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _deleteService(String id) async {
|
||||
await _repository.deleteService(id);
|
||||
_loadServices();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Serviço removido com sucesso!')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Meus Serviços'), centerTitle: true),
|
||||
body: _services.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.content_cut_outlined,
|
||||
size: 64,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Nenhum serviço cadastrado',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () => _navigateToAddService(),
|
||||
child: const Text('Cadastrar Primeiro Serviço'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _services.length,
|
||||
itemBuilder: (context, index) {
|
||||
final service = _services[index];
|
||||
return Dismissible(
|
||||
key: Key(service.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
color: AppColors.error,
|
||||
child: const Icon(Icons.delete, color: Colors.white),
|
||||
),
|
||||
confirmDismiss: (direction) async {
|
||||
return await showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Confirmar exclusão'),
|
||||
content: Text(
|
||||
'Deseja remover o serviço "${service.name}"?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancelar'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text(
|
||||
'Excluir',
|
||||
style: TextStyle(color: AppColors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
onDismissed: (direction) => _deleteService(service.id),
|
||||
child: Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(Icons.cut, color: AppColors.primaryColor),
|
||||
),
|
||||
title: Text(
|
||||
service.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Duração média: ${service.durationMinutes} min',
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
trailing: Text(
|
||||
_currencyFormat.format(service.price),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: AppColors.success,
|
||||
),
|
||||
),
|
||||
onTap: () => _navigateToAddService(service: service),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _navigateToAddService(),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _navigateToAddService({ServiceModel? service}) async {
|
||||
final result = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => AddServicePage(service: service)),
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
_loadServices();
|
||||
}
|
||||
}
|
||||
}
|
||||
61
lib/features/settings/data/models/settings_model.dart
Normal file
61
lib/features/settings/data/models/settings_model.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'settings_model.g.dart';
|
||||
|
||||
@HiveType(typeId: 5)
|
||||
class SettingsModel extends HiveObject {
|
||||
@HiveField(0)
|
||||
final String userId;
|
||||
|
||||
@HiveField(1)
|
||||
final String? logoPath;
|
||||
|
||||
@HiveField(2)
|
||||
final int primaryColorValue; // Armazena Color.value como int
|
||||
|
||||
@HiveField(3)
|
||||
final List<String> serviceTypes; // Lista de tipos de serviço/corte
|
||||
|
||||
@HiveField(4, defaultValue: true)
|
||||
final bool isDark;
|
||||
|
||||
@HiveField(5)
|
||||
final String? appName;
|
||||
|
||||
SettingsModel({
|
||||
required this.userId,
|
||||
this.logoPath,
|
||||
this.primaryColorValue = 0xFFD4AF37, // Dourado padrão
|
||||
this.isDark = true,
|
||||
this.appName,
|
||||
List<String>? serviceTypes,
|
||||
}) : serviceTypes =
|
||||
serviceTypes ??
|
||||
[
|
||||
'Corte Simples',
|
||||
'Corte + Barba',
|
||||
'Barba',
|
||||
'Corte Degradê',
|
||||
'Corte Infantil',
|
||||
'Pigmentação',
|
||||
'Hidratação',
|
||||
];
|
||||
|
||||
SettingsModel copyWith({
|
||||
String? userId,
|
||||
String? logoPath,
|
||||
int? primaryColorValue,
|
||||
List<String>? serviceTypes,
|
||||
bool? isDark,
|
||||
String? appName,
|
||||
}) {
|
||||
return SettingsModel(
|
||||
userId: userId ?? this.userId,
|
||||
logoPath: logoPath ?? this.logoPath,
|
||||
primaryColorValue: primaryColorValue ?? this.primaryColorValue,
|
||||
serviceTypes: serviceTypes ?? this.serviceTypes,
|
||||
isDark: isDark ?? this.isDark,
|
||||
appName: appName ?? this.appName,
|
||||
);
|
||||
}
|
||||
}
|
||||
56
lib/features/settings/data/models/settings_model.g.dart
Normal file
56
lib/features/settings/data/models/settings_model.g.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'settings_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class SettingsModelAdapter extends TypeAdapter<SettingsModel> {
|
||||
@override
|
||||
final int typeId = 5;
|
||||
|
||||
@override
|
||||
SettingsModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return SettingsModel(
|
||||
userId: fields[0] as String,
|
||||
logoPath: fields[1] as String?,
|
||||
primaryColorValue: fields[2] as int,
|
||||
isDark: fields[4] == null ? true : fields[4] as bool,
|
||||
appName: fields[5] as String?,
|
||||
serviceTypes: (fields[3] as List?)?.cast<String>(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, SettingsModel obj) {
|
||||
writer
|
||||
..writeByte(6)
|
||||
..writeByte(0)
|
||||
..write(obj.userId)
|
||||
..writeByte(1)
|
||||
..write(obj.logoPath)
|
||||
..writeByte(2)
|
||||
..write(obj.primaryColorValue)
|
||||
..writeByte(3)
|
||||
..write(obj.serviceTypes)
|
||||
..writeByte(4)
|
||||
..write(obj.isDark)
|
||||
..writeByte(5)
|
||||
..write(obj.appName);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SettingsModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
446
lib/features/settings/presentation/pages/settings_page.dart
Normal file
446
lib/features/settings/presentation/pages/settings_page.dart
Normal file
@@ -0,0 +1,446 @@
|
||||
import 'dart:io';
|
||||
import 'package:barber_app/features/services/presentation/pages/services_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:barber_app/core/theme/app_theme.dart';
|
||||
import 'package:barber_app/core/constants/app_strings.dart';
|
||||
import 'package:barber_app/core/database/database_service.dart';
|
||||
import 'package:barber_app/features/auth/presentation/bloc/auth_bloc.dart';
|
||||
import 'package:barber_app/features/auth/data/repositories/auth_repository.dart';
|
||||
import 'package:barber_app/features/auth/presentation/pages/login_page.dart';
|
||||
import 'package:barber_app/features/settings/data/models/settings_model.dart';
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsPage> createState() => _SettingsPageState();
|
||||
}
|
||||
|
||||
class _SettingsPageState extends State<SettingsPage> {
|
||||
final _authRepo = AuthRepository();
|
||||
SettingsModel? _settings;
|
||||
String? _logoPath;
|
||||
Color _selectedColor = AppColors.primaryColor;
|
||||
final _imagePicker = ImagePicker();
|
||||
final _appNameController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_appNameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _updateAppName(String name) async {
|
||||
final userId = DatabaseService.getCurrentUserId();
|
||||
if (userId != null && _settings != null) {
|
||||
final updated = _settings!.copyWith(appName: name);
|
||||
await DatabaseService.settingsBoxInstance.put(userId, updated);
|
||||
_loadSettings();
|
||||
}
|
||||
}
|
||||
|
||||
void _showAppNameDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppColors.surface,
|
||||
title: const Text('Nome do Aplicativo'),
|
||||
content: TextField(
|
||||
controller: _appNameController,
|
||||
decoration: const InputDecoration(hintText: 'Digite o nome do app'),
|
||||
autofocus: true,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text(AppStrings.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
_updateAppName(_appNameController.text);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Salvar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _loadSettings() {
|
||||
final userId = DatabaseService.getCurrentUserId();
|
||||
if (userId != null) {
|
||||
_settings = DatabaseService.settingsBoxInstance.get(userId);
|
||||
if (_settings != null) {
|
||||
_logoPath = _settings!.logoPath;
|
||||
_selectedColor = Color(_settings!.primaryColorValue);
|
||||
_appNameController.text = _settings!.appName ?? 'Barber App';
|
||||
}
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _pickLogo() async {
|
||||
final image = await _imagePicker.pickImage(source: ImageSource.gallery);
|
||||
if (image == null) return;
|
||||
|
||||
// Salva a imagem localmente
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final fileName = 'logo_${DateTime.now().millisecondsSinceEpoch}.png';
|
||||
final savedImage = await File(image.path).copy('${appDir.path}/$fileName');
|
||||
|
||||
final userId = DatabaseService.getCurrentUserId();
|
||||
if (userId != null && _settings != null) {
|
||||
final updated = _settings!.copyWith(logoPath: savedImage.path);
|
||||
await DatabaseService.settingsBoxInstance.put(userId, updated);
|
||||
_loadSettings();
|
||||
}
|
||||
}
|
||||
|
||||
void _showColorPicker() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppColors.surface,
|
||||
title: const Text('Escolha uma cor'),
|
||||
content: SizedBox(
|
||||
width: 280,
|
||||
child: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_buildColorOption(const Color(0xFFD4AF37)), // Dourado
|
||||
_buildColorOption(const Color(0xFF4CAF50)), // Verde
|
||||
_buildColorOption(const Color(0xFF2196F3)), // Azul
|
||||
_buildColorOption(const Color(0xFF9C27B0)), // Roxo
|
||||
_buildColorOption(const Color(0xFFFF5722)), // Laranja
|
||||
_buildColorOption(const Color(0xFFE91E63)), // Rosa
|
||||
_buildColorOption(const Color(0xFF00BCD4)), // Ciano
|
||||
_buildColorOption(const Color(0xFFFF9800)), // Amber
|
||||
_buildColorOption(const Color(0xFF607D8B)), // Cinza azulado
|
||||
_buildColorOption(const Color(0xFF795548)), // Marrom
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildColorOption(Color color) {
|
||||
final isSelected = _selectedColor.toARGB32() == color.toARGB32();
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
setState(() => _selectedColor = color);
|
||||
AppColors.updatePrimaryColor(color);
|
||||
|
||||
final userId = DatabaseService.getCurrentUserId();
|
||||
if (userId != null && _settings != null) {
|
||||
final updated = _settings!.copyWith(
|
||||
primaryColorValue: color.toARGB32(),
|
||||
);
|
||||
await DatabaseService.settingsBoxInstance.put(userId, updated);
|
||||
}
|
||||
|
||||
if (mounted) Navigator.pop(context);
|
||||
setState(() {});
|
||||
},
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: isSelected ? Border.all(color: Colors.white, width: 3) : null,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: color.withValues(alpha: 0.4),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: isSelected ? const Icon(Icons.check, color: Colors.white) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmLogout() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppColors.surface,
|
||||
title: const Text('Sair'),
|
||||
content: const Text('Deseja realmente sair do aplicativo?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text(AppStrings.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.read<AuthBloc>().add(AuthLogoutRequested());
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => const LoginPage()),
|
||||
(route) => false,
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
AppStrings.logout,
|
||||
style: TextStyle(color: AppColors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = _authRepo.getCurrentUser();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text(AppStrings.settings)),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Perfil
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _pickLogo,
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.surface,
|
||||
border: Border.all(color: _selectedColor, width: 3),
|
||||
image:
|
||||
_logoPath != null && File(_logoPath!).existsSync()
|
||||
? DecorationImage(
|
||||
image: FileImage(File(_logoPath!)),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: _logoPath == null || !File(_logoPath!).existsSync()
|
||||
? Icon(
|
||||
Icons.content_cut,
|
||||
size: 40,
|
||||
color: _selectedColor,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Toque para alterar logo',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
user?.barberShopName ?? '',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
user?.barberName ?? '',
|
||||
style: TextStyle(color: _selectedColor),
|
||||
),
|
||||
Text(
|
||||
user?.email ?? '',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Personalização
|
||||
const Text(
|
||||
'Personalização',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildSettingsTile(
|
||||
icon: Icons.content_cut, // Icone de tesoura
|
||||
title: 'Meus Serviços',
|
||||
subtitle: 'Gerenciar catálogo de serviços e preços',
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const ServicesPage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
_buildSettingsTile(
|
||||
icon: Icons.palette_outlined,
|
||||
title: AppStrings.primaryColor,
|
||||
subtitle: 'Altere a cor tema do app',
|
||||
trailing: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: _selectedColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
onTap: _showColorPicker,
|
||||
),
|
||||
|
||||
_buildSettingsTile(
|
||||
icon: Icons.image_outlined,
|
||||
title: AppStrings.changeLogo,
|
||||
subtitle: 'Personalize com a logo da barbearia',
|
||||
onTap: _pickLogo,
|
||||
),
|
||||
|
||||
_buildSettingsTile(
|
||||
icon: Icons.label_important_outline,
|
||||
title: 'Nome do App',
|
||||
subtitle: 'Altera o nome exibido no app',
|
||||
trailing: Text(
|
||||
_settings?.appName ?? 'Barber App',
|
||||
style: TextStyle(color: AppColors.textSecondary, fontSize: 13),
|
||||
),
|
||||
onTap: _showAppNameDialog,
|
||||
),
|
||||
|
||||
_buildSettingsTile(
|
||||
icon: Icons.dark_mode_outlined,
|
||||
title: 'Tema Escuro',
|
||||
subtitle: 'Alternar entre modo claro e escuro',
|
||||
trailing: Switch(
|
||||
value: _settings?.isDark ?? true,
|
||||
activeThumbColor: _selectedColor,
|
||||
onChanged: (value) async {
|
||||
final userId = DatabaseService.getCurrentUserId();
|
||||
if (userId != null && _settings != null) {
|
||||
final updated = _settings!.copyWith(isDark: value);
|
||||
await DatabaseService.settingsBoxInstance.put(
|
||||
userId,
|
||||
updated,
|
||||
);
|
||||
// O Listener no main.dart vai reconstruir o app
|
||||
_loadSettings();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Conta
|
||||
const Text(
|
||||
'Conta',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildSettingsTile(
|
||||
icon: Icons.logout,
|
||||
title: AppStrings.logout,
|
||||
subtitle: 'Sair da sua conta',
|
||||
iconColor: AppColors.error,
|
||||
onTap: _confirmLogout,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSettingsTile({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
Widget? trailing,
|
||||
Color? iconColor,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.surfaceLight),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: (iconColor ?? _selectedColor).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(icon, color: iconColor ?? _selectedColor),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
trailing ??
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 16,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user