Add AddFeaturesDialog widget and enhance error handling in login form and account settings dialog

This commit is contained in:
Leon Bösche
2026-02-13 18:05:33 +01:00
parent 111e403ebd
commit 4e133b4cb8
5 changed files with 487 additions and 40 deletions

View File

@@ -21,6 +21,7 @@ import 'login_form.dart' show LoginForm;
import 'file_explorer.dart'; import 'file_explorer.dart';
import '../widgets/organization_settings_dialog.dart'; import '../widgets/organization_settings_dialog.dart';
import '../widgets/account_settings_dialog.dart'; import '../widgets/account_settings_dialog.dart';
import '../widgets/add_features_dialog.dart';
import '../widgets/audio_player_bar.dart'; import '../widgets/audio_player_bar.dart';
import '../injection.dart'; import '../injection.dart';
@@ -231,6 +232,13 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
); );
} }
void _showAddFeaturesDialog(BuildContext context) {
showDialog(
context: context,
builder: (dialogContext) => const AddFeaturesDialog(),
);
}
Widget _buildOrgRow(BuildContext context) { Widget _buildOrgRow(BuildContext context) {
return BlocBuilder<OrganizationBloc, OrganizationState>( return BlocBuilder<OrganizationBloc, OrganizationState>(
builder: (context, state) { builder: (context, state) {
@@ -384,6 +392,7 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
IconData icon, { IconData icon, {
bool isAvatar = false, bool isAvatar = false,
VoidCallback? onTap, VoidCallback? onTap,
bool showSoonBadge = false,
}) { }) {
final isSelected = _selectedTab == label; final isSelected = _selectedTab == label;
final highlightColor = const Color.fromARGB(255, 100, 200, 255); final highlightColor = const Color.fromARGB(255, 100, 200, 255);
@@ -391,7 +400,7 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
? AppTheme.primaryText ? AppTheme.primaryText
: AppTheme.secondaryText; : AppTheme.secondaryText;
return GestureDetector( Widget button = GestureDetector(
onTap: onTap:
onTap ?? onTap ??
() { () {
@@ -508,6 +517,36 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
], ],
), ),
); );
if (showSoonBadge) {
return Stack(
clipBehavior: Clip.none,
children: [
button,
Positioned(
top: -6,
right: -12,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.accentColor,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'soon',
style: TextStyle(
color: Colors.black,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
),
),
],
);
}
return button;
} }
@override @override
@@ -599,12 +638,17 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
children: [ children: [
_buildNavButton('Drive', Icons.cloud), _buildNavButton('Drive', Icons.cloud),
const SizedBox(width: 16), const SizedBox(width: 16),
_buildNavButton('Mail', Icons.mail), _buildNavButton(
'Mail',
Icons.mail,
showSoonBadge: true,
),
const SizedBox(width: 16), const SizedBox(width: 16),
_buildNavButton( _buildNavButton(
'Add', 'Add',
Icons.add, Icons.add,
onTap: () {}, onTap: () =>
_showAddFeaturesDialog(context),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
if (hasSelectedOrg) if (hasSelectedOrg)

View File

@@ -354,20 +354,18 @@ class _LoginFormState extends State<LoginForm> {
isLoading: state is AuthLoading, isLoading: state is AuthLoading,
onPressed: () { onPressed: () {
if (_usernameController.text.isEmpty) { if (_usernameController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( setState(() {
const SnackBar( _usernameHasError = true;
content: Text('Username is required'), _usernameErrorText = 'username is required';
), });
);
return; return;
} }
if (_isSignup) { if (_isSignup) {
if (_passwordController.text.isEmpty) { if (_passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( setState(() {
const SnackBar( _passwordHasError = true;
content: Text('Password is required'), _passwordErrorText = 'password is required';
), });
);
return; return;
} }
context.read<AuthBloc>().add( context.read<AuthBloc>().add(
@@ -387,11 +385,11 @@ class _LoginFormState extends State<LoginForm> {
); );
} else { } else {
if (_passwordController.text.isEmpty) { if (_passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( setState(() {
const SnackBar( _passwordHasError = true;
content: Text('Password is required'), _passwordErrorText =
), 'password is required';
); });
return; return;
} }
context.read<AuthBloc>().add( context.read<AuthBloc>().add(

View File

@@ -35,6 +35,14 @@ class _AccountSettingsDialogState extends State<AccountSettingsDialog> {
late TextEditingController _newPasswordController; late TextEditingController _newPasswordController;
late TextEditingController _confirmPasswordController; late TextEditingController _confirmPasswordController;
// Password field error state
bool _currentPasswordHasError = false;
bool _newPasswordHasError = false;
bool _confirmPasswordHasError = false;
String? _currentPasswordErrorText;
String? _newPasswordErrorText;
String? _confirmPasswordErrorText;
User? _currentUser; User? _currentUser;
@override @override
@@ -236,12 +244,50 @@ class _AccountSettingsDialogState extends State<AccountSettingsDialog> {
} }
Future<void> _changePassword() async { Future<void> _changePassword() async {
if (_newPasswordController.text != _confirmPasswordController.text) { // Clear previous errors
if (mounted) { setState(() {
ScaffoldMessenger.of( _currentPasswordHasError = false;
context, _newPasswordHasError = false;
).showSnackBar(const SnackBar(content: Text('Passwords do not match'))); _confirmPasswordHasError = false;
_currentPasswordErrorText = null;
_newPasswordErrorText = null;
_confirmPasswordErrorText = null;
});
// Validate current password
if (_currentPasswordController.text.isEmpty) {
setState(() {
_currentPasswordHasError = true;
_currentPasswordErrorText = 'current password is required';
});
return;
} }
// Validate new password
if (_newPasswordController.text.isEmpty) {
setState(() {
_newPasswordHasError = true;
_newPasswordErrorText = 'new password is required';
});
return;
}
// Validate confirm password
if (_confirmPasswordController.text.isEmpty) {
setState(() {
_confirmPasswordHasError = true;
_confirmPasswordErrorText = 'please confirm your password';
});
return;
}
// Validate passwords match
if (_newPasswordController.text != _confirmPasswordController.text) {
setState(() {
_newPasswordHasError = true;
_confirmPasswordHasError = true;
_confirmPasswordErrorText = 'passwords do not match';
});
return; return;
} }
@@ -258,16 +304,35 @@ class _AccountSettingsDialogState extends State<AccountSettingsDialog> {
); );
} }
// Clear fields // Clear fields and errors
_currentPasswordController.clear(); _currentPasswordController.clear();
_newPasswordController.clear(); _newPasswordController.clear();
_confirmPasswordController.clear(); _confirmPasswordController.clear();
setState(() {
_currentPasswordHasError = false;
_newPasswordHasError = false;
_confirmPasswordHasError = false;
_currentPasswordErrorText = null;
_newPasswordErrorText = null;
_confirmPasswordErrorText = null;
});
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
final errorMsg = e.toString().toLowerCase();
if (errorMsg.contains('incorrect') ||
errorMsg.contains('invalid') ||
errorMsg.contains('wrong')) {
setState(() {
_currentPasswordHasError = true;
_currentPasswordErrorText = 'incorrect password';
_currentPasswordController.clear();
});
} else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to change password: $e')), SnackBar(content: Text('Failed to change password: $e')),
); );
} }
}
} finally { } finally {
setState(() => _isLoading = false); setState(() => _isLoading = false);
} }
@@ -784,7 +849,9 @@ class _AccountSettingsDialogState extends State<AccountSettingsDialog> {
color: AppTheme.primaryBackground.withValues(alpha: 0.5), color: AppTheme.primaryBackground.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3), color: _currentPasswordHasError
? Colors.red
: AppTheme.accentColor.withValues(alpha: 0.3),
), ),
), ),
child: TextFormField( child: TextFormField(
@@ -792,6 +859,15 @@ class _AccountSettingsDialogState extends State<AccountSettingsDialog> {
obscureText: true, obscureText: true,
cursorColor: AppTheme.accentColor, cursorColor: AppTheme.accentColor,
style: TextStyle(color: AppTheme.primaryText), style: TextStyle(color: AppTheme.primaryText),
onChanged: (_) {
if (_currentPasswordHasError ||
_currentPasswordErrorText != null) {
setState(() {
_currentPasswordHasError = false;
_currentPasswordErrorText = null;
});
}
},
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Enter current password', hintText: 'Enter current password',
hintStyle: TextStyle(color: AppTheme.secondaryText), hintStyle: TextStyle(color: AppTheme.secondaryText),
@@ -800,6 +876,14 @@ class _AccountSettingsDialogState extends State<AccountSettingsDialog> {
), ),
), ),
), ),
if (_currentPasswordErrorText != null)
Padding(
padding: const EdgeInsets.only(top: 6, left: 8),
child: Text(
_currentPasswordErrorText!,
style: const TextStyle(color: Colors.red, fontSize: 12),
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
// New Password // New Password
@@ -816,7 +900,9 @@ class _AccountSettingsDialogState extends State<AccountSettingsDialog> {
color: AppTheme.primaryBackground.withValues(alpha: 0.5), color: AppTheme.primaryBackground.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3), color: _newPasswordHasError
? Colors.red
: AppTheme.accentColor.withValues(alpha: 0.3),
), ),
), ),
child: TextFormField( child: TextFormField(
@@ -824,6 +910,14 @@ class _AccountSettingsDialogState extends State<AccountSettingsDialog> {
obscureText: true, obscureText: true,
cursorColor: AppTheme.accentColor, cursorColor: AppTheme.accentColor,
style: TextStyle(color: AppTheme.primaryText), style: TextStyle(color: AppTheme.primaryText),
onChanged: (_) {
if (_newPasswordHasError || _newPasswordErrorText != null) {
setState(() {
_newPasswordHasError = false;
_newPasswordErrorText = null;
});
}
},
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Enter new password', hintText: 'Enter new password',
hintStyle: TextStyle(color: AppTheme.secondaryText), hintStyle: TextStyle(color: AppTheme.secondaryText),
@@ -832,6 +926,14 @@ class _AccountSettingsDialogState extends State<AccountSettingsDialog> {
), ),
), ),
), ),
if (_newPasswordErrorText != null)
Padding(
padding: const EdgeInsets.only(top: 6, left: 8),
child: Text(
_newPasswordErrorText!,
style: const TextStyle(color: Colors.red, fontSize: 12),
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
// Confirm Password // Confirm Password
@@ -848,7 +950,9 @@ class _AccountSettingsDialogState extends State<AccountSettingsDialog> {
color: AppTheme.primaryBackground.withValues(alpha: 0.5), color: AppTheme.primaryBackground.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3), color: _confirmPasswordHasError
? Colors.red
: AppTheme.accentColor.withValues(alpha: 0.3),
), ),
), ),
child: TextFormField( child: TextFormField(
@@ -856,6 +960,15 @@ class _AccountSettingsDialogState extends State<AccountSettingsDialog> {
obscureText: true, obscureText: true,
cursorColor: AppTheme.accentColor, cursorColor: AppTheme.accentColor,
style: TextStyle(color: AppTheme.primaryText), style: TextStyle(color: AppTheme.primaryText),
onChanged: (_) {
if (_confirmPasswordHasError ||
_confirmPasswordErrorText != null) {
setState(() {
_confirmPasswordHasError = false;
_confirmPasswordErrorText = null;
});
}
},
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Confirm new password', hintText: 'Confirm new password',
hintStyle: TextStyle(color: AppTheme.secondaryText), hintStyle: TextStyle(color: AppTheme.secondaryText),
@@ -864,6 +977,14 @@ class _AccountSettingsDialogState extends State<AccountSettingsDialog> {
), ),
), ),
), ),
if (_confirmPasswordErrorText != null)
Padding(
padding: const EdgeInsets.only(top: 6, left: 8),
child: Text(
_confirmPasswordErrorText!,
style: const TextStyle(color: Colors.red, fontSize: 12),
),
),
const SizedBox(height: 24), const SizedBox(height: 24),
// Change Password Button // Change Password Button

