implemented RMV public transit module including trip search, station departures, and nearby stop lookup, added "Marianum Connect" API integration with bearer token authentication and auto-refresh logic, integrated geolocator for location-based station search, added persistent storage for favorite stations and recent trip queries, and implemented comprehensive UI for journey details, trip results, and disruption alerts
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../api/connect/rmv/rmv_models.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../widgets/leg_tile.dart';
|
||||
import '../widgets/trip_tile.dart';
|
||||
|
||||
class TripDetailView extends StatelessWidget {
|
||||
final Trip trip;
|
||||
|
||||
const TripDetailView({super.key, required this.trip});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final first = trip.legs.isEmpty ? null : trip.legs.first;
|
||||
final last = trip.legs.isEmpty ? null : trip.legs.last;
|
||||
final duration = trip.realDuration ?? trip.duration;
|
||||
final transfers =
|
||||
trip.transferCount ?? _journeyLegs(trip).length.clamp(1, 99) - 1;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: first == null
|
||||
? const Text('Verbindung')
|
||||
: Text('${first.origin.name} → ${last!.destination.name}'),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: _summary(context, duration, transfers, first, last),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
...trip.legs.map(
|
||||
(l) => LegTile(
|
||||
leg: l,
|
||||
onShowJourneyDetail: l.journeyRef == null
|
||||
? null
|
||||
: () => AppRoutes.openRmvJourneyDetail(
|
||||
context,
|
||||
l.journeyRef!,
|
||||
date: l.origin.scheduledTime,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _summary(
|
||||
BuildContext context,
|
||||
Duration? duration,
|
||||
int transfers,
|
||||
Leg? first,
|
||||
Leg? last,
|
||||
) {
|
||||
if (first == null || last == null) return const SizedBox.shrink();
|
||||
return Row(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
first.origin.scheduledTime.formatDateRelativeShort(),
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${first.origin.scheduledTime.formatHm()} – ${last.destination.scheduledTime.formatHm()}',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (duration != null)
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.schedule, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Text(formatTripDuration(duration)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.swap_horiz, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
transfers == 0
|
||||
? 'Direkt'
|
||||
: '$transfers Umstieg${transfers > 1 ? 'e' : ''}',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<Leg> _journeyLegs(Trip t) =>
|
||||
t.legs.where((l) => l.type == LegType.journey);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../api/connect/rmv/rmv_models.dart';
|
||||
import '../../../../api/errors/error_mapper.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../state/app/modules/rmv/repository/rmv_repository.dart';
|
||||
import '../../../../widget/app_progress_indicator.dart';
|
||||
import '../widgets/trip_tile.dart';
|
||||
|
||||
class TripResultsView extends StatefulWidget {
|
||||
final StopLocation from;
|
||||
final StopLocation to;
|
||||
final DateTime? when;
|
||||
final bool byArrival;
|
||||
|
||||
const TripResultsView({
|
||||
super.key,
|
||||
required this.from,
|
||||
required this.to,
|
||||
this.when,
|
||||
this.byArrival = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TripResultsView> createState() => _TripResultsViewState();
|
||||
}
|
||||
|
||||
class _TripResultsViewState extends State<TripResultsView> {
|
||||
final RmvRepository _repo = RmvRepository();
|
||||
final List<Trip> _trips = [];
|
||||
String? _scrollLater;
|
||||
String? _scrollEarlier;
|
||||
bool _loading = true;
|
||||
bool _loadingMore = false;
|
||||
Object? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initial();
|
||||
}
|
||||
|
||||
Future<void> _initial() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
_trips.clear();
|
||||
_scrollEarlier = null;
|
||||
_scrollLater = null;
|
||||
});
|
||||
try {
|
||||
final r = await _repo.searchTrips(
|
||||
fromStopId: widget.from.id,
|
||||
toStopId: widget.to.id,
|
||||
when: widget.when,
|
||||
searchByArrival: widget.byArrival,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_trips.addAll(r.trips);
|
||||
_scrollEarlier = r.scrollContextEarlier;
|
||||
_scrollLater = r.scrollContextLater;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore({required bool later}) async {
|
||||
final ctx = later ? _scrollLater : _scrollEarlier;
|
||||
if (ctx == null || _loadingMore) return;
|
||||
setState(() => _loadingMore = true);
|
||||
try {
|
||||
final r = await _repo.moreTrips(ctx);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
if (later) {
|
||||
_trips.addAll(r.trips);
|
||||
} else {
|
||||
_trips.insertAll(0, r.trips);
|
||||
}
|
||||
if (r.scrollContextEarlier != null) {
|
||||
_scrollEarlier = r.scrollContextEarlier;
|
||||
}
|
||||
if (r.scrollContextLater != null) {
|
||||
_scrollLater = r.scrollContextLater;
|
||||
}
|
||||
_loadingMore = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loadingMore = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(errorToUserMessage(e))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final whenLabel = widget.when == null
|
||||
? 'jetzt'
|
||||
: '${widget.byArrival ? 'an' : 'ab'} ${widget.when!.formatDateTime()}';
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('${widget.from.name} → ${widget.to.name}'),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(24),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
whenLabel,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: _body(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body() {
|
||||
if (_loading) return const Center(child: AppProgressIndicator.large());
|
||||
final err = _error;
|
||||
if (err != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
errorToUserMessage(err),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _initial,
|
||||
label: const Text('Erneut versuchen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_trips.isEmpty) {
|
||||
return const Center(child: Text('Keine Verbindungen gefunden.'));
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _initial,
|
||||
child: ListView.separated(
|
||||
itemCount: _trips.length + 2,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (_, i) {
|
||||
if (i == 0) {
|
||||
return _scrollButton(
|
||||
icon: Icons.arrow_upward,
|
||||
label: 'Frühere Verbindungen',
|
||||
enabled: _scrollEarlier != null,
|
||||
onTap: () => _loadMore(later: false),
|
||||
);
|
||||
}
|
||||
if (i == _trips.length + 1) {
|
||||
return _scrollButton(
|
||||
icon: Icons.arrow_downward,
|
||||
label: 'Spätere Verbindungen',
|
||||
enabled: _scrollLater != null,
|
||||
onTap: () => _loadMore(later: true),
|
||||
);
|
||||
}
|
||||
final trip = _trips[i - 1];
|
||||
return TripTile(
|
||||
trip: trip,
|
||||
onTap: () => AppRoutes.openRmvTripDetail(context, trip),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _scrollButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required bool enabled,
|
||||
required VoidCallback onTap,
|
||||
}) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: OutlinedButton.icon(
|
||||
icon: _loadingMore
|
||||
? const AppProgressIndicator.small()
|
||||
: Icon(icon),
|
||||
label: Text(label),
|
||||
onPressed: enabled && !_loadingMore ? onTap : null,
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(40),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../api/connect/rmv/rmv_models.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../storage/rmv_settings.dart';
|
||||
import '../../../../storage/settings.dart';
|
||||
import '../../../../widget/centered_leading.dart';
|
||||
import '../../../../widget/confirm_dialog.dart';
|
||||
import '../favorites_controller.dart';
|
||||
import '../widgets/station_picker_sheet.dart';
|
||||
import '../widgets/when_picker.dart';
|
||||
|
||||
class TripSearchTab extends StatefulWidget {
|
||||
const TripSearchTab({super.key});
|
||||
|
||||
@override
|
||||
State<TripSearchTab> createState() => _TripSearchTabState();
|
||||
}
|
||||
|
||||
class _TripSearchTabState extends State<TripSearchTab> {
|
||||
StopLocation? _from;
|
||||
StopLocation? _to;
|
||||
DateTime? _when;
|
||||
bool _byArrival = false;
|
||||
|
||||
Future<void> _pickFrom() async {
|
||||
final s = await showStationPickerSheet(
|
||||
context,
|
||||
title: 'Von welcher Station?',
|
||||
);
|
||||
if (s != null) setState(() => _from = s);
|
||||
}
|
||||
|
||||
Future<void> _pickTo() async {
|
||||
final s = await showStationPickerSheet(
|
||||
context,
|
||||
title: 'Wohin?',
|
||||
);
|
||||
if (s != null) setState(() => _to = s);
|
||||
}
|
||||
|
||||
void _swap() {
|
||||
setState(() {
|
||||
final tmp = _from;
|
||||
_from = _to;
|
||||
_to = tmp;
|
||||
});
|
||||
}
|
||||
|
||||
void _search(BuildContext context) {
|
||||
final from = _from;
|
||||
final to = _to;
|
||||
if (from == null || to == null) return;
|
||||
RmvFavoritesController(context.read<SettingsCubit>())
|
||||
.addRecentTrip(from, to);
|
||||
AppRoutes.openRmvTripResults(
|
||||
context,
|
||||
from: from,
|
||||
to: to,
|
||||
when: _when,
|
||||
byArrival: _byArrival,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
BlocBuilder<SettingsCubit, Settings>(builder: _buildContent);
|
||||
|
||||
Widget _buildContent(BuildContext context, Settings settings) {
|
||||
final canSearch = _from != null && _to != null;
|
||||
final recents = settings.rmvSettings.recentTripQueries;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 8),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_stationField(
|
||||
label: 'Von',
|
||||
icon: Icons.trip_origin,
|
||||
value: _from,
|
||||
onTap: _pickFrom,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _stationField(
|
||||
label: 'Nach',
|
||||
icon: Icons.place,
|
||||
value: _to,
|
||||
onTap: _pickTo,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.swap_vert),
|
||||
tooltip: 'Start und Ziel tauschen',
|
||||
onPressed: _from == null && _to == null ? null : _swap,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
WhenPicker(
|
||||
value: _when,
|
||||
byArrival: _byArrival,
|
||||
onValueChanged: (v) => setState(() => _when = v),
|
||||
onByArrivalChanged: (v) => setState(() => _byArrival = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.search),
|
||||
label: const Text('Verbindungen suchen'),
|
||||
onPressed: canSearch ? () => _search(context) : null,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
if (recents.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Noch keine Suchen. Wähle Start und Ziel, um die erste Verbindung zu suchen.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
_recentsSection(context, recents),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stationField({
|
||||
required String label,
|
||||
required IconData icon,
|
||||
required StopLocation? value,
|
||||
required VoidCallback onTap,
|
||||
}) => InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
prefixIcon: Icon(icon),
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: const Icon(Icons.arrow_drop_down),
|
||||
),
|
||||
child: Text(
|
||||
value?.name ?? 'Station wählen',
|
||||
style: value == null
|
||||
? TextStyle(color: Theme.of(context).hintColor)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _recentsSection(
|
||||
BuildContext context,
|
||||
List<RecentTripQuery> recents,
|
||||
) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 8, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Letzte Suchen',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_sweep_outlined),
|
||||
tooltip: 'Alle löschen',
|
||||
onPressed: () => ConfirmDialog(
|
||||
title: 'Suchverlauf leeren?',
|
||||
content:
|
||||
'Die letzten Verbindungssuchen werden entfernt. Favoriten bleiben bestehen.',
|
||||
confirmButton: 'Leeren',
|
||||
onConfirm: () => RmvFavoritesController(
|
||||
context.read<SettingsCubit>(),
|
||||
).clearRecentTrips(),
|
||||
).asDialog(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
...recents.map(
|
||||
(q) => ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.history)),
|
||||
title: Text('${q.from.name} → ${q.to.name}'),
|
||||
onTap: () => setState(() {
|
||||
_from = q.from;
|
||||
_to = q.to;
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user