Skip to content

Commit 8d86e2d

Browse files
Switch to MVVM Architecture (#5)
* Switched station_line_labels to MVVM * Upgraded mallorca_transit_services package * Fixed disposal in station_line_labels_viewmodel * Switched timeline_sheet to MVVM * Switched station_sheet to MVVM * Refactored providers & settings picker * Added disposal to station_viewmodel * Switched Routes tab to MVVM * Switched Stations to MVVM * Switched timetable viewer to MVVM * Switched nearby to MVVM * Fixed UI regression introduced by refactor Regression was introduced in 3ab6600 * Station line labels now scrollable on long list * Switched map to MVVM
1 parent 8a35afe commit 8d86e2d

34 files changed

+2127
-2203
lines changed

lib/apis/location.dart

+5-9
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,26 @@ import 'package:geolocator/geolocator.dart';
22

33
/// A class that provides methods for interacting with location services.
44
class LocationApi {
5-
LocationPermission permission = LocationPermission.denied;
6-
75
/// Checks the permission status for accessing the device's location.
86
///
97
/// Returns the current permission status.
10-
Future<LocationPermission> checkPermission() async {
11-
permission = await Geolocator.checkPermission();
12-
return permission;
8+
Future<LocationPermission> permissionStatus() async {
9+
return await Geolocator.checkPermission();
1310
}
1411

1512
/// Requests permission to access the device's location.
1613
///
1714
/// Returns `true` if the permission is granted, `false` otherwise.
18-
Future<bool> requestPermission() async {
19-
permission = await Geolocator.checkPermission();
15+
Future<LocationPermission> requestPermission() async {
16+
LocationPermission permission = await Geolocator.checkPermission();
2017
if (permission == LocationPermission.denied) {
2118
permission = await Geolocator.requestPermission();
2219
if (permission == LocationPermission.denied) {
2320
permission = LocationPermission.deniedForever;
2421
}
2522
}
2623

27-
return permission == LocationPermission.whileInUse ||
28-
permission == LocationPermission.always;
24+
return permission;
2925
}
3026

3127
/// Retrieves the current device location.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:intl/intl.dart';
3+
import 'package:latlong2/latlong.dart';
4+
import 'package:mallorca_transit_services/mallorca_transit_services.dart';
5+
import 'package:provider/provider.dart';
6+
import 'package:skeletonizer/skeletonizer.dart';
7+
import 'package:url_launcher/url_launcher.dart';
8+
import 'package:via_mallorca/components/station_line_labels/station_line_labels_view.dart';
9+
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
10+
import 'package:via_mallorca/providers/map_provider.dart';
11+
import 'package:via_mallorca/providers/tracking_provider.dart';
12+
import 'station_viewmodel.dart';
13+
14+
class StationSheet extends StatelessWidget {
15+
final Station station;
16+
17+
const StationSheet({super.key, required this.station});
18+
19+
@override
20+
Widget build(BuildContext context) {
21+
return ChangeNotifierProvider(
22+
create: (_) => StationSheetViewModel(station)..initialize(),
23+
child: Consumer<StationSheetViewModel>(
24+
builder: (context, viewModel, child) {
25+
return Stack(
26+
children: [
27+
SizedBox(
28+
width: double.infinity,
29+
child: Padding(
30+
padding: const EdgeInsets.symmetric(horizontal: 16.0),
31+
child: Column(
32+
mainAxisSize: MainAxisSize.min,
33+
children: [
34+
const SizedBox(height: 24),
35+
Column(
36+
children: [
37+
Text(
38+
"${station.name} (${station.code})",
39+
style: const TextStyle(fontSize: 24),
40+
),
41+
if (station.ref != null) Text(station.ref!),
42+
const SizedBox(height: 16),
43+
StationLineLabels(station: station),
44+
const SizedBox(height: 32),
45+
_buildDeparturesList(context, viewModel),
46+
],
47+
),
48+
],
49+
),
50+
),
51+
),
52+
Positioned(
53+
top: 0,
54+
right: 0,
55+
child: IconButton(
56+
icon: const Icon(Icons.close),
57+
onPressed: () => Navigator.of(context).pop(),
58+
),
59+
),
60+
Positioned(
61+
top: 16,
62+
left: 16,
63+
child: Column(
64+
children: [
65+
IconButton(
66+
icon: const Icon(Icons.directions),
67+
onPressed: () => launchUrl(Uri(
68+
scheme: "geo",
69+
path: "${station.lat},${station.long}",
70+
query: "q=${station.lat},${station.long}")),
71+
),
72+
const SizedBox(height: 8),
73+
IconButton(
74+
icon: Icon(viewModel.isFavourite
75+
? Icons.star
76+
: Icons.star_outline),
77+
onPressed: viewModel.toggleFavourite,
78+
),
79+
],
80+
),
81+
),
82+
],
83+
);
84+
},
85+
),
86+
);
87+
}
88+
89+
Widget _buildDeparturesList(
90+
BuildContext context, StationSheetViewModel viewModel) {
91+
if (viewModel.hasError) {
92+
return Padding(
93+
padding: const EdgeInsets.only(bottom: 32.0),
94+
child: Card(
95+
color: Theme.of(context).colorScheme.errorContainer,
96+
child: Padding(
97+
padding: const EdgeInsets.all(4.0),
98+
child: ListTile(
99+
title: Text(AppLocalizations.of(context)!.info,
100+
style: const TextStyle(fontSize: 24)),
101+
subtitle: Text(
102+
AppLocalizations.of(context)!.noDepartures,
103+
style: TextStyle(
104+
fontSize: 16, color: Theme.of(context).colorScheme.error),
105+
),
106+
),
107+
)),
108+
);
109+
}
110+
111+
final departures = viewModel.departures ?? [];
112+
return RefreshIndicator(
113+
onRefresh: viewModel.fetchDepartures,
114+
child: ConstrainedBox(
115+
constraints:
116+
BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.5),
117+
child: Skeletonizer(
118+
enabled: departures.isEmpty,
119+
child: ListView.separated(
120+
separatorBuilder: (context, index) => const SizedBox(height: 8),
121+
shrinkWrap: true,
122+
itemCount: departures.isEmpty ? 5 : departures.length,
123+
itemBuilder: (context, index) {
124+
if (departures.isEmpty) {
125+
// Show a loading skeleton if there are no departures yet.
126+
return Card(
127+
child: Padding(
128+
padding: const EdgeInsets.all(4.0),
129+
child: ListTile(
130+
title: Text(AppLocalizations.of(context)!.loading,
131+
style: const TextStyle(fontSize: 20)),
132+
subtitle: Text(
133+
AppLocalizations.of(context)!.pleaseWait,
134+
style: const TextStyle(fontSize: 16),
135+
),
136+
)));
137+
}
138+
Departure departure = departures[index];
139+
return Consumer<TrackingProvider>(
140+
builder: (context, trackingProvider, _) {
141+
return Container(
142+
decoration: BoxDecoration(
143+
borderRadius: BorderRadius.circular(16),
144+
border: trackingProvider.trackingTripId ==
145+
departure.realTrip?.id &&
146+
departure.realTrip != null
147+
? Border.all(
148+
color: Theme.of(context)
149+
.colorScheme
150+
.tertiary
151+
.withValues(alpha: 1),
152+
width: 3)
153+
: null),
154+
child: Card(
155+
child: Padding(
156+
padding: const EdgeInsets.all(4.0),
157+
child: ListTile(
158+
title: Text(
159+
"${departure.lineCode}${departure.destination != null ? " - ${departure.destination}" : ""}",
160+
style: const TextStyle(fontSize: 20)),
161+
subtitle: Column(
162+
mainAxisSize: MainAxisSize.min,
163+
crossAxisAlignment: CrossAxisAlignment.start,
164+
children: [
165+
Text(departure.name,
166+
style: const TextStyle(fontSize: 16)),
167+
168+
// Display the estimated arrival time of the departure.
169+
Builder(
170+
builder: (context) {
171+
final minutesDifference = departure
172+
.estimatedArrival
173+
.difference(DateTime.now())
174+
.inMinutes;
175+
final estimatedArrivalTime = DateFormat.Hm()
176+
.format(departure.estimatedArrival);
177+
String arrivalText;
178+
Color textColor;
179+
180+
if (minutesDifference < 0) {
181+
arrivalText = AppLocalizations.of(context)!
182+
.arrivingLate;
183+
textColor =
184+
Theme.of(context).colorScheme.error;
185+
} else if (minutesDifference > 59) {
186+
arrivalText = estimatedArrivalTime;
187+
textColor =
188+
Theme.of(context).colorScheme.onSurface;
189+
} else {
190+
arrivalText =
191+
"$minutesDifference ${AppLocalizations.of(context)!.min} ($estimatedArrivalTime)";
192+
textColor =
193+
Theme.of(context).colorScheme.onSurface;
194+
}
195+
196+
return Text(
197+
arrivalText,
198+
style: TextStyle(
199+
fontSize: 16, color: textColor),
200+
);
201+
},
202+
),
203+
],
204+
),
205+
trailing: Row(
206+
mainAxisAlignment: MainAxisAlignment.end,
207+
mainAxisSize: MainAxisSize.min,
208+
children: [
209+
if (departure.realTrip != null) ...[
210+
const SizedBox(width: 12),
211+
Material(
212+
color: Colors.transparent,
213+
borderRadius: BorderRadius.circular(50),
214+
child: InkWell(
215+
borderRadius: BorderRadius.circular(50),
216+
onTap: () async {
217+
final line = await RouteLine.getLine(
218+
departure.lineCode);
219+
if (context.mounted) {
220+
Provider.of<MapProvider>(context,
221+
listen: false)
222+
.viewRoute(line, context, true);
223+
Provider.of<TrackingProvider>(context,
224+
listen: false)
225+
.startTracking(
226+
departure.realTrip!.id,
227+
departure.lineCode,
228+
LatLng(departure.realTrip!.lat,
229+
departure.realTrip!.long),
230+
station.id);
231+
Provider.of<MapProvider>(context,
232+
listen: false)
233+
.updateLocation(
234+
LatLng(departure.realTrip!.lat,
235+
departure.realTrip!.long),
236+
15);
237+
}
238+
},
239+
child: Column(
240+
mainAxisSize: MainAxisSize.min,
241+
mainAxisAlignment:
242+
MainAxisAlignment.center,
243+
children: [
244+
const Icon(Icons.directions_bus),
245+
const SizedBox(height: 4),
246+
Text(AppLocalizations.of(context)!
247+
.track),
248+
],
249+
),
250+
),
251+
),
252+
]
253+
],
254+
),
255+
),
256+
),
257+
),
258+
);
259+
});
260+
}),
261+
),
262+
),
263+
);
264+
}
265+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:via_mallorca/apis/local_storage.dart';
3+
import 'package:mallorca_transit_services/mallorca_transit_services.dart';
4+
5+
class StationSheetViewModel extends ChangeNotifier {
6+
final Station station;
7+
8+
StationSheetViewModel(this.station);
9+
10+
bool isFavourite = false;
11+
List<Departure>? departures;
12+
bool isLoading = true;
13+
bool hasError = false;
14+
final int numberOfDepartures = 10;
15+
16+
Future<void> initialize() async {
17+
await _loadFavouriteStatus();
18+
await fetchDepartures();
19+
}
20+
21+
Future<void> _loadFavouriteStatus() async {
22+
final favourites = await LocalStorageApi.getFavouriteStations();
23+
isFavourite = favourites.contains(station.code.toString());
24+
if (!_isDisposed) {
25+
notifyListeners();
26+
}
27+
}
28+
29+
Future<void> toggleFavourite() async {
30+
final favourites = await LocalStorageApi.getFavouriteStations();
31+
32+
if (isFavourite) {
33+
favourites.remove(station.code.toString());
34+
} else {
35+
favourites.add(station.code.toString());
36+
}
37+
await LocalStorageApi.setFavouriteStations(favourites);
38+
isFavourite = !isFavourite;
39+
if (!_isDisposed) {
40+
notifyListeners();
41+
}
42+
}
43+
44+
Future<void> fetchDepartures() async {
45+
try {
46+
isLoading = true;
47+
hasError = false;
48+
if (!_isDisposed) {
49+
notifyListeners();
50+
}
51+
52+
departures = await Departures.getDepartures(
53+
stationCode: station.code,
54+
numberOfDepartures: numberOfDepartures,
55+
);
56+
} catch (e) {
57+
hasError = true;
58+
} finally {
59+
isLoading = false;
60+
if (!_isDisposed) {
61+
notifyListeners();
62+
}
63+
}
64+
}
65+
66+
bool _isDisposed = false;
67+
68+
@override
69+
void dispose() {
70+
_isDisposed = true;
71+
super.dispose();
72+
}
73+
}

0 commit comments

Comments
 (0)