feat(wip): implement trip persistence through a local repository. Include loaded trips in the start page UI
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:anyway/core/constants.dart';
|
||||
import 'package:anyway/presentation/utils/trip_location_utils.dart';
|
||||
|
||||
class TripHeroHeader extends StatelessWidget {
|
||||
const TripHeroHeader({super.key, this.localeInfo});
|
||||
|
||||
final TripLocaleInfo? localeInfo;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final resolvedCity = localeInfo?.hasResolvedCity == true ? localeInfo!.cityName : null;
|
||||
final title = resolvedCity == null ? 'Welcome to your trip!' : 'Welcome to $resolvedCity!';
|
||||
final flag = localeInfo?.flagEmoji ?? '🏁';
|
||||
|
||||
return SizedBox(
|
||||
height: 70,
|
||||
child: Center(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: const BoxDecoration(shape: BoxShape.circle, color: Color(0x11000000)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Text(flag, style: const TextStyle(fontSize: 26)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_GradientText(title, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800, letterSpacing: -0.2)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GradientText extends StatelessWidget {
|
||||
const _GradientText(this.text, {this.style});
|
||||
|
||||
final String text;
|
||||
final TextStyle? style;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ShaderMask(
|
||||
shaderCallback: (bounds) => APP_GRADIENT.createShader(Rect.fromLTWH(0, 0, bounds.width, bounds.height)),
|
||||
blendMode: BlendMode.srcIn,
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: (style ?? const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)).copyWith(color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class TripHeroMetaCard extends StatelessWidget {
|
||||
const TripHeroMetaCard({super.key, required this.subtitle, required this.totalStops, required this.totalMinutes, this.countryName, this.isLoading = false});
|
||||
|
||||
final String subtitle;
|
||||
final int totalStops;
|
||||
final int? totalMinutes;
|
||||
final String? countryName;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final totalTimeLabel = _formatTripDuration(totalMinutes);
|
||||
|
||||
return Card(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
|
||||
elevation: 6,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(subtitle, style: theme.textTheme.titleSmall?.copyWith(height: 1.3)),
|
||||
if (countryName != null && countryName!.trim().isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.public, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Text(countryName!, style: theme.textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.2)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isLoading) const Padding(padding: EdgeInsets.only(top: 8), child: LinearProgressIndicator(minHeight: 3)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_HeroStat(icon: Icons.flag, label: 'Stops', value: '$totalStops'),
|
||||
const SizedBox(width: 14),
|
||||
_HeroStat(icon: Icons.hourglass_bottom_rounded, label: 'Total time', value: totalTimeLabel),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeroStat extends StatelessWidget {
|
||||
const _HeroStat({required this.icon, required this.label, required this.value});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: Colors.grey.shade600),
|
||||
const SizedBox(width: 4),
|
||||
Text(label, style: theme.textTheme.labelSmall?.copyWith(color: Colors.grey.shade600)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(value, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTripDuration(int? totalMinutes) {
|
||||
if (totalMinutes == null) return '--';
|
||||
final hours = totalMinutes ~/ 60;
|
||||
final minutes = totalMinutes % 60;
|
||||
if (hours == 0) return '${minutes}m';
|
||||
if (minutes == 0) return '${hours}h';
|
||||
return '${hours}h ${minutes}m';
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:anyway/domain/entities/landmark.dart';
|
||||
import 'package:anyway/domain/entities/landmark_type.dart';
|
||||
|
||||
class TripLandmarkCard extends StatelessWidget {
|
||||
const TripLandmarkCard({super.key, required this.landmark, required this.position, required this.onToggleVisited, this.onOpenWebsite});
|
||||
|
||||
final Landmark landmark;
|
||||
final int position;
|
||||
final VoidCallback onToggleVisited;
|
||||
final VoidCallback? onOpenWebsite;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final visited = landmark.isVisited;
|
||||
final metaItems = _buildMetaItems(
|
||||
duration: landmark.durationMinutes,
|
||||
reachTime: landmark.timeToReachNextMinutes,
|
||||
tags: landmark.description?.tags ?? const [],
|
||||
tagCount: landmark.tagCount,
|
||||
attractiveness: landmark.attractiveness,
|
||||
isSecondary: landmark.isSecondary ?? false,
|
||||
websiteLabel: landmark.websiteUrl,
|
||||
onOpenWebsite: onOpenWebsite,
|
||||
);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: Card(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
elevation: 4,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_LandmarkMedia(landmark: landmark, position: position, type: landmark.type.type),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
landmark.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
iconSize: 22,
|
||||
padding: EdgeInsets.zero,
|
||||
splashRadius: 18,
|
||||
tooltip: visited ? 'Mark as pending' : 'Mark visited',
|
||||
onPressed: onToggleVisited,
|
||||
icon: Icon(visited ? Icons.radio_button_checked : Icons.radio_button_unchecked, color: visited ? Colors.green : Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (landmark.nameEn != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
landmark.nameEn!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(color: Colors.grey.shade600),
|
||||
),
|
||||
),
|
||||
if (landmark.description?.description != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(landmark.description!.description, maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.3)),
|
||||
),
|
||||
if (metaItems.isNotEmpty) ...[const SizedBox(height: 10), _MetaScroller(items: metaItems)],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<_MetaItem> _buildMetaItems({
|
||||
int? duration,
|
||||
int? reachTime,
|
||||
List<String> tags = const [],
|
||||
int? tagCount,
|
||||
int? attractiveness,
|
||||
bool isSecondary = false,
|
||||
String? websiteLabel,
|
||||
VoidCallback? onOpenWebsite,
|
||||
}) {
|
||||
final items = <_MetaItem>[];
|
||||
if (duration != null) {
|
||||
items.add(_MetaItem(icon: Icons.timer_outlined, label: '$duration min stay'));
|
||||
}
|
||||
if (reachTime != null) {
|
||||
items.add(_MetaItem(icon: Icons.directions_walk, label: '$reachTime min walk'));
|
||||
}
|
||||
if (attractiveness != null) {
|
||||
items.add(_MetaItem(icon: Icons.star, label: 'Score $attractiveness'));
|
||||
}
|
||||
if (tagCount != null) {
|
||||
items.add(_MetaItem(icon: Icons.label, label: '$tagCount tags'));
|
||||
}
|
||||
if (isSecondary) {
|
||||
items.add(_MetaItem(icon: Icons.layers, label: 'Secondary stop'));
|
||||
}
|
||||
for (final tag in tags.where((tag) => tag.trim().isNotEmpty).take(4)) {
|
||||
items.add(_MetaItem(icon: Icons.local_offer, label: tag));
|
||||
}
|
||||
if (websiteLabel != null && websiteLabel.isNotEmpty && onOpenWebsite != null) {
|
||||
items.add(_MetaItem(icon: Icons.link, label: 'Website', onTap: onOpenWebsite));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
class _LandmarkMedia extends StatelessWidget {
|
||||
const _LandmarkMedia({required this.landmark, required this.position, required this.type});
|
||||
|
||||
final Landmark landmark;
|
||||
final int position;
|
||||
final LandmarkTypeEnum type;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const width = 116.0;
|
||||
const mediaHeight = 140.0;
|
||||
final label = _typeLabel(type);
|
||||
final icon = _typeIcon(type);
|
||||
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: mediaHeight,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(child: _buildMedia()),
|
||||
Positioned(
|
||||
left: 8,
|
||||
bottom: 8,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.65), borderRadius: BorderRadius.circular(18)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: Colors.white),
|
||||
const SizedBox(width: 4),
|
||||
Text(label, style: const TextStyle(color: Colors.white, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'#$position',
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMedia() {
|
||||
if (landmark.imageUrl != null && landmark.imageUrl!.isNotEmpty) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: landmark.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
errorWidget: (context, url, error) => _placeholder(),
|
||||
);
|
||||
}
|
||||
return _placeholder();
|
||||
}
|
||||
|
||||
Widget _placeholder() {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFF9B208), Color(0xFFE72E77)]),
|
||||
),
|
||||
child: const Center(child: Icon(Icons.photo, color: Colors.white, size: 32)),
|
||||
);
|
||||
}
|
||||
|
||||
String _typeLabel(LandmarkTypeEnum type) {
|
||||
switch (type) {
|
||||
case LandmarkTypeEnum.start:
|
||||
return 'Start';
|
||||
case LandmarkTypeEnum.finish:
|
||||
return 'Finish';
|
||||
case LandmarkTypeEnum.shopping:
|
||||
return 'Shopping';
|
||||
case LandmarkTypeEnum.nature:
|
||||
return 'Nature';
|
||||
case LandmarkTypeEnum.sightseeing:
|
||||
return 'Sight';
|
||||
}
|
||||
}
|
||||
|
||||
IconData _typeIcon(LandmarkTypeEnum type) {
|
||||
switch (type) {
|
||||
case LandmarkTypeEnum.start:
|
||||
return Icons.flag;
|
||||
case LandmarkTypeEnum.finish:
|
||||
return Icons.flag_circle;
|
||||
case LandmarkTypeEnum.shopping:
|
||||
return Icons.shopping_bag;
|
||||
case LandmarkTypeEnum.nature:
|
||||
return Icons.park;
|
||||
case LandmarkTypeEnum.sightseeing:
|
||||
return Icons.place;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MetaScroller extends StatelessWidget {
|
||||
const _MetaScroller({required this.items});
|
||||
|
||||
final List<_MetaItem> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 34,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
final chip = Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(item.icon, size: 14, color: Colors.grey.shade800),
|
||||
const SizedBox(width: 4),
|
||||
Text(item.label, style: const TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (item.onTap == null) return chip;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(borderRadius: BorderRadius.circular(14), onTap: item.onTap, child: chip),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetaItem {
|
||||
const _MetaItem({required this.icon, required this.label, this.onTap});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:anyway/domain/entities/landmark.dart';
|
||||
|
||||
class TripStepBetweenLandmarks extends StatelessWidget {
|
||||
const TripStepBetweenLandmarks({super.key, required this.current, required this.next, this.onRequestDirections});
|
||||
|
||||
final Landmark current;
|
||||
final Landmark next;
|
||||
final VoidCallback? onRequestDirections;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final minutes = current.timeToReachNextMinutes;
|
||||
final text = minutes == null ? 'Continue to ${next.name}' : '$minutes min to ${next.name}';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.directions_walk, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(text, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
),
|
||||
if (onRequestDirections != null) TextButton.icon(onPressed: onRequestDirections, icon: const Icon(Icons.navigation), label: const Text('Navigate')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user