@@ -56,18 +56,65 @@ class RouteLeg {
|
||||
String getDistanceKm() => (distance / 1000).toStringAsFixed(1);
|
||||
String getDurationMinutes() => (duration ~/ 60).toString();
|
||||
|
||||
factory RouteLeg.fromJson(Map<String, dynamic> json) {
|
||||
final coords = json['geometry']['coordinates'] as List;
|
||||
factory RouteLeg.fromCoordinates(
|
||||
List<dynamic> coordinates, {
|
||||
double distance = 0.0,
|
||||
int duration = 0,
|
||||
String summary = 'Route',
|
||||
}) {
|
||||
return RouteLeg(
|
||||
points: coords
|
||||
.map((coord) => NavigationPoint(
|
||||
latitude: coord[1] as double,
|
||||
longitude: coord[0] as double,
|
||||
))
|
||||
points: coordinates
|
||||
.map((coord) {
|
||||
if (coord is List && coord.length >= 2) {
|
||||
return NavigationPoint(
|
||||
latitude: (coord[1] as num).toDouble(),
|
||||
longitude: (coord[0] as num).toDouble(),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.whereType<NavigationPoint>()
|
||||
.toList(),
|
||||
distance: (json['distance'] as num).toDouble(),
|
||||
duration: (json['duration'] as num).toInt(),
|
||||
summary: json['summary'] as String? ?? 'Route',
|
||||
distance: distance,
|
||||
duration: duration,
|
||||
summary: summary,
|
||||
);
|
||||
}
|
||||
|
||||
factory RouteLeg.fromPoints({
|
||||
required List<NavigationPoint> points,
|
||||
required double distance,
|
||||
required int duration,
|
||||
String summary = 'Route',
|
||||
}) {
|
||||
return RouteLeg(
|
||||
points: points,
|
||||
distance: distance,
|
||||
duration: duration,
|
||||
summary: summary,
|
||||
);
|
||||
}
|
||||
|
||||
factory RouteLeg.fromJson(Map<String, dynamic> json) {
|
||||
final geometry = json['geometry'] as Map<String, dynamic>?;
|
||||
final coordinates = geometry?['coordinates'] as List? ?? [];
|
||||
|
||||
return RouteLeg(
|
||||
points: coordinates
|
||||
.map((coord) {
|
||||
if (coord is List && coord.length >= 2) {
|
||||
return NavigationPoint(
|
||||
latitude: (coord[1] as num).toDouble(),
|
||||
longitude: (coord[0] as num).toDouble(),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.whereType<NavigationPoint>()
|
||||
.toList(),
|
||||
distance: (json['distance'] as num?)?.toDouble() ?? 0.0,
|
||||
duration: (json['duration'] as num?)?.toInt() ?? 0,
|
||||
summary: (json['summary'] as String?) ?? 'Route',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -108,22 +155,57 @@ class NavigationRoute {
|
||||
}
|
||||
|
||||
factory NavigationRoute.fromJson(Map<String, dynamic> json) {
|
||||
final routes = json['routes'] as List;
|
||||
if (routes.isEmpty) {
|
||||
throw Exception('No route found');
|
||||
final routes = json['routes'] as List?;
|
||||
if (routes == null || routes.isEmpty) {
|
||||
throw Exception('No route found in response');
|
||||
}
|
||||
|
||||
final routeData = routes[0] as Map<String, dynamic>;
|
||||
final legs = (routeData['legs'] as List)
|
||||
.map((leg) => RouteLeg.fromJson(leg as Map<String, dynamic>))
|
||||
.toList();
|
||||
final routeData = routes[0] as Map<String, dynamic>?;
|
||||
if (routeData == null) {
|
||||
throw Exception('Invalid route data');
|
||||
}
|
||||
|
||||
return NavigationRoute.fromOsrmRouteData(
|
||||
routeData,
|
||||
start: NavigationPoint(latitude: 0, longitude: 0),
|
||||
end: NavigationPoint(latitude: 0, longitude: 0),
|
||||
);
|
||||
}
|
||||
|
||||
factory NavigationRoute.fromOsrmRouteData(
|
||||
Map<String, dynamic> routeData, {
|
||||
required NavigationPoint start,
|
||||
required NavigationPoint end,
|
||||
}) {
|
||||
final geometry = routeData['geometry'] as Map<String, dynamic>?;
|
||||
final coordinates = geometry?['coordinates'] as List? ?? [];
|
||||
final distance = (routeData['distance'] as num?)?.toDouble() ?? 0.0;
|
||||
final duration = (routeData['duration'] as num?)?.toInt() ?? 0;
|
||||
|
||||
final leg = RouteLeg.fromCoordinates(
|
||||
coordinates,
|
||||
distance: distance,
|
||||
duration: duration,
|
||||
summary: (routeData['summary'] as String?) ?? 'Route',
|
||||
);
|
||||
|
||||
return NavigationRoute(
|
||||
start: NavigationPoint(latitude: 0, longitude: 0), // TODO: Set from request
|
||||
end: NavigationPoint(latitude: 0, longitude: 0), // TODO: Set from request
|
||||
legs: legs,
|
||||
totalDistance: (routeData['distance'] as num).toDouble(),
|
||||
totalDuration: (routeData['duration'] as num).toInt(),
|
||||
start: start,
|
||||
end: end,
|
||||
legs: [leg],
|
||||
totalDistance: distance,
|
||||
totalDuration: duration,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of an optimized trip calculation with reordered stops.
|
||||
class OptimizedRouteResult {
|
||||
final NavigationRoute route;
|
||||
final List<NavigationPoint> orderedStops;
|
||||
|
||||
OptimizedRouteResult({
|
||||
required this.route,
|
||||
required this.orderedStops,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
|
||||
}
|
||||
|
||||
Future<void> _selectDate() async {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedDate,
|
||||
@@ -36,7 +37,7 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
|
||||
builder: (context, child) {
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
dialogBackgroundColor: Colors.white,
|
||||
dialogBackgroundColor: colorScheme.surface,
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
@@ -53,236 +54,210 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final dateStr =
|
||||
'${_selectedDate.year}-${_selectedDate.month.toString().padLeft(2, '0')}-${_selectedDate.day.toString().padLeft(2, '0')}';
|
||||
final isToday = DateTime(
|
||||
_selectedDate.year,
|
||||
_selectedDate.month,
|
||||
_selectedDate.day,
|
||||
) ==
|
||||
DateTime(
|
||||
DateTime.now().year,
|
||||
DateTime.now().month,
|
||||
DateTime.now().day,
|
||||
);
|
||||
final today = DateTime.now();
|
||||
final isToday = DateTime(_selectedDate.year, _selectedDate.month, _selectedDate.day) ==
|
||||
DateTime(today.year, today.month, today.day);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Your Schedule'),
|
||||
title: const Text('Today on the Road'),
|
||||
elevation: 0,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Date selector
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isToday ? 'Today' : 'Scheduled Date',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
dateStr,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
body: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: Theme.of(context).brightness == Brightness.dark
|
||||
? const [Color(0xFF0B1220), Color(0xFF111827)]
|
||||
: const [Color(0xFFF1F5F9), Color(0xFFE2E8F0)],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _selectDate,
|
||||
icon: const Icon(Icons.calendar_today),
|
||||
label: const Text('Change'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.16),
|
||||
blurRadius: 22,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isToday ? 'Vandaag' : 'Geplande datum',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Colors.white.withOpacity(0.78),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
dateStr,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Routes, stops en status in één overzicht',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.white.withOpacity(0.84),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _selectDate,
|
||||
icon: const Icon(Icons.calendar_today, size: 18),
|
||||
label: const Text('Wijzig'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: BorderSide(color: Colors.white.withOpacity(0.28)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child: FutureBuilder<DailySchedule>(
|
||||
future: _scheduleFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
// Routes list
|
||||
Expanded(
|
||||
child: FutureBuilder<DailySchedule>(
|
||||
future: _scheduleFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Failed to load schedule',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
snapshot.error.toString(),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: _refreshSchedule,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
if (!snapshot.hasData || snapshot.data!.routes.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.calendar_today,
|
||||
size: 64,
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No routes scheduled',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Check back later for your schedule',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final routes = snapshot.data!.routes;
|
||||
final scheduledRoutes = routes.where((r) => r.status == 'scheduled').toList();
|
||||
final inProgressRoutes = routes.where((r) => r.status == 'in_progress').toList();
|
||||
final completedRoutes = routes.where((r) => r.status == 'completed').toList();
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refreshSchedule,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.red[300],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Failed to load schedule',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
if (inProgressRoutes.isNotEmpty) ...[
|
||||
_sectionHeader('In Progress', colorScheme.tertiary),
|
||||
...inProgressRoutes.map((route) => RouteCard(route: route, onTap: () {})),
|
||||
],
|
||||
if (scheduledRoutes.isNotEmpty) ...[
|
||||
_sectionHeader('Scheduled', colorScheme.primary),
|
||||
...scheduledRoutes.map((route) => RouteCard(route: route, onTap: () {})),
|
||||
],
|
||||
if (completedRoutes.isNotEmpty) ...[
|
||||
_sectionHeader('Completed', colorScheme.secondary),
|
||||
...completedRoutes.map((route) => RouteCard(route: route, onTap: () {})),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
snapshot.error.toString(),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: _refreshSchedule,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot.hasData || snapshot.data!.routes.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.calendar_today,
|
||||
size: 64,
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No routes scheduled',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Check back later for your schedule',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final schedule = snapshot.data!;
|
||||
final routes = schedule.routes;
|
||||
|
||||
// Separate routes by status
|
||||
final scheduledRoutes = routes
|
||||
.where((r) => r.status == 'scheduled')
|
||||
.toList();
|
||||
final inProgressRoutes = routes
|
||||
.where((r) => r.status == 'in_progress')
|
||||
.toList();
|
||||
final completedRoutes = routes
|
||||
.where((r) => r.status == 'completed')
|
||||
.toList();
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refreshSchedule,
|
||||
child: ListView(
|
||||
children: [
|
||||
// In Progress section
|
||||
if (inProgressRoutes.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
top: 24,
|
||||
bottom: 8,
|
||||
),
|
||||
child: Text(
|
||||
'In Progress',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.orange,
|
||||
),
|
||||
),
|
||||
),
|
||||
...inProgressRoutes.map((route) => RouteCard(
|
||||
route: route,
|
||||
onTap: () {},
|
||||
)),
|
||||
],
|
||||
|
||||
// Scheduled section
|
||||
if (scheduledRoutes.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
top: 24,
|
||||
bottom: 8,
|
||||
),
|
||||
child: Text(
|
||||
'Scheduled',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
),
|
||||
...scheduledRoutes.map((route) => RouteCard(
|
||||
route: route,
|
||||
onTap: () {},
|
||||
)),
|
||||
],
|
||||
|
||||
// Completed section
|
||||
if (completedRoutes.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
top: 24,
|
||||
bottom: 8,
|
||||
),
|
||||
child: Text(
|
||||
'Completed',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
),
|
||||
...completedRoutes.map((route) => RouteCard(
|
||||
route: route,
|
||||
onTap: () {},
|
||||
)),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionHeader(String title, Color color) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 24, bottom: 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
throw Exception('User code must be a number');
|
||||
}
|
||||
|
||||
final response = await _authService.login(
|
||||
await _authService.login(
|
||||
userCode: parsedUserCode,
|
||||
password: _passwordController.text,
|
||||
);
|
||||
@@ -85,105 +85,214 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Bus Terminal Login'),
|
||||
elevation: 0,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Title
|
||||
Text(
|
||||
'Driver Login',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Enter your credentials to continue',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// Error message
|
||||
if (_errorMessage != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red[50],
|
||||
border: Border.all(color: Colors.red[300]!),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: Colors.red[700],
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_errorMessage != null) const SizedBox(height: 24),
|
||||
|
||||
// User Code Field
|
||||
CustomInputField(
|
||||
label: 'User Code',
|
||||
hint: 'Enter your user code',
|
||||
controller: _userCodeController,
|
||||
validator: _validateUserCode,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Password Field
|
||||
CustomInputField(
|
||||
label: 'Password',
|
||||
hint: 'Enter your password',
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
validator: _validatePassword,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// Login Button - Large for touch accessibility
|
||||
FilledButton(
|
||||
onPressed: _isLoading ? null : _handleLogin,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(56), // Large touch target
|
||||
disabledBackgroundColor: Colors.grey[400],
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Login',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
body: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: Theme.of(context).brightness == Brightness.dark
|
||||
? const [Color(0xFF0B1220), Color(0xFF111827), Color(0xFF1D4ED8)]
|
||||
: const [Color(0xFFF1F5F9), Color(0xFFE2E8F0), Color(0xFFBAE6FD)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.18),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.16),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.directions_bus,
|
||||
color: Colors.white,
|
||||
size: 30,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Bus Terminal',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Driver access dashboard',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.white.withOpacity(0.82),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface.withOpacity(0.92),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: colorScheme.outline.withOpacity(0.14)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
blurRadius: 22,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Driver Login',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Sign in to view your route, stops and live navigation.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
|
||||
if (_errorMessage != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.error.withOpacity(0.12),
|
||||
border: Border.all(color: colorScheme.error.withOpacity(0.35)),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.error),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
CustomInputField(
|
||||
label: 'User Code',
|
||||
hint: 'Enter your user code',
|
||||
controller: _userCodeController,
|
||||
validator: _validateUserCode,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
CustomInputField(
|
||||
label: 'Password',
|
||||
hint: 'Enter your password',
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
validator: _validatePassword,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
|
||||
FilledButton(
|
||||
onPressed: _isLoading ? null : _handleLogin,
|
||||
child: _isLoading
|
||||
? SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.onPrimary),
|
||||
),
|
||||
)
|
||||
: const Text('Login'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_statusPill(Icons.shield_outlined, 'Secure access'),
|
||||
const SizedBox(width: 10),
|
||||
_statusPill(Icons.route_outlined, 'Live route data'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statusPill(IconData icon, String label) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.72),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:ffi';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/user.dart';
|
||||
|
||||
@@ -47,21 +47,25 @@ class NavigationService {
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
|
||||
if (data['code'] != 'Ok') {
|
||||
throw Exception('Route not found: ${data['message']}');
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||
if (data == null) {
|
||||
throw Exception('Empty response from routing service');
|
||||
}
|
||||
|
||||
final route = NavigationRoute.fromJson(data);
|
||||
if (data['code'] != 'Ok') {
|
||||
throw Exception('Route not found: ${data['message'] ?? 'Unknown error'}');
|
||||
}
|
||||
|
||||
// Set start and end points properly
|
||||
return NavigationRoute(
|
||||
final routes = data['routes'] as List?;
|
||||
if (routes == null || routes.isEmpty) {
|
||||
throw Exception('No route found in response');
|
||||
}
|
||||
|
||||
final routeData = routes.first as Map<String, dynamic>;
|
||||
return NavigationRoute.fromOsrmRouteData(
|
||||
routeData,
|
||||
start: start,
|
||||
end: end,
|
||||
legs: route.legs,
|
||||
totalDistance: route.totalDistance,
|
||||
totalDuration: route.totalDuration,
|
||||
);
|
||||
} else {
|
||||
throw Exception('Failed to get route: ${response.statusCode}');
|
||||
@@ -71,6 +75,144 @@ class NavigationService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a route that passes through the provided stops in the given order.
|
||||
Future<NavigationRoute> getRouteThroughStops({
|
||||
required List<NavigationPoint> stops,
|
||||
}) async {
|
||||
try {
|
||||
if (stops.length < 2) {
|
||||
throw Exception('At least two stops are required');
|
||||
}
|
||||
|
||||
final coordinates = stops
|
||||
.map((stop) => '${stop.longitude},${stop.latitude}')
|
||||
.join(';');
|
||||
final url =
|
||||
'$_osrmBaseUrl/$coordinates?overview=full&steps=true&geometries=geojson&source=first&destination=last';
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to get route: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||
if (data == null) {
|
||||
throw Exception('Empty response from routing service');
|
||||
}
|
||||
|
||||
if (data['code'] != 'Ok') {
|
||||
throw Exception('Route not found: ${data['message'] ?? 'Unknown error'}');
|
||||
}
|
||||
|
||||
final routes = data['routes'] as List?;
|
||||
if (routes == null || routes.isEmpty) {
|
||||
throw Exception('No route found in response');
|
||||
}
|
||||
|
||||
final routeData = routes.first as Map<String, dynamic>;
|
||||
return NavigationRoute.fromOsrmRouteData(
|
||||
routeData,
|
||||
start: stops.first,
|
||||
end: stops.last,
|
||||
);
|
||||
} catch (e) {
|
||||
throw Exception('Navigation error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get an optimized trip through multiple stops.
|
||||
/// Keeps the first and last stops fixed and lets OSRM order the intermediates.
|
||||
Future<OptimizedRouteResult> getOptimizedRouteThroughStops({
|
||||
required List<NavigationPoint> stops,
|
||||
}) async {
|
||||
try {
|
||||
if (stops.length < 2) {
|
||||
throw Exception('At least two stops are required');
|
||||
}
|
||||
|
||||
final coordinates = stops
|
||||
.map((stop) => '${stop.longitude},${stop.latitude}')
|
||||
.join(';');
|
||||
final url =
|
||||
'https://router.project-osrm.org/trip/v1/car/$coordinates?overview=full&steps=true&geometries=geojson&source=first&destination=last&roundtrip=false';
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to get optimized route: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||
if (data == null) {
|
||||
throw Exception('Empty response from routing service');
|
||||
}
|
||||
|
||||
if (data['code'] != 'Ok') {
|
||||
throw Exception('Optimized route not found: ${data['message'] ?? 'Unknown error'}');
|
||||
}
|
||||
|
||||
final trips = data['trips'] as List?;
|
||||
if (trips == null || trips.isEmpty) {
|
||||
throw Exception('No optimized trip found');
|
||||
}
|
||||
|
||||
final trip = trips.first as Map<String, dynamic>;
|
||||
final geometry = trip['geometry'] as Map<String, dynamic>?;
|
||||
final coordinatesData = geometry?['coordinates'] as List? ?? [];
|
||||
final points = coordinatesData
|
||||
.map((coord) {
|
||||
if (coord is List && coord.length >= 2) {
|
||||
return NavigationPoint(
|
||||
latitude: (coord[1] as num).toDouble(),
|
||||
longitude: (coord[0] as num).toDouble(),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.whereType<NavigationPoint>()
|
||||
.toList();
|
||||
|
||||
final waypointItems = (data['waypoints'] as List?) ?? [];
|
||||
final orderedPairs = <MapEntry<int, NavigationPoint>>[];
|
||||
|
||||
for (var i = 0; i < waypointItems.length && i < stops.length; i++) {
|
||||
final waypoint = waypointItems[i];
|
||||
if (waypoint is! Map<String, dynamic>) continue;
|
||||
final index = waypoint['waypoint_index'] as num?;
|
||||
if (index == null) continue;
|
||||
orderedPairs.add(MapEntry(index.toInt(), stops[i]));
|
||||
}
|
||||
|
||||
orderedPairs.sort((a, b) => a.key.compareTo(b.key));
|
||||
final orderedStops = orderedPairs.isNotEmpty
|
||||
? orderedPairs.map((entry) => entry.value).toList()
|
||||
: stops;
|
||||
|
||||
final route = NavigationRoute(
|
||||
start: orderedStops.first,
|
||||
end: orderedStops.last,
|
||||
legs: [
|
||||
RouteLeg.fromPoints(
|
||||
points: points,
|
||||
distance: (trip['distance'] as num?)?.toDouble() ?? 0.0,
|
||||
duration: (trip['duration'] as num?)?.toInt() ?? 0,
|
||||
summary: 'Optimized trip',
|
||||
),
|
||||
],
|
||||
totalDistance: (trip['distance'] as num?)?.toDouble() ?? 0.0,
|
||||
totalDuration: (trip['duration'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
|
||||
return OptimizedRouteResult(
|
||||
route: route,
|
||||
orderedStops: orderedStops.isNotEmpty ? orderedStops : stops,
|
||||
);
|
||||
} catch (e) {
|
||||
throw Exception('Navigation error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get multiple routes (alternatives)
|
||||
Future<List<NavigationRoute>> getMultipleRoutes({
|
||||
required NavigationPoint start,
|
||||
@@ -86,26 +228,30 @@ class NavigationService {
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
|
||||
if (data['code'] != 'Ok') {
|
||||
throw Exception('Routes not found: ${data['message']}');
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||
if (data == null) {
|
||||
throw Exception('Empty response from routing service');
|
||||
}
|
||||
|
||||
final routes = (data['routes'] as List)
|
||||
if (data['code'] != 'Ok') {
|
||||
throw Exception('Routes not found: ${data['message'] ?? 'Unknown error'}');
|
||||
}
|
||||
|
||||
final routesData = data['routes'] as List?;
|
||||
if (routesData == null || routesData.isEmpty) {
|
||||
throw Exception('No routes found in response');
|
||||
}
|
||||
|
||||
final routes = routesData
|
||||
.map((routeData) {
|
||||
return NavigationRoute(
|
||||
if (routeData is! Map<String, dynamic>) return null;
|
||||
return NavigationRoute.fromOsrmRouteData(
|
||||
routeData,
|
||||
start: start,
|
||||
end: end,
|
||||
legs: (routeData['legs'] as List)
|
||||
.map((leg) =>
|
||||
RouteLeg.fromJson(leg as Map<String, dynamic>))
|
||||
.toList(),
|
||||
totalDistance:
|
||||
(routeData['distance'] as num).toDouble(),
|
||||
totalDuration: (routeData['duration'] as num).toInt(),
|
||||
);
|
||||
})
|
||||
.whereType<NavigationRoute>()
|
||||
.toList();
|
||||
|
||||
return routes;
|
||||
@@ -125,4 +271,37 @@ class NavigationService {
|
||||
LatLng(p2.latitude, p2.longitude),
|
||||
);
|
||||
}
|
||||
|
||||
/// Geocode a place name to coordinates using Nominatim (OpenStreetMap)
|
||||
Future<NavigationPoint> geocodePlace(String name) async {
|
||||
try {
|
||||
final uri = Uri.https('nominatim.openstreetmap.org', '/search', {
|
||||
'q': name,
|
||||
'format': 'json',
|
||||
'limit': '1',
|
||||
});
|
||||
|
||||
final response = await http.get(uri, headers: {
|
||||
'User-Agent': 'bus-terminal-app',
|
||||
});
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Geocoding failed: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final list = jsonDecode(response.body) as List<dynamic>?;
|
||||
if (list == null || list.isEmpty) {
|
||||
throw Exception('No geocoding result for "$name"');
|
||||
}
|
||||
|
||||
final item = list[0] as Map<String, dynamic>;
|
||||
final lat = double.parse(item['lat'] as String);
|
||||
final lon = double.parse(item['lon'] as String);
|
||||
final display = item['display_name'] as String? ?? name;
|
||||
|
||||
return NavigationPoint(latitude: lat, longitude: lon, name: display);
|
||||
} catch (e) {
|
||||
throw Exception('Geocode error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/schedule.dart';
|
||||
import '../models/navigation.dart';
|
||||
import 'auth_service.dart';
|
||||
|
||||
/// Schedule/Route Service
|
||||
@@ -82,4 +83,42 @@ class ScheduleService {
|
||||
throw Exception('Status update error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get stops for a bus line
|
||||
Future<List<NavigationPoint>> getStops(int busLineId) async {
|
||||
try {
|
||||
final headers = await _authService.getAuthHeaders();
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse('$_apiBaseUrl/bus-lines/$busLineId/stops'),
|
||||
headers: headers,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final stops = (data['stops'] as List?)
|
||||
?.map((s) => s as Map<String, dynamic>)
|
||||
.map((s) {
|
||||
final lat = (s['latitude'] ?? s['lat']) as num?;
|
||||
final lon = (s['longitude'] ?? s['lon']) as num?;
|
||||
final name = (s['name'] ?? s['stop_name'] ?? s['stopName']) as String?;
|
||||
if (lat == null || lon == null) return null;
|
||||
return NavigationPoint(
|
||||
latitude: lat.toDouble(),
|
||||
longitude: lon.toDouble(),
|
||||
name: name,
|
||||
);
|
||||
})
|
||||
.whereType<NavigationPoint>()
|
||||
.toList() ?? [];
|
||||
return stops;
|
||||
} else if (response.statusCode == 401) {
|
||||
throw Exception('Unauthorized - Please login again');
|
||||
} else {
|
||||
throw Exception('Failed to load stops: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Get stops error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,23 +2,203 @@ import 'package:flutter/material.dart';
|
||||
|
||||
/// Material 3 Theme configuratie voor bus_terminal
|
||||
class AppTheme {
|
||||
// TODO: Implement Material 3 theme for bus drivers
|
||||
// - Large touch targets
|
||||
// - High contrast
|
||||
// - Clear visual hierarchy
|
||||
static const Color _navy = Color(0xFF0F172A);
|
||||
static const Color _surface = Color(0xFFF8FAFC);
|
||||
static const Color _surfaceDark = Color(0xFF111827);
|
||||
static const Color _amber = Color(0xFFF59E0B);
|
||||
static const Color _cyan = Color(0xFF38BDF8);
|
||||
static const Color _success = Color(0xFF22C55E);
|
||||
static const Color _error = Color(0xFFEF4444);
|
||||
|
||||
static TextTheme _textTheme(ColorScheme colorScheme) {
|
||||
return TextTheme(
|
||||
displayLarge: const TextStyle(fontSize: 46, fontWeight: FontWeight.w800, letterSpacing: -1.2),
|
||||
displayMedium: const TextStyle(fontSize: 38, fontWeight: FontWeight.w800, letterSpacing: -0.9),
|
||||
headlineLarge: const TextStyle(fontSize: 30, fontWeight: FontWeight.w800, letterSpacing: -0.4),
|
||||
headlineMedium: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, letterSpacing: -0.3),
|
||||
titleLarge: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700),
|
||||
titleMedium: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
|
||||
titleSmall: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
|
||||
bodyLarge: TextStyle(fontSize: 16, height: 1.45, color: colorScheme.onSurface),
|
||||
bodyMedium: TextStyle(fontSize: 14, height: 1.45, color: colorScheme.onSurfaceVariant),
|
||||
bodySmall: TextStyle(fontSize: 12, height: 1.35, color: colorScheme.onSurfaceVariant),
|
||||
labelLarge: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
|
||||
labelMedium: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||
labelSmall: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 0.25),
|
||||
);
|
||||
}
|
||||
|
||||
static ColorScheme _buildLightScheme() {
|
||||
return ColorScheme.fromSeed(
|
||||
seedColor: _cyan,
|
||||
brightness: Brightness.light,
|
||||
primary: _navy,
|
||||
secondary: _amber,
|
||||
tertiary: _cyan,
|
||||
surface: _surface,
|
||||
onSurface: _navy,
|
||||
error: _error,
|
||||
).copyWith(
|
||||
surfaceContainerHighest: const Color(0xFFE2E8F0),
|
||||
outline: const Color(0xFF94A3B8),
|
||||
);
|
||||
}
|
||||
|
||||
static ColorScheme _buildDarkScheme() {
|
||||
return ColorScheme.fromSeed(
|
||||
seedColor: _cyan,
|
||||
brightness: Brightness.dark,
|
||||
primary: _cyan,
|
||||
secondary: _amber,
|
||||
tertiary: _success,
|
||||
surface: _surfaceDark,
|
||||
onSurface: Colors.white,
|
||||
error: _error,
|
||||
).copyWith(
|
||||
surfaceContainerHighest: const Color(0xFF1F2937),
|
||||
outline: const Color(0xFF334155),
|
||||
);
|
||||
}
|
||||
|
||||
static ThemeData lightTheme() {
|
||||
final colorScheme = _buildLightScheme();
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
// TODO: Configure Material 3 theme
|
||||
brightness: Brightness.light,
|
||||
colorScheme: colorScheme,
|
||||
scaffoldBackgroundColor: const Color(0xFFF1F5F9),
|
||||
textTheme: _textTheme(colorScheme),
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: colorScheme.surface,
|
||||
foregroundColor: colorScheme.onSurface,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
titleTextStyle: _textTheme(colorScheme).titleLarge?.copyWith(color: colorScheme.onSurface),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: colorScheme.surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
dividerTheme: DividerThemeData(color: colorScheme.outline.withOpacity(0.18), thickness: 1),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.25))),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.25))),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)),
|
||||
errorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.error)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
|
||||
labelStyle: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant.withOpacity(0.8)),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size.fromHeight(56),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.secondary,
|
||||
foregroundColor: colorScheme.onSecondary,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colorScheme.onSurface,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
side: BorderSide(color: colorScheme.outline.withOpacity(0.35)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
),
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
labelStyle: TextStyle(color: colorScheme.onSurface, fontWeight: FontWeight.w600),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)),
|
||||
),
|
||||
progressIndicatorTheme: ProgressIndicatorThemeData(
|
||||
color: colorScheme.primary,
|
||||
linearTrackColor: colorScheme.primary.withOpacity(0.12),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static ThemeData darkTheme() {
|
||||
final colorScheme = _buildDarkScheme();
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
// TODO: Configure Material 3 dark theme
|
||||
colorScheme: colorScheme,
|
||||
scaffoldBackgroundColor: const Color(0xFF0B1220),
|
||||
textTheme: _textTheme(colorScheme).apply(bodyColor: Colors.white, displayColor: Colors.white),
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: const Color(0xFF0B1220),
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
titleTextStyle: _textTheme(colorScheme).titleLarge?.copyWith(color: Colors.white),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: const Color(0xFF111827),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
dividerTheme: const DividerThemeData(color: Color(0xFF243244), thickness: 1),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF111827),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF334155))),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF334155))),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)),
|
||||
errorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.error)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
|
||||
labelStyle: const TextStyle(color: Color(0xFFCBD5E1)),
|
||||
hintStyle: const TextStyle(color: Color(0xFF94A3B8)),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: Colors.black,
|
||||
minimumSize: const Size.fromHeight(56),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.secondary,
|
||||
foregroundColor: Colors.black,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
side: const BorderSide(color: Color(0xFF334155)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
),
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: const Color(0xFF1E293B),
|
||||
labelStyle: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)),
|
||||
),
|
||||
progressIndicatorTheme: ProgressIndicatorThemeData(
|
||||
color: colorScheme.primary,
|
||||
linearTrackColor: colorScheme.primary.withOpacity(0.16),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ class _CustomInputFieldState extends State<CustomInputField> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -54,13 +56,30 @@ class _CustomInputFieldState extends State<CustomInputField> {
|
||||
validator: widget.validator,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hint,
|
||||
hintStyle: Theme.of(context).textTheme.bodyMedium,
|
||||
hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16, // Larger vertical padding for touch targets
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
widget.obscureText ? Icons.lock_outline : Icons.badge_outlined,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surface,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.25)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.25)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(color: colorScheme.primary, width: 2),
|
||||
),
|
||||
suffixIcon: widget.obscureText
|
||||
? GestureDetector(
|
||||
@@ -70,6 +89,7 @@ class _CustomInputFieldState extends State<CustomInputField> {
|
||||
child: Icon(
|
||||
_obscureText ? Icons.visibility_off : Icons.visibility,
|
||||
size: 28, // Larger icon for accessibility
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
|
||||
@@ -49,6 +49,7 @@ class RouteCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final statusColor = _getStatusColor(route.status);
|
||||
final startTimeStr = _formatTime(route.startTime);
|
||||
|
||||
@@ -63,6 +64,9 @@ class RouteCard extends StatelessWidget {
|
||||
},
|
||||
child: Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
elevation: 0,
|
||||
color: colorScheme.surface,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -77,7 +81,11 @@ class RouteCard extends StatelessWidget {
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue,
|
||||
gradient: LinearGradient(
|
||||
colors: [colorScheme.primary, colorScheme.tertiary],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
@@ -94,7 +102,7 @@ class RouteCard extends StatelessWidget {
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withOpacity(0.2),
|
||||
color: statusColor.withOpacity(0.14),
|
||||
border: Border.all(color: statusColor),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
@@ -134,7 +142,7 @@ class RouteCard extends StatelessWidget {
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_forward,
|
||||
color: Colors.grey[400],
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -181,7 +189,7 @@ class RouteCard extends StatelessWidget {
|
||||
Text(
|
||||
route.busType.licensePlate,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -199,14 +207,14 @@ class RouteCard extends StatelessWidget {
|
||||
style:
|
||||
Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${route.busLine.estimatedDuration} min duration',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user