feat(wip): implement trip persistence through a local repository. Include loaded trips in the start page UI
This commit is contained in:
185
frontend/lib/presentation/widgets/trip_details_panel.dart
Normal file
185
frontend/lib/presentation/widgets/trip_details_panel.dart
Normal file
@@ -0,0 +1,185 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:map_launcher/map_launcher.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'package:anyway/domain/entities/landmark.dart';
|
||||
import 'package:anyway/domain/entities/trip.dart';
|
||||
import 'package:anyway/presentation/utils/trip_location_utils.dart';
|
||||
import 'package:anyway/presentation/widgets/trip_details/trip_hero_header.dart';
|
||||
import 'package:anyway/presentation/widgets/trip_details/trip_hero_meta_card.dart';
|
||||
import 'package:anyway/presentation/widgets/trip_details/trip_landmark_card.dart';
|
||||
import 'package:anyway/presentation/widgets/trip_details/trip_step_between_landmarks.dart';
|
||||
|
||||
class TripDetailsPanel extends StatefulWidget {
|
||||
const TripDetailsPanel({super.key, required this.trip, required this.controller, this.onLandmarkUpdated});
|
||||
|
||||
final Trip trip;
|
||||
final ScrollController controller;
|
||||
final VoidCallback? onLandmarkUpdated;
|
||||
|
||||
@override
|
||||
State<TripDetailsPanel> createState() => _TripDetailsPanelState();
|
||||
}
|
||||
|
||||
class _TripDetailsPanelState extends State<TripDetailsPanel> {
|
||||
late Future<TripLocaleInfo> _localeFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_localeFuture = TripLocationUtils.resolveLocaleInfo(widget.trip);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TripDetailsPanel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.trip.uuid != widget.trip.uuid) {
|
||||
_localeFuture = TripLocationUtils.resolveLocaleInfo(widget.trip);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final landmarks = widget.trip.landmarks;
|
||||
if (landmarks.isEmpty) {
|
||||
return const Center(child: Text('No landmarks in this trip yet.'));
|
||||
}
|
||||
|
||||
return CustomScrollView(
|
||||
controller: widget.controller,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
width: 42,
|
||||
height: 5,
|
||||
decoration: BoxDecoration(color: Colors.grey.shade300, borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: FutureBuilder<TripLocaleInfo>(
|
||||
future: _localeFuture,
|
||||
builder: (context, snapshot) {
|
||||
final info = snapshot.data;
|
||||
final isLoading = snapshot.connectionState == ConnectionState.waiting;
|
||||
final subtitle = isLoading ? 'Pinpointing your destination...' : 'Here is a recommended route for your trip in ${info?.cityName ?? 'your area'}.';
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TripHeroHeader(localeInfo: info),
|
||||
const SizedBox(height: 10),
|
||||
TripHeroMetaCard(
|
||||
subtitle: subtitle,
|
||||
totalStops: widget.trip.landmarks.length,
|
||||
totalMinutes: widget.trip.totalTimeMinutes,
|
||||
countryName: info?.countryName,
|
||||
isLoading: isLoading,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 32),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final landmark = landmarks[index];
|
||||
return Column(
|
||||
children: [
|
||||
TripLandmarkCard(
|
||||
landmark: landmark,
|
||||
position: index + 1,
|
||||
onToggleVisited: () => _toggleVisited(landmark),
|
||||
onOpenWebsite: landmark.websiteUrl == null ? null : () => _openWebsite(landmark.websiteUrl!),
|
||||
),
|
||||
if (index < landmarks.length - 1)
|
||||
TripStepBetweenLandmarks(current: landmark, next: landmarks[index + 1], onRequestDirections: () => _showNavigationOptions(context, landmark, landmarks[index + 1])),
|
||||
],
|
||||
);
|
||||
}, childCount: landmarks.length),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleVisited(Landmark landmark) {
|
||||
setState(() => landmark.isVisited = !landmark.isVisited);
|
||||
widget.onLandmarkUpdated?.call();
|
||||
}
|
||||
|
||||
Future<void> _openWebsite(String url) async {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null) return;
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
Future<void> _showNavigationOptions(BuildContext context, Landmark current, Landmark next) async {
|
||||
if (!_hasValidLocation(current) || !_hasValidLocation(next)) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Coordinates unavailable for directions.')));
|
||||
return;
|
||||
}
|
||||
|
||||
List<AvailableMap> availableMaps = [];
|
||||
try {
|
||||
availableMaps = await MapLauncher.installedMaps;
|
||||
} catch (error) {
|
||||
debugPrint('Unable to load maps: $error');
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (availableMaps.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('No navigation apps detected.')));
|
||||
return;
|
||||
}
|
||||
|
||||
final origin = Coords(current.location[0], current.location[1]);
|
||||
final destination = Coords(next.location[0], next.location[1]);
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (sheetContext) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Navigate to ${next.name}', style: Theme.of(sheetContext).textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text('Choose your preferred maps app.', style: Theme.of(sheetContext).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final map in availableMaps)
|
||||
ListTile(
|
||||
leading: ClipRRect(borderRadius: BorderRadius.circular(8), child: SvgPicture.asset(map.icon, height: 30, width: 30)),
|
||||
title: Text(map.mapName),
|
||||
onTap: () async {
|
||||
await map.showDirections(origin: origin, originTitle: current.name, destination: destination, destinationTitle: next.name, directionsMode: DirectionsMode.walking);
|
||||
if (sheetContext.mounted) Navigator.of(sheetContext).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool _hasValidLocation(Landmark landmark) => landmark.location.length >= 2;
|
||||
}
|
||||
Reference in New Issue
Block a user