Files
anyway/frontend/lib/presentation/widgets/trip_details/trip_landmark_card.dart

293 lines
10 KiB
Dart

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;
}