Refactor theme system and remove unused platforms

- Overhaul theme system with Svrnty design and WCAG AAA compliance
- Remove android, macos, and web platform files (iOS-only focus)
- Update components with improved dark mode map and UI refinements
- Enhance settings page with additional configuration options
- Add theme system documentation in lib/theme/README.md
- Update CLAUDE.md with comprehensive theme guidelines

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-26 14:47:51 -05:00
parent 554b26cfd1
commit edb106a7fd
102 changed files with 1225 additions and 2785 deletions
+1
View File
@@ -318,6 +318,7 @@ class _DeliveriesPageState extends ConsumerState<DeliveriesPage> {
endpoint: 'completeDelivery',
command: CompleteDeliveryCommand(
deliveryId: delivery.id,
deliveredAt: DateTime.now().toUtc().toIso8601String(),
),
);
result.when(
+65 -78
View File
@@ -6,9 +6,7 @@ import 'package:image_picker/image_picker.dart';
import 'package:http/http.dart' as http;
import '../models/delivery.dart';
import '../models/delivery_route.dart';
import '../models/delivery_commands.dart';
import '../providers/providers.dart';
import '../api/client.dart';
import '../utils/toast_helper.dart';
import '../api/openapi_config.dart';
import '../utils/http_client_factory.dart';
@@ -76,7 +74,7 @@ class _RoutesPageState extends ConsumerState<RoutesPage> {
void _backToRoutes() {
setState(() {
_selectedRoute = null;
_selectedDelivery = null;
// Keep _selectedDelivery to preserve selection when returning to map
});
}
@@ -98,11 +96,8 @@ class _RoutesPageState extends ConsumerState<RoutesPage> {
return;
}
// Create API client with auth service for automatic token refresh
final authClient = CqrsApiClient(
config: ApiClientConfig.development,
authService: authService,
);
// Use gRPC client for commands
final grpcClient = ref.read(grpcClientProvider);
switch (action) {
case 'complete':
@@ -110,11 +105,8 @@ class _RoutesPageState extends ConsumerState<RoutesPage> {
LoadingDialog.show(context, message: l10n.completingDelivery);
}
final result = await authClient.executeCommand(
endpoint: 'completeDelivery',
command: CompleteDeliveryCommand(
deliveryId: delivery.id,
),
final result = await grpcClient.completeDelivery(
deliveryId: delivery.id,
);
result.when(
success: (_) async {
@@ -123,17 +115,13 @@ class _RoutesPageState extends ConsumerState<RoutesPage> {
}
if (mounted) {
// Invalidate both providers to force refresh
ref.invalidate(deliveriesProvider(routeFragmentId));
ref.invalidate(allDeliveriesProvider);
ref.invalidate(deliveryRoutesProvider);
// Wait for providers to refresh
await Future.delayed(const Duration(milliseconds: 500));
// Refresh providers to force fresh data fetch
await ref.refresh(deliveriesProvider(routeFragmentId).future);
await ref.refresh(allDeliveriesProvider.future);
if (mounted) {
// Get refreshed deliveries
final allDeliveries = await ref.read(allDeliveriesProvider.future);
final allDeliveries = ref.read(allDeliveriesProvider).value ?? [];
final routeDeliveries = allDeliveries
.where((d) => d.routeFragmentId == routeFragmentId)
.toList();
@@ -172,12 +160,17 @@ class _RoutesPageState extends ConsumerState<RoutesPage> {
debugPrint('Complete delivery failed - Type: ${error.type}, Message: ${error.message}');
debugPrint('Error details: ${error.details}');
debugPrint('Status code: ${error.statusCode}');
if (mounted) {
String errorMessage = l10n.error(error.message);
if (error.statusCode == 500) {
errorMessage = l10n.serverError;
} else if (error.statusCode == 401) {
errorMessage = 'Unauthorized: You may not have permission to complete this delivery. Please check with your administrator.';
} else if (error.details != null) {
errorMessage = 'Error: ${error.details}';
}
ToastHelper.showError(context, errorMessage);
ToastHelper.showError(context, errorMessage, duration: const Duration(seconds: 5));
}
},
);
@@ -188,9 +181,8 @@ class _RoutesPageState extends ConsumerState<RoutesPage> {
LoadingDialog.show(context, message: l10n.markingAsUncompleted);
}
final uncompleteResult = await authClient.executeCommand(
endpoint: 'markDeliveryAsUncompleted',
command: MarkDeliveryAsUncompletedCommand(deliveryId: delivery.id),
final uncompleteResult = await grpcClient.markDeliveryAsUncompleted(
deliveryId: delivery.id,
);
uncompleteResult.when(
success: (_) async {
@@ -199,17 +191,13 @@ class _RoutesPageState extends ConsumerState<RoutesPage> {
}
if (mounted) {
// Invalidate both providers to force refresh
ref.invalidate(deliveriesProvider(routeFragmentId));
ref.invalidate(allDeliveriesProvider);
ref.invalidate(deliveryRoutesProvider);
// Wait for providers to refresh
await Future.delayed(const Duration(milliseconds: 500));
// Refresh providers to force fresh data fetch
await ref.refresh(deliveriesProvider(routeFragmentId).future);
await ref.refresh(allDeliveriesProvider.future);
if (mounted) {
// Get refreshed deliveries
final allDeliveries = await ref.read(allDeliveriesProvider.future);
final allDeliveries = ref.read(allDeliveriesProvider).value ?? [];
final updatedDelivery = allDeliveries.firstWhere(
(d) => d.id == delivery.id,
orElse: () => delivery,
@@ -299,12 +287,12 @@ class _RoutesPageState extends ConsumerState<RoutesPage> {
try {
final Uri uploadUrl = Uri.parse(
'${ApiClientConfig.development.baseUrl}/api/delivery/uploadDeliveryPicture?deliveryId=${delivery.id}',
'${ApiClientConfig.production.baseUrl}/api/delivery/uploadDeliveryPicture?deliveryId=${delivery.id}',
);
// Create HTTP client that accepts self-signed certificates
final client = HttpClientFactory.createClient(
allowSelfSigned: ApiClientConfig.development.allowSelfSignedCertificate,
allowSelfSigned: ApiClientConfig.production.allowSelfSignedCertificate,
);
final http.MultipartRequest request = http.MultipartRequest('POST', uploadUrl);
@@ -385,28 +373,40 @@ class _RoutesPageState extends ConsumerState<RoutesPage> {
final userProfile = ref.watch(userProfileProvider);
final l10n = AppLocalizations.of(context);
final isMobile = context.isMobile;
return Scaffold(
appBar: AppBar(
leading: (isMobile && _selectedRoute != null)
? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _backToRoutes,
tooltip: 'Back to routes',
)
: null,
title: Text(l10n.deliveryRoutes),
elevation: 0,
scrolledUnderElevation: 0,
actions: [
IconButton(
icon: (routesData.isLoading || allDeliveriesData.isLoading)
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
onPressed: (routesData.isLoading || allDeliveriesData.isLoading)
? null
: () {
ref.invalidate(deliveryRoutesProvider);
ref.invalidate(allDeliveriesProvider);
},
tooltip: 'Refresh',
),
// Hide refresh button when on map view (mobile + route selected)
// Google Maps Navigation has its own built-in volume controls
if (!(isMobile && _selectedRoute != null))
IconButton(
icon: (routesData.isLoading || allDeliveriesData.isLoading)
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
onPressed: (routesData.isLoading || allDeliveriesData.isLoading)
? null
: () {
ref.invalidate(deliveryRoutesProvider);
ref.invalidate(allDeliveriesProvider);
},
tooltip: 'Refresh',
),
userProfile.when(
data: (profile) {
String getInitials(String? fullName) {
@@ -431,12 +431,13 @@ class _RoutesPageState extends ConsumerState<RoutesPage> {
child: CircleAvatar(
radius: 16,
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
child: Text(
getInitials(profile?.fullName),
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontSize: 14,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w700,
),
),
),
@@ -496,32 +497,18 @@ class _RoutesPageState extends ConsumerState<RoutesPage> {
// ignore: unused_result
ref.refresh(allDeliveriesProvider);
},
child: Stack(
children: [
MobileMapWithOverlay(
deliveries: routeDeliveries,
selectedDelivery: _selectedDelivery,
onDeliverySelected: (delivery) {
setState(() {
_selectedDelivery = delivery;
});
_autoShowNotesIfNeeded(delivery);
},
onDeliveryAction: (delivery, action) {
_handleDeliveryAction(action, delivery, _selectedRoute!.id);
},
),
// Back button to return to routes list
Positioned(
top: 16,
left: 16,
child: FloatingActionButton.small(
onPressed: _backToRoutes,
backgroundColor: Theme.of(context).colorScheme.surface,
child: const Icon(Icons.arrow_back),
),
),
],
child: MobileMapWithOverlay(
deliveries: routeDeliveries,
selectedDelivery: _selectedDelivery,
onDeliverySelected: (delivery) {
setState(() {
_selectedDelivery = delivery;
});
_autoShowNotesIfNeeded(delivery);
},
onDeliveryAction: (delivery, action) {
_handleDeliveryAction(action, delivery, _selectedRoute!.id);
},
),
);
}
+171 -63
View File
@@ -31,7 +31,10 @@ class SettingsPage extends ConsumerWidget {
children: [
Text(
l10n.profile,
style: Theme.of(context).textTheme.titleMedium,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
userProfile.when(
@@ -49,9 +52,14 @@ class SettingsPage extends ConsumerWidget {
children: [
CircleAvatar(
radius: 32,
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
child: Text(
profile.firstName[0].toUpperCase(),
style: Theme.of(context).textTheme.titleLarge,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.onPrimary,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 16),
@@ -61,34 +69,53 @@ class SettingsPage extends ConsumerWidget {
children: [
Text(
profile.fullName,
style: Theme.of(context).textTheme.titleMedium,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
profile.email,
style: Theme.of(context).textTheme.bodySmall,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
],
),
),
IconButton.filled(
icon: const Icon(Icons.logout),
onPressed: () async {
final authService = ref.read(authServiceProvider);
await authService.logout();
if (context.mounted) {
// ignore: unused_result
ref.refresh(isAuthenticatedProvider);
if (context.mounted) {
Navigator.of(context).pushReplacementNamed('/');
}
}
},
color: Theme.of(context).colorScheme.error,
tooltip: l10n.logout,
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.tonalIcon(
onPressed: () async {
final authService = ref.read(authServiceProvider);
await authService.logout();
if (context.mounted) {
// ignore: unused_result
ref.refresh(isAuthenticatedProvider);
if (context.mounted) {
Navigator.of(context).pushReplacementNamed('/');
}
}
},
icon: const Icon(Icons.logout),
label: Text(
l10n.logout,
style: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.errorContainer,
foregroundColor: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
],
),
),
@@ -108,17 +135,32 @@ class SettingsPage extends ConsumerWidget {
children: [
Text(
l10n.preferences,
style: Theme.of(context).textTheme.titleMedium,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
ListTile(
title: Text(l10n.language),
title: Text(
l10n.language,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
subtitle: Text(
language == 'system'
? l10n.systemLanguage
: language == 'fr'
? l10n.french
: l10n.english
: l10n.english,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
trailing: DropdownButton<String>(
value: language,
@@ -130,52 +172,105 @@ class SettingsPage extends ConsumerWidget {
items: [
DropdownMenuItem(
value: 'system',
child: Text(l10n.systemLanguage),
child: Text(
l10n.systemLanguage,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
DropdownMenuItem(
value: 'en',
child: Text(l10n.english),
child: Text(
l10n.english,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
DropdownMenuItem(
value: 'fr',
child: Text(l10n.french),
child: Text(
l10n.french,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
],
),
),
const SizedBox(height: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.theme,
style: Theme.of(context).textTheme.titleSmall,
ListTile(
title: Text(
l10n.theme,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w700,
fontSize: 16,
),
const SizedBox(height: 8),
SegmentedButton<ThemeMode>(
selected: {themeMode},
onSelectionChanged: (Set<ThemeMode> newSelection) {
ref.read(themeModeProvider.notifier).setThemeMode(newSelection.first);
},
segments: [
ButtonSegment<ThemeMode>(
value: ThemeMode.light,
label: Text(l10n.themeLight),
icon: const Icon(Icons.light_mode),
),
ButtonSegment<ThemeMode>(
value: ThemeMode.dark,
label: Text(l10n.themeDark),
icon: const Icon(Icons.dark_mode),
),
ButtonSegment<ThemeMode>(
value: ThemeMode.system,
label: Text(l10n.themeSystem),
icon: const Icon(Icons.brightness_auto),
),
],
),
subtitle: Text(
themeMode == ThemeMode.light
? l10n.themeLight
: themeMode == ThemeMode.dark
? l10n.themeDark
: 'Device',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 14,
),
],
),
trailing: DropdownButton<ThemeMode>(
value: themeMode,
onChanged: (ThemeMode? newValue) {
if (newValue != null) {
ref.read(themeModeProvider.notifier).setThemeMode(newValue);
}
},
items: [
DropdownMenuItem(
value: ThemeMode.light,
child: Text(
l10n.themeLight,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
DropdownMenuItem(
value: ThemeMode.dark,
child: Text(
l10n.themeDark,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
DropdownMenuItem(
value: ThemeMode.system,
child: Text(
'Device',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
],
),
),
],
),
@@ -188,16 +283,29 @@ class SettingsPage extends ConsumerWidget {
children: [
Text(
l10n.about,
style: Theme.of(context).textTheme.titleMedium,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
ListTile(
title: Text(l10n.appVersion),
subtitle: const Text('1.0.0'),
),
ListTile(
title: Text(l10n.builtWithFlutter),
subtitle: Text(l10n.appDescription),
title: Text(
l10n.appVersion,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
subtitle: Text(
'1.0.0',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
],
),