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 '../screens/workforce/workforce_screen.dart';
|
||||||
import '../widgets/app_shell.dart';
|
import '../widgets/app_shell.dart';
|
||||||
import '../screens/teams/teams_screen.dart';
|
import '../screens/teams/teams_screen.dart';
|
||||||
|
import '../theme/m3_motion.dart';
|
||||||
|
|
||||||
final appRouterProvider = Provider<GoRouter>((ref) {
|
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
final notifier = RouterNotifier(ref);
|
final notifier = RouterNotifier(ref);
|
||||||
|
|
@ -79,82 +80,131 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/settings/teams',
|
path: '/settings/teams',
|
||||||
builder: (context, state) => const TeamsScreen(),
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: const TeamsScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
builder: (context, state) => const DashboardScreen(),
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: const DashboardScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/tickets',
|
path: '/tickets',
|
||||||
builder: (context, state) => const TicketsListScreen(),
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: const TicketsListScreen(),
|
||||||
|
),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':id',
|
path: ':id',
|
||||||
builder: (context, state) => TicketDetailScreen(
|
pageBuilder: (context, state) => M3ContainerTransformPage(
|
||||||
ticketId: state.pathParameters['id'] ?? '',
|
key: state.pageKey,
|
||||||
|
child: TicketDetailScreen(
|
||||||
|
ticketId: state.pathParameters['id'] ?? '',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/tasks',
|
path: '/tasks',
|
||||||
builder: (context, state) => const TasksListScreen(),
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: const TasksListScreen(),
|
||||||
|
),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':id',
|
path: ':id',
|
||||||
builder: (context, state) =>
|
pageBuilder: (context, state) => M3ContainerTransformPage(
|
||||||
TaskDetailScreen(taskId: state.pathParameters['id'] ?? ''),
|
key: state.pageKey,
|
||||||
|
child: TaskDetailScreen(
|
||||||
|
taskId: state.pathParameters['id'] ?? '',
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/events',
|
path: '/events',
|
||||||
builder: (context, state) => const UnderDevelopmentScreen(
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||||
title: 'Events',
|
key: state.pageKey,
|
||||||
subtitle: 'Event monitoring is under development.',
|
child: const UnderDevelopmentScreen(
|
||||||
icon: Icons.event,
|
title: 'Events',
|
||||||
|
subtitle: 'Event monitoring is under development.',
|
||||||
|
icon: Icons.event,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/announcements',
|
path: '/announcements',
|
||||||
builder: (context, state) => const UnderDevelopmentScreen(
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||||
title: 'Announcement',
|
key: state.pageKey,
|
||||||
subtitle: 'Operational broadcasts are coming soon.',
|
child: const UnderDevelopmentScreen(
|
||||||
icon: Icons.campaign,
|
title: 'Announcement',
|
||||||
|
subtitle: 'Operational broadcasts are coming soon.',
|
||||||
|
icon: Icons.campaign,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/workforce',
|
path: '/workforce',
|
||||||
builder: (context, state) => const WorkforceScreen(),
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: const WorkforceScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/reports',
|
path: '/reports',
|
||||||
builder: (context, state) => const ReportsScreen(),
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: const ReportsScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/settings/users',
|
path: '/settings/users',
|
||||||
builder: (context, state) => const UserManagementScreen(),
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: const UserManagementScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/settings/offices',
|
path: '/settings/offices',
|
||||||
builder: (context, state) => const OfficesScreen(),
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: const OfficesScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/settings/geofence-test',
|
path: '/settings/geofence-test',
|
||||||
builder: (context, state) => const GeofenceTestScreen(),
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: const GeofenceTestScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/settings/permissions',
|
path: '/settings/permissions',
|
||||||
builder: (context, state) => const PermissionsScreen(),
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: const PermissionsScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/notifications',
|
path: '/notifications',
|
||||||
builder: (context, state) => const NotificationsScreen(),
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: const NotificationsScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/profile',
|
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,
|
right: 12,
|
||||||
top: 12,
|
top: 12,
|
||||||
child: Card(
|
child: Card(
|
||||||
elevation: 2,
|
elevation: 0,
|
||||||
|
shadowColor: Colors.transparent,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 10.0,
|
horizontal: 10.0,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../theme/m3_motion.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../models/office.dart';
|
import '../../models/office.dart';
|
||||||
|
|
@ -153,7 +154,7 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
|
||||||
right: 16,
|
right: 16,
|
||||||
bottom: 16,
|
bottom: 16,
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: FloatingActionButton.extended(
|
child: M3ExpandedFab(
|
||||||
onPressed: () => _showOfficeDialog(context, ref),
|
onPressed: () => _showOfficeDialog(context, ref),
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: const Text('New Office'),
|
label: const Text('New Office'),
|
||||||
|
|
@ -172,7 +173,7 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
|
||||||
final nameController = TextEditingController(text: office?.name ?? '');
|
final nameController = TextEditingController(text: office?.name ?? '');
|
||||||
String? selectedServiceId = office?.serviceId;
|
String? selectedServiceId = office?.serviceId;
|
||||||
|
|
||||||
await showDialog<void>(
|
await m3ShowDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
bool saving = false;
|
bool saving = false;
|
||||||
|
|
@ -298,7 +299,7 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
Office office,
|
Office office,
|
||||||
) async {
|
) async {
|
||||||
await showDialog<void>(
|
await m3ShowDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../theme/m3_motion.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../models/office.dart';
|
import '../../models/office.dart';
|
||||||
|
|
@ -306,7 +307,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
await showDialog<void>(
|
await m3ShowDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
|
|
@ -314,8 +315,8 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
shape: AppSurfaces.of(context).dialogShape,
|
shape: AppSurfaces.of(context).dialogShape,
|
||||||
title: const Text('Update user'),
|
title: const Text('Update user'),
|
||||||
content: ConstrainedBox(
|
content: SizedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 520),
|
width: 520,
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: _buildUserForm(
|
child: _buildUserForm(
|
||||||
context,
|
context,
|
||||||
|
|
@ -442,10 +443,17 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (offices.isEmpty) const Text('No offices available.'),
|
if (offices.isEmpty) const Text('No offices available.'),
|
||||||
if (offices.isNotEmpty)
|
if (offices.isNotEmpty)
|
||||||
Column(
|
Container(
|
||||||
children: offices
|
height: 240,
|
||||||
.map(
|
clipBehavior: Clip.hardEdge,
|
||||||
(office) => CheckboxListTile(
|
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),
|
value: _selectedOfficeIds.contains(office.id),
|
||||||
onChanged: _isSaving
|
onChanged: _isSaving
|
||||||
? null
|
? null
|
||||||
|
|
@ -460,10 +468,11 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
},
|
},
|
||||||
title: Text(office.name),
|
title: Text(office.name),
|
||||||
controlAffinity: ListTileControlAffinity.leading,
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
),
|
);
|
||||||
)
|
}).toList(),
|
||||||
.toList(),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -559,7 +568,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
final controller = TextEditingController();
|
final controller = TextEditingController();
|
||||||
final formKey = GlobalKey<FormState>();
|
final formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
await showDialog<void>(
|
await m3ShowDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../theme/m3_motion.dart';
|
||||||
import '../../utils/snackbar.dart';
|
import '../../utils/snackbar.dart';
|
||||||
|
|
||||||
class LoginScreen extends ConsumerStatefulWidget {
|
class LoginScreen extends ConsumerStatefulWidget {
|
||||||
|
|
@ -17,15 +17,39 @@ class LoginScreen extends ConsumerStatefulWidget {
|
||||||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _emailController = TextEditingController();
|
final _emailController = TextEditingController();
|
||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
|
late final AnimationController _entranceController;
|
||||||
|
late final Animation<double> _fadeIn;
|
||||||
|
late final Animation<Offset> _slideIn;
|
||||||
|
|
||||||
bool _isLoading = false;
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_entranceController.dispose();
|
||||||
_emailController.dispose();
|
_emailController.dispose();
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|
@ -83,93 +107,230 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final tt = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Sign In')),
|
body: SafeArea(
|
||||||
body: ResponsiveBody(
|
child: Center(
|
||||||
maxWidth: 480,
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||||
child: Form(
|
child: FadeTransition(
|
||||||
key: _formKey,
|
opacity: _fadeIn,
|
||||||
child: Column(
|
child: SlideTransition(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
position: _slideIn,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: ConstrainedBox(
|
||||||
children: [
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
Center(
|
child: Column(
|
||||||
child: Column(
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Image.asset('assets/tasq_ico.png', height: 72, width: 72),
|
// ── Branding ──
|
||||||
const SizedBox(height: 12),
|
Hero(
|
||||||
Text(
|
tag: 'tasq-logo',
|
||||||
'TasQ',
|
child: Image.asset(
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
'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/auth_provider.dart';
|
||||||
import '../../providers/tickets_provider.dart';
|
import '../../providers/tickets_provider.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../theme/m3_motion.dart';
|
||||||
import '../../utils/snackbar.dart';
|
import '../../utils/snackbar.dart';
|
||||||
|
|
||||||
class SignUpScreen extends ConsumerStatefulWidget {
|
class SignUpScreen extends ConsumerStatefulWidget {
|
||||||
|
|
@ -14,28 +14,49 @@ class SignUpScreen extends ConsumerStatefulWidget {
|
||||||
ConsumerState<SignUpScreen> createState() => _SignUpScreenState();
|
ConsumerState<SignUpScreen> createState() => _SignUpScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
class _SignUpScreenState extends ConsumerState<SignUpScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _fullNameController = TextEditingController();
|
final _fullNameController = TextEditingController();
|
||||||
final _emailController = TextEditingController();
|
final _emailController = TextEditingController();
|
||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
final _confirmPasswordController = TextEditingController();
|
final _confirmPasswordController = TextEditingController();
|
||||||
|
|
||||||
|
late final AnimationController _entranceController;
|
||||||
|
late final Animation<double> _fadeIn;
|
||||||
|
late final Animation<Offset> _slideIn;
|
||||||
|
|
||||||
final Set<String> _selectedOfficeIds = {};
|
final Set<String> _selectedOfficeIds = {};
|
||||||
double _passwordStrength = 0.0;
|
double _passwordStrength = 0.0;
|
||||||
String _passwordStrengthLabel = 'Very weak';
|
String _passwordStrengthLabel = 'Very weak';
|
||||||
Color _passwordStrengthColor = Colors.red;
|
Color _passwordStrengthColor = Colors.red;
|
||||||
|
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
bool _obscureConfirm = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_passwordController.addListener(_updatePasswordStrength);
|
_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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_entranceController.dispose();
|
||||||
_passwordController.removeListener(_updatePasswordStrength);
|
_passwordController.removeListener(_updatePasswordStrength);
|
||||||
_fullNameController.dispose();
|
_fullNameController.dispose();
|
||||||
_emailController.dispose();
|
_emailController.dispose();
|
||||||
|
|
@ -76,196 +97,377 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final tt = Theme.of(context).textTheme;
|
||||||
final officesAsync = ref.watch(officesOnceProvider);
|
final officesAsync = ref.watch(officesOnceProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Create Account')),
|
body: SafeArea(
|
||||||
body: ResponsiveBody(
|
child: Center(
|
||||||
maxWidth: 480,
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||||
child: SingleChildScrollView(
|
child: FadeTransition(
|
||||||
child: Form(
|
opacity: _fadeIn,
|
||||||
key: _formKey,
|
child: SlideTransition(
|
||||||
child: Column(
|
position: _slideIn,
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: ConstrainedBox(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
children: [
|
|
||||||
Center(
|
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Image.asset('assets/tasq_ico.png', height: 72, width: 72),
|
// ── Branding ──
|
||||||
const SizedBox(height: 12),
|
Hero(
|
||||||
|
tag: 'tasq-logo',
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/tasq_ico.png',
|
||||||
|
height: 80,
|
||||||
|
width: 80,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'TasQ',
|
'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 {
|
Future<void> _showOfficeSelectionDialog(List<dynamic> offices) async {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
final tempSelected = Set<String>.from(_selectedOfficeIds);
|
final tempSelected = Set<String>.from(_selectedOfficeIds);
|
||||||
|
|
||||||
await showDialog<void>(
|
await m3ShowDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogCtx) => StatefulBuilder(
|
builder: (dialogCtx) => StatefulBuilder(
|
||||||
builder: (ctx2, setStateDialog) {
|
builder: (ctx2, setStateDialog) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
),
|
||||||
title: const Text('Select Offices'),
|
title: const Text('Select Offices'),
|
||||||
content: ConstrainedBox(
|
content: Container(
|
||||||
constraints: const BoxConstraints(maxWidth: 480),
|
width: 480,
|
||||||
|
height: 400,
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
color: cs.surfaceContainerLowest,
|
||||||
|
),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|
@ -303,7 +515,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||||
},
|
},
|
||||||
title: Text(name),
|
title: Text(name),
|
||||||
controlAffinity: ListTileControlAffinity.leading,
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
|
|
@ -314,7 +526,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||||
onPressed: () => Navigator.of(dialogCtx).pop(),
|
onPressed: () => Navigator.of(dialogCtx).pop(),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
FilledButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedOfficeIds
|
_selectedOfficeIds
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../theme/m3_motion.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:permission_handler/permission_handler.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;
|
final seen = prefs.getBool('has_seen_notif_showcase') ?? false;
|
||||||
if (!seen) {
|
if (!seen) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await showDialog<void>(
|
await m3ShowDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Never miss an update'),
|
title: const Text('Never miss an update'),
|
||||||
|
|
@ -469,9 +470,10 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
child: Text(
|
child: Text(
|
||||||
title,
|
title,
|
||||||
style: Theme.of(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
context,
|
fontWeight: FontWeight.w700,
|
||||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -571,6 +573,7 @@ class _MetricCard extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
// Only watch the single string value for this card so unrelated metric
|
// Only watch the single string value for this card so unrelated metric
|
||||||
// updates don't rebuild the whole card. This makes updates feel much
|
// updates don't rebuild the whole card. This makes updates feel much
|
||||||
// smoother and avoids full-page refreshes.
|
// 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(
|
return AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 220),
|
duration: const Duration(milliseconds: 400),
|
||||||
padding: const EdgeInsets.all(16),
|
curve: Curves.easeOutCubic,
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: cs.surfaceContainerLow,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: Theme.of(
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
context,
|
fontWeight: FontWeight.w600,
|
||||||
).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
|
// Animate only the metric text (not the whole card) for a
|
||||||
// subtle, smooth update.
|
// subtle, smooth update.
|
||||||
AnimatedSwitcher(
|
AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 220),
|
duration: const Duration(milliseconds: 400),
|
||||||
|
switchInCurve: Curves.easeOutCubic,
|
||||||
|
switchOutCurve: Curves.easeInCubic,
|
||||||
transitionBuilder: (child, anim) =>
|
transitionBuilder: (child, anim) =>
|
||||||
FadeTransition(opacity: anim, child: child),
|
FadeTransition(opacity: anim, child: child),
|
||||||
child: MonoText(
|
child: MonoText(
|
||||||
value,
|
value,
|
||||||
key: ValueKey(value),
|
key: ValueKey(value),
|
||||||
style: Theme.of(
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
context,
|
fontWeight: FontWeight.w700,
|
||||||
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700),
|
color: cs.onSurface,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -628,12 +636,15 @@ class _StaffTable extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
// M3 Expressive: tonal surface container, 28 dp radius for large containers.
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: cs.surfaceContainerLow,
|
||||||
borderRadius: BorderRadius.circular(AppSurfaces.of(context).cardRadius),
|
borderRadius: BorderRadius.circular(
|
||||||
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
|
AppSurfaces.of(context).containerRadius,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
|
||||||
|
|
@ -124,11 +124,9 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
|
||||||
final title = _notificationTitle(item.type, actorName);
|
final title = _notificationTitle(item.type, actorName);
|
||||||
final icon = _notificationIcon(item.type);
|
final icon = _notificationIcon(item.type);
|
||||||
|
|
||||||
// Use a slightly more compact card for dense notification lists
|
// M3 Expressive: compact card shape, no shadow.
|
||||||
// — 12px radius, subtle shadow so the list remains readable.
|
|
||||||
return Card(
|
return Card(
|
||||||
shape: AppSurfaces.of(context).compactShape,
|
shape: AppSurfaces.of(context).compactShape,
|
||||||
shadowColor: AppSurfaces.of(context).compactShadowColor,
|
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Icon(icon),
|
leading: Icon(icon),
|
||||||
title: Text(title),
|
title: Text(title),
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton(
|
FilledButton(
|
||||||
onPressed: _savingDetails ? null : _onSaveDetails,
|
onPressed: _savingDetails ? null : _onSaveDetails,
|
||||||
child: Text(
|
child: Text(
|
||||||
_savingDetails ? 'Saving...' : 'Save details',
|
_savingDetails ? 'Saving...' : 'Save details',
|
||||||
|
|
@ -176,7 +176,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton(
|
FilledButton(
|
||||||
onPressed: _changingPassword
|
onPressed: _changingPassword
|
||||||
? null
|
? null
|
||||||
: _onChangePassword,
|
: _onChangePassword,
|
||||||
|
|
@ -224,7 +224,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton(
|
FilledButton(
|
||||||
onPressed: _savingOffices
|
onPressed: _savingOffices
|
||||||
? null
|
? null
|
||||||
: _onSaveOffices,
|
: _onSaveOffices,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../theme/m3_motion.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../providers/reports_provider.dart';
|
import '../../providers/reports_provider.dart';
|
||||||
|
|
@ -50,7 +51,7 @@ class ReportDateFilter extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showDateFilterDialog(BuildContext context, WidgetRef ref) {
|
void _showDateFilterDialog(BuildContext context, WidgetRef ref) {
|
||||||
showDialog(
|
m3ShowDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => _DateFilterDialog(
|
builder: (ctx) => _DateFilterDialog(
|
||||||
current: ref.read(reportDateRangeProvider),
|
current: ref.read(reportDateRangeProvider),
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,10 @@ class ReportCardWrapper extends StatelessWidget {
|
||||||
);
|
);
|
||||||
|
|
||||||
final card = Card(
|
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,
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
|
@ -84,8 +84,7 @@ class _RequestTypeChartState extends ConsumerState<RequestTypeChart> {
|
||||||
child: PieChart(
|
child: PieChart(
|
||||||
PieChartData(
|
PieChartData(
|
||||||
pieTouchData: PieTouchData(
|
pieTouchData: PieTouchData(
|
||||||
touchCallback:
|
touchCallback: (FlTouchEvent event, pieTouchResponse) {
|
||||||
(FlTouchEvent event, pieTouchResponse) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
if (!event.isInterestedForInteractions ||
|
if (!event.isInterestedForInteractions ||
|
||||||
pieTouchResponse == null ||
|
pieTouchResponse == null ||
|
||||||
|
|
@ -93,9 +92,8 @@ class _RequestTypeChartState extends ConsumerState<RequestTypeChart> {
|
||||||
_touchedIndex = -1;
|
_touchedIndex = -1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_touchedIndex = pieTouchResponse
|
_touchedIndex =
|
||||||
.touchedSection!
|
pieTouchResponse.touchedSection!.touchedSectionIndex;
|
||||||
.touchedSectionIndex;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -217,8 +215,7 @@ class _RequestCategoryChartState extends ConsumerState<RequestCategoryChart> {
|
||||||
child: PieChart(
|
child: PieChart(
|
||||||
PieChartData(
|
PieChartData(
|
||||||
pieTouchData: PieTouchData(
|
pieTouchData: PieTouchData(
|
||||||
touchCallback:
|
touchCallback: (FlTouchEvent event, pieTouchResponse) {
|
||||||
(FlTouchEvent event, pieTouchResponse) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
if (!event.isInterestedForInteractions ||
|
if (!event.isInterestedForInteractions ||
|
||||||
pieTouchResponse == null ||
|
pieTouchResponse == null ||
|
||||||
|
|
@ -226,9 +223,8 @@ class _RequestCategoryChartState extends ConsumerState<RequestCategoryChart> {
|
||||||
_touchedIndex = -1;
|
_touchedIndex = -1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_touchedIndex = pieTouchResponse
|
_touchedIndex =
|
||||||
.touchedSection!
|
pieTouchResponse.touchedSection!.touchedSectionIndex;
|
||||||
.touchedSectionIndex;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
|
@ -58,8 +58,7 @@ class _TicketsByStatusChartState extends ConsumerState<TicketsByStatusChart> {
|
||||||
child: PieChart(
|
child: PieChart(
|
||||||
PieChartData(
|
PieChartData(
|
||||||
pieTouchData: PieTouchData(
|
pieTouchData: PieTouchData(
|
||||||
touchCallback:
|
touchCallback: (FlTouchEvent event, pieTouchResponse) {
|
||||||
(FlTouchEvent event, pieTouchResponse) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
if (!event.isInterestedForInteractions ||
|
if (!event.isInterestedForInteractions ||
|
||||||
pieTouchResponse == null ||
|
pieTouchResponse == null ||
|
||||||
|
|
@ -85,10 +84,7 @@ class _TicketsByStatusChartState extends ConsumerState<TicketsByStatusChart> {
|
||||||
radius: isTouched ? 60 : 50,
|
radius: isTouched ? 60 : 50,
|
||||||
color: _ticketStatusColor(context, e.status),
|
color: _ticketStatusColor(context, e.status),
|
||||||
borderSide: isTouched
|
borderSide: isTouched
|
||||||
? const BorderSide(
|
? const BorderSide(color: Colors.white, width: 2)
|
||||||
color: Colors.white,
|
|
||||||
width: 2,
|
|
||||||
)
|
|
||||||
: BorderSide.none,
|
: BorderSide.none,
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
|
|
@ -140,8 +136,7 @@ class TasksByStatusChart extends ConsumerStatefulWidget {
|
||||||
final GlobalKey? repaintKey;
|
final GlobalKey? repaintKey;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<TasksByStatusChart> createState() =>
|
ConsumerState<TasksByStatusChart> createState() => _TasksByStatusChartState();
|
||||||
_TasksByStatusChartState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
|
class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
|
||||||
|
|
@ -187,8 +182,7 @@ class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
|
||||||
child: PieChart(
|
child: PieChart(
|
||||||
PieChartData(
|
PieChartData(
|
||||||
pieTouchData: PieTouchData(
|
pieTouchData: PieTouchData(
|
||||||
touchCallback:
|
touchCallback: (FlTouchEvent event, pieTouchResponse) {
|
||||||
(FlTouchEvent event, pieTouchResponse) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
if (!event.isInterestedForInteractions ||
|
if (!event.isInterestedForInteractions ||
|
||||||
pieTouchResponse == null ||
|
pieTouchResponse == null ||
|
||||||
|
|
@ -214,10 +208,7 @@ class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
|
||||||
radius: isTouched ? 60 : 50,
|
radius: isTouched ? 60 : 50,
|
||||||
color: _taskStatusColor(context, e.status),
|
color: _taskStatusColor(context, e.status),
|
||||||
borderSide: isTouched
|
borderSide: isTouched
|
||||||
? const BorderSide(
|
? const BorderSide(color: Colors.white, width: 2)
|
||||||
color: Colors.white,
|
|
||||||
width: 2,
|
|
||||||
)
|
|
||||||
: BorderSide.none,
|
: BorderSide.none,
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../theme/m3_motion.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
|
|
@ -84,10 +85,10 @@ class _PermissionsScreenState extends ConsumerState<PermissionsScreen> {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: M3Fab(
|
||||||
onPressed: _refreshStatuses,
|
onPressed: _refreshStatuses,
|
||||||
tooltip: 'Refresh',
|
tooltip: 'Refresh',
|
||||||
child: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../theme/app_surfaces.dart';
|
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
|
|
||||||
class UnderDevelopmentScreen extends StatelessWidget {
|
class UnderDevelopmentScreen extends StatelessWidget {
|
||||||
|
|
@ -17,34 +16,28 @@ class UnderDevelopmentScreen extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
return ResponsiveBody(
|
return ResponsiveBody(
|
||||||
maxWidth: 720,
|
maxWidth: 720,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||||
child: Center(
|
child: Center(
|
||||||
|
// M3 Expressive: elevated card with tonal fill, 28 dp radius.
|
||||||
child: Card(
|
child: Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(40),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 72,
|
width: 80,
|
||||||
height: 72,
|
height: 80,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(
|
color: cs.primaryContainer,
|
||||||
context,
|
borderRadius: BorderRadius.circular(28),
|
||||||
).colorScheme.primaryContainer.withValues(alpha: 0.7),
|
|
||||||
borderRadius: BorderRadius.circular(
|
|
||||||
AppSurfaces.of(context).dialogRadius,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
icon,
|
|
||||||
size: 36,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
),
|
||||||
|
child: Icon(icon, size: 40, color: cs.onPrimaryContainer),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
|
@ -55,26 +48,27 @@ class UnderDevelopmentScreen extends StatelessWidget {
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
context,
|
||||||
),
|
).textTheme.bodyMedium?.copyWith(color: cs.onSurfaceVariant),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 24),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 20,
|
||||||
vertical: 8,
|
vertical: 10,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(28),
|
||||||
color: Theme.of(
|
color: cs.secondaryContainer,
|
||||||
context,
|
|
||||||
).colorScheme.surfaceContainerHighest,
|
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Under development',
|
'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
|
// ignore_for_file: use_build_context_synchronously
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../theme/m3_motion.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
|
@ -1919,7 +1920,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final urlCtrl =
|
final urlCtrl =
|
||||||
TextEditingController();
|
TextEditingController();
|
||||||
final res = await showDialog<String?>(
|
final res = await m3ShowDialog<String?>(
|
||||||
context:
|
context:
|
||||||
context,
|
context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
|
|
@ -2234,7 +2235,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final urlCtrl =
|
final urlCtrl =
|
||||||
TextEditingController();
|
TextEditingController();
|
||||||
final res = await showDialog<String?>(
|
final res = await m3ShowDialog<String?>(
|
||||||
context:
|
context:
|
||||||
context,
|
context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
|
|
@ -2848,7 +2849,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 280,
|
width: 280,
|
||||||
child: Card(
|
child: Card(
|
||||||
elevation: 4,
|
elevation: 0,
|
||||||
|
shadowColor: Colors.transparent,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12.0),
|
padding: const EdgeInsets.all(12.0),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|
@ -3779,7 +3781,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
Timer? titleTypingTimer;
|
Timer? titleTypingTimer;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await showDialog<void>(
|
await m3ShowDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
var saving = false;
|
var saving = false;
|
||||||
|
|
@ -3999,7 +4001,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
: () => Navigator.of(dialogContext).pop(),
|
: () => Navigator.of(dialogContext).pop(),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
FilledButton(
|
||||||
onPressed: saving
|
onPressed: saving
|
||||||
? null
|
? null
|
||||||
: () async {
|
: () async {
|
||||||
|
|
@ -4174,7 +4176,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
// If cancelling, require a reason — show dialog with spinner.
|
// If cancelling, require a reason — show dialog with spinner.
|
||||||
if (value == 'cancelled') {
|
if (value == 'cancelled') {
|
||||||
final reasonCtrl = TextEditingController();
|
final reasonCtrl = TextEditingController();
|
||||||
await showDialog<void>(
|
await m3ShowDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
var isSaving = false;
|
var isSaving = false;
|
||||||
|
|
@ -4399,7 +4401,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
// Show loading dialog
|
// Show loading dialog
|
||||||
showDialog(
|
m3ShowDialog(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (BuildContext dialogContext) {
|
builder: (BuildContext dialogContext) {
|
||||||
|
|
@ -4543,7 +4545,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
|
|
||||||
Future<void> _deleteTaskAttachment(String taskId, String fileName) async {
|
Future<void> _deleteTaskAttachment(String taskId, String fileName) async {
|
||||||
try {
|
try {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await m3ShowDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Delete Attachment?'),
|
title: const Text('Delete Attachment?'),
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../theme/m3_motion.dart';
|
||||||
import 'package:flutter/services.dart' show rootBundle;
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
import 'package:flutter_quill/flutter_quill.dart' as quill;
|
import 'package:flutter_quill/flutter_quill.dart' as quill;
|
||||||
import 'package:pdf/widgets.dart' as pw;
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
|
|
@ -495,7 +496,7 @@ Future<void> showTaskPdfPreview(
|
||||||
List<TaskAssignment> assignments,
|
List<TaskAssignment> assignments,
|
||||||
List<Profile> profiles,
|
List<Profile> profiles,
|
||||||
) async {
|
) async {
|
||||||
await showDialog<void>(
|
await m3ShowDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => TaskPdfDialog(
|
builder: (ctx) => TaskPdfDialog(
|
||||||
task: task,
|
task: task,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../theme/m3_motion.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||||
|
|
@ -554,7 +555,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||||
right: 16,
|
right: 16,
|
||||||
bottom: 16,
|
bottom: 16,
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: FloatingActionButton.extended(
|
child: M3ExpandedFab(
|
||||||
onPressed: () => _showCreateTaskDialog(context, ref),
|
onPressed: () => _showCreateTaskDialog(context, ref),
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: const Text('New Task'),
|
label: const Text('New Task'),
|
||||||
|
|
@ -588,7 +589,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||||
var showTitleGemini = false;
|
var showTitleGemini = false;
|
||||||
Timer? titleTypingTimer;
|
Timer? titleTypingTimer;
|
||||||
|
|
||||||
await showDialog<void>(
|
await m3ShowDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
bool saving = false;
|
bool saving = false;
|
||||||
|
|
@ -1099,8 +1100,11 @@ class _StatusSummaryCard extends StatelessWidget {
|
||||||
_ => scheme.onSurfaceVariant,
|
_ => scheme.onSurfaceVariant,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// M3 Expressive: filled card with semantic tonal color, no shadow.
|
||||||
return Card(
|
return Card(
|
||||||
color: background,
|
color: background,
|
||||||
|
elevation: 0,
|
||||||
|
shadowColor: Colors.transparent,
|
||||||
// summary cards are compact — use compact token for consistent density
|
// summary cards are compact — use compact token for consistent density
|
||||||
shape: AppSurfaces.of(context).compactShape,
|
shape: AppSurfaces.of(context).compactShape,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../theme/m3_motion.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../models/office.dart';
|
import '../../models/office.dart';
|
||||||
import '../../models/profile.dart';
|
import '../../models/profile.dart';
|
||||||
|
|
@ -257,10 +258,10 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (err, stack) => Center(child: Text('Error: $err')),
|
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: M3Fab(
|
||||||
onPressed: () => _showTeamDialog(context),
|
onPressed: () => _showTeamDialog(context),
|
||||||
tooltip: 'Add Team',
|
tooltip: 'Add Team',
|
||||||
child: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -520,10 +521,9 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
|
||||||
|
|
||||||
if (isMobileDialog) {
|
if (isMobileDialog) {
|
||||||
// Mobile: bottom sheet presentation
|
// Mobile: bottom sheet presentation
|
||||||
await showModalBottomSheet<void>(
|
await m3ShowBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
shape: AppSurfaces.of(context).dialogShape,
|
|
||||||
builder: (sheetContext) {
|
builder: (sheetContext) {
|
||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
builder: (sheetContext, setState) {
|
builder: (sheetContext, setState) {
|
||||||
|
|
@ -555,7 +555,7 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
ElevatedButton(
|
FilledButton(
|
||||||
onPressed: () => onSave(setState, navigator),
|
onPressed: () => onSave(setState, navigator),
|
||||||
child: Text(isEdit ? 'Save' : 'Add'),
|
child: Text(isEdit ? 'Save' : 'Add'),
|
||||||
),
|
),
|
||||||
|
|
@ -571,7 +571,7 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Desktop / Tablet: centered fixed-width AlertDialog
|
// Desktop / Tablet: centered fixed-width AlertDialog
|
||||||
await showDialog<void>(
|
await m3ShowDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
return Center(
|
return Center(
|
||||||
|
|
@ -589,7 +589,7 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
|
||||||
onPressed: () => navigator.pop(),
|
onPressed: () => navigator.pop(),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
FilledButton(
|
||||||
onPressed: () => onSave(setState, navigator),
|
onPressed: () => onSave(setState, navigator),
|
||||||
child: Text(isEdit ? 'Save' : 'Add'),
|
child: Text(isEdit ? 'Save' : 'Add'),
|
||||||
),
|
),
|
||||||
|
|
@ -605,7 +605,7 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _deleteTeam(BuildContext context, String teamId) async {
|
void _deleteTeam(BuildContext context, String teamId) async {
|
||||||
final confirmed = await showDialog<bool?>(
|
final confirmed = await m3ShowDialog<bool?>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
shape: AppSurfaces.of(dialogContext).dialogShape,
|
shape: AppSurfaces.of(dialogContext).dialogShape,
|
||||||
|
|
@ -619,7 +619,7 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
child: const Text('Delete'),
|
child: const Text('Delete'),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../theme/m3_motion.dart';
|
||||||
import 'package:tasq/utils/app_time.dart';
|
import 'package:tasq/utils/app_time.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.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 {
|
Future<void> _showTimelineDialog(BuildContext context, Ticket ticket) async {
|
||||||
await showDialog<void>(
|
await m3ShowDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
|
|
@ -897,7 +898,7 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
final descCtrl = TextEditingController(text: ticket.description);
|
final descCtrl = TextEditingController(text: ticket.description);
|
||||||
String? selectedOffice = ticket.officeId;
|
String? selectedOffice = ticket.officeId;
|
||||||
|
|
||||||
await showDialog<void>(
|
await m3ShowDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
var saving = false;
|
var saving = false;
|
||||||
|
|
@ -967,7 +968,7 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
: () => Navigator.of(dialogContext).pop(),
|
: () => Navigator.of(dialogContext).pop(),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
FilledButton(
|
||||||
onPressed: saving
|
onPressed: saving
|
||||||
? null
|
? null
|
||||||
: () async {
|
: () async {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../theme/m3_motion.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:tasq/utils/app_time.dart';
|
import 'package:tasq/utils/app_time.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
@ -341,7 +342,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||||
right: 16,
|
right: 16,
|
||||||
bottom: 16,
|
bottom: 16,
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: FloatingActionButton.extended(
|
child: M3ExpandedFab(
|
||||||
onPressed: () => _showCreateTicketDialog(context, ref),
|
onPressed: () => _showCreateTicketDialog(context, ref),
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: const Text('New Ticket'),
|
label: const Text('New Ticket'),
|
||||||
|
|
@ -361,7 +362,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||||
final descriptionController = TextEditingController();
|
final descriptionController = TextEditingController();
|
||||||
Office? selectedOffice;
|
Office? selectedOffice;
|
||||||
|
|
||||||
await showDialog<void>(
|
await m3ShowDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
bool saving = false;
|
bool saving = false;
|
||||||
|
|
@ -644,8 +645,11 @@ class _StatusSummaryCard extends StatelessWidget {
|
||||||
_ => scheme.onSurfaceVariant,
|
_ => scheme.onSurfaceVariant,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// M3 Expressive: filled card with semantic tonal color, no shadow.
|
||||||
return Card(
|
return Card(
|
||||||
color: background,
|
color: background,
|
||||||
|
elevation: 0,
|
||||||
|
shadowColor: Colors.transparent,
|
||||||
// summary cards are compact — use compact token for consistent density
|
// summary cards are compact — use compact token for consistent density
|
||||||
shape: AppSurfaces.of(context).compactShape,
|
shape: AppSurfaces.of(context).compactShape,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../theme/m3_motion.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:tasq/utils/app_time.dart';
|
import 'package:tasq/utils/app_time.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
|
@ -451,7 +452,7 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
List<DutySchedule> recipientShifts = [];
|
List<DutySchedule> recipientShifts = [];
|
||||||
String? selectedTargetShiftId;
|
String? selectedTargetShiftId;
|
||||||
|
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await m3ShowDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
|
|
@ -614,7 +615,7 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
required String title,
|
required String title,
|
||||||
required String message,
|
required String message,
|
||||||
}) async {
|
}) async {
|
||||||
await showDialog<void>(
|
await m3ShowDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
|
|
@ -633,7 +634,7 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
|
|
||||||
Future<BuildContext> _showCheckInProgress(BuildContext context) {
|
Future<BuildContext> _showCheckInProgress(BuildContext context) {
|
||||||
final completer = Completer<BuildContext>();
|
final completer = Completer<BuildContext>();
|
||||||
showDialog<void>(
|
m3ShowDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
|
|
@ -1140,7 +1141,7 @@ class _ScheduleGeneratorPanelState
|
||||||
existing?.endTime ?? start.add(const Duration(hours: 8)),
|
existing?.endTime ?? start.add(const Duration(hours: 8)),
|
||||||
);
|
);
|
||||||
|
|
||||||
final result = await showDialog<_DraftSchedule>(
|
final result = await m3ShowDialog<_DraftSchedule>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
|
|
@ -2002,7 +2003,7 @@ class _SwapRequestsPanel extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Profile? choice = eligible.first;
|
Profile? choice = eligible.first;
|
||||||
final selected = await showDialog<Profile?>(
|
final selected = await m3ShowDialog<Profile?>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,34 @@
|
||||||
import 'package:flutter/material.dart';
|
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
|
@immutable
|
||||||
class AppSurfaces extends ThemeExtension<AppSurfaces> {
|
class AppSurfaces extends ThemeExtension<AppSurfaces> {
|
||||||
const AppSurfaces({
|
const AppSurfaces({
|
||||||
required this.cardRadius,
|
required this.cardRadius,
|
||||||
required this.compactCardRadius,
|
required this.compactCardRadius,
|
||||||
|
required this.containerRadius,
|
||||||
required this.dialogRadius,
|
required this.dialogRadius,
|
||||||
required this.cardElevation,
|
required this.chipRadius,
|
||||||
required this.cardShadowColor,
|
|
||||||
required this.compactShadowColor,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Standard card radius – 16 dp (M3 medium shape).
|
||||||
final double cardRadius;
|
final double cardRadius;
|
||||||
|
|
||||||
|
/// Compact card radius for dense list tiles – 12 dp.
|
||||||
final double compactCardRadius;
|
final double compactCardRadius;
|
||||||
|
|
||||||
|
/// Large container radius – 28 dp (M3 Expressive).
|
||||||
|
final double containerRadius;
|
||||||
|
|
||||||
|
/// Dialog / bottom-sheet radius – 28 dp.
|
||||||
final double dialogRadius;
|
final double dialogRadius;
|
||||||
final double cardElevation;
|
|
||||||
final Color cardShadowColor;
|
/// Chip / badge radius – 12 dp.
|
||||||
final Color compactShadowColor;
|
final double chipRadius;
|
||||||
|
|
||||||
// convenience shapes
|
// convenience shapes
|
||||||
RoundedRectangleBorder get standardShape =>
|
RoundedRectangleBorder get standardShape =>
|
||||||
|
|
@ -24,6 +36,9 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
|
||||||
RoundedRectangleBorder get compactShape => RoundedRectangleBorder(
|
RoundedRectangleBorder get compactShape => RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(compactCardRadius),
|
borderRadius: BorderRadius.circular(compactCardRadius),
|
||||||
);
|
);
|
||||||
|
RoundedRectangleBorder get containerShape => RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(containerRadius),
|
||||||
|
);
|
||||||
RoundedRectangleBorder get dialogShape =>
|
RoundedRectangleBorder get dialogShape =>
|
||||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(dialogRadius));
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(dialogRadius));
|
||||||
|
|
||||||
|
|
@ -33,10 +48,9 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
|
||||||
const AppSurfaces(
|
const AppSurfaces(
|
||||||
cardRadius: 16,
|
cardRadius: 16,
|
||||||
compactCardRadius: 12,
|
compactCardRadius: 12,
|
||||||
dialogRadius: 20,
|
containerRadius: 28,
|
||||||
cardElevation: 3,
|
dialogRadius: 28,
|
||||||
cardShadowColor: Color.fromRGBO(0, 0, 0, 0.12),
|
chipRadius: 12,
|
||||||
compactShadowColor: Color.fromRGBO(0, 0, 0, 0.08),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,18 +58,16 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
|
||||||
AppSurfaces copyWith({
|
AppSurfaces copyWith({
|
||||||
double? cardRadius,
|
double? cardRadius,
|
||||||
double? compactCardRadius,
|
double? compactCardRadius,
|
||||||
|
double? containerRadius,
|
||||||
double? dialogRadius,
|
double? dialogRadius,
|
||||||
double? cardElevation,
|
double? chipRadius,
|
||||||
Color? cardShadowColor,
|
|
||||||
Color? compactShadowColor,
|
|
||||||
}) {
|
}) {
|
||||||
return AppSurfaces(
|
return AppSurfaces(
|
||||||
cardRadius: cardRadius ?? this.cardRadius,
|
cardRadius: cardRadius ?? this.cardRadius,
|
||||||
compactCardRadius: compactCardRadius ?? this.compactCardRadius,
|
compactCardRadius: compactCardRadius ?? this.compactCardRadius,
|
||||||
|
containerRadius: containerRadius ?? this.containerRadius,
|
||||||
dialogRadius: dialogRadius ?? this.dialogRadius,
|
dialogRadius: dialogRadius ?? this.dialogRadius,
|
||||||
cardElevation: cardElevation ?? this.cardElevation,
|
chipRadius: chipRadius ?? this.chipRadius,
|
||||||
cardShadowColor: cardShadowColor ?? this.cardShadowColor,
|
|
||||||
compactShadowColor: compactShadowColor ?? this.compactShadowColor,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,28 +75,17 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
|
||||||
AppSurfaces lerp(ThemeExtension<AppSurfaces>? other, double t) {
|
AppSurfaces lerp(ThemeExtension<AppSurfaces>? other, double t) {
|
||||||
if (other is! AppSurfaces) return this;
|
if (other is! AppSurfaces) return this;
|
||||||
return AppSurfaces(
|
return AppSurfaces(
|
||||||
cardRadius: lerpDouble(cardRadius, other.cardRadius, t) ?? cardRadius,
|
cardRadius: _lerpDouble(cardRadius, other.cardRadius, t),
|
||||||
compactCardRadius:
|
compactCardRadius: _lerpDouble(
|
||||||
lerpDouble(compactCardRadius, other.compactCardRadius, t) ??
|
compactCardRadius,
|
||||||
compactCardRadius,
|
other.compactCardRadius,
|
||||||
dialogRadius:
|
t,
|
||||||
lerpDouble(dialogRadius, other.dialogRadius, t) ?? dialogRadius,
|
),
|
||||||
cardElevation:
|
containerRadius: _lerpDouble(containerRadius, other.containerRadius, t),
|
||||||
lerpDouble(cardElevation, other.cardElevation, t) ?? cardElevation,
|
dialogRadius: _lerpDouble(dialogRadius, other.dialogRadius, t),
|
||||||
cardShadowColor:
|
chipRadius: _lerpDouble(chipRadius, other.chipRadius, t),
|
||||||
Color.lerp(cardShadowColor, other.cardShadowColor, t) ??
|
|
||||||
cardShadowColor,
|
|
||||||
compactShadowColor:
|
|
||||||
Color.lerp(compactShadowColor, other.compactShadowColor, t) ??
|
|
||||||
compactShadowColor,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper because dart:ui lerpDouble isn't exported here
|
double _lerpDouble(double a, double b, double t) => a + (b - a) * t;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -4,143 +4,59 @@ import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'app_typography.dart';
|
import 'app_typography.dart';
|
||||||
import 'app_surfaces.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 {
|
class AppTheme {
|
||||||
|
/// The seed color drives M3's entire tonal palette generation.
|
||||||
|
static const Color _seed = Color(0xFF4A6FA5);
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
// LIGHT
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
static ThemeData light() {
|
static ThemeData light() {
|
||||||
final base = ThemeData(
|
final base = ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
seedColor: const Color(0xFF334155),
|
seedColor: _seed,
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
),
|
),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
final textTheme = GoogleFonts.spaceGroteskTextTheme(base.textTheme);
|
return _apply(base);
|
||||||
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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
// DARK
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
static ThemeData dark() {
|
static ThemeData dark() {
|
||||||
final base = ThemeData(
|
final base = ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
seedColor: const Color(0xFF334155),
|
seedColor: _seed,
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
),
|
),
|
||||||
useMaterial3: true,
|
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 textTheme = GoogleFonts.spaceGroteskTextTheme(base.textTheme);
|
||||||
final monoTheme = GoogleFonts.robotoMonoTextTheme(base.textTheme);
|
final monoTheme = GoogleFonts.robotoMonoTextTheme(base.textTheme);
|
||||||
final mono = AppMonoText(
|
final mono = AppMonoText(
|
||||||
|
|
@ -152,110 +68,261 @@ class AppTheme {
|
||||||
const TextStyle(letterSpacing: 0.2),
|
const TextStyle(letterSpacing: 0.2),
|
||||||
);
|
);
|
||||||
|
|
||||||
final surfaces = AppSurfaces(
|
const surfaces = AppSurfaces(
|
||||||
cardRadius: 16,
|
cardRadius: 16,
|
||||||
compactCardRadius: 12,
|
compactCardRadius: 12,
|
||||||
dialogRadius: 20,
|
containerRadius: 28,
|
||||||
cardElevation: 3,
|
dialogRadius: 28,
|
||||||
cardShadowColor: const Color.fromRGBO(0, 0, 0, 0.24),
|
chipRadius: 12,
|
||||||
compactShadowColor: const Color.fromRGBO(0, 0, 0, 0.12),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return base.copyWith(
|
return base.copyWith(
|
||||||
textTheme: textTheme,
|
textTheme: textTheme,
|
||||||
scaffoldBackgroundColor: base.colorScheme.surface,
|
scaffoldBackgroundColor: isDark ? cs.surface : cs.surfaceContainerLowest,
|
||||||
extensions: [mono, surfaces],
|
extensions: [mono, surfaces],
|
||||||
|
|
||||||
|
// ── AppBar ──────────────────────────────────────────────
|
||||||
appBarTheme: AppBarTheme(
|
appBarTheme: AppBarTheme(
|
||||||
backgroundColor: base.colorScheme.surface,
|
backgroundColor: cs.surface,
|
||||||
foregroundColor: base.colorScheme.onSurface,
|
foregroundColor: cs.onSurface,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
scrolledUnderElevation: 1,
|
scrolledUnderElevation: 2,
|
||||||
surfaceTintColor: base.colorScheme.surfaceTint,
|
surfaceTintColor: cs.surfaceTint,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
titleTextStyle: textTheme.titleLarge?.copyWith(
|
titleTextStyle: textTheme.titleLarge?.copyWith(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 0.2,
|
letterSpacing: 0.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// ── Cards — M3 Elevated (tonal surface tint, no hard shadow) ──
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
color: base.colorScheme.surfaceContainer,
|
color: isDark ? cs.surfaceContainer : cs.surfaceContainerLow,
|
||||||
elevation: 3, // M2-style elevation for visible separation (2-4 allowed)
|
elevation: 1,
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
shadowColor: const Color.fromRGBO(0, 0, 0, 0.24),
|
shadowColor: Colors.transparent,
|
||||||
shape: RoundedRectangleBorder(
|
surfaceTintColor: cs.surfaceTint,
|
||||||
borderRadius: BorderRadius.circular(16),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
side: BorderSide(color: base.colorScheme.outlineVariant, width: 1),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// ── Chips ───────────────────────────────────────────────
|
||||||
chipTheme: ChipThemeData(
|
chipTheme: ChipThemeData(
|
||||||
backgroundColor: base.colorScheme.surfaceContainerHighest,
|
backgroundColor: cs.surfaceContainerHighest,
|
||||||
side: BorderSide(color: base.colorScheme.outlineVariant),
|
side: BorderSide.none,
|
||||||
labelStyle: textTheme.labelSmall,
|
labelStyle: textTheme.labelSmall,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
),
|
),
|
||||||
dividerTheme: DividerThemeData(
|
|
||||||
color: base.colorScheme.outlineVariant,
|
// ── Dividers ────────────────────────────────────────────
|
||||||
thickness: 1,
|
dividerTheme: DividerThemeData(color: cs.outlineVariant, thickness: 1),
|
||||||
),
|
|
||||||
|
// ── Input Fields ────────────────────────────────────────
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: base.colorScheme.surfaceContainerLow,
|
fillColor: cs.surfaceContainerLow,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16),
|
||||||
borderSide: BorderSide(color: base.colorScheme.outlineVariant),
|
borderSide: BorderSide(color: cs.outlineVariant),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16),
|
||||||
borderSide: BorderSide(color: base.colorScheme.outlineVariant),
|
borderSide: BorderSide(color: cs.outlineVariant),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16),
|
||||||
borderSide: BorderSide(color: base.colorScheme.primary, width: 1.5),
|
borderSide: BorderSide(color: cs.primary, width: 2),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 14,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// ── Buttons — M3 Expressive hierarchy ───────────────────
|
||||||
filledButtonTheme: FilledButtonThemeData(
|
filledButtonTheme: FilledButtonThemeData(
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
shape: RoundedRectangleBorder(
|
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(
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
style: OutlinedButton.styleFrom(
|
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(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
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(
|
navigationDrawerTheme: NavigationDrawerThemeData(
|
||||||
backgroundColor: base.colorScheme.surface,
|
backgroundColor: cs.surface,
|
||||||
indicatorColor: base.colorScheme.secondaryContainer,
|
indicatorColor: cs.secondaryContainer,
|
||||||
tileHeight: 52,
|
tileHeight: 56,
|
||||||
|
indicatorShape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
navigationRailTheme: NavigationRailThemeData(
|
navigationRailTheme: NavigationRailThemeData(
|
||||||
backgroundColor: base.colorScheme.surface,
|
backgroundColor: cs.surface,
|
||||||
selectedIconTheme: IconThemeData(color: base.colorScheme.primary),
|
selectedIconTheme: IconThemeData(color: cs.onSecondaryContainer),
|
||||||
selectedLabelTextStyle: textTheme.labelLarge?.copyWith(
|
unselectedIconTheme: IconThemeData(color: cs.onSurfaceVariant),
|
||||||
fontWeight: FontWeight.w600,
|
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(
|
navigationBarTheme: NavigationBarThemeData(
|
||||||
backgroundColor: base.colorScheme.surface,
|
backgroundColor: cs.surfaceContainer,
|
||||||
indicatorColor: base.colorScheme.primaryContainer,
|
indicatorColor: cs.secondaryContainer,
|
||||||
labelTextStyle: WidgetStateProperty.all(
|
indicatorShape: RoundedRectangleBorder(
|
||||||
textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600),
|
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(
|
listTileTheme: ListTileThemeData(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
tileColor: base.colorScheme.surfaceContainer,
|
tileColor: Colors.transparent,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
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 'package:flutter/material.dart';
|
||||||
|
import '../theme/m3_motion.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
// showcaseview removed due to null-safety incompatibility; onboarding shown via dialog
|
// 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/notifications_provider.dart';
|
||||||
import '../providers/profile_provider.dart';
|
import '../providers/profile_provider.dart';
|
||||||
import 'app_breakpoints.dart';
|
import 'app_breakpoints.dart';
|
||||||
|
import 'profile_avatar.dart';
|
||||||
|
|
||||||
final GlobalKey notificationBellKey = GlobalKey();
|
final GlobalKey notificationBellKey = GlobalKey();
|
||||||
|
|
||||||
|
|
@ -49,7 +51,7 @@ class AppScaffold extends ConsumerWidget {
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.memory),
|
Image.asset('assets/tasq_ico.png', width: 28, height: 28),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text('TasQ'),
|
Text('TasQ'),
|
||||||
],
|
],
|
||||||
|
|
@ -73,7 +75,7 @@ class AppScaffold extends ConsumerWidget {
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.account_circle),
|
ProfileAvatar(fullName: displayName, radius: 16),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(displayName),
|
Text(displayName),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
|
|
@ -89,7 +91,7 @@ class AppScaffold extends ConsumerWidget {
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Profile',
|
tooltip: 'Profile',
|
||||||
onPressed: () => context.go('/profile'),
|
onPressed: () => context.go('/profile'),
|
||||||
icon: const Icon(Icons.account_circle),
|
icon: ProfileAvatar(fullName: displayName, radius: 16),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Sign out',
|
tooltip: 'Sign out',
|
||||||
|
|
@ -164,23 +166,44 @@ class AppNavigationRail extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final currentIndex = _currentIndex(location, items);
|
final currentIndex = _currentIndex(location, items);
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
// M3 Expressive: tonal surface container instead of a hard border divider.
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(color: cs.surfaceContainerLow),
|
||||||
color: Theme.of(context).colorScheme.surface,
|
|
||||||
border: Border(
|
|
||||||
right: BorderSide(
|
|
||||||
color: Theme.of(context).colorScheme.outlineVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: NavigationRail(
|
child: NavigationRail(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
extended: extended,
|
extended: extended,
|
||||||
selectedIndex: currentIndex,
|
selectedIndex: currentIndex,
|
||||||
onDestinationSelected: (value) {
|
onDestinationSelected: (value) {
|
||||||
items[value].onTap(context, onLogout: onLogout);
|
items[value].onTap(context, onLogout: onLogout);
|
||||||
},
|
},
|
||||||
leading: const SizedBox.shrink(),
|
leading: Padding(
|
||||||
trailing: const SizedBox.shrink(),
|
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: [
|
destinations: [
|
||||||
for (final item in items)
|
for (final item in items)
|
||||||
NavigationRailDestination(
|
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 {
|
class _ShellBackground extends StatelessWidget {
|
||||||
const _ShellBackground({required this.child});
|
const _ShellBackground({required this.child});
|
||||||
|
|
||||||
|
|
@ -263,17 +288,8 @@ class _ShellBackground extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return ColoredBox(
|
||||||
decoration: BoxDecoration(
|
color: Theme.of(context).scaffoldBackgroundColor,
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [
|
|
||||||
Theme.of(context).colorScheme.surface,
|
|
||||||
Theme.of(context).colorScheme.surfaceContainerLowest,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -531,9 +547,8 @@ Future<void> _showOverflowSheet(
|
||||||
List<NavItem> items,
|
List<NavItem> items,
|
||||||
VoidCallback onLogout,
|
VoidCallback onLogout,
|
||||||
) async {
|
) async {
|
||||||
await showModalBottomSheet<void>(
|
await m3ShowBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
showDragHandle: true,
|
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: ListView(
|
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 'package:flutter/material.dart';
|
||||||
|
import '../theme/m3_motion.dart';
|
||||||
|
|
||||||
/// Lightweight, bounds-safe multi-select picker used in dialogs.
|
/// Lightweight, bounds-safe multi-select picker used in dialogs.
|
||||||
/// - Renders chips for selected items and a `Select` ActionChip.
|
/// - Renders chips for selected items and a `Select` ActionChip.
|
||||||
|
|
@ -43,7 +44,7 @@ class _MultiSelectPickerState<T> extends State<MultiSelectPicker<T>> {
|
||||||
List<String>? result;
|
List<String>? result;
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
result = await showModalBottomSheet<List<String>>(
|
result = await m3ShowBottomSheet<List<String>>(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder: (sheetContext) {
|
builder: (sheetContext) {
|
||||||
|
|
@ -132,7 +133,7 @@ class _MultiSelectPickerState<T> extends State<MultiSelectPicker<T>> {
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
ElevatedButton(
|
FilledButton(
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
Navigator.of(sheetContext).pop(working),
|
Navigator.of(sheetContext).pop(working),
|
||||||
child: const Text('Done'),
|
child: const Text('Done'),
|
||||||
|
|
@ -148,9 +149,8 @@ class _MultiSelectPickerState<T> extends State<MultiSelectPicker<T>> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
result = await showDialog<List<String>>(
|
result = await m3ShowDialog<List<String>>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
List<String> working = List<String>.from(_selectedIds);
|
List<String> working = List<String>.from(_selectedIds);
|
||||||
bool workingSelectAll =
|
bool workingSelectAll =
|
||||||
|
|
@ -232,7 +232,7 @@ class _MultiSelectPickerState<T> extends State<MultiSelectPicker<T>> {
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
FilledButton(
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
Navigator.of(dialogContext).pop(working),
|
Navigator.of(dialogContext).pop(working),
|
||||||
child: const Text('Done'),
|
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';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// M3 Expressive status pill — uses tonal container colors with a
|
||||||
|
/// smooth, spring-physics-inspired animation.
|
||||||
class StatusPill extends StatelessWidget {
|
class StatusPill extends StatelessWidget {
|
||||||
const StatusPill({super.key, required this.label, this.isEmphasized = false});
|
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 scheme = Theme.of(context).colorScheme;
|
||||||
final background = isEmphasized
|
final background = isEmphasized
|
||||||
? scheme.tertiaryContainer
|
? scheme.tertiaryContainer
|
||||||
: scheme.tertiaryContainer.withValues(alpha: 0.65);
|
: scheme.tertiaryContainer;
|
||||||
final foreground = scheme.onTertiaryContainer;
|
final foreground = scheme.onTertiaryContainer;
|
||||||
|
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 220),
|
duration: const Duration(milliseconds: 400),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
curve: Curves.easeOutCubic,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: background,
|
color: background,
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(28),
|
||||||
border: Border.all(color: scheme.tertiary.withValues(alpha: 0.3)),
|
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
label.toUpperCase(),
|
label.toUpperCase(),
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
color: foreground,
|
color: foreground,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 0.4,
|
letterSpacing: 0.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../theme/m3_motion.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../models/profile.dart';
|
import '../models/profile.dart';
|
||||||
|
|
@ -117,7 +118,7 @@ class TaskAssignmentSection extends ConsumerWidget {
|
||||||
// consider vacancy anymore because everyone is eligible, so the only
|
// consider vacancy anymore because everyone is eligible, so the only
|
||||||
// reason for the dialog to be unusable is an empty staff list.
|
// reason for the dialog to be unusable is an empty staff list.
|
||||||
if (eligibleStaff.isEmpty && assignedIds.isEmpty) {
|
if (eligibleStaff.isEmpty && assignedIds.isEmpty) {
|
||||||
await showDialog<void>(
|
await m3ShowDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
|
|
@ -137,7 +138,7 @@ class TaskAssignmentSection extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
final selection = assignedIds.toSet();
|
final selection = assignedIds.toSet();
|
||||||
await showDialog<void>(
|
await m3ShowDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
var isSaving = false;
|
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 {
|
class _MobileTile<T> extends StatelessWidget {
|
||||||
const _MobileTile({
|
const _MobileTile({
|
||||||
required this.item,
|
required this.item,
|
||||||
|
|
@ -615,21 +615,14 @@ class _MobileTile<T> extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tile = mobileTileBuilder(context, item, actions);
|
final tile = mobileTileBuilder(context, item, actions);
|
||||||
|
|
||||||
// Apply Material 2 style elevation for Cards (per Hybrid M3/M2 guidelines).
|
// M3 Expressive: cards use tonal surface tints. The theme's CardThemeData
|
||||||
// Mobile tiles deliberately use a slightly smaller corner radius for
|
// already specifies surfaceTintColor and low elevation. We apply the
|
||||||
// compactness, but they should inherit the global card elevation and
|
// compact shape for list density.
|
||||||
// shadow color from the theme to maintain visual consistency.
|
|
||||||
if (tile is Card) {
|
if (tile is Card) {
|
||||||
final themeCard = Theme.of(context).cardTheme;
|
|
||||||
return Card(
|
return Card(
|
||||||
color: tile.color,
|
color: tile.color,
|
||||||
elevation: themeCard.elevation ?? 3,
|
|
||||||
margin: tile.margin,
|
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,
|
shape: tile.shape ?? AppSurfaces.of(context).compactShape,
|
||||||
shadowColor: AppSurfaces.of(context).compactShadowColor,
|
|
||||||
clipBehavior: tile.clipBehavior,
|
clipBehavior: tile.clipBehavior,
|
||||||
child: tile.child,
|
child: tile.child,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user