diff --git a/lib/app.dart b/lib/app.dart index 13a8ae4..d47ae03 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -8,6 +8,10 @@ import 'features/detail/screen/detail_screen.dart'; import 'features/gallery/screen/gallery_screen.dart'; import 'features/result/screen/result_screen.dart'; import 'features/locations/screen/locations_screen.dart'; +import 'features/settings/screen/settings_screen.dart'; +import 'features/account/screen/account_screen.dart'; +import 'models/app_settings.dart'; +import 'services/settings_service.dart'; import 'models/captured_media.dart'; import 'models/location_info.dart'; @@ -30,23 +34,42 @@ class ResultScreenArgs { final MediaType type; } -class MyApp extends StatelessWidget { +class MyApp extends StatefulWidget { const MyApp({super.key, required this.permissionsGranted}); final bool permissionsGranted; + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + AppSettings _settings = const AppSettings(); + + @override + void initState() { + super.initState(); + const SettingsService().load().then((settings) { + if (mounted) setState(() => _settings = settings); + }); + } + @override Widget build(BuildContext context) { return MaterialApp( title: 'GPS Camera', debugShowCheckedModeBanner: false, theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: _settings.themeMode, initialRoute: AppConstants.routeCamera, routes: { AppConstants.routeCamera: (_) => - CameraScreen(permissionsGranted: permissionsGranted), + CameraScreen(permissionsGranted: widget.permissionsGranted), AppConstants.routeGallery: (_) => const GalleryScreen(), AppConstants.routeLocations: (_) => const LocationsScreen(), + AppConstants.routeSettings: (_) => const SettingsScreen(), + AppConstants.routeAccount: (_) => const AccountScreen(), }, onGenerateRoute: (settings) { if (settings.name == AppConstants.routeDetail) { diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index 3dfa5b0..3700db1 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -4,6 +4,8 @@ class AppConstants { static const String routeDetail = '/detail'; static const String routeResult = '/result'; static const String routeLocations = '/locations'; + static const String routeSettings = '/settings'; + static const String routeAccount = '/account'; static const int minZoom = 1; static const int maxZoom = 8; diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 162ed25..23eecfa 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -60,4 +60,36 @@ class AppTheme { ), ); } + + static ThemeData get darkTheme { + final base = ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + colorScheme: ColorScheme.fromSeed( + seedColor: AppColors.primary, + brightness: Brightness.dark, + primary: AppColors.primary, + ), + scaffoldBackgroundColor: Colors.black, + ); + + return base.copyWith( + appBarTheme: const AppBarTheme( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + titleTextStyle: AppTextStyles.appBarTitle, + iconTheme: IconThemeData(color: Colors.white), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + textStyle: AppTextStyles.pillButton, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + ), + ), + ), + ); + } } diff --git a/lib/core/widgets/location_stamp_card.dart b/lib/core/widgets/location_stamp_card.dart index ffb6b96..8acf06a 100644 --- a/lib/core/widgets/location_stamp_card.dart +++ b/lib/core/widgets/location_stamp_card.dart @@ -1,11 +1,12 @@ import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:latlong2/latlong.dart'; import 'package:permission_handler/permission_handler.dart'; +import '../../models/app_settings.dart'; import '../../models/location_info.dart'; +import '../../services/google_map_service.dart'; +import '../../services/settings_service.dart'; class LocationStampCard extends StatelessWidget { const LocationStampCard({ @@ -23,37 +24,43 @@ class LocationStampCard extends StatelessWidget { Widget build(BuildContext context) { final info = locationInfo; - 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 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), + ], + ), + child: info == null ? _fallback() : _content(info, settings), + ), ), - child: info == null ? _fallback() : _content(info), ), - ), - ), + ); + }, ); } @@ -67,14 +74,14 @@ class LocationStampCard extends StatelessWidget { color: Colors.grey.shade300, borderRadius: BorderRadius.circular(8), ), - child: const Icon(Icons.location_off), + child: const Center(child: CircularProgressIndicator(strokeWidth: 2)), ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('šŸ“ Location unavailable'), + const Text('Getting GPS and map...'), TextButton( onPressed: openAppSettings, child: const Text('Enable location'), @@ -86,41 +93,11 @@ class LocationStampCard extends StatelessWidget { ); } - Widget _content(LocationInfo info) { + Widget _content(LocationInfo info, AppSettings settings) { return Row( children: [ - if (showMap) - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: SizedBox( - width: 70, - height: 70, - child: FlutterMap( - options: MapOptions( - initialCenter: LatLng(info.latitude, info.longitude), - initialZoom: 14, - interactionOptions: - const InteractionOptions(flags: InteractiveFlag.none), - ), - children: [ - TileLayer( - urlTemplate: - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - ), - MarkerLayer(markers: [ - Marker( - point: LatLng(info.latitude, info.longitude), - width: 24, - height: 24, - child: const Icon(Icons.location_pin, - color: Colors.red, size: 16), - ) - ]) - ], - ), - ), - ), - const SizedBox(width: 8), + if (showMap) _StaticMap(info: info, settings: settings), + if (showMap) const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -152,6 +129,20 @@ class LocationStampCard extends StatelessWidget { 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), + ), + ], ], ), ), @@ -159,3 +150,44 @@ class LocationStampCard extends StatelessWidget { ); } } + +class _StaticMap extends StatelessWidget { + const _StaticMap({required this.info, required this.settings}); + + final LocationInfo info; + final AppSettings settings; + + @override + Widget build(BuildContext context) { + final uri = const GoogleMapService().staticMapUri( + latitude: info.latitude, + longitude: info.longitude, + width: 200, + height: 150, + zoom: settings.mapZoomLevel.round(), + mapStyle: settings.mapStyle, + ); + + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: 70, + height: 70, + child: Image.network( + uri.toString(), + fit: BoxFit.cover, + loadingBuilder: (context, child, progress) => progress == null + ? child + : Container( + color: Colors.grey.shade300, + child: const Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + errorBuilder: (_, __, ___) => Container( + color: Colors.grey.shade300, + child: const Icon(Icons.map_outlined, color: Colors.black54), + ), + ), + ), + ); + } +} diff --git a/lib/features/account/screen/account_screen.dart b/lib/features/account/screen/account_screen.dart new file mode 100644 index 0000000..1e7a00f --- /dev/null +++ b/lib/features/account/screen/account_screen.dart @@ -0,0 +1,235 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../core/constants/app_constants.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../services/app_photo_store.dart'; +import '../../../services/backup_service.dart'; + +class AccountScreen extends StatefulWidget { + const AccountScreen({super.key}); + + @override + State createState() => _AccountScreenState(); +} + +class _AccountScreenState extends State { + static const _nameKey = 'account_name'; + static const _emailKey = 'account_email'; + static const _bioKey = 'account_bio'; + static const _createdKey = 'account_created_at'; + + final AppPhotoStore _photoStore = const AppPhotoStore(); + final BackupService _backupService = const BackupService(); + String _name = 'Guest User'; + String _email = 'guest@gpscamera.local'; + String _bio = ''; + DateTime _createdAt = DateTime.now(); + int _photoCount = 0; + int _locationCount = 0; + bool _backupEnabled = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final prefs = await SharedPreferences.getInstance(); + final photos = await _photoStore.loadPhotos(); + final createdRaw = prefs.getString(_createdKey); + if (createdRaw == null) { + await prefs.setString(_createdKey, DateTime.now().toIso8601String()); + } + if (!mounted) return; + setState(() { + _name = prefs.getString(_nameKey) ?? 'Guest User'; + _email = prefs.getString(_emailKey) ?? 'guest@gpscamera.local'; + _bio = prefs.getString(_bioKey) ?? ''; + _createdAt = DateTime.tryParse(createdRaw ?? '') ?? DateTime.now(); + _photoCount = photos.length; + _locationCount = photos.map((p) => p.locationInfo.address).toSet().length; + }); + } + + Future _editProfile() async { + final nameController = TextEditingController(text: _name); + final emailController = TextEditingController(text: _email); + final bioController = TextEditingController(text: _bio); + final saved = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.of(context).viewInsets.bottom + 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + children: [ + CircleAvatar(radius: 44, child: Text(_initials, style: const TextStyle(fontSize: 28))), + const Positioned( + right: 0, + bottom: 0, + child: CircleAvatar(radius: 16, child: Icon(Icons.camera_alt, size: 16)), + ), + ], + ), + const SizedBox(height: 16), + TextField(controller: nameController, decoration: const InputDecoration(labelText: 'Name')), + TextField(controller: emailController, decoration: const InputDecoration(labelText: 'Email')), + TextField( + controller: bioController, + maxLength: 100, + decoration: const InputDecoration(labelText: 'Bio / note'), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Save'), + ), + ), + ], + ), + ), + ); + if (saved != true) return; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_nameKey, nameController.text.trim().isEmpty ? 'Guest User' : nameController.text.trim()); + await prefs.setString(_emailKey, emailController.text.trim()); + await prefs.setString(_bioKey, bioController.text.trim()); + await _load(); + } + + String get _initials { + final parts = _name.trim().split(RegExp(r'\s+')).where((p) => p.isNotEmpty).toList(); + if (parts.isEmpty) return 'G'; + return parts.take(2).map((p) => p[0].toUpperCase()).join(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar(title: const Text('Account'), centerTitle: true), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(28), + gradient: const LinearGradient(colors: [Color(0xFF2B2B2B), Color(0xFF111111)]), + border: Border.all(color: Colors.white12), + ), + child: Column( + children: [ + CircleAvatar(radius: 46, backgroundColor: AppColors.primary, child: Text(_initials, style: const TextStyle(fontSize: 30, color: Colors.white))), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(_name, style: const TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.bold)), + IconButton(onPressed: _editProfile, icon: const Icon(Icons.edit, color: Colors.white70)), + ], + ), + Text(_email, style: const TextStyle(color: Colors.white70)), + if (_bio.isNotEmpty) Padding(padding: const EdgeInsets.only(top: 8), child: Text(_bio, style: const TextStyle(color: Colors.white70))), + TextButton(onPressed: _editProfile, child: const Text('Edit Profile')), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration(color: Colors.amber.withValues(alpha: .15), borderRadius: BorderRadius.circular(12)), + child: const Text("You're using Guest Mode — sign in to back up your photos.", style: TextStyle(color: Colors.amber)), + ), + ], + ), + ), + const SizedBox(height: 16), + Row( + children: [ + _stat('šŸ“ø', 'Photos Taken', _photoCount.toString()), + _stat('šŸ“', 'Locations', _locationCount.toString()), + _stat('šŸ—“ļø', 'Member Since', DateFormat('MMM yyyy').format(_createdAt)), + ], + ), + const SizedBox(height: 16), + _tile(Icons.cloud_upload, 'Backup & Sync', () => _showBackup()), + _tile(Icons.photo_library, 'My Photos', () => Navigator.pushNamed(context, AppConstants.routeGallery)), + _tile(Icons.location_on, 'Saved Locations', () => Navigator.pushNamed(context, AppConstants.routeLocations)), + _tile(Icons.privacy_tip, 'Privacy Policy', () => _placeholder('Privacy Policy')), + _tile(Icons.star_rate, 'Rate the App', () => _placeholder('Rate the App')), + _tile(Icons.help_outline, 'Help & Support', () => _placeholder('Help & Support')), + _tile(Icons.logout, 'Sign Out', _signOut), + ], + ), + ); + } + + Widget _stat(String emoji, String label, String value) => Expanded( + child: Card( + color: const Color(0xFF1D1D1D), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column(children: [Text(emoji), Text(value, style: const TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold)), Text(label, textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70, fontSize: 11))]), + ), + ), + ); + + Widget _tile(IconData icon, String title, VoidCallback onTap) => Card( + child: ListTile(leading: Icon(icon), title: Text(title), trailing: const Icon(Icons.chevron_right), onTap: onTap), + ); + + void _placeholder(String title) => showModalBottomSheet( + context: context, + builder: (_) => SizedBox(height: 180, child: Center(child: Text('$title coming soon'))), + ); + + void _showBackup() => showModalBottomSheet( + context: context, + builder: (_) => StatefulBuilder(builder: (context, setSheetState) { + return SafeArea( + child: SwitchListTile( + title: const Text('Auto-backup photos to Firebase Storage'), + subtitle: const Text('Sign in with Google to enable cloud backup.'), + value: _backupEnabled, + onChanged: (value) async { + setSheetState(() => _backupEnabled = value); + if (value) { + final photos = await _photoStore.loadPhotos(); + await _backupService + .uploadPhotosForUser(userId: 'guest', photos: photos) + .last; + } + }, + ), + ); + }), + ); + + Future _signOut() async { + final confirm = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Sign Out?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Sign Out')), + ], + ), + ); + if (confirm != true) return; + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_nameKey); + await prefs.remove(_emailKey); + await prefs.remove(_bioKey); + if (mounted) Navigator.popUntil(context, (route) => route.isFirst); + } +} diff --git a/lib/features/camera/screen/camera_screen.dart b/lib/features/camera/screen/camera_screen.dart index c910269..8ab5a5e 100644 --- a/lib/features/camera/screen/camera_screen.dart +++ b/lib/features/camera/screen/camera_screen.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../../app.dart'; @@ -11,9 +12,11 @@ import '../../../core/utils/location_utils.dart'; import '../../../core/utils/permission_handler.dart'; import '../../../core/widgets/location_stamp_card.dart'; import '../../../models/captured_media.dart'; +import '../../../models/app_settings.dart'; import '../../../models/geo_photo_model.dart'; import '../../../models/location_info.dart'; import '../../../services/location_service.dart'; +import '../../../services/settings_service.dart'; class CameraScreen extends StatefulWidget { const CameraScreen({super.key, this.permissionsGranted = true}); @@ -31,20 +34,18 @@ class _CameraScreenState extends State bool _loading = true; bool _cameraError = false; bool _hasCameraPermission = false; - bool _isPhotoMode = true; - bool _isRecording = false; + AppSettings _settings = const AppSettings(); FlashMode _flashMode = FlashMode.off; int _cameraIndex = 0; double _zoom = 1.0; double _baseZoom = 1.0; LocationInfo? _locationInfo; StreamSubscription? _locationSub; - Timer? _recordTimer; - Duration _recordDuration = Duration.zero; late final AnimationController _captureAnim; bool _flashOverlay = false; bool _isGeoProcessing = false; final LocationService _locationService = const LocationService(); + final SettingsService _settingsService = const SettingsService(); @override void initState() { @@ -58,7 +59,12 @@ class _CameraScreenState extends State value: 1, ); _hasCameraPermission = widget.permissionsGranted; - _checkCameraPermissionAndInit(); + _loadSettingsAndInit(); + } + + Future _loadSettingsAndInit() async { + _settings = await _settingsService.load(); + await _checkCameraPermissionAndInit(); } Future _checkCameraPermissionAndInit({bool request = false}) async { @@ -146,7 +152,7 @@ class _CameraScreenState extends State _controller?.dispose(); _controller = CameraController( _cameras[_cameraIndex], - ResolutionPreset.max, + _settings.resolutionPreset, enableAudio: false, ); await _controller!.initialize(); @@ -156,10 +162,8 @@ class _CameraScreenState extends State @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.paused && _isRecording) { - _stopRecording(); - } else if (state == AppLifecycleState.resumed) { - _checkCameraPermissionAndInit(); + if (state == AppLifecycleState.resumed) { + _loadSettingsAndInit(); } } @@ -168,35 +172,27 @@ class _CameraScreenState extends State try { await _captureAnim.reverse(); await _captureAnim.forward(); - if (_isPhotoMode) { - setState(() => _flashOverlay = true); - Future.delayed(const Duration(milliseconds: 200), () { - if (mounted) setState(() => _flashOverlay = false); - }); - final file = await _controller!.takePicture(); - if (!mounted) return; - setState(() => _isGeoProcessing = true); + setState(() => _flashOverlay = true); + Future.delayed(const Duration(milliseconds: 200), () { + if (mounted) setState(() => _flashOverlay = false); + }); + final file = await _controller!.takePicture(); + if (!mounted) return; + setState(() => _isGeoProcessing = true); - final locationInfo = await _resolveCaptureLocation(); - if (!mounted) return; - setState(() => _isGeoProcessing = false); - - Navigator.pushNamed( - context, - AppConstants.routeResult, - arguments: ResultScreenArgs( - filePath: file.path, - locationInfo: locationInfo, - type: MediaType.photo, - ), - ); - } else { - if (_isRecording) { - await _stopRecording(); - } else { - await _startRecording(); - } - } + final locationInfo = await _resolveCaptureLocation(); + if (!mounted) return; + setState(() => _isGeoProcessing = false); + + Navigator.pushNamed( + context, + AppConstants.routeResult, + arguments: ResultScreenArgs( + filePath: file.path, + locationInfo: locationInfo, + type: MediaType.photo, + ), + ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context) @@ -235,7 +231,7 @@ class _CameraScreenState extends State LocationInfo _locationInfoFromGeoPhoto(GeoPhotoModel geoPhoto) { return LocationInfo( address: geoPhoto.address, - date: geoPhoto.capturedAt.toLocal().toString().split(' ').first, + date: _formatCaptureDate(geoPhoto.capturedAt.toLocal()), time: geoPhoto.formattedDateTime.split(' ').last, latitude: geoPhoto.latitude, longitude: geoPhoto.longitude, @@ -251,47 +247,14 @@ class _CameraScreenState extends State ); } - Future _startRecording() async { - await _controller!.startVideoRecording(); - setState(() { - _isRecording = true; - _recordDuration = Duration.zero; - }); - _recordTimer?.cancel(); - _recordTimer = Timer.periodic(const Duration(seconds: 1), (_) { - if (mounted) { - setState(() => _recordDuration += const Duration(seconds: 1)); - } - }); - } - - Future _stopRecording() async { - _recordTimer?.cancel(); - final file = await _controller!.stopVideoRecording(); - if (!mounted) return; - setState(() => _isRecording = false); - Navigator.pushNamed( - context, - AppConstants.routeResult, - arguments: ResultScreenArgs( - filePath: file.path, - locationInfo: _locationInfo ?? - const LocationInfo( - address: 'Location unavailable', - date: '', - time: '', - latitude: 0, - longitude: 0, - ), - type: MediaType.video, - ), - ); + String _formatCaptureDate(DateTime dateTime) { + final pattern = _settings.dateFormat == 'YYYY-MM-DD' ? 'yyyy-MM-dd' : 'dd/MM/yyyy'; + return DateFormat(pattern).format(dateTime); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); - _recordTimer?.cancel(); _locationSub?.cancel(); _controller?.dispose(); _captureAnim.dispose(); @@ -364,26 +327,11 @@ class _CameraScreenState extends State ), ), _buildTopToolbar(), - if (_isRecording) - Positioned( - top: 80, - left: 16, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - _recordDuration.toString().split('.').first, - style: const TextStyle(color: Colors.white), - ), - ), - ), Positioned( left: 0, right: 0, - bottom: 180, + top: _settings.overlayPosition == 'top' ? 110 : null, + bottom: _settings.overlayPosition == 'bottom' ? 180 : null, child: Center( child: LocationStampCard( locationInfo: _locationInfo, @@ -437,14 +385,8 @@ class _CameraScreenState extends State ), const Icon(Icons.camera_alt, color: Colors.white), IconButton( - icon: const Icon(Icons.grid_view, color: Colors.white), - onPressed: () => showModalBottomSheet( - context: context, - builder: (_) => const SizedBox( - height: 150, - child: Center(child: Text('Template selection coming soon')), - ), - ), + icon: const Icon(Icons.settings, color: Colors.white), + onPressed: () => Navigator.pushNamed(context, AppConstants.routeSettings), ), IconButton( icon: const Icon(Icons.workspace_premium, color: Colors.amber), @@ -517,9 +459,7 @@ class _CameraScreenState extends State decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( - color: _isRecording - ? Colors.red - : AppColors.primary, + color: AppColors.primary, width: 4), ), child: Center( @@ -527,7 +467,7 @@ class _CameraScreenState extends State width: 56, height: 56, decoration: BoxDecoration( - color: _isRecording ? Colors.red : Colors.white, + color: Colors.white, shape: BoxShape.circle, ), ), @@ -535,20 +475,14 @@ class _CameraScreenState extends State ), ), ), - _nav('Template', Icons.dashboard), - _nav('Settings', Icons.settings), + _nav('Account', Icons.person_rounded, + onTap: () => Navigator.pushNamed(context, AppConstants.routeAccount)), + _nav('Settings', Icons.settings, + onTap: () => Navigator.pushNamed(context, AppConstants.routeSettings)), ], ), const SizedBox(height: 4), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _mode('PHOTO', _isPhotoMode, () => setState(() => _isPhotoMode = true)), - const SizedBox(width: 18), - _mode('VIDEO', !_isPhotoMode, - () => setState(() => _isPhotoMode = false)), - ], - ) + Center(child: _mode('PHOTO', true, () {})) ], ), ), diff --git a/lib/features/gallery/screen/gallery_screen.dart b/lib/features/gallery/screen/gallery_screen.dart index 563df11..308bde8 100644 --- a/lib/features/gallery/screen/gallery_screen.dart +++ b/lib/features/gallery/screen/gallery_screen.dart @@ -1,127 +1,119 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; -import 'package:photo_manager/photo_manager.dart'; import 'package:share_plus/share_plus.dart'; -import '../../../app.dart'; -import '../../../core/constants/app_constants.dart'; -import '../widgets/media_tile.dart'; +import '../../../models/app_photo.dart'; +import '../../../services/app_photo_store.dart'; class GalleryScreen extends StatefulWidget { - const GalleryScreen({super.key, this.filteredAssets}); - - final List? filteredAssets; + const GalleryScreen({super.key}); @override State createState() => _GalleryScreenState(); } class _GalleryScreenState extends State { - final List _assets = []; - final Set _selectedIds = {}; - bool _selectMode = false; + final AppPhotoStore _photoStore = const AppPhotoStore(); + List _photos = const []; bool _loading = true; @override void initState() { super.initState(); - _loadAssets(); + _loadPhotos(); } - Future _loadAssets() async { + Future _loadPhotos() async { setState(() => _loading = true); - if (widget.filteredAssets != null) { - _assets - ..clear() - ..addAll(widget.filteredAssets!); - setState(() => _loading = false); - return; - } - - final permission = await PhotoManager.requestPermissionExtend(); - if (!permission.isAuth) { - setState(() => _loading = false); - return; - } - - final paths = await PhotoManager.getAssetPathList( - type: RequestType.common, - onlyAll: true, - ); - final media = await paths.first.getAssetListPaged(page: 0, size: 60); - _assets - ..clear() - ..addAll(media); - setState(() => _loading = false); + final photos = await _photoStore.loadPhotos(); + if (!mounted) return; + setState(() { + _photos = photos; + _loading = false; + }); } - Future _deleteSelected() async { - final selected = _assets.where((a) => _selectedIds.contains(a.id)).toList(); - await PhotoManager.editor.deleteWithIds(selected.map((e) => e.id).toList()); - _selectedIds.clear(); - await _loadAssets(); + void _openPhoto(AppPhoto photo) { + Navigator.of(context) + .push(MaterialPageRoute( + builder: (_) => AppPhotoViewer(photo: photo, photoStore: _photoStore), + )) + .then((_) => _loadPhotos()); + } + + Future _sharePhoto(AppPhoto photo) async { + await Share.shareXFiles( + [XFile(photo.filePath)], + text: + 'šŸ“ ${photo.locationInfo.address}\nšŸ“… ${photo.locationInfo.date} ${photo.locationInfo.time}\nCaptured with GPS Camera', + ); } - Future _shareSelected() async { - final selected = _assets.where((a) => _selectedIds.contains(a.id)).toList(); - final files = []; - for (final a in selected) { - final f = await a.file; - if (f != null) files.add(XFile(f.path)); - } - if (files.isNotEmpty) await Share.shareXFiles(files); + void _showActions(AppPhoto photo) { + showModalBottomSheet( + 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); + }, + ), + ], + ), + ), + ); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Gallery'), + title: const Text('My Photos'), centerTitle: true, leading: IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context), ), - actions: [ - if (_selectMode && _selectedIds.isNotEmpty) - IconButton( - onPressed: _shareSelected, - icon: const Icon(Icons.share), - ), - if (_selectMode && _selectedIds.isNotEmpty) - IconButton( - onPressed: _deleteSelected, - icon: const Icon(Icons.delete, color: Colors.red), - ), - Padding( - padding: const EdgeInsets.only(right: 10), - child: ActionChip( - backgroundColor: Colors.white, - label: Text(_selectMode ? 'Done' : 'Select'), - onPressed: () => setState(() { - _selectMode = !_selectMode; - _selectedIds.clear(); - }), - ), - ) - ], ), body: _loading ? const Center(child: CircularProgressIndicator()) - : _assets.isEmpty + : _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 media yet'), - FilledButton( + 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')) - ]), + child: const Text('Open Camera'), + ), + ], + ), ) : RefreshIndicator( - onRefresh: _loadAssets, + onRefresh: _loadPhotos, child: GridView.builder( - itemCount: _assets.length, + padding: const EdgeInsets.all(2), + itemCount: _photos.length, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, @@ -129,48 +121,142 @@ class _GalleryScreenState extends State { mainAxisSpacing: 2, ), itemBuilder: (context, index) { - final asset = _assets[index]; - return MediaTile( - asset: asset, - index: index, - selectMode: _selectMode, - isSelected: _selectedIds.contains(asset.id), - onTap: () { - if (_selectMode) { - setState(() { - if (_selectedIds.contains(asset.id)) { - _selectedIds.remove(asset.id); - } else { - _selectedIds.add(asset.id); - } - }); - } else { - Navigator.pushNamed( - context, - AppConstants.routeDetail, - arguments: DetailScreenArgs( - assets: _assets, initialIndex: index), - ); - } - }, + final photo = _photos[index]; + return GestureDetector( + onTap: () => _openPhoto(photo), + onLongPress: () => _showActions(photo), + child: Hero( + tag: photo.id, + child: Image.file( + File(photo.filePath), + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + color: Colors.black12, + child: const Icon(Icons.broken_image), + ), + ), + ), ); }, ), ), - bottomSheet: _selectMode && _selectedIds.isNotEmpty - ? Container( - color: Colors.white, - height: 72, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Text('${_selectedIds.length} items selected'), - FilledButton(onPressed: _shareSelected, child: const Text('Share')), - FilledButton(onPressed: _deleteSelected, child: const Text('Delete')), - ], + ); + } +} + +class AppPhotoViewer extends StatelessWidget { + const AppPhotoViewer({ + required this.photo, + required this.photoStore, + super.key, + }); + + final AppPhoto photo; + final AppPhotoStore photoStore; + + Future _share() async { + 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 { + 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'), + ), + ], + ), + ); + if (confirm != true || !context.mounted) return; + await photoStore.deletePhoto(photo); + if (context.mounted) Navigator.pop(context); + } + + @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), ), - ) - : null, + ), + ), + 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, + icon: const Icon(Icons.share, color: Colors.white), + ), + IconButton( + onPressed: () => _delete(context), + icon: const Icon(Icons.delete, 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)}', + ), + ], + ), + ), + ), + ), + ), + ], + ), ); } } diff --git a/lib/features/result/screen/result_screen.dart b/lib/features/result/screen/result_screen.dart index 5e62a22..ca332af 100644 --- a/lib/features/result/screen/result_screen.dart +++ b/lib/features/result/screen/result_screen.dart @@ -4,15 +4,17 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:video_player/video_player.dart'; import '../../../core/theme/app_theme.dart'; import '../../../models/captured_media.dart'; import '../../../models/geo_photo_model.dart'; import '../../../models/location_info.dart'; +import '../../../models/app_photo.dart'; +import '../../../services/app_photo_store.dart'; import '../../../services/gallery_service.dart'; import '../../../services/google_map_service.dart'; import '../../../services/image_overlay_service.dart'; +import '../../../services/settings_service.dart'; class ResultScreen extends StatefulWidget { const ResultScreen({ @@ -31,11 +33,12 @@ class ResultScreen extends StatefulWidget { } class _ResultScreenState extends State { + final AppPhotoStore _photoStore = const AppPhotoStore(); final GalleryService _galleryService = const GalleryService(); final GoogleMapService _googleMapService = const GoogleMapService(); final ImageOverlayService _imageOverlayService = const ImageOverlayService(); + final SettingsService _settingsService = const SettingsService(); - VideoPlayerController? _video; bool _showBanner = false; bool _processing = true; String? _displayFilePath; @@ -50,12 +53,7 @@ class _ResultScreenState extends State { Future _init() async { try { - if (widget.type == MediaType.video) { - await _initVideo(); - _savedGalleryPath = await _galleryService.saveFile(widget.filePath); - } else { - _savedGalleryPath = await _generateAndSaveGeoTaggedPhoto(); - } + _savedGalleryPath = await _generateAndSaveGeoTaggedPhoto(); if (!mounted) return; if (_savedGalleryPath != null) { @@ -71,24 +69,18 @@ class _ResultScreenState extends State { } } - Future _initVideo() async { - _video = VideoPlayerController.file(File(widget.filePath)); - await _video!.initialize(); - _video! - ..setLooping(true) - ..setVolume(0) - ..play(); - } - /// Builds the permanent overlay image, saves the final pixels to gallery, /// and returns the platform gallery path/content URI when available. Future _generateAndSaveGeoTaggedPhoto() async { + final settings = await _settingsService.load(); final geoPhoto = _geoPhotoFromLocationInfo(widget.locationInfo); Uint8List? mapBytes; try { mapBytes = await _googleMapService.fetchThumbnail( latitude: geoPhoto.latitude, longitude: geoPhoto.longitude, + zoom: settings.mapZoomLevel.round(), + mapStyle: settings.mapStyle, ); } catch (_) { // Keep saving functional if the static map request fails offline or by API quota. @@ -102,10 +94,21 @@ class _ResultScreenState extends State { ); if (mounted) setState(() => _displayFilePath = finalResult.filePath); - return _galleryService.saveImageBytes( - bytes: finalResult.bytes, - name: 'GPS_Map_Camera_${DateTime.now().millisecondsSinceEpoch}', + final savedPath = settings.autoSaveToGallery + ? await _galleryService.saveImageBytes( + bytes: finalResult.bytes, + name: 'GPS_Map_Camera_${DateTime.now().millisecondsSinceEpoch}', + ) + : finalResult.filePath; + await _photoStore.addPhoto( + AppPhoto( + id: DateTime.now().microsecondsSinceEpoch.toString(), + filePath: finalResult.filePath, + locationInfo: widget.locationInfo, + capturedAt: DateTime.now(), + ), ); + return savedPath; } GeoPhotoModel _geoPhotoFromLocationInfo(LocationInfo info) { @@ -146,7 +149,6 @@ class _ResultScreenState extends State { @override void dispose() { - _video?.dispose(); super.dispose(); } @@ -168,18 +170,7 @@ class _ResultScreenState extends State { body: Stack( children: [ Positioned.fill( - child: widget.type == MediaType.photo - ? Image.file(displayFile, fit: BoxFit.cover) - : (_video != null && _video!.value.isInitialized) - ? FittedBox( - fit: BoxFit.cover, - child: SizedBox( - width: _video!.value.size.width, - height: _video!.value.size.height, - child: VideoPlayer(_video!), - ), - ) - : const Center(child: CircularProgressIndicator()), + child: Image.file(displayFile, fit: BoxFit.cover), ), if (_processing) Positioned.fill( diff --git a/lib/features/settings/screen/settings_screen.dart b/lib/features/settings/screen/settings_screen.dart new file mode 100644 index 0000000..02b3405 --- /dev/null +++ b/lib/features/settings/screen/settings_screen.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; + +import '../../../models/app_settings.dart'; +import '../../../services/settings_service.dart'; + +class SettingsScreen extends StatefulWidget { + const SettingsScreen({super.key}); + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + final SettingsService _settingsService = const SettingsService(); + AppSettings _settings = const AppSettings(); + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final settings = await _settingsService.load(); + if (!mounted) return; + setState(() { + _settings = settings; + _loading = false; + }); + } + + Future _save(AppSettings settings) async { + setState(() => _settings = settings); + await _settingsService.save(settings); + } + + @override + Widget build(BuildContext context) { + if (_loading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + return Scaffold( + appBar: AppBar(title: const Text('Settings'), centerTitle: true), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _dropdown( + title: 'Map Style', + value: _settings.mapStyle, + items: const {'standard': 'Standard', 'satellite': 'Satellite', 'terrain': 'Terrain'}, + onChanged: (value) => _save(_settings.copyWith(mapStyle: value)), + ), + _dropdown( + title: 'Overlay Position', + value: _settings.overlayPosition, + items: const {'bottom': 'Bottom', 'top': 'Top'}, + onChanged: (value) => _save(_settings.copyWith(overlayPosition: value)), + ), + SwitchListTile( + title: const Text('Show Coordinates'), + value: _settings.showCoordinates, + onChanged: (value) => _save(_settings.copyWith(showCoordinates: value)), + ), + SwitchListTile( + title: const Text('Show Compass/Speed/Altitude'), + subtitle: const Text('Shows or hides the W/C/S/A status row.'), + value: _settings.showCompassSpeedAltitude, + onChanged: (value) => _save(_settings.copyWith(showCompassSpeedAltitude: value)), + ), + _dropdown( + title: 'Photo Quality', + value: _settings.photoQuality, + items: const {'high': 'High', 'medium': 'Medium', 'low': 'Low'}, + onChanged: (value) => _save(_settings.copyWith(photoQuality: value)), + ), + SwitchListTile( + title: const Text('Auto-save to Gallery'), + value: _settings.autoSaveToGallery, + onChanged: (value) => _save(_settings.copyWith(autoSaveToGallery: value)), + ), + _dropdown( + title: 'Date Format', + value: _settings.dateFormat, + items: const {'DD/MM/YYYY': 'DD/MM/YYYY', 'YYYY-MM-DD': 'YYYY-MM-DD'}, + onChanged: (value) => _save(_settings.copyWith(dateFormat: value)), + ), + ListTile( + title: const Text('Map Zoom Level'), + subtitle: Slider( + value: _settings.mapZoomLevel, + min: 10, + max: 18, + divisions: 8, + label: _settings.mapZoomLevel.round().toString(), + onChanged: (value) => _save(_settings.copyWith(mapZoomLevel: value.roundToDouble())), + ), + trailing: Text(_settings.mapZoomLevel.round().toString()), + ), + SwitchListTile( + title: const Text('App Theme'), + subtitle: Text(_settings.darkTheme ? 'Dark' : 'Light'), + value: _settings.darkTheme, + onChanged: (value) => _save(_settings.copyWith(darkTheme: value)), + ), + ], + ), + ); + } + + Widget _dropdown({ + required String title, + required String value, + required Map items, + required ValueChanged onChanged, + }) { + return ListTile( + title: Text(title), + trailing: DropdownButton( + value: value, + items: items.entries + .map((entry) => DropdownMenuItem(value: entry.key, child: Text(entry.value))) + .toList(), + onChanged: (value) { + if (value != null) onChanged(value); + }, + ), + ); + } +} diff --git a/lib/models/app_photo.dart b/lib/models/app_photo.dart new file mode 100644 index 0000000..d743f9f --- /dev/null +++ b/lib/models/app_photo.dart @@ -0,0 +1,32 @@ +import 'location_info.dart'; + +class AppPhoto { + const AppPhoto({ + required this.id, + required this.filePath, + required this.locationInfo, + required this.capturedAt, + }); + + final String id; + final String filePath; + final LocationInfo locationInfo; + final DateTime capturedAt; + + Map toJson() => { + 'id': id, + 'filePath': filePath, + 'locationInfo': locationInfo.toJson(), + 'capturedAt': capturedAt.toIso8601String(), + }; + + factory AppPhoto.fromJson(Map json) { + return AppPhoto( + id: json['id'] as String, + filePath: json['filePath'] as String, + locationInfo: + LocationInfo.fromJson(json['locationInfo'] as Map), + capturedAt: DateTime.parse(json['capturedAt'] as String), + ); + } +} diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart new file mode 100644 index 0000000..1484469 --- /dev/null +++ b/lib/models/app_settings.dart @@ -0,0 +1,59 @@ +import 'package:camera/camera.dart'; +import 'package:flutter/material.dart'; + +class AppSettings { + const AppSettings({ + this.mapStyle = 'standard', + this.overlayPosition = 'bottom', + this.showCoordinates = true, + this.showCompassSpeedAltitude = true, + this.photoQuality = 'high', + this.autoSaveToGallery = true, + this.dateFormat = 'DD/MM/YYYY', + this.mapZoomLevel = 15, + this.darkTheme = true, + }); + + final String mapStyle; + final String overlayPosition; + final bool showCoordinates; + final bool showCompassSpeedAltitude; + final String photoQuality; + final bool autoSaveToGallery; + final String dateFormat; + final double mapZoomLevel; + final bool darkTheme; + + ResolutionPreset get resolutionPreset => switch (photoQuality) { + 'medium' => ResolutionPreset.medium, + 'low' => ResolutionPreset.low, + _ => ResolutionPreset.max, + }; + + ThemeMode get themeMode => darkTheme ? ThemeMode.dark : ThemeMode.light; + + AppSettings copyWith({ + String? mapStyle, + String? overlayPosition, + bool? showCoordinates, + bool? showCompassSpeedAltitude, + String? photoQuality, + bool? autoSaveToGallery, + String? dateFormat, + double? mapZoomLevel, + bool? darkTheme, + }) { + return AppSettings( + mapStyle: mapStyle ?? this.mapStyle, + overlayPosition: overlayPosition ?? this.overlayPosition, + showCoordinates: showCoordinates ?? this.showCoordinates, + showCompassSpeedAltitude: + showCompassSpeedAltitude ?? this.showCompassSpeedAltitude, + photoQuality: photoQuality ?? this.photoQuality, + autoSaveToGallery: autoSaveToGallery ?? this.autoSaveToGallery, + dateFormat: dateFormat ?? this.dateFormat, + mapZoomLevel: mapZoomLevel ?? this.mapZoomLevel, + darkTheme: darkTheme ?? this.darkTheme, + ); + } +} diff --git a/lib/services/app_photo_store.dart b/lib/services/app_photo_store.dart new file mode 100644 index 0000000..de4e89e --- /dev/null +++ b/lib/services/app_photo_store.dart @@ -0,0 +1,52 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/app_photo.dart'; + +class AppPhotoStore { + static const _photosKey = 'app_saved_photos'; + + const AppPhotoStore(); + + Future> loadPhotos() async { + final prefs = await SharedPreferences.getInstance(); + final rawItems = prefs.getStringList(_photosKey) ?? const []; + final photos = rawItems + .map((raw) => AppPhoto.fromJson(jsonDecode(raw) as Map)) + .where((photo) => File(photo.filePath).existsSync()) + .toList() + ..sort((a, b) => b.capturedAt.compareTo(a.capturedAt)); + + if (photos.length != rawItems.length) { + await _saveAll(photos); + } + return photos; + } + + Future addPhoto(AppPhoto photo) async { + final photos = await loadPhotos(); + photos.removeWhere((item) => item.filePath == photo.filePath || item.id == photo.id); + photos.insert(0, photo); + await _saveAll(photos); + } + + Future deletePhoto(AppPhoto photo, {bool deleteFile = true}) async { + if (deleteFile) { + final file = File(photo.filePath); + if (file.existsSync()) await file.delete(); + } + final photos = await loadPhotos(); + photos.removeWhere((item) => item.id == photo.id || item.filePath == photo.filePath); + await _saveAll(photos); + } + + Future _saveAll(List photos) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList( + _photosKey, + photos.map((photo) => jsonEncode(photo.toJson())).toList(), + ); + } +} diff --git a/lib/services/backup_service.dart b/lib/services/backup_service.dart new file mode 100644 index 0000000..07dc5da --- /dev/null +++ b/lib/services/backup_service.dart @@ -0,0 +1,25 @@ +import '../models/app_photo.dart'; + +class BackupProgress { + const BackupProgress({required this.completed, required this.total}); + + final int completed; + final int total; + + double get value => total == 0 ? 0 : completed / total; +} + +class BackupService { + const BackupService(); + + Stream uploadPhotosForUser({ + required String userId, + required List photos, + }) async* { + // Firebase Storage integration point: upload each file to + // users/{uid}/photos/ and yield progress as uploads complete. + for (var index = 0; index <= photos.length; index++) { + yield BackupProgress(completed: index, total: photos.length); + } + } +} diff --git a/lib/services/google_map_service.dart b/lib/services/google_map_service.dart index 0642a07..122b43b 100644 --- a/lib/services/google_map_service.dart +++ b/lib/services/google_map_service.dart @@ -19,6 +19,7 @@ class GoogleMapService { int width = 640, int height = 420, int zoom = 16, + String mapStyle = 'satellite', }) { final controller = static_maps.StaticMapController( googleApiKey: _apiKey, @@ -39,7 +40,7 @@ class GoogleMapService { queryParameters: { ...url.queryParameters, 'scale': '2', - 'maptype': 'hybrid', + 'maptype': mapStyle == 'standard' ? 'roadmap' : mapStyle, }, ); } @@ -49,6 +50,8 @@ class GoogleMapService { required double longitude, int width = 640, int height = 420, + int zoom = 16, + String mapStyle = 'satellite', }) async { if (_apiKey.trim().isEmpty || _apiKey == 'YOUR_API_KEY') { return null; @@ -61,6 +64,8 @@ class GoogleMapService { longitude: longitude, width: width, height: height, + zoom: zoom, + mapStyle: mapStyle, ), ); diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart new file mode 100644 index 0000000..8887403 --- /dev/null +++ b/lib/services/settings_service.dart @@ -0,0 +1,47 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/app_settings.dart'; + +class SettingsService { + static const mapStyleKey = 'settings_map_style'; + static const overlayPositionKey = 'settings_overlay_position'; + static const showCoordinatesKey = 'settings_show_coordinates'; + static const showCompassSpeedAltitudeKey = 'settings_show_compass_speed_altitude'; + static const photoQualityKey = 'settings_photo_quality'; + static const autoSaveToGalleryKey = 'settings_auto_save_to_gallery'; + static const dateFormatKey = 'settings_date_format'; + static const mapZoomLevelKey = 'settings_map_zoom_level'; + static const darkThemeKey = 'settings_dark_theme'; + + const SettingsService(); + + Future load() async { + final prefs = await SharedPreferences.getInstance(); + return AppSettings( + mapStyle: prefs.getString(mapStyleKey) ?? 'standard', + overlayPosition: prefs.getString(overlayPositionKey) ?? 'bottom', + showCoordinates: prefs.getBool(showCoordinatesKey) ?? true, + showCompassSpeedAltitude: + prefs.getBool(showCompassSpeedAltitudeKey) ?? true, + photoQuality: prefs.getString(photoQualityKey) ?? 'high', + autoSaveToGallery: prefs.getBool(autoSaveToGalleryKey) ?? true, + dateFormat: prefs.getString(dateFormatKey) ?? 'DD/MM/YYYY', + mapZoomLevel: prefs.getDouble(mapZoomLevelKey) ?? 15, + darkTheme: prefs.getBool(darkThemeKey) ?? true, + ); + } + + Future save(AppSettings settings) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(mapStyleKey, settings.mapStyle); + await prefs.setString(overlayPositionKey, settings.overlayPosition); + await prefs.setBool(showCoordinatesKey, settings.showCoordinates); + await prefs.setBool( + showCompassSpeedAltitudeKey, settings.showCompassSpeedAltitude); + await prefs.setString(photoQualityKey, settings.photoQuality); + await prefs.setBool(autoSaveToGalleryKey, settings.autoSaveToGallery); + await prefs.setString(dateFormatKey, settings.dateFormat); + await prefs.setDouble(mapZoomLevelKey, settings.mapZoomLevel); + await prefs.setBool(darkThemeKey, settings.darkTheme); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index acd7a87..f27ae89 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,6 +50,7 @@ dependencies: path_provider: ^2.1.5 image: ^4.5.2 http: ^1.2.2 + shared_preferences: ^2.3.3 dev_dependencies: flutter_test: