M3 Overhaul
This commit is contained in:
parent
82fe619f22
commit
73dc735cce
|
|
@ -24,6 +24,7 @@ import '../screens/tickets/tickets_list_screen.dart';
|
|||
import '../screens/workforce/workforce_screen.dart';
|
||||
import '../widgets/app_shell.dart';
|
||||
import '../screens/teams/teams_screen.dart';
|
||||
import '../theme/m3_motion.dart';
|
||||
|
||||
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
final notifier = RouterNotifier(ref);
|
||||
|
|
@ -79,82 +80,131 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||
routes: [
|
||||
GoRoute(
|
||||
path: '/settings/teams',
|
||||
builder: (context, state) => const TeamsScreen(),
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: const TeamsScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/dashboard',
|
||||
builder: (context, state) => const DashboardScreen(),
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: const DashboardScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/tickets',
|
||||
builder: (context, state) => const TicketsListScreen(),
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: const TicketsListScreen(),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':id',
|
||||
builder: (context, state) => TicketDetailScreen(
|
||||
ticketId: state.pathParameters['id'] ?? '',
|
||||
pageBuilder: (context, state) => M3ContainerTransformPage(
|
||||
key: state.pageKey,
|
||||
child: TicketDetailScreen(
|
||||
ticketId: state.pathParameters['id'] ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: '/tasks',
|
||||
builder: (context, state) => const TasksListScreen(),
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: const TasksListScreen(),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':id',
|
||||
builder: (context, state) =>
|
||||
TaskDetailScreen(taskId: state.pathParameters['id'] ?? ''),
|
||||
pageBuilder: (context, state) => M3ContainerTransformPage(
|
||||
key: state.pageKey,
|
||||
child: TaskDetailScreen(
|
||||
taskId: state.pathParameters['id'] ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: '/events',
|
||||
builder: (context, state) => const UnderDevelopmentScreen(
|
||||
title: 'Events',
|
||||
subtitle: 'Event monitoring is under development.',
|
||||
icon: Icons.event,
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: const UnderDevelopmentScreen(
|
||||
title: 'Events',
|
||||
subtitle: 'Event monitoring is under development.',
|
||||
icon: Icons.event,
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/announcements',
|
||||
builder: (context, state) => const UnderDevelopmentScreen(
|
||||
title: 'Announcement',
|
||||
subtitle: 'Operational broadcasts are coming soon.',
|
||||
icon: Icons.campaign,
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: const UnderDevelopmentScreen(
|
||||
title: 'Announcement',
|
||||
subtitle: 'Operational broadcasts are coming soon.',
|
||||
icon: Icons.campaign,
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/workforce',
|
||||
builder: (context, state) => const WorkforceScreen(),
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: const WorkforceScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/reports',
|
||||
builder: (context, state) => const ReportsScreen(),
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: const ReportsScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/users',
|
||||
builder: (context, state) => const UserManagementScreen(),
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: const UserManagementScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/offices',
|
||||
builder: (context, state) => const OfficesScreen(),
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: const OfficesScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/geofence-test',
|
||||
builder: (context, state) => const GeofenceTestScreen(),
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: const GeofenceTestScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/permissions',
|
||||
builder: (context, state) => const PermissionsScreen(),
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: const PermissionsScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/notifications',
|
||||
builder: (context, state) => const NotificationsScreen(),
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: const NotificationsScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/profile',
|
||||
builder: (context, state) => const ProfileScreen(),
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: const ProfileScreen(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -370,7 +370,8 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
|
|||
right: 12,
|
||||
top: 12,
|
||||
child: Card(
|
||||
elevation: 2,
|
||||
elevation: 0,
|
||||
shadowColor: Colors.transparent,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10.0,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/office.dart';
|
||||
|
|
@ -153,7 +154,7 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
|
|||
right: 16,
|
||||
bottom: 16,
|
||||
child: SafeArea(
|
||||
child: FloatingActionButton.extended(
|
||||
child: M3ExpandedFab(
|
||||
onPressed: () => _showOfficeDialog(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New Office'),
|
||||
|
|
@ -172,7 +173,7 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
|
|||
final nameController = TextEditingController(text: office?.name ?? '');
|
||||
String? selectedServiceId = office?.serviceId;
|
||||
|
||||
await showDialog<void>(
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
bool saving = false;
|
||||
|
|
@ -298,7 +299,7 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
|
|||
WidgetRef ref,
|
||||
Office office,
|
||||
) async {
|
||||
await showDialog<void>(
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/office.dart';
|
||||
|
|
@ -306,7 +307,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
|||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
await showDialog<void>(
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return StatefulBuilder(
|
||||
|
|
@ -314,8 +315,8 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
|||
return AlertDialog(
|
||||
shape: AppSurfaces.of(context).dialogShape,
|
||||
title: const Text('Update user'),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
content: SizedBox(
|
||||
width: 520,
|
||||
child: SingleChildScrollView(
|
||||
child: _buildUserForm(
|
||||
context,
|
||||
|
|
@ -442,10 +443,17 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
|||
const SizedBox(height: 8),
|
||||
if (offices.isEmpty) const Text('No offices available.'),
|
||||
if (offices.isNotEmpty)
|
||||
Column(
|
||||
children: offices
|
||||
.map(
|
||||
(office) => CheckboxListTile(
|
||||
Container(
|
||||
height: 240,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: offices.map((office) {
|
||||
return CheckboxListTile(
|
||||
value: _selectedOfficeIds.contains(office.id),
|
||||
onChanged: _isSaving
|
||||
? null
|
||||
|
|
@ -460,10 +468,11 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
|||
},
|
||||
title: Text(office.name),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
|
|
@ -559,7 +568,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
|||
final controller = TextEditingController();
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
await showDialog<void>(
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
|||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
|
|
@ -17,15 +17,39 @@ class LoginScreen extends ConsumerStatefulWidget {
|
|||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
late final AnimationController _entranceController;
|
||||
late final Animation<double> _fadeIn;
|
||||
late final Animation<Offset> _slideIn;
|
||||
|
||||
bool _isLoading = false;
|
||||
bool _obscurePassword = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_entranceController = AnimationController(
|
||||
vsync: this,
|
||||
duration: M3Motion.long,
|
||||
);
|
||||
_fadeIn = CurvedAnimation(
|
||||
parent: _entranceController,
|
||||
curve: M3Motion.emphasizedEnter,
|
||||
);
|
||||
_slideIn = Tween<Offset>(
|
||||
begin: const Offset(0, 0.06),
|
||||
end: Offset.zero,
|
||||
).animate(_fadeIn);
|
||||
_entranceController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_entranceController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
|
|
@ -83,93 +107,230 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Sign In')),
|
||||
body: ResponsiveBody(
|
||||
maxWidth: 480,
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Image.asset('assets/tasq_ico.png', height: 72, width: 72),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'TasQ',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
],
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||
child: FadeTransition(
|
||||
opacity: _fadeIn,
|
||||
child: SlideTransition(
|
||||
position: _slideIn,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// ── Branding ──
|
||||
Hero(
|
||||
tag: 'tasq-logo',
|
||||
child: Image.asset(
|
||||
'assets/tasq_ico.png',
|
||||
height: 80,
|
||||
width: 80,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'TasQ',
|
||||
style: tt.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Task management, simplified',
|
||||
style: tt.bodyMedium?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// ── Sign-in card ──
|
||||
Card(
|
||||
elevation: 0,
|
||||
color: cs.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 28,
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Sign in',
|
||||
style: tt.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
prefixIcon: Icon(Icons.email_outlined),
|
||||
),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Email is required.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
prefixIcon: const Icon(Icons.lock_outlined),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
onPressed: () => setState(
|
||||
() => _obscurePassword =
|
||||
!_obscurePassword,
|
||||
),
|
||||
),
|
||||
),
|
||||
obscureText: _obscurePassword,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) {
|
||||
if (!_isLoading) _handleEmailSignIn();
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Password is required.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
M3AnimatedSwitcher(
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
key: ValueKey('loading'),
|
||||
height: 48,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
: FilledButton(
|
||||
key: const ValueKey('sign-in'),
|
||||
onPressed: _handleEmailSignIn,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(
|
||||
48,
|
||||
),
|
||||
),
|
||||
child: const Text('Sign In'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Divider ──
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Divider(color: cs.outlineVariant)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'or continue with',
|
||||
style: tt.labelMedium?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: Divider(color: cs.outlineVariant)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── OAuth buttons ──
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _isLoading
|
||||
? null
|
||||
: () => _handleOAuthSignIn(google: true),
|
||||
icon: const FaIcon(
|
||||
FontAwesomeIcons.google,
|
||||
size: 18,
|
||||
),
|
||||
label: const Text('Google'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _isLoading
|
||||
? null
|
||||
: () => _handleOAuthSignIn(google: false),
|
||||
icon: const FaIcon(
|
||||
FontAwesomeIcons.facebook,
|
||||
size: 18,
|
||||
),
|
||||
label: const Text('Meta'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Create account link ──
|
||||
TextButton(
|
||||
onPressed: _isLoading
|
||||
? null
|
||||
: () => context.go('/signup'),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: "Don't have an account? ",
|
||||
style: tt.bodyMedium?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'Sign up',
|
||||
style: TextStyle(
|
||||
color: cs.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(labelText: 'Email'),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Email is required.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(labelText: 'Password'),
|
||||
obscureText: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) {
|
||||
if (!_isLoading) {
|
||||
_handleEmailSignIn();
|
||||
}
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Password is required.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _isLoading ? null : _handleEmailSignIn,
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Sign In'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isLoading
|
||||
? null
|
||||
: () => _handleOAuthSignIn(google: true),
|
||||
icon: const FaIcon(FontAwesomeIcons.google, size: 18),
|
||||
label: const Text('Continue with Google'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isLoading
|
||||
? null
|
||||
: () => _handleOAuthSignIn(google: false),
|
||||
icon: const FaIcon(FontAwesomeIcons.facebook, size: 18),
|
||||
label: const Text('Continue with Meta'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: _isLoading ? null : () => context.go('/signup'),
|
||||
child: const Text('Create account'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart';
|
|||
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
|
||||
class SignUpScreen extends ConsumerStatefulWidget {
|
||||
|
|
@ -14,28 +14,49 @@ class SignUpScreen extends ConsumerStatefulWidget {
|
|||
ConsumerState<SignUpScreen> createState() => _SignUpScreenState();
|
||||
}
|
||||
|
||||
class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||
class _SignUpScreenState extends ConsumerState<SignUpScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _fullNameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _confirmPasswordController = TextEditingController();
|
||||
|
||||
late final AnimationController _entranceController;
|
||||
late final Animation<double> _fadeIn;
|
||||
late final Animation<Offset> _slideIn;
|
||||
|
||||
final Set<String> _selectedOfficeIds = {};
|
||||
double _passwordStrength = 0.0;
|
||||
String _passwordStrengthLabel = 'Very weak';
|
||||
Color _passwordStrengthColor = Colors.red;
|
||||
|
||||
bool _isLoading = false;
|
||||
bool _obscurePassword = true;
|
||||
bool _obscureConfirm = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_passwordController.addListener(_updatePasswordStrength);
|
||||
_entranceController = AnimationController(
|
||||
vsync: this,
|
||||
duration: M3Motion.long,
|
||||
);
|
||||
_fadeIn = CurvedAnimation(
|
||||
parent: _entranceController,
|
||||
curve: M3Motion.emphasizedEnter,
|
||||
);
|
||||
_slideIn = Tween<Offset>(
|
||||
begin: const Offset(0, 0.06),
|
||||
end: Offset.zero,
|
||||
).animate(_fadeIn);
|
||||
_entranceController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_entranceController.dispose();
|
||||
_passwordController.removeListener(_updatePasswordStrength);
|
||||
_fullNameController.dispose();
|
||||
_emailController.dispose();
|
||||
|
|
@ -76,196 +97,377 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final officesAsync = ref.watch(officesOnceProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Create Account')),
|
||||
body: ResponsiveBody(
|
||||
maxWidth: 480,
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: SingleChildScrollView(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||
child: FadeTransition(
|
||||
opacity: _fadeIn,
|
||||
child: SlideTransition(
|
||||
position: _slideIn,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset('assets/tasq_ico.png', height: 72, width: 72),
|
||||
const SizedBox(height: 12),
|
||||
// ── Branding ──
|
||||
Hero(
|
||||
tag: 'tasq-logo',
|
||||
child: Image.asset(
|
||||
'assets/tasq_ico.png',
|
||||
height: 80,
|
||||
width: 80,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'TasQ',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
style: tt.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Create your account',
|
||||
style: tt.bodyMedium?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// ── Sign-up card ──
|
||||
Card(
|
||||
elevation: 0,
|
||||
color: cs.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 28,
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Sign up',
|
||||
style: tt.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TextFormField(
|
||||
controller: _fullNameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Full name',
|
||||
prefixIcon: Icon(Icons.person_outlined),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Full name is required.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
prefixIcon: Icon(Icons.email_outlined),
|
||||
),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Email is required.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
prefixIcon: const Icon(Icons.lock_outlined),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
onPressed: () => setState(
|
||||
() => _obscurePassword =
|
||||
!_obscurePassword,
|
||||
),
|
||||
),
|
||||
),
|
||||
obscureText: _obscurePassword,
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Password is required.';
|
||||
}
|
||||
if (value.length < 6) {
|
||||
return 'Use at least 6 characters.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// ── Password strength (AnimatedSize) ──
|
||||
AnimatedSize(
|
||||
duration: M3Motion.short,
|
||||
curve: M3Motion.standard_,
|
||||
child: _passwordController.text.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Strength: ',
|
||||
style: tt.labelSmall
|
||||
?.copyWith(
|
||||
color:
|
||||
cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_passwordStrengthLabel,
|
||||
style: tt.labelSmall?.copyWith(
|
||||
color:
|
||||
_passwordStrengthColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius:
|
||||
BorderRadius.circular(8),
|
||||
child: LinearProgressIndicator(
|
||||
value: _passwordStrength,
|
||||
minHeight: 6,
|
||||
color: _passwordStrengthColor,
|
||||
backgroundColor:
|
||||
cs.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
TextFormField(
|
||||
controller: _confirmPasswordController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Confirm password',
|
||||
prefixIcon: const Icon(Icons.lock_outlined),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscureConfirm
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
onPressed: () => setState(
|
||||
() =>
|
||||
_obscureConfirm = !_obscureConfirm,
|
||||
),
|
||||
),
|
||||
),
|
||||
obscureText: _obscureConfirm,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) {
|
||||
if (!_isLoading) _handleSignUp();
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Confirm your password.';
|
||||
}
|
||||
if (value != _passwordController.text) {
|
||||
return 'Passwords do not match.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Office selection ──
|
||||
Text('Offices', style: tt.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
officesAsync.when(
|
||||
data: (offices) {
|
||||
if (offices.isEmpty) {
|
||||
return Text(
|
||||
'No offices available.',
|
||||
style: tt.bodySmall?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final officeNameById = <String, String>{
|
||||
for (final o in offices) o.id: o.name,
|
||||
};
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _isLoading
|
||||
? null
|
||||
: () =>
|
||||
_showOfficeSelectionDialog(
|
||||
offices,
|
||||
),
|
||||
icon: const Icon(
|
||||
Icons.place_outlined,
|
||||
),
|
||||
label: const Text('Select Offices'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
AnimatedSize(
|
||||
duration: M3Motion.short,
|
||||
curve: M3Motion.standard_,
|
||||
child: _selectedOfficeIds.isEmpty
|
||||
? Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(
|
||||
top: 4,
|
||||
),
|
||||
child: Text(
|
||||
'No office selected.',
|
||||
style: tt.bodySmall
|
||||
?.copyWith(
|
||||
color: cs
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Builder(
|
||||
builder: (context) {
|
||||
final sortedIds =
|
||||
List<String>.from(
|
||||
_selectedOfficeIds,
|
||||
)..sort(
|
||||
(
|
||||
a,
|
||||
b,
|
||||
) => (officeNameById[a] ?? a)
|
||||
.toLowerCase()
|
||||
.compareTo(
|
||||
(officeNameById[b] ??
|
||||
b)
|
||||
.toLowerCase(),
|
||||
),
|
||||
);
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: sortedIds.map((
|
||||
id,
|
||||
) {
|
||||
final name =
|
||||
officeNameById[id] ??
|
||||
id;
|
||||
return Chip(
|
||||
label: Text(name),
|
||||
onDeleted: _isLoading
|
||||
? null
|
||||
: () {
|
||||
setState(
|
||||
() => _selectedOfficeIds
|
||||
.remove(
|
||||
id,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () =>
|
||||
const LinearProgressIndicator(),
|
||||
error: (error, _) =>
|
||||
Text('Failed to load offices: $error'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
M3AnimatedSwitcher(
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
key: ValueKey('loading'),
|
||||
height: 48,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
: FilledButton(
|
||||
key: const ValueKey('sign-up'),
|
||||
onPressed: _handleSignUp,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(
|
||||
48,
|
||||
),
|
||||
),
|
||||
child: const Text('Create Account'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Back to sign in ──
|
||||
TextButton(
|
||||
onPressed: _isLoading
|
||||
? null
|
||||
: () => context.go('/login'),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: 'Already have an account? ',
|
||||
style: tt.bodyMedium?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'Sign in',
|
||||
style: TextStyle(
|
||||
color: cs.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextFormField(
|
||||
controller: _fullNameController,
|
||||
decoration: const InputDecoration(labelText: 'Full name'),
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Full name is required.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(labelText: 'Email'),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Email is required.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(labelText: 'Password'),
|
||||
obscureText: true,
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Password is required.';
|
||||
}
|
||||
if (value.length < 6) {
|
||||
return 'Use at least 6 characters.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Password strength: $_passwordStrengthLabel',
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
LinearProgressIndicator(
|
||||
value: _passwordStrength,
|
||||
minHeight: 8,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: _passwordStrengthColor,
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _confirmPasswordController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Confirm password',
|
||||
),
|
||||
obscureText: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) {
|
||||
if (!_isLoading) {
|
||||
_handleSignUp();
|
||||
}
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Confirm your password.';
|
||||
}
|
||||
if (value != _passwordController.text) {
|
||||
return 'Passwords do not match.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text('Offices', style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
officesAsync.when(
|
||||
data: (offices) {
|
||||
if (offices.isEmpty) {
|
||||
return const Text('No offices available.');
|
||||
}
|
||||
|
||||
final officeNameById = <String, String>{
|
||||
for (final o in offices) o.id: o.name,
|
||||
};
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isLoading
|
||||
? null
|
||||
: () => _showOfficeSelectionDialog(offices),
|
||||
icon: const Icon(Icons.place),
|
||||
label: const Text('Select Offices'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_selectedOfficeIds.isEmpty)
|
||||
const Text('No office selected.')
|
||||
else
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final sortedIds =
|
||||
List<String>.from(_selectedOfficeIds)..sort(
|
||||
(a, b) => (officeNameById[a] ?? a)
|
||||
.toLowerCase()
|
||||
.compareTo(
|
||||
(officeNameById[b] ?? b)
|
||||
.toLowerCase(),
|
||||
),
|
||||
);
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: sortedIds.map((id) {
|
||||
final name = officeNameById[id] ?? id;
|
||||
return Chip(
|
||||
label: Text(name),
|
||||
onDeleted: _isLoading
|
||||
? null
|
||||
: () {
|
||||
setState(
|
||||
() =>
|
||||
_selectedOfficeIds.remove(id),
|
||||
);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
error: (error, _) => Text('Failed to load offices: $error'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _isLoading ? null : _handleSignUp,
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Create Account'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: _isLoading ? null : () => context.go('/login'),
|
||||
child: const Text('Back to sign in'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -274,16 +476,26 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
|||
}
|
||||
|
||||
Future<void> _showOfficeSelectionDialog(List<dynamic> offices) async {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tempSelected = Set<String>.from(_selectedOfficeIds);
|
||||
|
||||
await showDialog<void>(
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogCtx) => StatefulBuilder(
|
||||
builder: (ctx2, setStateDialog) {
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
title: const Text('Select Offices'),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
content: Container(
|
||||
width: 480,
|
||||
height: 400,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: cs.surfaceContainerLowest,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
|
@ -303,7 +515,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
|||
},
|
||||
title: Text(name),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
|
|
@ -314,7 +526,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
|||
onPressed: () => Navigator.of(dialogCtx).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_selectedOfficeIds
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
|
|
@ -319,7 +320,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||
final seen = prefs.getBool('has_seen_notif_showcase') ?? false;
|
||||
if (!seen) {
|
||||
if (!mounted) return;
|
||||
await showDialog<void>(
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Never miss an update'),
|
||||
|
|
@ -469,9 +470,10 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -571,6 +573,7 @@ class _MetricCard extends ConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
// Only watch the single string value for this card so unrelated metric
|
||||
// updates don't rebuild the whole card. This makes updates feel much
|
||||
// smoother and avoids full-page refreshes.
|
||||
|
|
@ -584,37 +587,42 @@ class _MetricCard extends ConsumerWidget {
|
|||
),
|
||||
);
|
||||
|
||||
// M3 Expressive: tonal surface container with 16 dp radius, no hard border.
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
padding: const EdgeInsets.all(16),
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
|
||||
color: cs.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Animate only the metric text (not the whole card) for a
|
||||
// subtle, smooth update.
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
duration: const Duration(milliseconds: 400),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (child, anim) =>
|
||||
FadeTransition(opacity: anim, child: child),
|
||||
child: MonoText(
|
||||
value,
|
||||
key: ValueKey(value),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700),
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -628,12 +636,15 @@ class _StaffTable extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
// M3 Expressive: tonal surface container, 28 dp radius for large containers.
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(AppSurfaces.of(context).cardRadius),
|
||||
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
|
||||
color: cs.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSurfaces.of(context).containerRadius,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
|
|||
|
|
@ -124,11 +124,9 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
|
|||
final title = _notificationTitle(item.type, actorName);
|
||||
final icon = _notificationIcon(item.type);
|
||||
|
||||
// Use a slightly more compact card for dense notification lists
|
||||
// — 12px radius, subtle shadow so the list remains readable.
|
||||
// M3 Expressive: compact card shape, no shadow.
|
||||
return Card(
|
||||
shape: AppSurfaces.of(context).compactShape,
|
||||
shadowColor: AppSurfaces.of(context).compactShadowColor,
|
||||
child: ListTile(
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
ElevatedButton(
|
||||
FilledButton(
|
||||
onPressed: _savingDetails ? null : _onSaveDetails,
|
||||
child: Text(
|
||||
_savingDetails ? 'Saving...' : 'Save details',
|
||||
|
|
@ -176,7 +176,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
ElevatedButton(
|
||||
FilledButton(
|
||||
onPressed: _changingPassword
|
||||
? null
|
||||
: _onChangePassword,
|
||||
|
|
@ -224,7 +224,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
ElevatedButton(
|
||||
FilledButton(
|
||||
onPressed: _savingOffices
|
||||
? null
|
||||
: _onSaveOffices,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../providers/reports_provider.dart';
|
||||
|
|
@ -50,7 +51,7 @@ class ReportDateFilter extends ConsumerWidget {
|
|||
}
|
||||
|
||||
void _showDateFilterDialog(BuildContext context, WidgetRef ref) {
|
||||
showDialog(
|
||||
m3ShowDialog(
|
||||
context: context,
|
||||
builder: (ctx) => _DateFilterDialog(
|
||||
current: ref.read(reportDateRangeProvider),
|
||||
|
|
|
|||
|
|
@ -91,7 +91,10 @@ class ReportCardWrapper extends StatelessWidget {
|
|||
);
|
||||
|
||||
final card = Card(
|
||||
// Rely on CardTheme for elevation (M2 exception in hybrid system).
|
||||
elevation: 0,
|
||||
shadowColor: Colors.transparent,
|
||||
color: colors.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: cardContent,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
|
|
@ -84,8 +84,7 @@ class _RequestTypeChartState extends ConsumerState<RequestTypeChart> {
|
|||
child: PieChart(
|
||||
PieChartData(
|
||||
pieTouchData: PieTouchData(
|
||||
touchCallback:
|
||||
(FlTouchEvent event, pieTouchResponse) {
|
||||
touchCallback: (FlTouchEvent event, pieTouchResponse) {
|
||||
setState(() {
|
||||
if (!event.isInterestedForInteractions ||
|
||||
pieTouchResponse == null ||
|
||||
|
|
@ -93,9 +92,8 @@ class _RequestTypeChartState extends ConsumerState<RequestTypeChart> {
|
|||
_touchedIndex = -1;
|
||||
return;
|
||||
}
|
||||
_touchedIndex = pieTouchResponse
|
||||
.touchedSection!
|
||||
.touchedSectionIndex;
|
||||
_touchedIndex =
|
||||
pieTouchResponse.touchedSection!.touchedSectionIndex;
|
||||
});
|
||||
},
|
||||
),
|
||||
|
|
@ -217,8 +215,7 @@ class _RequestCategoryChartState extends ConsumerState<RequestCategoryChart> {
|
|||
child: PieChart(
|
||||
PieChartData(
|
||||
pieTouchData: PieTouchData(
|
||||
touchCallback:
|
||||
(FlTouchEvent event, pieTouchResponse) {
|
||||
touchCallback: (FlTouchEvent event, pieTouchResponse) {
|
||||
setState(() {
|
||||
if (!event.isInterestedForInteractions ||
|
||||
pieTouchResponse == null ||
|
||||
|
|
@ -226,9 +223,8 @@ class _RequestCategoryChartState extends ConsumerState<RequestCategoryChart> {
|
|||
_touchedIndex = -1;
|
||||
return;
|
||||
}
|
||||
_touchedIndex = pieTouchResponse
|
||||
.touchedSection!
|
||||
.touchedSectionIndex;
|
||||
_touchedIndex =
|
||||
pieTouchResponse.touchedSection!.touchedSectionIndex;
|
||||
});
|
||||
},
|
||||
),
|
||||
|
|
@ -274,7 +270,7 @@ class _RequestCategoryChartState extends ConsumerState<RequestCategoryChart> {
|
|||
}
|
||||
}
|
||||
|
||||
// Shared helpers
|
||||
// Shared helpers
|
||||
|
||||
class _HoverLegendItem extends StatelessWidget {
|
||||
const _HoverLegendItem({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
|
|
@ -58,8 +58,7 @@ class _TicketsByStatusChartState extends ConsumerState<TicketsByStatusChart> {
|
|||
child: PieChart(
|
||||
PieChartData(
|
||||
pieTouchData: PieTouchData(
|
||||
touchCallback:
|
||||
(FlTouchEvent event, pieTouchResponse) {
|
||||
touchCallback: (FlTouchEvent event, pieTouchResponse) {
|
||||
setState(() {
|
||||
if (!event.isInterestedForInteractions ||
|
||||
pieTouchResponse == null ||
|
||||
|
|
@ -85,10 +84,7 @@ class _TicketsByStatusChartState extends ConsumerState<TicketsByStatusChart> {
|
|||
radius: isTouched ? 60 : 50,
|
||||
color: _ticketStatusColor(context, e.status),
|
||||
borderSide: isTouched
|
||||
? const BorderSide(
|
||||
color: Colors.white,
|
||||
width: 2,
|
||||
)
|
||||
? const BorderSide(color: Colors.white, width: 2)
|
||||
: BorderSide.none,
|
||||
);
|
||||
}).toList(),
|
||||
|
|
@ -140,8 +136,7 @@ class TasksByStatusChart extends ConsumerStatefulWidget {
|
|||
final GlobalKey? repaintKey;
|
||||
|
||||
@override
|
||||
ConsumerState<TasksByStatusChart> createState() =>
|
||||
_TasksByStatusChartState();
|
||||
ConsumerState<TasksByStatusChart> createState() => _TasksByStatusChartState();
|
||||
}
|
||||
|
||||
class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
|
||||
|
|
@ -187,8 +182,7 @@ class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
|
|||
child: PieChart(
|
||||
PieChartData(
|
||||
pieTouchData: PieTouchData(
|
||||
touchCallback:
|
||||
(FlTouchEvent event, pieTouchResponse) {
|
||||
touchCallback: (FlTouchEvent event, pieTouchResponse) {
|
||||
setState(() {
|
||||
if (!event.isInterestedForInteractions ||
|
||||
pieTouchResponse == null ||
|
||||
|
|
@ -214,10 +208,7 @@ class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
|
|||
radius: isTouched ? 60 : 50,
|
||||
color: _taskStatusColor(context, e.status),
|
||||
borderSide: isTouched
|
||||
? const BorderSide(
|
||||
color: Colors.white,
|
||||
width: 2,
|
||||
)
|
||||
? const BorderSide(color: Colors.white, width: 2)
|
||||
: BorderSide.none,
|
||||
);
|
||||
}).toList(),
|
||||
|
|
@ -280,7 +271,7 @@ class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
|
|||
}
|
||||
}
|
||||
|
||||
// Shared helpers
|
||||
// Shared helpers
|
||||
|
||||
class _LegendItem extends StatelessWidget {
|
||||
const _LegendItem({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
|
|
@ -84,10 +85,10 @@ class _PermissionsScreenState extends ConsumerState<PermissionsScreen> {
|
|||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
floatingActionButton: M3Fab(
|
||||
onPressed: _refreshStatuses,
|
||||
tooltip: 'Refresh',
|
||||
child: const Icon(Icons.refresh),
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../theme/app_surfaces.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
|
||||
class UnderDevelopmentScreen extends StatelessWidget {
|
||||
|
|
@ -17,34 +16,28 @@ class UnderDevelopmentScreen extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return ResponsiveBody(
|
||||
maxWidth: 720,
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(
|
||||
// M3 Expressive: elevated card with tonal fill, 28 dp radius.
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
padding: const EdgeInsets.all(40),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSurfaces.of(context).dialogRadius,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 36,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
color: cs.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
child: Icon(icon, size: 40, color: cs.onPrimaryContainer),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
|
|
@ -55,26 +48,27 @@ class UnderDevelopmentScreen extends StatelessWidget {
|
|||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: cs.onSurfaceVariant),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
horizontal: 20,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
color: cs.secondaryContainer,
|
||||
),
|
||||
child: Text(
|
||||
'Under development',
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: cs.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// ignore_for_file: use_build_context_synchronously
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
|
|
@ -1919,7 +1920,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
onPressed: () async {
|
||||
final urlCtrl =
|
||||
TextEditingController();
|
||||
final res = await showDialog<String?>(
|
||||
final res = await m3ShowDialog<String?>(
|
||||
context:
|
||||
context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
|
|
@ -2234,7 +2235,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
onPressed: () async {
|
||||
final urlCtrl =
|
||||
TextEditingController();
|
||||
final res = await showDialog<String?>(
|
||||
final res = await m3ShowDialog<String?>(
|
||||
context:
|
||||
context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
|
|
@ -2848,7 +2849,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
child: SizedBox(
|
||||
width: 280,
|
||||
child: Card(
|
||||
elevation: 4,
|
||||
elevation: 0,
|
||||
shadowColor: Colors.transparent,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Row(
|
||||
|
|
@ -3779,7 +3781,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
Timer? titleTypingTimer;
|
||||
|
||||
try {
|
||||
await showDialog<void>(
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
var saving = false;
|
||||
|
|
@ -3999,7 +4001,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
FilledButton(
|
||||
onPressed: saving
|
||||
? null
|
||||
: () async {
|
||||
|
|
@ -4174,7 +4176,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
// If cancelling, require a reason — show dialog with spinner.
|
||||
if (value == 'cancelled') {
|
||||
final reasonCtrl = TextEditingController();
|
||||
await showDialog<void>(
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
var isSaving = false;
|
||||
|
|
@ -4399,7 +4401,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
if (!mounted) return;
|
||||
|
||||
// Show loading dialog
|
||||
showDialog(
|
||||
m3ShowDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext dialogContext) {
|
||||
|
|
@ -4543,7 +4545,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
|
||||
Future<void> _deleteTaskAttachment(String taskId, String fileName) async {
|
||||
try {
|
||||
final confirmed = await showDialog<bool>(
|
||||
final confirmed = await m3ShowDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Delete Attachment?'),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter_quill/flutter_quill.dart' as quill;
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
|
|
@ -495,7 +496,7 @@ Future<void> showTaskPdfPreview(
|
|||
List<TaskAssignment> assignments,
|
||||
List<Profile> profiles,
|
||||
) async {
|
||||
await showDialog<void>(
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => TaskPdfDialog(
|
||||
task: task,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||
|
|
@ -554,7 +555,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
|||
right: 16,
|
||||
bottom: 16,
|
||||
child: SafeArea(
|
||||
child: FloatingActionButton.extended(
|
||||
child: M3ExpandedFab(
|
||||
onPressed: () => _showCreateTaskDialog(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New Task'),
|
||||
|
|
@ -588,7 +589,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
|||
var showTitleGemini = false;
|
||||
Timer? titleTypingTimer;
|
||||
|
||||
await showDialog<void>(
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
bool saving = false;
|
||||
|
|
@ -1099,8 +1100,11 @@ class _StatusSummaryCard extends StatelessWidget {
|
|||
_ => scheme.onSurfaceVariant,
|
||||
};
|
||||
|
||||
// M3 Expressive: filled card with semantic tonal color, no shadow.
|
||||
return Card(
|
||||
color: background,
|
||||
elevation: 0,
|
||||
shadowColor: Colors.transparent,
|
||||
// summary cards are compact — use compact token for consistent density
|
||||
shape: AppSurfaces.of(context).compactShape,
|
||||
child: Padding(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../models/office.dart';
|
||||
import '../../models/profile.dart';
|
||||
|
|
@ -257,10 +258,10 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
|
|||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
floatingActionButton: M3Fab(
|
||||
onPressed: () => _showTeamDialog(context),
|
||||
tooltip: 'Add Team',
|
||||
child: const Icon(Icons.add),
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -520,10 +521,9 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
|
|||
|
||||
if (isMobileDialog) {
|
||||
// Mobile: bottom sheet presentation
|
||||
await showModalBottomSheet<void>(
|
||||
await m3ShowBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: AppSurfaces.of(context).dialogShape,
|
||||
builder: (sheetContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (sheetContext, setState) {
|
||||
|
|
@ -555,7 +555,7 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
|
|||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
FilledButton(
|
||||
onPressed: () => onSave(setState, navigator),
|
||||
child: Text(isEdit ? 'Save' : 'Add'),
|
||||
),
|
||||
|
|
@ -571,7 +571,7 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
|
|||
);
|
||||
} else {
|
||||
// Desktop / Tablet: centered fixed-width AlertDialog
|
||||
await showDialog<void>(
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return Center(
|
||||
|
|
@ -589,7 +589,7 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
|
|||
onPressed: () => navigator.pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
FilledButton(
|
||||
onPressed: () => onSave(setState, navigator),
|
||||
child: Text(isEdit ? 'Save' : 'Add'),
|
||||
),
|
||||
|
|
@ -605,7 +605,7 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
|
|||
}
|
||||
|
||||
void _deleteTeam(BuildContext context, String teamId) async {
|
||||
final confirmed = await showDialog<bool?>(
|
||||
final confirmed = await m3ShowDialog<bool?>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
shape: AppSurfaces.of(dialogContext).dialogShape,
|
||||
|
|
@ -619,7 +619,7 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
|
|||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import 'package:tasq/utils/app_time.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
|
@ -858,7 +859,7 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||
}
|
||||
|
||||
Future<void> _showTimelineDialog(BuildContext context, Ticket ticket) async {
|
||||
await showDialog<void>(
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
|
|
@ -897,7 +898,7 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||
final descCtrl = TextEditingController(text: ticket.description);
|
||||
String? selectedOffice = ticket.officeId;
|
||||
|
||||
await showDialog<void>(
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
var saving = false;
|
||||
|
|
@ -967,7 +968,7 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||
: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
FilledButton(
|
||||
onPressed: saving
|
||||
? null
|
||||
: () async {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tasq/utils/app_time.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
|
@ -341,7 +342,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
|||
right: 16,
|
||||
bottom: 16,
|
||||
child: SafeArea(
|
||||
child: FloatingActionButton.extended(
|
||||
child: M3ExpandedFab(
|
||||
onPressed: () => _showCreateTicketDialog(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New Ticket'),
|
||||
|
|
@ -361,7 +362,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
|||
final descriptionController = TextEditingController();
|
||||
Office? selectedOffice;
|
||||
|
||||
await showDialog<void>(
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
bool saving = false;
|
||||
|
|
@ -644,8 +645,11 @@ class _StatusSummaryCard extends StatelessWidget {
|
|||
_ => scheme.onSurfaceVariant,
|
||||
};
|
||||
|
||||
// M3 Expressive: filled card with semantic tonal color, no shadow.
|
||||
return Card(
|
||||
color: background,
|
||||
elevation: 0,
|
||||
shadowColor: Colors.transparent,
|
||||
// summary cards are compact — use compact token for consistent density
|
||||
shape: AppSurfaces.of(context).compactShape,
|
||||
child: Padding(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tasq/utils/app_time.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
|
@ -451,7 +452,7 @@ class _ScheduleTile extends ConsumerWidget {
|
|||
List<DutySchedule> recipientShifts = [];
|
||||
String? selectedTargetShiftId;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
final confirmed = await m3ShowDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return StatefulBuilder(
|
||||
|
|
@ -614,7 +615,7 @@ class _ScheduleTile extends ConsumerWidget {
|
|||
required String title,
|
||||
required String message,
|
||||
}) async {
|
||||
await showDialog<void>(
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
|
|
@ -633,7 +634,7 @@ class _ScheduleTile extends ConsumerWidget {
|
|||
|
||||
Future<BuildContext> _showCheckInProgress(BuildContext context) {
|
||||
final completer = Completer<BuildContext>();
|
||||
showDialog<void>(
|
||||
m3ShowDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) {
|
||||
|
|
@ -1140,7 +1141,7 @@ class _ScheduleGeneratorPanelState
|
|||
existing?.endTime ?? start.add(const Duration(hours: 8)),
|
||||
);
|
||||
|
||||
final result = await showDialog<_DraftSchedule>(
|
||||
final result = await m3ShowDialog<_DraftSchedule>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return StatefulBuilder(
|
||||
|
|
@ -2002,7 +2003,7 @@ class _SwapRequestsPanel extends ConsumerWidget {
|
|||
}
|
||||
|
||||
Profile? choice = eligible.first;
|
||||
final selected = await showDialog<Profile?>(
|
||||
final selected = await m3ShowDialog<Profile?>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
|
|
|
|||
|
|
@ -1,22 +1,34 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
/// M3 Expressive surface tokens.
|
||||
///
|
||||
/// Cards now use **tonal elevation** (color tints) instead of drop-shadows.
|
||||
/// Large containers adopt the M3 standard 28 dp corner radius; compact items
|
||||
/// use 16 dp; small chips/badges use 12 dp.
|
||||
@immutable
|
||||
class AppSurfaces extends ThemeExtension<AppSurfaces> {
|
||||
const AppSurfaces({
|
||||
required this.cardRadius,
|
||||
required this.compactCardRadius,
|
||||
required this.containerRadius,
|
||||
required this.dialogRadius,
|
||||
required this.cardElevation,
|
||||
required this.cardShadowColor,
|
||||
required this.compactShadowColor,
|
||||
required this.chipRadius,
|
||||
});
|
||||
|
||||
/// Standard card radius – 16 dp (M3 medium shape).
|
||||
final double cardRadius;
|
||||
|
||||
/// Compact card radius for dense list tiles – 12 dp.
|
||||
final double compactCardRadius;
|
||||
|
||||
/// Large container radius – 28 dp (M3 Expressive).
|
||||
final double containerRadius;
|
||||
|
||||
/// Dialog / bottom-sheet radius – 28 dp.
|
||||
final double dialogRadius;
|
||||
final double cardElevation;
|
||||
final Color cardShadowColor;
|
||||
final Color compactShadowColor;
|
||||
|
||||
/// Chip / badge radius – 12 dp.
|
||||
final double chipRadius;
|
||||
|
||||
// convenience shapes
|
||||
RoundedRectangleBorder get standardShape =>
|
||||
|
|
@ -24,6 +36,9 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
|
|||
RoundedRectangleBorder get compactShape => RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(compactCardRadius),
|
||||
);
|
||||
RoundedRectangleBorder get containerShape => RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(containerRadius),
|
||||
);
|
||||
RoundedRectangleBorder get dialogShape =>
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(dialogRadius));
|
||||
|
||||
|
|
@ -33,10 +48,9 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
|
|||
const AppSurfaces(
|
||||
cardRadius: 16,
|
||||
compactCardRadius: 12,
|
||||
dialogRadius: 20,
|
||||
cardElevation: 3,
|
||||
cardShadowColor: Color.fromRGBO(0, 0, 0, 0.12),
|
||||
compactShadowColor: Color.fromRGBO(0, 0, 0, 0.08),
|
||||
containerRadius: 28,
|
||||
dialogRadius: 28,
|
||||
chipRadius: 12,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -44,18 +58,16 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
|
|||
AppSurfaces copyWith({
|
||||
double? cardRadius,
|
||||
double? compactCardRadius,
|
||||
double? containerRadius,
|
||||
double? dialogRadius,
|
||||
double? cardElevation,
|
||||
Color? cardShadowColor,
|
||||
Color? compactShadowColor,
|
||||
double? chipRadius,
|
||||
}) {
|
||||
return AppSurfaces(
|
||||
cardRadius: cardRadius ?? this.cardRadius,
|
||||
compactCardRadius: compactCardRadius ?? this.compactCardRadius,
|
||||
containerRadius: containerRadius ?? this.containerRadius,
|
||||
dialogRadius: dialogRadius ?? this.dialogRadius,
|
||||
cardElevation: cardElevation ?? this.cardElevation,
|
||||
cardShadowColor: cardShadowColor ?? this.cardShadowColor,
|
||||
compactShadowColor: compactShadowColor ?? this.compactShadowColor,
|
||||
chipRadius: chipRadius ?? this.chipRadius,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -63,28 +75,17 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
|
|||
AppSurfaces lerp(ThemeExtension<AppSurfaces>? other, double t) {
|
||||
if (other is! AppSurfaces) return this;
|
||||
return AppSurfaces(
|
||||
cardRadius: lerpDouble(cardRadius, other.cardRadius, t) ?? cardRadius,
|
||||
compactCardRadius:
|
||||
lerpDouble(compactCardRadius, other.compactCardRadius, t) ??
|
||||
compactCardRadius,
|
||||
dialogRadius:
|
||||
lerpDouble(dialogRadius, other.dialogRadius, t) ?? dialogRadius,
|
||||
cardElevation:
|
||||
lerpDouble(cardElevation, other.cardElevation, t) ?? cardElevation,
|
||||
cardShadowColor:
|
||||
Color.lerp(cardShadowColor, other.cardShadowColor, t) ??
|
||||
cardShadowColor,
|
||||
compactShadowColor:
|
||||
Color.lerp(compactShadowColor, other.compactShadowColor, t) ??
|
||||
compactShadowColor,
|
||||
cardRadius: _lerpDouble(cardRadius, other.cardRadius, t),
|
||||
compactCardRadius: _lerpDouble(
|
||||
compactCardRadius,
|
||||
other.compactCardRadius,
|
||||
t,
|
||||
),
|
||||
containerRadius: _lerpDouble(containerRadius, other.containerRadius, t),
|
||||
dialogRadius: _lerpDouble(dialogRadius, other.dialogRadius, t),
|
||||
chipRadius: _lerpDouble(chipRadius, other.chipRadius, t),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper because dart:ui lerpDouble isn't exported here
|
||||
double? lerpDouble(num? a, num? b, double t) {
|
||||
if (a == null && b == null) return null;
|
||||
a = a ?? 0;
|
||||
b = b ?? 0;
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
double _lerpDouble(double a, double b, double t) => a + (b - a) * t;
|
||||
|
|
|
|||
|
|
@ -4,143 +4,59 @@ import 'package:google_fonts/google_fonts.dart';
|
|||
import 'app_typography.dart';
|
||||
import 'app_surfaces.dart';
|
||||
|
||||
/// M3 Expressive theme for TasQ.
|
||||
///
|
||||
/// Key differences from the previous Hybrid M2/M3 theme:
|
||||
/// * Cards use **tonal elevation** (surfaceTint color overlays) instead of
|
||||
/// drop-shadows, giving surfaces an organic, seed-tinted look.
|
||||
/// * Large containers use the M3 standard **28 dp** corner radius.
|
||||
/// * Buttons follow the M3 hierarchy: FilledButton (primary), Tonal, Elevated,
|
||||
/// Outlined, and Text.
|
||||
/// * NavigationBar / NavigationRail use pill-shaped indicators with the
|
||||
/// secondary-container tonal color.
|
||||
/// * Spring-physics inspired durations: transitions default to 400 ms with an
|
||||
/// emphasized easing curve.
|
||||
class AppTheme {
|
||||
/// The seed color drives M3's entire tonal palette generation.
|
||||
static const Color _seed = Color(0xFF4A6FA5);
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// LIGHT
|
||||
// ────────────────────────────────────────────────────────────
|
||||
static ThemeData light() {
|
||||
final base = ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF334155),
|
||||
seedColor: _seed,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
useMaterial3: true,
|
||||
);
|
||||
|
||||
final textTheme = GoogleFonts.spaceGroteskTextTheme(base.textTheme);
|
||||
final monoTheme = GoogleFonts.robotoMonoTextTheme(base.textTheme);
|
||||
final mono = AppMonoText(
|
||||
label:
|
||||
monoTheme.labelMedium?.copyWith(letterSpacing: 0.3) ??
|
||||
const TextStyle(letterSpacing: 0.3),
|
||||
body:
|
||||
monoTheme.bodyMedium?.copyWith(letterSpacing: 0.2) ??
|
||||
const TextStyle(letterSpacing: 0.2),
|
||||
);
|
||||
|
||||
final surfaces = AppSurfaces(
|
||||
cardRadius: 16,
|
||||
compactCardRadius: 12,
|
||||
dialogRadius: 20,
|
||||
cardElevation: 3,
|
||||
cardShadowColor: const Color.fromRGBO(0, 0, 0, 0.12),
|
||||
compactShadowColor: const Color.fromRGBO(0, 0, 0, 0.08),
|
||||
);
|
||||
|
||||
return base.copyWith(
|
||||
textTheme: textTheme,
|
||||
scaffoldBackgroundColor: base.colorScheme.surfaceContainerLowest,
|
||||
extensions: [mono, surfaces],
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: base.colorScheme.surface,
|
||||
foregroundColor: base.colorScheme.onSurface,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 1,
|
||||
surfaceTintColor: base.colorScheme.surfaceTint,
|
||||
centerTitle: false,
|
||||
titleTextStyle: textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: base.colorScheme.surface,
|
||||
elevation: 3, // M2-style elevation for visible separation (2-4 allowed)
|
||||
margin: EdgeInsets.zero,
|
||||
shadowColor: const Color.fromRGBO(0, 0, 0, 0.12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: base.colorScheme.outlineVariant, width: 1),
|
||||
),
|
||||
),
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: base.colorScheme.surfaceContainerHighest,
|
||||
side: BorderSide(color: base.colorScheme.outlineVariant),
|
||||
labelStyle: textTheme.labelSmall,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
dividerTheme: DividerThemeData(
|
||||
color: base.colorScheme.outlineVariant,
|
||||
thickness: 1,
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: base.colorScheme.surfaceContainerLow,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: base.colorScheme.outlineVariant),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: base.colorScheme.outlineVariant),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: base.colorScheme.primary, width: 1.5),
|
||||
),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
side: BorderSide(color: base.colorScheme.outline),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||
),
|
||||
),
|
||||
navigationDrawerTheme: NavigationDrawerThemeData(
|
||||
backgroundColor: base.colorScheme.surface,
|
||||
indicatorColor: base.colorScheme.secondaryContainer,
|
||||
tileHeight: 52,
|
||||
),
|
||||
navigationRailTheme: NavigationRailThemeData(
|
||||
backgroundColor: base.colorScheme.surface,
|
||||
selectedIconTheme: IconThemeData(color: base.colorScheme.primary),
|
||||
selectedLabelTextStyle: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
unselectedIconTheme: IconThemeData(color: base.colorScheme.onSurface),
|
||||
indicatorColor: base.colorScheme.secondaryContainer,
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: base.colorScheme.surface,
|
||||
indicatorColor: base.colorScheme.primaryContainer,
|
||||
labelTextStyle: WidgetStateProperty.all(
|
||||
textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
listTileTheme: ListTileThemeData(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
tileColor: base.colorScheme.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
),
|
||||
);
|
||||
return _apply(base);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// DARK
|
||||
// ────────────────────────────────────────────────────────────
|
||||
static ThemeData dark() {
|
||||
final base = ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF334155),
|
||||
seedColor: _seed,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
);
|
||||
|
||||
return _apply(base);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// SHARED BUILDER
|
||||
// ────────────────────────────────────────────────────────────
|
||||
static ThemeData _apply(ThemeData base) {
|
||||
final cs = base.colorScheme;
|
||||
final isDark = cs.brightness == Brightness.dark;
|
||||
|
||||
final textTheme = GoogleFonts.spaceGroteskTextTheme(base.textTheme);
|
||||
final monoTheme = GoogleFonts.robotoMonoTextTheme(base.textTheme);
|
||||
final mono = AppMonoText(
|
||||
|
|
@ -152,110 +68,261 @@ class AppTheme {
|
|||
const TextStyle(letterSpacing: 0.2),
|
||||
);
|
||||
|
||||
final surfaces = AppSurfaces(
|
||||
const surfaces = AppSurfaces(
|
||||
cardRadius: 16,
|
||||
compactCardRadius: 12,
|
||||
dialogRadius: 20,
|
||||
cardElevation: 3,
|
||||
cardShadowColor: const Color.fromRGBO(0, 0, 0, 0.24),
|
||||
compactShadowColor: const Color.fromRGBO(0, 0, 0, 0.12),
|
||||
containerRadius: 28,
|
||||
dialogRadius: 28,
|
||||
chipRadius: 12,
|
||||
);
|
||||
|
||||
return base.copyWith(
|
||||
textTheme: textTheme,
|
||||
scaffoldBackgroundColor: base.colorScheme.surface,
|
||||
scaffoldBackgroundColor: isDark ? cs.surface : cs.surfaceContainerLowest,
|
||||
extensions: [mono, surfaces],
|
||||
|
||||
// ── AppBar ──────────────────────────────────────────────
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: base.colorScheme.surface,
|
||||
foregroundColor: base.colorScheme.onSurface,
|
||||
backgroundColor: cs.surface,
|
||||
foregroundColor: cs.onSurface,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 1,
|
||||
surfaceTintColor: base.colorScheme.surfaceTint,
|
||||
scrolledUnderElevation: 2,
|
||||
surfaceTintColor: cs.surfaceTint,
|
||||
centerTitle: false,
|
||||
titleTextStyle: textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
|
||||
// ── Cards — M3 Elevated (tonal surface tint, no hard shadow) ──
|
||||
cardTheme: CardThemeData(
|
||||
color: base.colorScheme.surfaceContainer,
|
||||
elevation: 3, // M2-style elevation for visible separation (2-4 allowed)
|
||||
color: isDark ? cs.surfaceContainer : cs.surfaceContainerLow,
|
||||
elevation: 1,
|
||||
margin: EdgeInsets.zero,
|
||||
shadowColor: const Color.fromRGBO(0, 0, 0, 0.24),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: base.colorScheme.outlineVariant, width: 1),
|
||||
),
|
||||
shadowColor: Colors.transparent,
|
||||
surfaceTintColor: cs.surfaceTint,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
|
||||
// ── Chips ───────────────────────────────────────────────
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: base.colorScheme.surfaceContainerHighest,
|
||||
side: BorderSide(color: base.colorScheme.outlineVariant),
|
||||
backgroundColor: cs.surfaceContainerHighest,
|
||||
side: BorderSide.none,
|
||||
labelStyle: textTheme.labelSmall,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
dividerTheme: DividerThemeData(
|
||||
color: base.colorScheme.outlineVariant,
|
||||
thickness: 1,
|
||||
),
|
||||
|
||||
// ── Dividers ────────────────────────────────────────────
|
||||
dividerTheme: DividerThemeData(color: cs.outlineVariant, thickness: 1),
|
||||
|
||||
// ── Input Fields ────────────────────────────────────────
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: base.colorScheme.surfaceContainerLow,
|
||||
fillColor: cs.surfaceContainerLow,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: base.colorScheme.outlineVariant),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(color: cs.outlineVariant),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: base.colorScheme.outlineVariant),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(color: cs.outlineVariant),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: base.colorScheme.primary, width: 1.5),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(color: cs.primary, width: 2),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
|
||||
// ── Buttons — M3 Expressive hierarchy ───────────────────
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
textStyle: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: cs.surfaceContainerLow,
|
||||
foregroundColor: cs.primary,
|
||||
elevation: 1,
|
||||
shadowColor: Colors.transparent,
|
||||
surfaceTintColor: cs.surfaceTint,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
textStyle: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
side: BorderSide(color: cs.outline),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||
textStyle: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
textStyle: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
iconButtonTheme: IconButtonThemeData(
|
||||
style: IconButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
side: BorderSide(color: base.colorScheme.outline),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||
),
|
||||
),
|
||||
segmentedButtonTheme: SegmentedButtonThemeData(
|
||||
style: ButtonStyle(
|
||||
shape: WidgetStateProperty.all(
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── FAB — M3 Expressive ──────────────────────────────────
|
||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||
backgroundColor: cs.primaryContainer,
|
||||
foregroundColor: cs.onPrimaryContainer,
|
||||
elevation: 3,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
|
||||
// ── Navigation — M3 Expressive pill indicators ──────────
|
||||
navigationDrawerTheme: NavigationDrawerThemeData(
|
||||
backgroundColor: base.colorScheme.surface,
|
||||
indicatorColor: base.colorScheme.secondaryContainer,
|
||||
tileHeight: 52,
|
||||
backgroundColor: cs.surface,
|
||||
indicatorColor: cs.secondaryContainer,
|
||||
tileHeight: 56,
|
||||
indicatorShape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
),
|
||||
navigationRailTheme: NavigationRailThemeData(
|
||||
backgroundColor: base.colorScheme.surface,
|
||||
selectedIconTheme: IconThemeData(color: base.colorScheme.primary),
|
||||
selectedLabelTextStyle: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
backgroundColor: cs.surface,
|
||||
selectedIconTheme: IconThemeData(color: cs.onSecondaryContainer),
|
||||
unselectedIconTheme: IconThemeData(color: cs.onSurfaceVariant),
|
||||
selectedLabelTextStyle: textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
unselectedLabelTextStyle: textTheme.labelMedium?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
indicatorColor: cs.secondaryContainer,
|
||||
indicatorShape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
unselectedIconTheme: IconThemeData(color: base.colorScheme.onSurface),
|
||||
indicatorColor: base.colorScheme.secondaryContainer,
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: base.colorScheme.surface,
|
||||
indicatorColor: base.colorScheme.primaryContainer,
|
||||
labelTextStyle: WidgetStateProperty.all(
|
||||
textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
backgroundColor: cs.surfaceContainer,
|
||||
indicatorColor: cs.secondaryContainer,
|
||||
indicatorShape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
||||
final selected = states.contains(WidgetState.selected);
|
||||
return textTheme.labelMedium?.copyWith(
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: selected ? cs.onSurface : cs.onSurfaceVariant,
|
||||
);
|
||||
}),
|
||||
elevation: 2,
|
||||
surfaceTintColor: cs.surfaceTint,
|
||||
),
|
||||
|
||||
// ── List Tiles ──────────────────────────────────────────
|
||||
listTileTheme: ListTileThemeData(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
tileColor: base.colorScheme.surfaceContainer,
|
||||
tileColor: Colors.transparent,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
),
|
||||
|
||||
// ── Dialogs ─────────────────────────────────────────────
|
||||
dialogTheme: DialogThemeData(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||
surfaceTintColor: cs.surfaceTint,
|
||||
backgroundColor: isDark
|
||||
? cs.surfaceContainerHigh
|
||||
: cs.surfaceContainerLowest,
|
||||
),
|
||||
|
||||
// ── Bottom Sheets ───────────────────────────────────────
|
||||
bottomSheetTheme: BottomSheetThemeData(
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
backgroundColor: isDark
|
||||
? cs.surfaceContainerHigh
|
||||
: cs.surfaceContainerLowest,
|
||||
surfaceTintColor: cs.surfaceTint,
|
||||
showDragHandle: true,
|
||||
),
|
||||
|
||||
// ── Snackbar ────────────────────────────────────────────
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
|
||||
// ── Search Bar ──────────────────────────────────────────
|
||||
searchBarTheme: SearchBarThemeData(
|
||||
elevation: WidgetStateProperty.all(0),
|
||||
backgroundColor: WidgetStateProperty.all(cs.surfaceContainerHigh),
|
||||
shape: WidgetStateProperty.all(
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Tooltips ────────────────────────────────────────────
|
||||
tooltipTheme: TooltipThemeData(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.inverseSurface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
textStyle: textTheme.bodySmall?.copyWith(color: cs.onInverseSurface),
|
||||
),
|
||||
|
||||
// ── Tab Bar ─────────────────────────────────────────────
|
||||
tabBarTheme: TabBarThemeData(
|
||||
labelStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
unselectedLabelStyle: textTheme.labelLarge,
|
||||
indicatorColor: cs.primary,
|
||||
labelColor: cs.primary,
|
||||
unselectedLabelColor: cs.onSurfaceVariant,
|
||||
indicatorSize: TabBarIndicatorSize.label,
|
||||
dividerColor: cs.outlineVariant,
|
||||
),
|
||||
|
||||
// ── PopupMenu ───────────────────────────────────────────
|
||||
popupMenuTheme: PopupMenuThemeData(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
surfaceTintColor: cs.surfaceTint,
|
||||
color: isDark ? cs.surfaceContainerHigh : cs.surfaceContainerLowest,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
516
lib/theme/m3_motion.dart
Normal file
516
lib/theme/m3_motion.dart
Normal file
|
|
@ -0,0 +1,516 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// M3 Expressive motion constants and helpers.
|
||||
///
|
||||
/// Transitions use spring-physics inspired curves with an emphasized easing
|
||||
/// feel. Duration targets are tuned for fluidity:
|
||||
/// * **Micro** (150 ms): status-pill toggles, icon swaps.
|
||||
/// * **Short** (250 ms): list item reveal, chip state changes.
|
||||
/// * **Standard** (400 ms): page transitions, container expansions.
|
||||
/// * **Long** (550 ms): full-page shared-axis transitions.
|
||||
class M3Motion {
|
||||
M3Motion._();
|
||||
|
||||
// ── Durations ──────────────────────────────────────────────
|
||||
static const Duration micro = Duration(milliseconds: 150);
|
||||
static const Duration short = Duration(milliseconds: 250);
|
||||
static const Duration standard = Duration(milliseconds: 400);
|
||||
static const Duration long = Duration(milliseconds: 550);
|
||||
|
||||
// ── Curves (M3 Expressive) ─────────────────────────────────
|
||||
/// Emphasized enter – starts slow then accelerates.
|
||||
static const Curve emphasizedEnter = Curves.easeOutCubic;
|
||||
|
||||
/// Emphasized exit – decelerates to a stop.
|
||||
static const Curve emphasizedExit = Curves.easeInCubic;
|
||||
|
||||
/// Standard easing for most container transforms.
|
||||
static const Curve standard_ = Curves.easeInOutCubicEmphasized;
|
||||
|
||||
/// Spring-physics inspired curve for bouncy interactions.
|
||||
static const Curve spring = _SpringCurve();
|
||||
|
||||
/// M3 Expressive emphasized decelerate — the hero curve for enter motions.
|
||||
static const Curve expressiveDecelerate = Cubic(0.05, 0.7, 0.1, 1.0);
|
||||
|
||||
/// M3 Expressive emphasized accelerate — for exit motions.
|
||||
static const Curve expressiveAccelerate = Cubic(0.3, 0.0, 0.8, 0.15);
|
||||
}
|
||||
|
||||
/// A simple spring-physics curve that produces a slight overshoot.
|
||||
class _SpringCurve extends Curve {
|
||||
const _SpringCurve();
|
||||
|
||||
@override
|
||||
double transformInternal(double t) {
|
||||
// Attempt a more natural spring feel: slight overshoot then settle.
|
||||
// Based on damped harmonic oscillator approximation.
|
||||
const damping = 0.7;
|
||||
const freq = 3.5;
|
||||
return 1.0 -
|
||||
math.pow(math.e, -damping * freq * t) *
|
||||
math.cos(freq * math.sqrt(1 - damping * damping) * t * math.pi);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps a child with a staggered fade + slide-up entrance animation.
|
||||
///
|
||||
/// Use inside lists for sequential reveal of items.
|
||||
class M3FadeSlideIn extends StatefulWidget {
|
||||
const M3FadeSlideIn({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.delay = Duration.zero,
|
||||
this.duration = const Duration(milliseconds: 400),
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Duration delay;
|
||||
final Duration duration;
|
||||
|
||||
@override
|
||||
State<M3FadeSlideIn> createState() => _M3FadeSlideInState();
|
||||
}
|
||||
|
||||
class _M3FadeSlideInState extends State<M3FadeSlideIn>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
late final Animation<double> _opacity;
|
||||
late final Animation<Offset> _slide;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(vsync: this, duration: widget.duration);
|
||||
_opacity = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: M3Motion.emphasizedEnter,
|
||||
);
|
||||
_slide = Tween<Offset>(begin: const Offset(0, 0.04), end: Offset.zero)
|
||||
.animate(
|
||||
CurvedAnimation(parent: _controller, curve: M3Motion.emphasizedEnter),
|
||||
);
|
||||
|
||||
if (widget.delay == Duration.zero) {
|
||||
_controller.forward();
|
||||
} else {
|
||||
Future.delayed(widget.delay, () {
|
||||
if (mounted) _controller.forward();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadeTransition(
|
||||
opacity: _opacity,
|
||||
child: SlideTransition(position: _slide, child: widget.child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A page-route that uses a shared-axis (vertical) transition.
|
||||
///
|
||||
/// ```dart
|
||||
/// Navigator.of(context).push(M3SharedAxisRoute(child: DetailScreen()));
|
||||
/// ```
|
||||
class M3SharedAxisRoute<T> extends PageRouteBuilder<T> {
|
||||
M3SharedAxisRoute({required this.child})
|
||||
: super(
|
||||
transitionDuration: M3Motion.standard,
|
||||
reverseTransitionDuration: M3Motion.short,
|
||||
pageBuilder: (_, a, b) => child,
|
||||
transitionsBuilder: _fadeThroughBuilder,
|
||||
);
|
||||
|
||||
final Widget child;
|
||||
|
||||
static Widget _fadeThroughBuilder(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) {
|
||||
return _M3FadeThrough(
|
||||
animation: animation,
|
||||
secondaryAnimation: secondaryAnimation,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── GoRouter-compatible transition pages ─────────────────────
|
||||
|
||||
/// A [CustomTransitionPage] that performs a container-transform-style
|
||||
/// transition: the incoming page fades in + scales up smoothly while the
|
||||
/// outgoing page fades through. Uses M3 Expressive emphasized curves for
|
||||
/// a fluid, spring-like feel.
|
||||
///
|
||||
/// Use this for card → detail navigations (tickets, tasks).
|
||||
class M3ContainerTransformPage<T> extends CustomTransitionPage<T> {
|
||||
const M3ContainerTransformPage({
|
||||
required super.child,
|
||||
super.key,
|
||||
super.name,
|
||||
super.arguments,
|
||||
super.restorationId,
|
||||
}) : super(
|
||||
transitionDuration: M3Motion.standard,
|
||||
reverseTransitionDuration: M3Motion.standard,
|
||||
transitionsBuilder: _containerTransformBuilder,
|
||||
);
|
||||
|
||||
static Widget _containerTransformBuilder(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) {
|
||||
// M3 Expressive: Fade-through with scale. The outgoing content fades out
|
||||
// in the first 35 % of the duration, then the incoming content fades in
|
||||
// with a subtle scale from 94 % → 100 %. This prevents the "double image"
|
||||
// overlap that makes transitions feel choppy.
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: Listenable.merge([animation, secondaryAnimation]),
|
||||
builder: (context, _) {
|
||||
// ── Forward / reverse animation ──
|
||||
final t = animation.value;
|
||||
final curved = M3Motion.expressiveDecelerate.transform(t);
|
||||
|
||||
// Outgoing: when this page is being moved out by a NEW incoming page
|
||||
final st = secondaryAnimation.value;
|
||||
|
||||
// Scale: 0.94 → 1.0 on enter, 1.0 → 0.96 on exit-by-secondary
|
||||
final scale = 1.0 - (1.0 - curved) * 0.06;
|
||||
final secondaryScale = 1.0 - st * 0.04;
|
||||
|
||||
// Opacity: fade in first 40 %, stay 1.0 rest. On secondary, fade first 30 %.
|
||||
final opacity = (t / 0.4).clamp(0.0, 1.0);
|
||||
final secondaryOpacity = (1.0 - (st / 0.3).clamp(0.0, 1.0));
|
||||
|
||||
return Opacity(
|
||||
opacity: secondaryOpacity,
|
||||
child: Transform.scale(
|
||||
scale: secondaryScale,
|
||||
child: Opacity(
|
||||
opacity: opacity,
|
||||
child: Transform.scale(scale: scale, child: child),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A [CustomTransitionPage] implementing the M3 fade-through transition.
|
||||
/// Best for top-level navigation changes within a shell.
|
||||
///
|
||||
/// Uses a proper "fade through" pattern: outgoing fades out first, then
|
||||
/// incoming fades in with a subtle vertical shift. This eliminates the
|
||||
/// "double image" overlap that causes choppiness.
|
||||
class M3SharedAxisPage<T> extends CustomTransitionPage<T> {
|
||||
const M3SharedAxisPage({
|
||||
required super.child,
|
||||
super.key,
|
||||
super.name,
|
||||
super.arguments,
|
||||
super.restorationId,
|
||||
}) : super(
|
||||
transitionDuration: M3Motion.standard,
|
||||
reverseTransitionDuration: M3Motion.short,
|
||||
transitionsBuilder: _sharedAxisBuilder,
|
||||
);
|
||||
|
||||
static Widget _sharedAxisBuilder(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) {
|
||||
return _M3FadeThrough(
|
||||
animation: animation,
|
||||
secondaryAnimation: secondaryAnimation,
|
||||
slideOffset: const Offset(0, 0.02),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Core M3 fade-through transition widget used by both shared-axis and
|
||||
/// route transitions.
|
||||
///
|
||||
/// **Pattern**: outgoing content fades to 0 in the first third → incoming
|
||||
/// content fades from 0 to 1 in the remaining two-thirds with an optional
|
||||
/// subtle slide. This two-phase approach prevents "ghosting".
|
||||
class _M3FadeThrough extends StatelessWidget {
|
||||
const _M3FadeThrough({
|
||||
required this.animation,
|
||||
required this.secondaryAnimation,
|
||||
required this.child,
|
||||
this.slideOffset = Offset.zero,
|
||||
});
|
||||
|
||||
final Animation<double> animation;
|
||||
final Animation<double> secondaryAnimation;
|
||||
final Widget child;
|
||||
final Offset slideOffset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: Listenable.merge([animation, secondaryAnimation]),
|
||||
builder: (context, _) {
|
||||
final t = animation.value;
|
||||
final st = secondaryAnimation.value;
|
||||
|
||||
// Incoming: fade in from t=0.3..1.0, with decelerate curve
|
||||
final enterT = ((t - 0.3) / 0.7).clamp(0.0, 1.0);
|
||||
final enterOpacity = M3Motion.expressiveDecelerate.transform(enterT);
|
||||
|
||||
// Outgoing: when THIS page is pushed off by a new page, fade first 35 %
|
||||
final exitOpacity = (1.0 - (st / 0.35).clamp(0.0, 1.0));
|
||||
|
||||
// Slide: subtle vertical offset on enter
|
||||
final slideY = slideOffset.dy * (1.0 - enterT);
|
||||
final slideX = slideOffset.dx * (1.0 - enterT);
|
||||
|
||||
return Opacity(
|
||||
opacity: exitOpacity,
|
||||
child: Transform.translate(
|
||||
offset: Offset(slideX * 100, slideY * 100),
|
||||
child: Opacity(opacity: enterOpacity, child: child),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An [AnimatedSwitcher] pre-configured with M3 Expressive timing.
|
||||
///
|
||||
/// Use for state-change animations (loading → content, empty → data, etc.).
|
||||
class M3AnimatedSwitcher extends StatelessWidget {
|
||||
const M3AnimatedSwitcher({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.duration = M3Motion.short,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Duration duration;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedSwitcher(
|
||||
duration: duration,
|
||||
switchInCurve: M3Motion.emphasizedEnter,
|
||||
switchOutCurve: M3Motion.emphasizedExit,
|
||||
transitionBuilder: (child, animation) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.02),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// M3 Expressive FAB — animated entrance + press feedback
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/// A [FloatingActionButton] with M3 Expressive entrance animation:
|
||||
/// scales up with spring on first build, and provides subtle scale-down
|
||||
/// feedback on press. Use [M3ExpandedFab] for the extended variant.
|
||||
class M3Fab extends StatefulWidget {
|
||||
const M3Fab({
|
||||
super.key,
|
||||
required this.onPressed,
|
||||
required this.icon,
|
||||
this.tooltip,
|
||||
this.heroTag,
|
||||
});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
final Widget icon;
|
||||
final String? tooltip;
|
||||
final Object? heroTag;
|
||||
|
||||
@override
|
||||
State<M3Fab> createState() => _M3FabState();
|
||||
}
|
||||
|
||||
class _M3FabState extends State<M3Fab> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _scaleCtrl;
|
||||
late final Animation<double> _scaleAnim;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scaleCtrl = AnimationController(vsync: this, duration: M3Motion.long);
|
||||
_scaleAnim = CurvedAnimation(parent: _scaleCtrl, curve: M3Motion.spring);
|
||||
_scaleCtrl.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scaleCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ScaleTransition(
|
||||
scale: _scaleAnim,
|
||||
child: FloatingActionButton(
|
||||
heroTag: widget.heroTag,
|
||||
onPressed: widget.onPressed,
|
||||
tooltip: widget.tooltip,
|
||||
child: widget.icon,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An extended [FloatingActionButton] with M3 Expressive entrance animation:
|
||||
/// slides in from the right + scales with spring, then provides a smooth
|
||||
/// press → dialog/navigation transition.
|
||||
class M3ExpandedFab extends StatefulWidget {
|
||||
const M3ExpandedFab({
|
||||
super.key,
|
||||
required this.onPressed,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.heroTag,
|
||||
});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
final Widget icon;
|
||||
final Widget label;
|
||||
final Object? heroTag;
|
||||
|
||||
@override
|
||||
State<M3ExpandedFab> createState() => _M3ExpandedFabState();
|
||||
}
|
||||
|
||||
class _M3ExpandedFabState extends State<M3ExpandedFab>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _entranceCtrl;
|
||||
late final Animation<double> _scale;
|
||||
late final Animation<Offset> _slide;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_entranceCtrl = AnimationController(vsync: this, duration: M3Motion.long);
|
||||
_scale = CurvedAnimation(parent: _entranceCtrl, curve: M3Motion.spring);
|
||||
_slide = Tween<Offset>(begin: const Offset(0.3, 0), end: Offset.zero)
|
||||
.animate(
|
||||
CurvedAnimation(
|
||||
parent: _entranceCtrl,
|
||||
curve: M3Motion.expressiveDecelerate,
|
||||
),
|
||||
);
|
||||
_entranceCtrl.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_entranceCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SlideTransition(
|
||||
position: _slide,
|
||||
child: ScaleTransition(
|
||||
scale: _scale,
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: widget.heroTag,
|
||||
onPressed: widget.onPressed,
|
||||
icon: widget.icon,
|
||||
label: widget.label,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// M3 Dialog / BottomSheet helpers — smooth open/close
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/// Opens a dialog with an M3 Expressive transition: the dialog scales up
|
||||
/// from 90 % with a decelerate curve and fades in, giving a smooth "surface
|
||||
/// rising" effect instead of the default abrupt material grow.
|
||||
Future<T?> m3ShowDialog<T>({
|
||||
required BuildContext context,
|
||||
required WidgetBuilder builder,
|
||||
bool barrierDismissible = true,
|
||||
}) {
|
||||
return showGeneralDialog<T>(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
|
||||
barrierColor: Colors.black54,
|
||||
transitionDuration: M3Motion.standard,
|
||||
transitionBuilder: (context, animation, secondaryAnimation, child) {
|
||||
final curved = CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: M3Motion.expressiveDecelerate,
|
||||
reverseCurve: M3Motion.expressiveAccelerate,
|
||||
);
|
||||
return FadeTransition(
|
||||
opacity: CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: const Interval(0.0, 0.65, curve: Curves.easeOut),
|
||||
reverseCurve: const Interval(0.2, 1.0, curve: Curves.easeIn),
|
||||
),
|
||||
child: ScaleTransition(
|
||||
scale: Tween<double>(begin: 0.88, end: 1.0).animate(curved),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
pageBuilder: (context, animation, secondaryAnimation) => builder(context),
|
||||
);
|
||||
}
|
||||
|
||||
/// Opens a modal bottom sheet with M3 Expressive spring animation.
|
||||
Future<T?> m3ShowBottomSheet<T>({
|
||||
required BuildContext context,
|
||||
required WidgetBuilder builder,
|
||||
bool showDragHandle = true,
|
||||
bool isScrollControlled = false,
|
||||
}) {
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
showDragHandle: showDragHandle,
|
||||
isScrollControlled: isScrollControlled,
|
||||
transitionAnimationController: AnimationController(
|
||||
vsync: Navigator.of(context),
|
||||
duration: M3Motion.standard,
|
||||
reverseDuration: M3Motion.short,
|
||||
),
|
||||
builder: builder,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../theme/m3_motion.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
// showcaseview removed due to null-safety incompatibility; onboarding shown via dialog
|
||||
|
|
@ -7,6 +8,7 @@ import '../providers/auth_provider.dart';
|
|||
import '../providers/notifications_provider.dart';
|
||||
import '../providers/profile_provider.dart';
|
||||
import 'app_breakpoints.dart';
|
||||
import 'profile_avatar.dart';
|
||||
|
||||
final GlobalKey notificationBellKey = GlobalKey();
|
||||
|
||||
|
|
@ -49,7 +51,7 @@ class AppScaffold extends ConsumerWidget {
|
|||
appBar: AppBar(
|
||||
title: Row(
|
||||
children: [
|
||||
const Icon(Icons.memory),
|
||||
Image.asset('assets/tasq_ico.png', width: 28, height: 28),
|
||||
const SizedBox(width: 8),
|
||||
Text('TasQ'),
|
||||
],
|
||||
|
|
@ -73,7 +75,7 @@ class AppScaffold extends ConsumerWidget {
|
|||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.account_circle),
|
||||
ProfileAvatar(fullName: displayName, radius: 16),
|
||||
const SizedBox(width: 8),
|
||||
Text(displayName),
|
||||
const SizedBox(width: 4),
|
||||
|
|
@ -89,7 +91,7 @@ class AppScaffold extends ConsumerWidget {
|
|||
IconButton(
|
||||
tooltip: 'Profile',
|
||||
onPressed: () => context.go('/profile'),
|
||||
icon: const Icon(Icons.account_circle),
|
||||
icon: ProfileAvatar(fullName: displayName, radius: 16),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Sign out',
|
||||
|
|
@ -164,23 +166,44 @@ class AppNavigationRail extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentIndex = _currentIndex(location, items);
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
// M3 Expressive: tonal surface container instead of a hard border divider.
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border(
|
||||
right: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
decoration: BoxDecoration(color: cs.surfaceContainerLow),
|
||||
child: NavigationRail(
|
||||
backgroundColor: Colors.transparent,
|
||||
extended: extended,
|
||||
selectedIndex: currentIndex,
|
||||
onDestinationSelected: (value) {
|
||||
items[value].onTap(context, onLogout: onLogout);
|
||||
},
|
||||
leading: const SizedBox.shrink(),
|
||||
trailing: const SizedBox.shrink(),
|
||||
leading: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: extended ? 12 : 8,
|
||||
horizontal: extended ? 16 : 0,
|
||||
),
|
||||
child: Center(
|
||||
child: Image.asset(
|
||||
'assets/tasq_ico.png',
|
||||
width: extended ? 48 : 40,
|
||||
height: extended ? 48 : 40,
|
||||
),
|
||||
),
|
||||
),
|
||||
trailing: Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: IconButton(
|
||||
tooltip: 'Sign out',
|
||||
onPressed: onLogout,
|
||||
icon: const Icon(Icons.logout),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
destinations: [
|
||||
for (final item in items)
|
||||
NavigationRailDestination(
|
||||
|
|
@ -256,6 +279,8 @@ class _NotificationBell extends ConsumerWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// M3 Expressive shell background — uses a subtle tonal surface tint
|
||||
/// rather than a gradient to create the organic, seed-colored feel.
|
||||
class _ShellBackground extends StatelessWidget {
|
||||
const _ShellBackground({required this.child});
|
||||
|
||||
|
|
@ -263,17 +288,8 @@ class _ShellBackground extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Theme.of(context).colorScheme.surface,
|
||||
Theme.of(context).colorScheme.surfaceContainerLowest,
|
||||
],
|
||||
),
|
||||
),
|
||||
return ColoredBox(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
|
@ -531,9 +547,8 @@ Future<void> _showOverflowSheet(
|
|||
List<NavItem> items,
|
||||
VoidCallback onLogout,
|
||||
) async {
|
||||
await showModalBottomSheet<void>(
|
||||
await m3ShowBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: ListView(
|
||||
|
|
|
|||
136
lib/widgets/m3_card.dart
Normal file
136
lib/widgets/m3_card.dart
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
/// M3 Expressive Card variants.
|
||||
///
|
||||
/// Use these factory constructors to build semantically correct cards:
|
||||
/// - [M3Card.elevated] — default, uses tonal surface tint (no hard shadow).
|
||||
/// - [M3Card.filled] — uses surfaceContainerHighest for emphasis.
|
||||
/// - [M3Card.outlined] — transparent fill with a subtle outline border.
|
||||
|
||||
class M3Card extends StatelessWidget {
|
||||
const M3Card._({
|
||||
required this.child,
|
||||
this.color,
|
||||
this.elevation,
|
||||
this.shadowColor,
|
||||
this.surfaceTintColor,
|
||||
this.shape,
|
||||
this.margin,
|
||||
this.clipBehavior = Clip.none,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
/// Elevated card — tonal surface tint, minimal shadow.
|
||||
/// Best for primary content surfaces (metric cards, detail panels).
|
||||
factory M3Card.elevated({
|
||||
required Widget child,
|
||||
Color? color,
|
||||
ShapeBorder? shape,
|
||||
EdgeInsetsGeometry? margin,
|
||||
Clip clipBehavior = Clip.none,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return M3Card._(
|
||||
color: color,
|
||||
elevation: 1,
|
||||
shadowColor: Colors.transparent,
|
||||
shape: shape,
|
||||
margin: margin,
|
||||
clipBehavior: clipBehavior,
|
||||
onTap: onTap,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
/// Filled card — uses surfaceContainerHighest for high emphasis.
|
||||
/// Best for summary cards, status counts, callout panels.
|
||||
factory M3Card.filled({
|
||||
required Widget child,
|
||||
Color? color,
|
||||
ShapeBorder? shape,
|
||||
EdgeInsetsGeometry? margin,
|
||||
Clip clipBehavior = Clip.none,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return M3Card._(
|
||||
color: color, // caller passes surfaceContainerHighest or semantic color
|
||||
elevation: 0,
|
||||
shadowColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: shape,
|
||||
margin: margin,
|
||||
clipBehavior: clipBehavior,
|
||||
onTap: onTap,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
/// Outlined card — transparent fill with outline border.
|
||||
/// Best for list items, form sections, grouped content.
|
||||
factory M3Card.outlined({
|
||||
required Widget child,
|
||||
Color? color,
|
||||
ShapeBorder? shape,
|
||||
EdgeInsetsGeometry? margin,
|
||||
Clip clipBehavior = Clip.none,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return M3Card._(
|
||||
color: color,
|
||||
elevation: 0,
|
||||
shadowColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: shape,
|
||||
margin: margin,
|
||||
clipBehavior: clipBehavior,
|
||||
onTap: onTap,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
final Widget child;
|
||||
final Color? color;
|
||||
final double? elevation;
|
||||
final Color? shadowColor;
|
||||
final Color? surfaceTintColor;
|
||||
final ShapeBorder? shape;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final Clip clipBehavior;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
// For outlined, we need the border side
|
||||
final resolvedShape =
|
||||
shape ??
|
||||
(elevation == 0 && surfaceTintColor == Colors.transparent
|
||||
? RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: (shadowColor == Colors.transparent && color == null)
|
||||
? BorderSide(color: cs.outlineVariant)
|
||||
: BorderSide.none,
|
||||
)
|
||||
: null);
|
||||
|
||||
final card = Card(
|
||||
color: color,
|
||||
elevation: elevation,
|
||||
shadowColor: shadowColor,
|
||||
surfaceTintColor: surfaceTintColor,
|
||||
shape: resolvedShape,
|
||||
margin: margin ?? EdgeInsets.zero,
|
||||
clipBehavior: clipBehavior,
|
||||
child: onTap != null
|
||||
? InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: child,
|
||||
)
|
||||
: child,
|
||||
);
|
||||
|
||||
return card;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../theme/m3_motion.dart';
|
||||
|
||||
/// Lightweight, bounds-safe multi-select picker used in dialogs.
|
||||
/// - Renders chips for selected items and a `Select` ActionChip.
|
||||
|
|
@ -43,7 +44,7 @@ class _MultiSelectPickerState<T> extends State<MultiSelectPicker<T>> {
|
|||
List<String>? result;
|
||||
|
||||
if (isMobile) {
|
||||
result = await showModalBottomSheet<List<String>>(
|
||||
result = await m3ShowBottomSheet<List<String>>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (sheetContext) {
|
||||
|
|
@ -132,7 +133,7 @@ class _MultiSelectPickerState<T> extends State<MultiSelectPicker<T>> {
|
|||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
FilledButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(sheetContext).pop(working),
|
||||
child: const Text('Done'),
|
||||
|
|
@ -148,9 +149,8 @@ class _MultiSelectPickerState<T> extends State<MultiSelectPicker<T>> {
|
|||
},
|
||||
);
|
||||
} else {
|
||||
result = await showDialog<List<String>>(
|
||||
result = await m3ShowDialog<List<String>>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
builder: (dialogContext) {
|
||||
List<String> working = List<String>.from(_selectedIds);
|
||||
bool workingSelectAll =
|
||||
|
|
@ -232,7 +232,7 @@ class _MultiSelectPickerState<T> extends State<MultiSelectPicker<T>> {
|
|||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
FilledButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(dialogContext).pop(working),
|
||||
child: const Text('Done'),
|
||||
|
|
|
|||
85
lib/widgets/profile_avatar.dart
Normal file
85
lib/widgets/profile_avatar.dart
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Native Flutter profile avatar that displays either:
|
||||
/// 1. User's avatar image URL (if provided)
|
||||
/// 2. Initials derived from full name (fallback)
|
||||
class ProfileAvatar extends StatelessWidget {
|
||||
const ProfileAvatar({
|
||||
super.key,
|
||||
required this.fullName,
|
||||
this.avatarUrl,
|
||||
this.radius = 18,
|
||||
});
|
||||
|
||||
final String fullName;
|
||||
final String? avatarUrl;
|
||||
final double radius;
|
||||
|
||||
String _getInitials() {
|
||||
final trimmed = fullName.trim();
|
||||
if (trimmed.isEmpty) return 'U';
|
||||
|
||||
final parts = trimmed.split(RegExp(r'\s+'));
|
||||
if (parts.length == 1) {
|
||||
return parts[0].substring(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
// Get first letter of first and last name
|
||||
return '${parts.first[0]}${parts.last[0]}'.toUpperCase();
|
||||
}
|
||||
|
||||
Color _getInitialsColor(String initials) {
|
||||
// Generate a deterministic color based on initials
|
||||
final hash =
|
||||
initials.codeUnitAt(0) +
|
||||
(initials.length > 1 ? initials.codeUnitAt(1) * 256 : 0);
|
||||
final colors = [
|
||||
Colors.red,
|
||||
Colors.pink,
|
||||
Colors.purple,
|
||||
Colors.deepPurple,
|
||||
Colors.indigo,
|
||||
Colors.blue,
|
||||
Colors.lightBlue,
|
||||
Colors.cyan,
|
||||
Colors.teal,
|
||||
Colors.green,
|
||||
Colors.lightGreen,
|
||||
Colors.orange,
|
||||
Colors.deepOrange,
|
||||
Colors.brown,
|
||||
];
|
||||
return colors[hash % colors.length];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final initials = _getInitials();
|
||||
|
||||
// If avatar URL is provided, attempt to load the image
|
||||
if (avatarUrl != null && avatarUrl!.isNotEmpty) {
|
||||
return CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundImage: NetworkImage(avatarUrl!),
|
||||
onBackgroundImageError: (_, __) {
|
||||
// Silently fall back to initials if image fails
|
||||
},
|
||||
child: null, // Image will display if loaded successfully
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to initials
|
||||
return CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: _getInitialsColor(initials),
|
||||
child: Text(
|
||||
initials,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: radius * 0.8,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
/// M3 Expressive status pill — uses tonal container colors with a
|
||||
/// smooth, spring-physics-inspired animation.
|
||||
class StatusPill extends StatelessWidget {
|
||||
const StatusPill({super.key, required this.label, this.isEmphasized = false});
|
||||
|
||||
|
|
@ -11,23 +13,23 @@ class StatusPill extends StatelessWidget {
|
|||
final scheme = Theme.of(context).colorScheme;
|
||||
final background = isEmphasized
|
||||
? scheme.tertiaryContainer
|
||||
: scheme.tertiaryContainer.withValues(alpha: 0.65);
|
||||
: scheme.tertiaryContainer;
|
||||
final foreground = scheme.onTertiaryContainer;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: scheme.tertiary.withValues(alpha: 0.3)),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
child: Text(
|
||||
label.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: foreground,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.4,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../theme/m3_motion.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/profile.dart';
|
||||
|
|
@ -117,7 +118,7 @@ class TaskAssignmentSection extends ConsumerWidget {
|
|||
// consider vacancy anymore because everyone is eligible, so the only
|
||||
// reason for the dialog to be unusable is an empty staff list.
|
||||
if (eligibleStaff.isEmpty && assignedIds.isEmpty) {
|
||||
await showDialog<void>(
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
|
|
@ -137,7 +138,7 @@ class TaskAssignmentSection extends ConsumerWidget {
|
|||
}
|
||||
|
||||
final selection = assignedIds.toSet();
|
||||
await showDialog<void>(
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
var isSaving = false;
|
||||
|
|
|
|||
|
|
@ -597,7 +597,7 @@ class _DesktopTableViewState<T> extends State<_DesktopTableView<T>> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Mobile tile wrapper that applies Material 2 style elevation.
|
||||
/// Mobile tile wrapper that applies M3 Expressive tonal elevation.
|
||||
class _MobileTile<T> extends StatelessWidget {
|
||||
const _MobileTile({
|
||||
required this.item,
|
||||
|
|
@ -615,21 +615,14 @@ class _MobileTile<T> extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final tile = mobileTileBuilder(context, item, actions);
|
||||
|
||||
// Apply Material 2 style elevation for Cards (per Hybrid M3/M2 guidelines).
|
||||
// Mobile tiles deliberately use a slightly smaller corner radius for
|
||||
// compactness, but they should inherit the global card elevation and
|
||||
// shadow color from the theme to maintain visual consistency.
|
||||
// M3 Expressive: cards use tonal surface tints. The theme's CardThemeData
|
||||
// already specifies surfaceTintColor and low elevation. We apply the
|
||||
// compact shape for list density.
|
||||
if (tile is Card) {
|
||||
final themeCard = Theme.of(context).cardTheme;
|
||||
return Card(
|
||||
color: tile.color,
|
||||
elevation: themeCard.elevation ?? 3,
|
||||
margin: tile.margin,
|
||||
// prefer the tile's explicit shape. For mobile tiles we intentionally
|
||||
// use the compact radius token so list items feel denser while
|
||||
// remaining theme-driven.
|
||||
shape: tile.shape ?? AppSurfaces.of(context).compactShape,
|
||||
shadowColor: AppSurfaces.of(context).compactShadowColor,
|
||||
clipBehavior: tile.clipBehavior,
|
||||
child: tile.child,
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user