View File

@@ -0,0 +1,284 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
class AddFeaturesDialog extends StatelessWidget {
const AddFeaturesDialog({super.key});
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: AppTheme.primaryBackground,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
width: 500,
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Icon(
Icons.add_circle_outline,
color: AppTheme.accentColor,
size: 28,
),
const SizedBox(width: 12),
Text(
'Add Features',
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: Icon(Icons.close, color: AppTheme.secondaryText),
splashRadius: 20,
),
],
),
const SizedBox(height: 8),
Text(
'Enhance your workspace with additional features',
style: TextStyle(color: AppTheme.secondaryText, fontSize: 14),
),
const SizedBox(height: 24),
// Features list
_FeatureCard(
icon: Icons.calendar_month,
title: 'Calendar',
description:
'Schedule events, set reminders, and manage your time efficiently. Sync with your team and never miss important deadlines.',
onAdd: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Calendar feature coming soon!'),
),
);
},
),
const SizedBox(height: 16),
_FeatureCard(
icon: Icons.dashboard_customize,
title: 'Board',
description:
'Organize tasks with kanban-style boards. Create columns, drag cards, and track progress visually across your projects.',
onAdd: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Board feature coming soon!')),
);
},
),
const SizedBox(height: 24),
// Footer hint
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.accentColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3),
),
),
child: Row(
children: [
Icon(
Icons.info_outline,
color: AppTheme.accentColor,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'More features will be available soon. Stay tuned!',
style: TextStyle(
color: AppTheme.accentColor,
fontSize: 13,
),
),
),
],
),
),
],
),
),
);
}
}
class _FeatureCard extends StatefulWidget {
final IconData icon;
final String title;
final String description;
final VoidCallback onAdd;
const _FeatureCard({
required this.icon,
required this.title,
required this.description,
required this.onAdd,
});
@override
State<_FeatureCard> createState() => _FeatureCardState();
}
class _FeatureCardState extends State<_FeatureCard> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _isHovered
? Colors.white.withValues(alpha: 0.08)
: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _isHovered
? AppTheme.accentColor.withValues(alpha: 0.5)
: Colors.white.withValues(alpha: 0.1),
),
),
child: Row(
children: [
// Feature icon with soon badge
Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppTheme.accentColor.withValues(alpha: 0.3),
AppTheme.accentColor.withValues(alpha: 0.1),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
widget.icon,
color: AppTheme.accentColor,
size: 28,
),
),
Positioned(
top: -6,
right: -8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 5,
vertical: 2,
),
decoration: BoxDecoration(
color: AppTheme.accentColor,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'soon',
style: TextStyle(
color: Colors.black,
fontSize: 9,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
const SizedBox(width: 16),
// Feature details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.title,
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
widget.description,
style: TextStyle(
color: AppTheme.secondaryText,
fontSize: 13,
height: 1.4,
),
),
],
),
),
const SizedBox(width: 12),
// Add button
_AddButton(onPressed: widget.onAdd),
],
),
),
);
}
}
class _AddButton extends StatefulWidget {
final VoidCallback onPressed;
const _AddButton({required this.onPressed});
@override
State<_AddButton> createState() => _AddButtonState();
}
class _AddButtonState extends State<_AddButton> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: GestureDetector(
onTap: widget.onPressed,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 40,
height: 40,
decoration: BoxDecoration(
color: _isHovered
? AppTheme.accentColor
: AppTheme.accentColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.5),
),
),
child: Icon(
Icons.add,
color: _isHovered ? Colors.black : AppTheme.accentColor,
size: 24,
),
),
),
);
}
}

View File

@@ -69,10 +69,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.1"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@@ -205,10 +205,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: ffi name: ffi
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.5" version: "2.2.0"
file: file:
dependency: transitive dependency: transitive
description: description:
@@ -535,10 +535,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.1" version: "0.13.0"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@@ -988,10 +988,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_ios name: url_launcher_ios
sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.6" version: "6.4.1"
url_launcher_linux: url_launcher_linux:
dependency: transitive dependency: transitive
description: description:
@@ -1092,10 +1092,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: video_player_avfoundation name: video_player_avfoundation
sha256: f46e9e20f1fe429760cf4dc118761336320d1bec0f50d255930c2355f2defb5b sha256: f93b93a3baa12ca0ff7d00ca8bc60c1ecd96865568a01ff0c18a99853ee201a5
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.9.1" version: "2.9.3"
video_player_platform_interface: video_player_platform_interface:
dependency: transitive dependency: transitive
description: description: