diff --git a/lib/core/widgets/location_stamp_card.dart b/lib/core/widgets/location_stamp_card.dart index 8acf06a..f87e236 100644 --- a/lib/core/widgets/location_stamp_card.dart +++ b/lib/core/widgets/location_stamp_card.dart @@ -13,178 +13,330 @@ class LocationStampCard extends StatelessWidget { required this.locationInfo, this.showMap = true, this.cardWidth = 260, + this.settings, + this.compactPreview = false, super.key, }); final LocationInfo? locationInfo; final bool showMap; final double cardWidth; + final AppSettings? settings; + final bool compactPreview; @override Widget build(BuildContext context) { - final info = locationInfo; + final providedSettings = settings; + if (providedSettings != null) { + return _buildWithSettings(providedSettings); + } return FutureBuilder( future: const SettingsService().load(), builder: (context, snapshot) { - final settings = snapshot.data ?? const AppSettings(); - return AnimatedSwitcher( - duration: const Duration(milliseconds: 350), - transitionBuilder: (child, animation) { - final offset = Tween( - begin: const Offset(0, 0.25), - end: Offset.zero, - ).animate(animation); - return FadeTransition( - opacity: animation, - child: SlideTransition(position: offset, child: child), - ); - }, - child: ClipRRect( - key: ValueKey(info?.address ?? 'fallback'), - borderRadius: BorderRadius.circular(12), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4), - child: Container( - width: cardWidth, - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.92), - borderRadius: BorderRadius.circular(12), - boxShadow: const [ - BoxShadow(color: Colors.black26, blurRadius: 8), + return _buildWithSettings(snapshot.data ?? const AppSettings()); + }, + ); + } + + Widget _buildWithSettings(AppSettings settings) { + final info = locationInfo; + return AnimatedSwitcher( + duration: const Duration(milliseconds: 350), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: const Offset(0, 0.18), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: KeyedSubtree( + key: ValueKey('${info?.address ?? 'fallback'}-${settings.overlayTemplate}'), + child: info == null ? _fallback(settings) : _content(info, settings), + ), + ); + } + + Widget _fallback(AppSettings settings) { + return _cardShell( + height: settings.overlayTemplate == OverlayTemplateIds.minimalStrip ? 64 : null, + child: Row( + children: [ + if (settings.overlayTemplate != OverlayTemplateIds.minimalStrip) ...[ + _mapPlaceholder(size: compactPreview ? 44 : 78), + const SizedBox(width: 12), + ], + const Expanded( + child: Text( + 'Getting GPS and map...', + style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700), + ), + ), + TextButton(onPressed: openAppSettings, child: const Text('Enable')), + ], + ), + ); + } + + Widget _content(LocationInfo info, AppSettings settings) { + return switch (settings.overlayTemplate) { + OverlayTemplateIds.minimalStrip => _minimalStrip(info), + OverlayTemplateIds.fieldReport => _fieldReport(info, settings), + _ => _classicDark(info, settings), + }; + } + + Widget _classicDark(LocationInfo info, AppSettings settings) { + return _cardShell( + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (showMap) _StaticMap(info: info, settings: settings, size: compactPreview ? 58 : 86), + if (showMap) const SizedBox(width: 14), + Expanded(child: _metadataColumn(info, settings, titleSize: compactPreview ? 11 : 15)), + ], + ), + ); + } + + Widget _minimalStrip(LocationInfo info) { + return _cardShell( + height: compactPreview ? 54 : 76, + padding: EdgeInsets.symmetric( + horizontal: compactPreview ? 10 : 16, + vertical: compactPreview ? 8 : 12, + ), + child: Row( + children: [ + const Icon(Icons.place, color: Color(0xFFF5A623), size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + info.address, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: compactPreview ? 11 : 15, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 10), + Text( + info.time.isEmpty ? info.date : '${info.date} ${info.time}', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.82), + fontSize: compactPreview ? 9 : 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } + + Widget _fieldReport(LocationInfo info, AppSettings settings) { + return _cardShell( + padding: EdgeInsets.all(compactPreview ? 8 : 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showMap) _StaticMap(info: info, settings: settings, size: compactPreview ? 64 : 108), + if (showMap) const SizedBox(width: 14), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFF1DB954).withValues(alpha: 0.22), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFF1DB954)), + ), + child: const Text( + 'GPS-STAMPED', + style: TextStyle( + color: Color(0xFF8DFFB0), + fontSize: 9, + fontWeight: FontWeight.w900, + letterSpacing: .7, + ), + ), + ), + const Spacer(), + Text( + info.time, + style: TextStyle(color: Colors.white.withValues(alpha: .8), fontSize: 11), + ), ], ), - child: info == null ? _fallback() : _content(info, settings), - ), + const SizedBox(height: 8), + _metadataColumn(info, settings, titleSize: compactPreview ? 11 : 16, structured: true), + ], ), ), - ); - }, + ], + ), ); } - Widget _fallback() { - return Row( + Widget _metadataColumn( + LocationInfo info, + AppSettings settings, { + required double titleSize, + bool structured = false, + }) { + final muted = Colors.white.withValues(alpha: 0.78); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 70, - height: 70, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(8), + Text( + info.address, + maxLines: structured ? 2 : 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: titleSize, + height: 1.16, + fontWeight: FontWeight.w800, ), - child: const Center(child: CircularProgressIndicator(strokeWidth: 2)), ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Getting GPS and map...'), - TextButton( - onPressed: openAppSettings, - child: const Text('Enable location'), - ) - ], + SizedBox(height: structured ? 8 : 6), + Wrap( + spacing: 10, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _metaPill(Icons.calendar_today, info.date, muted), + _metaPill(Icons.access_time, info.time, muted), + ], + ), + if (settings.showCoordinates) ...[ + SizedBox(height: structured ? 8 : 6), + Text( + '${info.latitude.toStringAsFixed(5)}, ${info.longitude.toStringAsFixed(5)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: const Color(0xFF80D8FF), + fontSize: compactPreview ? 9.5 : 13, + fontWeight: FontWeight.w800, + letterSpacing: .2, + ), ), - ) + ], + if (settings.showCompassSpeedAltitude && !compactPreview) ...[ + const SizedBox(height: 5), + Text( + 'C ${info.heading?.toStringAsFixed(0) ?? '--'}° S ${info.speedMetersPerSecond?.toStringAsFixed(1) ?? '--'} A ${info.altitude?.toStringAsFixed(0) ?? '--'}m', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: muted, fontSize: 11.5, fontWeight: FontWeight.w600), + ), + ], ], ); } - Widget _content(LocationInfo info, AppSettings settings) { + Widget _metaPill(IconData icon, String text, Color color) { return Row( + mainAxisSize: MainAxisSize.min, children: [ - if (showMap) _StaticMap(info: info, settings: settings), - if (showMap) const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row(children: [ - const Icon(Icons.location_on, color: Colors.red, size: 14), - const SizedBox(width: 4), - Expanded( - child: Text( - info.address, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w500), - ), - ) - ]), - const SizedBox(height: 2), - Row(children: [ - Icon(Icons.calendar_today, color: Colors.grey.shade700, size: 12), - const SizedBox(width: 4), - Text(info.date, - style: TextStyle(fontSize: 11, color: Colors.grey.shade700)), - ]), - const SizedBox(height: 2), - Row(children: [ - Icon(Icons.access_time, color: Colors.grey.shade700, size: 12), - const SizedBox(width: 4), - Text(info.time, - style: TextStyle(fontSize: 11, color: Colors.grey.shade700)), - ]), - if (settings.showCoordinates) ...[ - const SizedBox(height: 2), - Text( - '${info.latitude.toStringAsFixed(5)}, ${info.longitude.toStringAsFixed(5)}', - style: TextStyle(fontSize: 10, color: Colors.grey.shade700), - ), - ], - if (settings.showCompassSpeedAltitude) ...[ - const SizedBox(height: 2), - Text( - 'W -- C ${info.heading?.toStringAsFixed(0) ?? '--'}° S ${info.speedMetersPerSecond?.toStringAsFixed(1) ?? '--'} A ${info.altitude?.toStringAsFixed(0) ?? '--'}m', - style: TextStyle(fontSize: 10, color: Colors.grey.shade700), - ), - ], - ], - ), + Icon(icon, color: color, size: compactPreview ? 10 : 13), + const SizedBox(width: 4), + Text( + text, + style: TextStyle(color: color, fontSize: compactPreview ? 9 : 12, fontWeight: FontWeight.w600), ), ], ); } + + Widget _cardShell({required Widget child, double? height, EdgeInsets? padding}) { + return ClipRRect( + borderRadius: BorderRadius.circular(compactPreview ? 10 : 18), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 6, sigmaY: 6), + child: Container( + width: cardWidth, + height: height, + padding: padding ?? EdgeInsets.all(compactPreview ? 8 : 12), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.74), + borderRadius: BorderRadius.circular(compactPreview ? 10 : 18), + border: Border.all(color: Colors.white.withValues(alpha: 0.18)), + boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 16)], + ), + child: child, + ), + ), + ); + } + + Widget _mapPlaceholder({required double size}) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: const Color(0xFF263238), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white24), + ), + child: const Center(child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)), + ); + } } class _StaticMap extends StatelessWidget { - const _StaticMap({required this.info, required this.settings}); + const _StaticMap({required this.info, required this.settings, required this.size}); final LocationInfo info; final AppSettings settings; + final double size; @override Widget build(BuildContext context) { final uri = const GoogleMapService().staticMapUri( latitude: info.latitude, longitude: info.longitude, - width: 200, - height: 150, + width: 320, + height: 320, zoom: settings.mapZoomLevel.round(), mapStyle: settings.mapStyle, ); return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: SizedBox( - width: 70, - height: 70, + borderRadius: BorderRadius.circular(14), + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + border: Border.all(color: Colors.white.withValues(alpha: 0.28), width: 1.2), + borderRadius: BorderRadius.circular(14), + ), + clipBehavior: Clip.antiAlias, child: Image.network( uri.toString(), fit: BoxFit.cover, + filterQuality: FilterQuality.medium, loadingBuilder: (context, child, progress) => progress == null ? child : Container( - color: Colors.grey.shade300, - child: const Center(child: CircularProgressIndicator(strokeWidth: 2)), + color: const Color(0xFF263238), + child: const Center(child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)), ), errorBuilder: (_, __, ___) => Container( - color: Colors.grey.shade300, - child: const Icon(Icons.map_outlined, color: Colors.black54), + color: const Color(0xFF263238), + child: const Icon(Icons.map_outlined, color: Colors.white70), ), ), ), diff --git a/lib/features/camera/screen/camera_screen.dart b/lib/features/camera/screen/camera_screen.dart index 32b9000..2477e84 100644 --- a/lib/features/camera/screen/camera_screen.dart +++ b/lib/features/camera/screen/camera_screen.dart @@ -393,7 +393,7 @@ class _CameraScreenState extends State _zoom = next; await _controller!.setZoomLevel(next); }, - child: CameraPreview(_controller!), + child: _CameraPreviewCover(controller: _controller!), ), if (_flashOverlay) IgnorePointer( @@ -434,7 +434,8 @@ class _CameraScreenState extends State child: Center( child: LocationStampCard( locationInfo: _locationInfo, - cardWidth: MediaQuery.of(context).size.width * 0.8, + settings: _settings, + cardWidth: MediaQuery.of(context).size.width * 0.9, ), ), ), @@ -576,8 +577,8 @@ class _CameraScreenState extends State ), _nav('Account', Icons.person_rounded, onTap: () => Navigator.pushNamed(context, AppConstants.routeAccount)), - _nav('Settings', Icons.settings, - onTap: () => Navigator.pushNamed(context, AppConstants.routeSettings)), + _nav('Templates', Icons.dashboard_customize, + onTap: _showTemplateSheet), ], ), const SizedBox(height: 4), @@ -590,6 +591,46 @@ class _CameraScreenState extends State ); } + + Future _showTemplateSheet() async { + final selected = await showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF101010), + showDragHandle: true, + builder: (context) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 18), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Overlay Templates', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800), + ), + const SizedBox(height: 12), + ...OverlayTemplateIds.all.map( + (id) => _TemplateChoiceTile( + id: id, + selected: _settings.overlayTemplate == id, + locationInfo: _locationInfo, + settings: _settings.copyWith(overlayTemplate: id), + onTap: () => Navigator.pop(context, id), + ), + ), + ], + ), + ), + ), + ); + + if (selected == null || selected == _settings.overlayTemplate) return; + final next = _settings.copyWith(overlayTemplate: selected); + await _settingsService.save(next); + if (!mounted) return; + setState(() => _settings = next); + } + Widget _mode(String label, bool selected, VoidCallback onTap) => GestureDetector( onTap: onTap, child: Column( @@ -617,6 +658,112 @@ class _CameraScreenState extends State ); } + +class _CameraPreviewCover extends StatelessWidget { + const _CameraPreviewCover({required this.controller}); + + final CameraController controller; + + @override + Widget build(BuildContext context) { + final previewSize = controller.value.previewSize; + if (previewSize == null) return CameraPreview(controller); + + final portraitPreviewSize = Size(previewSize.height, previewSize.width); + return ClipRect( + child: SizedBox.expand( + child: FittedBox( + fit: BoxFit.cover, + child: SizedBox( + width: portraitPreviewSize.width, + height: portraitPreviewSize.height, + child: CameraPreview(controller), + ), + ), + ), + ); + } +} + +class _TemplateChoiceTile extends StatelessWidget { + const _TemplateChoiceTile({ + required this.id, + required this.selected, + required this.locationInfo, + required this.settings, + required this.onTap, + }); + + final String id; + final bool selected; + final LocationInfo? locationInfo; + final AppSettings settings; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final previewInfo = locationInfo ?? + const LocationInfo( + address: '123 Market Street, Springfield', + date: '05/16/2026', + time: '10:24 AM', + latitude: 37.42199, + longitude: -122.08406, + ); + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: selected ? AppColors.primary.withValues(alpha: 0.16) : Colors.white.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: selected ? AppColors.primary : Colors.white12, width: selected ? 2 : 1), + ), + child: Row( + children: [ + Container( + width: 122, + height: 82, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + gradient: const LinearGradient(colors: [Color(0xFF263238), Color(0xFF101010)]), + ), + child: LocationStampCard( + locationInfo: previewInfo, + settings: settings, + compactPreview: true, + cardWidth: 112, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(OverlayTemplateIds.label(id), style: const TextStyle(fontWeight: FontWeight.w800)), + const SizedBox(height: 4), + Text( + OverlayTemplateIds.description(id), + style: TextStyle(color: Colors.white.withValues(alpha: .72), fontSize: 12), + ), + ], + ), + ), + if (selected) const Icon(Icons.check_circle, color: AppColors.primary), + ], + ), + ), + ), + ); + } +} + class _PermissionUI extends StatelessWidget { const _PermissionUI({required this.onGrant}); final VoidCallback onGrant; diff --git a/lib/features/gallery/screen/gallery_screen.dart b/lib/features/gallery/screen/gallery_screen.dart index f5284df..3a25473 100644 --- a/lib/features/gallery/screen/gallery_screen.dart +++ b/lib/features/gallery/screen/gallery_screen.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:share_plus/share_plus.dart'; import '../../../models/app_photo.dart'; @@ -20,8 +21,11 @@ class GalleryScreen extends StatefulWidget { class _GalleryScreenState extends State { final AppPhotoStore _photoStore = const AppPhotoStore(); List _photos = const []; + final Set _selectedIds = {}; bool _loading = true; + bool get _selectionMode => _selectedIds.isNotEmpty; + @override void initState() { super.initState(); @@ -41,304 +45,326 @@ class _GalleryScreenState extends State { }); return; } - setState(() => _loading = true); try { final photos = await _photoStore.loadPhotos(); if (!mounted) return; setState(() { _photos = photos; + _selectedIds.clear(); _loading = false; }); } catch (error, stackTrace) { - await ErrorReporter.recordError( - error, - stackTrace, - reason: 'Failed to load gallery photos.', - ); + await ErrorReporter.recordError(error, stackTrace, reason: 'Failed to load gallery photos.'); if (!mounted) return; setState(() => _loading = false); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Could not load saved photos. Pull to retry.')), - ); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Could not load saved photos. Pull to retry.'))); + } + } + + Map> _groupByDate() { + final grouped = >{}; + for (final photo in _photos) { + final key = DateFormat.yMMMMEEEEd().format(photo.capturedAt); + grouped.putIfAbsent(key, () => []).add(photo); } + return grouped; } void _openPhoto(AppPhoto photo) { Navigator.of(context) - .push(MaterialPageRoute( - builder: (_) => AppPhotoViewer(photo: photo, photoStore: _photoStore), + .push(PageRouteBuilder( + pageBuilder: (_, animation, __) => AppPhotoViewer( + photos: _photos, + initialIndex: _photos.indexOf(photo), + photoStore: _photoStore, + ), + transitionsBuilder: (_, animation, __, child) => FadeTransition(opacity: animation, child: child), )) .then((_) => _loadPhotos()); } - Future _sharePhoto(AppPhoto photo) async { + void _toggleSelection(AppPhoto photo) { + setState(() { + if (_selectedIds.contains(photo.id)) { + _selectedIds.remove(photo.id); + } else { + _selectedIds.add(photo.id); + } + }); + } + + Future _shareSelected() async { + final selected = _photos.where((photo) => _selectedIds.contains(photo.id)).toList(); + if (selected.isEmpty) return; try { - await Share.shareXFiles( - [XFile(photo.filePath)], - text: - 'šŸ“ ${photo.locationInfo.address}\nšŸ“… ${photo.locationInfo.date} ${photo.locationInfo.time}\nCaptured with GPS Camera', - ); + await Share.shareXFiles(selected.map((photo) => XFile(photo.filePath)).toList(), text: 'GPS Camera photos'); } catch (error, stackTrace) { - await ErrorReporter.recordError( - error, - stackTrace, - reason: 'Failed to share gallery photo.', - ); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Could not share this photo.')), - ); + await ErrorReporter.recordError(error, stackTrace, reason: 'Failed to share selected photos.'); } } - void _showActions(AppPhoto photo) { - showModalBottomSheet( + Future _deleteSelected() async { + final selected = _photos.where((photo) => _selectedIds.contains(photo.id)).toList(); + final confirm = await showDialog( context: context, - builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.fullscreen), - title: const Text('View'), - onTap: () { - Navigator.pop(context); - _openPhoto(photo); - }, - ), - ListTile( - leading: const Icon(Icons.share), - title: const Text('Share'), - onTap: () { - Navigator.pop(context); - _sharePhoto(photo); - }, - ), - ], - ), + builder: (context) => AlertDialog( + title: Text('Delete ${selected.length} photos?'), + content: const Text('This removes selected photos from this app and deletes local files.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Delete')), + ], ), ); + if (confirm != true) return; + for (final photo in selected) { + await _photoStore.deletePhoto(photo); + } + await _loadPhotos(); } @override Widget build(BuildContext context) { + final grouped = _groupByDate(); return Scaffold( appBar: AppBar( - title: Text(widget.title), + title: Text(_selectionMode ? '${_selectedIds.length} selected' : widget.title), centerTitle: true, - leading: IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), + leading: _selectionMode + ? IconButton(icon: const Icon(Icons.close), onPressed: () => setState(_selectedIds.clear)) + : IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)), ), body: _loading ? const Center(child: CircularProgressIndicator()) : _photos.isEmpty - ? Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.photo_library, - size: 64, color: Colors.grey), - const SizedBox(height: 8), - const Text('No app photos yet'), - const SizedBox(height: 8), - FilledButton( - onPressed: () => Navigator.pop(context), - child: const Text('Open Camera'), - ), - ], - ), - ) + ? _EmptyGallery(onOpenCamera: () => Navigator.pop(context)) : RefreshIndicator( onRefresh: _loadPhotos, - child: GridView.builder( - padding: const EdgeInsets.all(2), - itemCount: _photos.length, - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 2, - mainAxisSpacing: 2, - ), - itemBuilder: (context, index) { - final photo = _photos[index]; - return GestureDetector( - onTap: () => _openPhoto(photo), - onLongPress: () => _showActions(photo), - child: Hero( - tag: photo.id, - child: _LazyPhotoThumbnail(photo: photo), + child: CustomScrollView( + slivers: grouped.entries.expand((entry) { + return [ + SliverToBoxAdapter(child: _DateHeader(label: '${entry.key} • ${entry.value.length} photos')), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 2), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, crossAxisSpacing: 2, mainAxisSpacing: 2), + delegate: SliverChildBuilderDelegate( + (context, index) { + final photo = entry.value[index]; + return _GalleryTile( + photo: photo, + selected: _selectedIds.contains(photo.id), + selectionMode: _selectionMode, + onTap: () => _selectionMode ? _toggleSelection(photo) : _openPhoto(photo), + onLongPress: () => _toggleSelection(photo), + ); + }, + childCount: entry.value.length, + ), + ), ), - ); - }, + ]; + }).toList(), ), ), + bottomNavigationBar: _selectionMode + ? SafeArea( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10), + color: Colors.black, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + TextButton.icon(onPressed: _shareSelected, icon: const Icon(Icons.share), label: const Text('Share')), + TextButton.icon(onPressed: _deleteSelected, icon: const Icon(Icons.delete), label: const Text('Delete')), + ], + ), + ), + ) + : null, ); } } +class _DateHeader extends StatelessWidget { + const _DateHeader({required this.label}); + final String label; + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(10, 18, 10, 8), + child: Text(label, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w900)), + ); +} + +class _GalleryTile extends StatelessWidget { + const _GalleryTile({required this.photo, required this.selected, required this.selectionMode, required this.onTap, required this.onLongPress}); + final AppPhoto photo; + final bool selected; + final bool selectionMode; + final VoidCallback onTap; + final VoidCallback onLongPress; + + @override + Widget build(BuildContext context) { + final info = photo.locationInfo; + return GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + child: Hero( + tag: photo.id, + child: Stack( + fit: StackFit.expand, + children: [ + _LazyPhotoThumbnail(photo: photo), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + padding: const EdgeInsets.fromLTRB(6, 14, 6, 5), + decoration: const BoxDecoration(gradient: LinearGradient(begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.transparent, Colors.black87])), + child: Text( + '${_shortPlace(info.address)} • ${DateFormat.MMMd().format(photo.capturedAt)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w700), + ), + ), + ), + if (selectionMode) Container(color: selected ? Colors.blue.withValues(alpha: .28) : Colors.black.withValues(alpha: .18)), + if (selected) + const Positioned(top: 7, right: 7, child: CircleAvatar(radius: 13, backgroundColor: Colors.blue, child: Icon(Icons.check, size: 16, color: Colors.white))), + ], + ), + ), + ); + } + + static String _shortPlace(String address) { + final parts = address.split(',').map((e) => e.trim()).where((e) => e.isNotEmpty).toList(); + return parts.isEmpty ? 'Location' : parts.first; + } +} class _LazyPhotoThumbnail extends StatelessWidget { const _LazyPhotoThumbnail({required this.photo}); - final AppPhoto photo; - @override Widget build(BuildContext context) { final targetWidth = (MediaQuery.sizeOf(context).width / 3).round(); - final pixelRatio = MediaQuery.devicePixelRatioOf(context); - final cacheExtent = (targetWidth * pixelRatio).round(); - + final cacheExtent = (targetWidth * MediaQuery.devicePixelRatioOf(context)).round(); return Image.file( File(photo.filePath), fit: BoxFit.cover, cacheWidth: cacheExtent, filterQuality: FilterQuality.low, - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) return child; - return const ColoredBox(color: Colors.black12); - }, - errorBuilder: (_, __, ___) => Container( - color: Colors.black12, - child: const Icon(Icons.broken_image), - ), + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) => wasSynchronouslyLoaded || frame != null ? child : const ColoredBox(color: Colors.black12), + errorBuilder: (_, __, ___) => Container(color: Colors.black12, child: const Icon(Icons.broken_image)), ); } } -class AppPhotoViewer extends StatelessWidget { - const AppPhotoViewer({ - required this.photo, - required this.photoStore, - super.key, - }); - - final AppPhoto photo; +class AppPhotoViewer extends StatefulWidget { + const AppPhotoViewer({required this.photos, required this.initialIndex, required this.photoStore, super.key}); + final List photos; + final int initialIndex; final AppPhotoStore photoStore; - Future _share(BuildContext context) async { - try { - await Share.shareXFiles( - [XFile(photo.filePath)], - text: - 'šŸ“ ${photo.locationInfo.address}\nšŸ“… ${photo.locationInfo.date} ${photo.locationInfo.time}\nCaptured with GPS Camera', - ); - } catch (error, stackTrace) { - await ErrorReporter.recordError( - error, - stackTrace, - reason: 'Failed to share photo from viewer.', - ); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Could not share this photo.')), - ); - } + @override + State createState() => _AppPhotoViewerState(); +} + +class _AppPhotoViewerState extends State { + late final PageController _pageController = PageController(initialPage: widget.initialIndex); + late int _index = widget.initialIndex; + + AppPhoto get _photo => widget.photos[_index]; + + Future _share() async { + final photo = _photo; + await Share.shareXFiles([XFile(photo.filePath)], text: 'šŸ“ ${photo.locationInfo.address}\nšŸ“… ${photo.locationInfo.date} ${photo.locationInfo.time}\nCaptured with GPS Camera'); } - Future _delete(BuildContext context) async { + Future _delete() async { final confirm = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Delete photo?'), content: const Text('This removes the photo from this app and deletes the local file.'), actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Delete'), - ), + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Delete')), ], ), ); - if (confirm != true || !context.mounted) return; - try { - await photoStore.deletePhoto(photo); - if (context.mounted) Navigator.pop(context); - } catch (error, stackTrace) { - await ErrorReporter.recordError( - error, - stackTrace, - reason: 'Failed to delete saved photo.', - ); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Could not delete this photo.')), - ); - } + if (confirm != true) return; + await widget.photoStore.deletePhoto(_photo); + if (mounted) Navigator.pop(context); + } + + void _showInfo() { + final info = _photo.locationInfo; + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Photo metadata', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w900)), + const SizedBox(height: 12), + Text(info.address), + const SizedBox(height: 8), + Text('${info.latitude.toStringAsFixed(6)}, ${info.longitude.toStringAsFixed(6)}'), + const SizedBox(height: 8), + Text('${info.date} ${info.time}'), + ], + ), + ), + ); } @override Widget build(BuildContext context) { - final info = photo.locationInfo; return Scaffold( backgroundColor: Colors.black, body: Stack( children: [ - Positioned.fill( - child: Hero( - tag: photo.id, - child: InteractiveViewer( - child: Image.file(File(photo.filePath), fit: BoxFit.contain), - ), - ), - ), - Positioned( - top: 36, - left: 8, - child: IconButton( - onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.arrow_back, color: Colors.white), - ), - ), - Positioned( - top: 36, - right: 8, - child: Row( - children: [ - IconButton( - onPressed: () => _share(context), - icon: const Icon(Icons.share, color: Colors.white), - ), - IconButton( - onPressed: () => _delete(context), - icon: const Icon(Icons.delete, color: Colors.white), + PageView.builder( + controller: _pageController, + itemCount: widget.photos.length, + onPageChanged: (value) => setState(() => _index = value), + itemBuilder: (context, index) { + final photo = widget.photos[index]; + return Hero( + tag: photo.id, + child: InteractiveViewer( + minScale: 1, + maxScale: 5, + child: Center(child: Image.file(File(photo.filePath), fit: BoxFit.contain)), ), - ], - ), + ); + }, ), + Positioned(top: 36, left: 8, child: IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.arrow_back, color: Colors.white))), Positioned( left: 16, right: 16, bottom: 24, child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.65), - borderRadius: BorderRadius.circular(16), - ), - child: Padding( - padding: const EdgeInsets.all(14), - child: DefaultTextStyle( - style: const TextStyle(color: Colors.white), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(info.address, - style: const TextStyle(fontWeight: FontWeight.w700)), - const SizedBox(height: 6), - Text('${info.date} ${info.time}'), - const SizedBox(height: 6), - Text( - '${info.latitude.toStringAsFixed(6)}, ${info.longitude.toStringAsFixed(6)}', - ), - ], - ), + decoration: BoxDecoration(color: Colors.black.withValues(alpha: .72), borderRadius: BorderRadius.circular(22)), + child: SafeArea( + top: false, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + IconButton(onPressed: _share, icon: const Icon(Icons.share, color: Colors.white), tooltip: 'Share'), + IconButton(onPressed: _showInfo, icon: const Icon(Icons.info_outline, color: Colors.white), tooltip: 'Info'), + IconButton(onPressed: _delete, icon: const Icon(Icons.delete, color: Colors.white), tooltip: 'Delete'), + ], ), ), ), @@ -348,3 +374,20 @@ class AppPhotoViewer extends StatelessWidget { ); } } + +class _EmptyGallery extends StatelessWidget { + const _EmptyGallery({required this.onOpenCamera}); + final VoidCallback onOpenCamera; + @override + Widget build(BuildContext context) => Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.photo_library_outlined, size: 68, color: Colors.grey), + const SizedBox(height: 10), + const Text('No photos yet', style: TextStyle(fontWeight: FontWeight.w800)), + const SizedBox(height: 6), + const Text('Capture your first GPS-stamped memory.'), + const SizedBox(height: 14), + FilledButton.icon(onPressed: onOpenCamera, icon: const Icon(Icons.camera_alt), label: const Text('Back to Camera')), + ]), + ); +} diff --git a/lib/features/locations/screen/locations_screen.dart b/lib/features/locations/screen/locations_screen.dart index b6736ff..a86f0a2 100644 --- a/lib/features/locations/screen/locations_screen.dart +++ b/lib/features/locations/screen/locations_screen.dart @@ -1,7 +1,15 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:intl/intl.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:share_plus/share_plus.dart'; import '../../../models/app_photo.dart'; import '../../../services/app_photo_store.dart'; +import '../../../services/error_reporter.dart'; +import '../../../services/google_map_service.dart'; import '../../gallery/screen/gallery_screen.dart'; class LocationsScreen extends StatefulWidget { @@ -13,8 +21,11 @@ class LocationsScreen extends StatefulWidget { class _LocationsScreenState extends State { final AppPhotoStore _photoStore = const AppPhotoStore(); + final Set _expanded = {}; + List<_LocationEntry> _entries = const []; bool _loading = true; - final Map> _grouped = {}; + bool _mapView = false; + String _query = ''; @override void initState() { @@ -23,58 +34,316 @@ class _LocationsScreenState extends State { } Future _load() async { - final photos = await _photoStore.loadPhotos(); - final grouped = >{}; - for (final photo in photos) { - final address = photo.locationInfo.address; - if (address.isEmpty) continue; - final parts = address.split(','); - final city = parts.length > 1 - ? parts[parts.length - 2].trim() - : parts.first.trim(); - grouped.putIfAbsent(city, () => []).add(photo); + setState(() => _loading = true); + try { + final photos = await _photoStore.loadPhotos(); + final grouped = >{}; + for (final photo in photos) { + final name = _locationName(photo); + grouped.putIfAbsent(name, () => []).add(photo); + } + final entries = grouped.entries + .map((entry) => _LocationEntry(name: entry.key, photos: entry.value)) + .toList() + ..sort((a, b) => b.latest.compareTo(a.latest)); + if (!mounted) return; + setState(() { + _entries = entries; + _loading = false; + }); + } catch (error, stackTrace) { + await ErrorReporter.recordError(error, stackTrace, reason: 'Failed to load locations.'); + if (!mounted) return; + setState(() => _loading = false); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Could not load locations. Pull to retry.'))); + } + } + + String _locationName(AppPhoto photo) { + final info = photo.locationInfo; + if ((info.locality ?? '').isNotEmpty) return info.locality!; + final parts = info.address.split(',').map((part) => part.trim()).where((part) => part.isNotEmpty).toList(); + if (parts.length > 1) return parts[parts.length - 2]; + return parts.isNotEmpty ? parts.first : 'Unknown location'; + } + + List<_LocationEntry> get _filteredEntries { + final q = _query.trim().toLowerCase(); + if (q.isEmpty) return _entries; + return _entries.where((entry) { + final date = DateFormat.yMMMMd().format(entry.latest).toLowerCase(); + return entry.name.toLowerCase().contains(q) || + entry.address.toLowerCase().contains(q) || + date.contains(q); + }).toList(); + } + + Map> _entriesByDate(List<_LocationEntry> entries) { + final grouped = >{}; + for (final entry in entries) { + final key = DateFormat.yMMMMEEEEd().format(entry.latest); + grouped.putIfAbsent(key, () => []).add(entry); + } + return grouped; + } + + void _openGallery(_LocationEntry entry) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => GalleryScreen(filteredPhotos: entry.photos, title: entry.name), + ), + ); + } + + Future _deleteEntry(_LocationEntry entry) async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Delete ${entry.name}?'), + content: Text('This removes ${entry.photos.length} photos from this app and deletes local files.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Delete')), + ], + ), + ); + if (confirm != true) return; + for (final photo in entry.photos) { + await _photoStore.deletePhoto(photo); } + await _load(); + } - if (!mounted) return; - setState(() { - _grouped - ..clear() - ..addAll(grouped); - _loading = false; - }); + void _showActions(_LocationEntry entry) { + showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile(leading: const Icon(Icons.photo_library), title: const Text('View photos'), onTap: () { Navigator.pop(context); _openGallery(entry); }), + ListTile(leading: const Icon(Icons.map), title: const Text('Open location in maps'), onTap: () { Navigator.pop(context); setState(() => _mapView = true); }), + ListTile(leading: const Icon(Icons.share), title: const Text('Share'), onTap: () { Navigator.pop(context); Share.share('šŸ“ ${entry.name}\n${entry.address}\n${entry.photos.length} photos captured with GPS Camera'); }), + ListTile(leading: const Icon(Icons.delete_outline), title: const Text('Delete'), onTap: () { Navigator.pop(context); _deleteEntry(entry); }), + ], + ), + ), + ); } @override Widget build(BuildContext context) { + final entries = _filteredEntries; return Scaffold( - appBar: AppBar(title: const Text('Locations')), + appBar: AppBar( + title: const Text('Locations'), + actions: [ + IconButton( + tooltip: _mapView ? 'Show list' : 'Show map', + onPressed: () => setState(() => _mapView = !_mapView), + icon: Icon(_mapView ? Icons.view_list : Icons.map), + ), + ], + ), body: _loading ? const Center(child: CircularProgressIndicator()) - : _grouped.isEmpty - ? const Center(child: Text('No location data available yet')) - : RefreshIndicator( - onRefresh: _load, - child: ListView( - children: _grouped.entries.map((entry) { - return ListTile( - title: Text(entry.key), - subtitle: Text('${entry.value.length} photos'), - leading: const Icon(Icons.place), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => GalleryScreen( - filteredPhotos: entry.value, - title: entry.key, - ), + : RefreshIndicator( + onRefresh: _load, + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter(child: _SearchHeader(onChanged: (value) => setState(() => _query = value))), + if (_entries.isEmpty) + const SliverFillRemaining(hasScrollBody: false, child: _EmptyLocations()) + else if (entries.isEmpty) + const SliverFillRemaining(hasScrollBody: false, child: Center(child: Text('No locations match your search.'))) + else if (_mapView) + SliverFillRemaining(child: _LocationsMap(entries: entries)) + else + ..._entriesByDate(entries).entries.map((section) => SliverMainAxisGroup(slivers: [ + SliverToBoxAdapter(child: _DateHeader(label: '${section.key} • ${section.value.length} locations')), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final entry = section.value[index]; + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: Duration(milliseconds: 220 + (index * 45)), + curve: Curves.easeOutCubic, + builder: (context, value, child) => Opacity( + opacity: value, + child: Transform.translate(offset: Offset(0, 18 * (1 - value)), child: child), + ), + child: _LocationCard( + entry: entry, + expanded: _expanded.contains(entry.name), + onTap: () => setState(() => _expanded.contains(entry.name) ? _expanded.remove(entry.name) : _expanded.add(entry.name)), + onLongPress: () => _showActions(entry), + ), + ); + }, + childCount: section.value.length, ), - ); - }, - ); - }).toList(), + ), + ])), + ], + ), + ), + ); + } +} + +class _LocationEntry { + const _LocationEntry({required this.name, required this.photos}); + final String name; + final List photos; + AppPhoto get primary => photos.first; + DateTime get latest => photos.map((photo) => photo.capturedAt).reduce((a, b) => a.isAfter(b) ? a : b); + String get address => primary.locationInfo.address; + double get latitude => primary.locationInfo.latitude; + double get longitude => primary.locationInfo.longitude; +} + +class _SearchHeader extends StatelessWidget { + const _SearchHeader({required this.onChanged}); + final ValueChanged onChanged; + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), + child: TextField( + onChanged: onChanged, + decoration: InputDecoration( + hintText: 'Search by location or date', + prefixIcon: const Icon(Icons.search), + filled: true, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(18), borderSide: BorderSide.none), + ), + ), + ); + } +} + +class _DateHeader extends StatelessWidget { + const _DateHeader({required this.label}); + final String label; + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(18, 18, 18, 8), + child: Text(label, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w900)), + ); +} + +class _LocationCard extends StatelessWidget { + const _LocationCard({required this.entry, required this.expanded, required this.onTap, required this.onLongPress}); + final _LocationEntry entry; + final bool expanded; + final VoidCallback onTap; + final VoidCallback onLongPress; + + @override + Widget build(BuildContext context) { + final info = entry.primary.locationInfo; + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 7), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MapThumb(latitude: entry.latitude, longitude: entry.longitude), + const SizedBox(width: 12), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(entry.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900)), + const SizedBox(height: 4), + Text(entry.address, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), + const SizedBox(height: 8), + Text('${DateFormat.yMMMd().format(entry.latest)} • ${info.time}', style: Theme.of(context).textTheme.bodySmall), + ]), + ), + const SizedBox(width: 8), + Chip(label: Text('${entry.photos.length} photos'), visualDensity: VisualDensity.compact), + ], + ), + AnimatedCrossFade( + firstChild: const SizedBox(width: double.infinity), + secondChild: Padding( + padding: const EdgeInsets.only(top: 12), + child: SizedBox( + height: 78, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) => ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.file(File(entry.photos[index].filePath), width: 78, height: 78, fit: BoxFit.cover), + ), + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemCount: entry.photos.length, + ), ), ), + crossFadeState: expanded ? CrossFadeState.showSecond : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 250), + ), + ], + ), + ), + ), ); } } + +class _MapThumb extends StatelessWidget { + const _MapThumb({required this.latitude, required this.longitude}); + final double latitude; + final double longitude; + @override + Widget build(BuildContext context) { + final uri = const GoogleMapService().staticMapUri(latitude: latitude, longitude: longitude, width: 220, height: 220, zoom: 15); + return ClipRRect( + borderRadius: BorderRadius.circular(14), + child: Image.network( + uri.toString(), + width: 86, + height: 86, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container(width: 86, height: 86, color: Colors.white10, child: const Icon(Icons.map)), + ), + ); + } +} + +class _LocationsMap extends StatelessWidget { + const _LocationsMap({required this.entries}); + final List<_LocationEntry> entries; + @override + Widget build(BuildContext context) { + final center = LatLng(entries.first.latitude, entries.first.longitude); + return Padding( + padding: const EdgeInsets.all(16), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: FlutterMap( + options: MapOptions(initialCenter: center, initialZoom: 11), + children: [ + TileLayer(urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'com.example.gps_camera'), + MarkerLayer(markers: entries.map((entry) => Marker(point: LatLng(entry.latitude, entry.longitude), width: 48, height: 48, child: const Icon(Icons.location_pin, color: Colors.red, size: 42))).toList()), + ], + ), + ), + ); + } +} + +class _EmptyLocations extends StatelessWidget { + const _EmptyLocations(); + @override + Widget build(BuildContext context) => const Center(child: Text('No location data available yet')); +} diff --git a/lib/features/result/screen/result_screen.dart b/lib/features/result/screen/result_screen.dart index 7a3c8fd..c96e8ea 100644 --- a/lib/features/result/screen/result_screen.dart +++ b/lib/features/result/screen/result_screen.dart @@ -109,6 +109,7 @@ class _ResultScreenState extends State { capturedImagePath: compressedCapturePath, geoPhoto: geoPhoto, mapThumbnailBytes: mapBytes, + overlayTemplate: settings.overlayTemplate, ); if (mounted) setState(() => _displayFilePath = finalResult.filePath); diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 1484469..191b121 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -1,6 +1,26 @@ import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; +class OverlayTemplateIds { + static const classicDark = 'classic_dark'; + static const minimalStrip = 'minimal_strip'; + static const fieldReport = 'field_report'; + + static const all = [classicDark, minimalStrip, fieldReport]; + + static String label(String id) => switch (id) { + minimalStrip => 'Minimal Strip', + fieldReport => 'Field Report', + _ => 'Classic Dark', + }; + + static String description(String id) => switch (id) { + minimalStrip => 'Slim address and time strip without a map.', + fieldReport => 'Structured GPS-stamped field documentation card.', + _ => 'Map thumbnail with metadata on a dark card.', + }; +} + class AppSettings { const AppSettings({ this.mapStyle = 'standard', @@ -12,6 +32,7 @@ class AppSettings { this.dateFormat = 'DD/MM/YYYY', this.mapZoomLevel = 15, this.darkTheme = true, + this.overlayTemplate = OverlayTemplateIds.classicDark, }); final String mapStyle; @@ -23,6 +44,7 @@ class AppSettings { final String dateFormat; final double mapZoomLevel; final bool darkTheme; + final String overlayTemplate; ResolutionPreset get resolutionPreset => switch (photoQuality) { 'medium' => ResolutionPreset.medium, @@ -42,6 +64,7 @@ class AppSettings { String? dateFormat, double? mapZoomLevel, bool? darkTheme, + String? overlayTemplate, }) { return AppSettings( mapStyle: mapStyle ?? this.mapStyle, @@ -54,6 +77,7 @@ class AppSettings { dateFormat: dateFormat ?? this.dateFormat, mapZoomLevel: mapZoomLevel ?? this.mapZoomLevel, darkTheme: darkTheme ?? this.darkTheme, + overlayTemplate: overlayTemplate ?? this.overlayTemplate, ); } } diff --git a/lib/services/image_overlay_service.dart b/lib/services/image_overlay_service.dart index 0cf5c7d..b2bde2e 100644 --- a/lib/services/image_overlay_service.dart +++ b/lib/services/image_overlay_service.dart @@ -6,6 +6,7 @@ import 'package:image/image.dart' as img; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import '../models/app_settings.dart'; import '../models/geo_photo_model.dart'; import '../utils/image_utils.dart'; @@ -18,6 +19,7 @@ class ImageOverlayService { required GeoPhotoModel geoPhoto, Uint8List? mapThumbnailBytes, String appName = 'GPS Map Camera', + String overlayTemplate = OverlayTemplateIds.classicDark, }) async { final sourceFile = File(capturedImagePath); final sourceBytes = await sourceFile.readAsBytes(); @@ -33,7 +35,7 @@ class ImageOverlayService { } _drawWatermark(normalized, appName); - _drawOverlayCard(normalized, geoPhoto, mapImage); + _drawOverlayCard(normalized, geoPhoto, mapImage, overlayTemplate); final outputBytes = Uint8List.fromList(img.encodeJpg(normalized, quality: 85)); final directory = await getTemporaryDirectory(); @@ -52,9 +54,18 @@ class ImageOverlayService { img.Image image, GeoPhotoModel geoPhoto, img.Image? mapImage, + String overlayTemplate, ) { final width = image.width; final height = image.height; + if (overlayTemplate == OverlayTemplateIds.minimalStrip) { + _drawMinimalStrip(image, geoPhoto); + return; + } + if (overlayTemplate == OverlayTemplateIds.fieldReport) { + _drawFieldReportCard(image, geoPhoto, mapImage); + return; + } final margin = (width * 0.045).round().clamp(28, 96); final cardWidth = width - (margin * 2); final cardHeight = (height * 0.23).round().clamp(310, 620); @@ -89,8 +100,8 @@ class ImageOverlayService { final textLeft = mapLeft + mapWidth + inner; final textTop = mapTop + (mapHeight * 0.03).round(); final textWidth = margin + cardWidth - inner - textLeft; - final titleFont = width >= 2200 ? img.arial48 : img.arial24; - final bodyFont = width >= 2200 ? img.arial24 : img.arial14; + final titleFont = width >= 1800 ? img.arial48 : img.arial24; + final bodyFont = img.arial24; final titleColor = img.ColorRgb8(255, 255, 255); final bodyColor = img.ColorRgb8(235, 238, 242); final mutedColor = img.ColorRgb8(210, 215, 222); @@ -128,7 +139,7 @@ class ImageOverlayService { font: bodyFont, x: textLeft, y: cursorY, - color: bodyColor, + color: img.ColorRgb8(128, 216, 255), ); cursorY += lineHeight; @@ -160,6 +171,100 @@ class ImageOverlayService { _drawFooterMetric(image, 'A', geoPhoto.altitudeLabel, textLeft + (textWidth * 0.75).round(), footerY, img.ColorRgb8(255, 160, 80), bodyColor, bodyFont); } + + void _drawMinimalStrip(img.Image image, GeoPhotoModel geoPhoto) { + final width = image.width; + final height = image.height; + final margin = (width * 0.04).round().clamp(24, 90); + final stripHeight = (height * 0.105).round().clamp(120, 230); + final y = height - stripHeight - (height * 0.035).round(); + final font = width >= 2200 ? img.arial48 : img.arial24; + final smallFont = width >= 2200 ? img.arial24 : img.arial14; + + ImageUtils.fillRoundedRect( + image, + x: margin, + y: y, + width: width - (margin * 2), + height: stripHeight, + radius: (stripHeight * .28).round(), + color: img.ColorRgba8(0, 0, 0, 198), + ); + img.drawString(image, '•', font: font, x: margin + 28, y: y + ((stripHeight - font.lineHeight) ~/ 2), color: img.ColorRgb8(245, 166, 35)); + ImageUtils.drawWrappedText( + image, + geoPhoto.address, + font: font, + x: margin + 72, + y: y + (stripHeight * .18).round(), + maxWidth: (width * .55).round(), + color: img.ColorRgb8(255, 255, 255), + maxLines: 1, + ); + img.drawString( + image, + geoPhoto.formattedDateTime, + font: smallFont, + x: width - margin - (width * .28).round(), + y: y + ((stripHeight - smallFont.lineHeight) ~/ 2), + color: img.ColorRgb8(220, 226, 232), + ); + } + + void _drawFieldReportCard(img.Image image, GeoPhotoModel geoPhoto, img.Image? mapImage) { + final width = image.width; + final height = image.height; + final margin = (width * 0.045).round().clamp(28, 96); + final cardWidth = width - (margin * 2); + final cardHeight = (height * 0.28).round().clamp(390, 720); + final cardTop = height - cardHeight - (height * 0.035).round(); + final inner = (cardHeight * 0.075).round().clamp(24, 50); + + ImageUtils.fillRoundedRect( + image, + x: margin, + y: cardTop, + width: cardWidth, + height: cardHeight, + radius: (cardHeight * 0.08).round().clamp(24, 48), + color: img.ColorRgba8(8, 14, 20, 214), + ); + + final mapSize = cardHeight - (inner * 2); + final mapLeft = margin + inner; + final mapTop = cardTop + inner; + _drawMapPreview(image, mapImage, x: mapLeft, y: mapTop, width: mapSize, height: mapSize); + + final textLeft = mapLeft + mapSize + inner; + final textWidth = margin + cardWidth - inner - textLeft; + final titleFont = width >= 1800 ? img.arial48 : img.arial24; + final bodyFont = img.arial24; + final badgeFont = img.arial14; + var cursorY = mapTop; + + ImageUtils.fillRoundedRect( + image, + x: textLeft, + y: cursorY, + width: (textWidth * .34).round(), + height: (bodyFont.lineHeight * 1.8).round(), + radius: bodyFont.lineHeight, + color: img.ColorRgba8(29, 185, 84, 70), + ); + img.drawString(image, 'GPS-STAMPED', font: badgeFont, x: textLeft + 18, y: cursorY + 10, color: img.ColorRgb8(141, 255, 176)); + cursorY += (bodyFont.lineHeight * 2.4).round(); + + ImageUtils.drawWrappedText(image, geoPhoto.address, font: titleFont, x: textLeft, y: cursorY, maxWidth: textWidth, color: img.ColorRgb8(255, 255, 255), maxLines: 2); + cursorY += (titleFont.lineHeight * 2.2).round(); + img.drawLine(image, x1: textLeft, y1: cursorY, x2: textLeft + textWidth, y2: cursorY, color: img.ColorRgba8(255, 255, 255, 55), thickness: 2); + cursorY += (bodyFont.lineHeight * .8).round(); + img.drawString(image, 'COORDINATES', font: bodyFont, x: textLeft, y: cursorY, color: img.ColorRgb8(128, 216, 255)); + cursorY += (bodyFont.lineHeight * 1.25).round(); + img.drawString(image, '${geoPhoto.formattedLatitude}°, ${geoPhoto.formattedLongitude}°', font: titleFont, x: textLeft, y: cursorY, color: img.ColorRgb8(128, 216, 255)); + cursorY += (titleFont.lineHeight * 1.45).round(); + img.drawString(image, geoPhoto.formattedDateTime, font: bodyFont, x: textLeft, y: cursorY, color: img.ColorRgb8(224, 231, 238)); + } + void _drawMapPreview( img.Image image, img.Image? mapImage, { @@ -218,12 +323,12 @@ class ImageOverlayService { void _drawWatermark(img.Image image, String appName) { final width = image.width; final height = image.height; - final font = width >= 2200 ? img.arial24 : img.arial14; + final font = img.arial14; final label = appName; - final boxWidth = (width * 0.31).round().clamp(300, 700); - final boxHeight = (height * 0.045).round().clamp(54, 100); - final x = width - boxWidth - (width * 0.05).round(); - final y = height - (height * 0.32).round(); + final boxWidth = (width * 0.22).round().clamp(230, 520); + final boxHeight = (height * 0.032).round().clamp(42, 72); + final x = width - boxWidth - (width * 0.045).round(); + final y = height - (height * 0.30).round(); ImageUtils.fillRoundedRect( image, @@ -238,7 +343,7 @@ class ImageOverlayService { image, x: x + (boxHeight ~/ 2), y: y + (boxHeight ~/ 2), - radius: (boxHeight * 0.28).round(), + radius: (boxHeight * 0.22).round(), color: img.ColorRgb8(255, 204, 0), ); img.drawString( diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 8887403..0afb02f 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -12,6 +12,7 @@ class SettingsService { static const dateFormatKey = 'settings_date_format'; static const mapZoomLevelKey = 'settings_map_zoom_level'; static const darkThemeKey = 'settings_dark_theme'; + static const overlayTemplateKey = 'settings_overlay_template'; const SettingsService(); @@ -28,6 +29,7 @@ class SettingsService { dateFormat: prefs.getString(dateFormatKey) ?? 'DD/MM/YYYY', mapZoomLevel: prefs.getDouble(mapZoomLevelKey) ?? 15, darkTheme: prefs.getBool(darkThemeKey) ?? true, + overlayTemplate: prefs.getString(overlayTemplateKey) ?? OverlayTemplateIds.classicDark, ); } @@ -43,5 +45,6 @@ class SettingsService { await prefs.setString(dateFormatKey, settings.dateFormat); await prefs.setDouble(mapZoomLevelKey, settings.mapZoomLevel); await prefs.setBool(darkThemeKey, settings.darkTheme); + await prefs.setString(overlayTemplateKey, settings.overlayTemplate); } }