diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index 8d512d6..f1cefb3 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -200,12 +200,16 @@ class _AppInitializerState extends State { // so the opt-in flag is loaded; never blocks or fails app init. readiness.refresh(); - // Fire-and-forget analytics in background. - api.sendHeartbeat(); - api.trackEvent('app_open'); - provider.getQuickStats().then((stats) => api.reportUsage(stats)).catchError( - (Object e) => debugPrint('Failed to report usage: $e'), - ); + // Fire-and-forget analytics in background — never fires for F-Droid + // installs, and honors the user's Settings toggle otherwise. Must + // run after settings.init() so both are loaded. + if (settings.telemetryAllowed) { + api.sendHeartbeat(); + api.trackEvent('app_open'); + provider.getQuickStats().then((stats) => api.reportUsage(stats)).catchError( + (Object e) => debugPrint('Failed to report usage: $e'), + ); + } if (!mounted) return; setState(() { diff --git a/workout-logger/lib/screens/profile_screen.dart b/workout-logger/lib/screens/profile_screen.dart index ecfbfaa..e7432fe 100644 --- a/workout-logger/lib/screens/profile_screen.dart +++ b/workout-logger/lib/screens/profile_screen.dart @@ -14,7 +14,6 @@ import 'package:package_info_plus/package_info_plus.dart'; import '../services/workout_provider.dart'; import '../services/settings_provider.dart'; -import '../services/api_service.dart'; import '../services/interfaces/health_connect_service_interface.dart'; import '../services/managers/readiness_manager.dart'; import '../theme/app_theme.dart'; @@ -31,7 +30,6 @@ class _ProfileScreenState extends State with WidgetsBindingObserver { bool _isExporting = false; bool _isImporting = false; - bool _isBackingUp = false; bool _isRequestingHcPermission = false; bool _isRequestingReadinessPermission = false; String _appVersion = ''; @@ -295,27 +293,6 @@ class _ProfileScreenState extends State } } - Future _performCloudBackup() async { - setState(() => _isBackingUp = true); - final provider = context.read(); - final api = context.read(); - try { - final jsonString = await provider.exportAllData(); - final data = jsonDecode(jsonString) as Map; - await api.trackEvent('backup_triggered').catchError((_) => null); - final success = await api.backupData(data); - if (!mounted) return; - _showSnack( - success ? 'Cloud backup successful!' : 'Backup failed. Please try again.', - success ? AppColors.success : AppColors.error, - ); - } catch (_) { - if (mounted) _showSnack('Something went wrong.', AppColors.error); - } finally { - if (mounted) setState(() => _isBackingUp = false); - } - } - void _showSnack(String message, Color color) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -379,15 +356,13 @@ class _ProfileScreenState extends State DataManagementSection( isExporting: _isExporting, isImporting: _isImporting, - isBackingUp: _isBackingUp, onExport: _isExporting ? null : _exportToFile, onImport: _isImporting ? null : _importFromFile, - onCloudBackup: _isBackingUp ? null : _performCloudBackup, ), const SizedBox(height: AppSpacing.md), const AiSettingsSection(), const SizedBox(height: AppSpacing.md), - const CloudSyncSection(), + PrivacySection(settings: settings), const SizedBox(height: AppSpacing.md), AboutSection(appVersion: _appVersion), const SizedBox(height: AppSpacing.xxl), diff --git a/workout-logger/lib/screens/widgets/profile_sections.dart b/workout-logger/lib/screens/widgets/profile_sections.dart index d6bbee3..313c176 100644 --- a/workout-logger/lib/screens/widgets/profile_sections.dart +++ b/workout-logger/lib/screens/widgets/profile_sections.dart @@ -356,18 +356,14 @@ class DataManagementSection extends StatelessWidget { super.key, required this.isExporting, required this.isImporting, - required this.isBackingUp, required this.onExport, required this.onImport, - required this.onCloudBackup, }); final bool isExporting; final bool isImporting; - final bool isBackingUp; final VoidCallback? onExport; final VoidCallback? onImport; - final VoidCallback? onCloudBackup; @override Widget build(BuildContext context) { @@ -375,7 +371,7 @@ class DataManagementSection extends StatelessWidget { icon: Icons.storage_rounded, iconColor: AppColors.secondary, title: 'Data Management', - subtitle: 'Export, import, or backup your workout data', + subtitle: 'Export or import your workout data', child: Column( children: [ _ActionTile( @@ -395,77 +391,67 @@ class DataManagementSection extends StatelessWidget { loading: isImporting, onTap: onImport, ), - const _SectionDivider(), - _ActionTile( - icon: Icons.cloud_upload_outlined, - iconColor: AppColors.primary, - title: 'Cloud Backup', - subtitle: 'Sync to RepForge cloud (requires account)', - loading: isBackingUp, - onTap: onCloudBackup, - ), ], ), ); } } -// ── Cloud Sync section (placeholder) ───────────────────────────────────────── -class CloudSyncSection extends StatelessWidget { - const CloudSyncSection({super.key}); +// ── Privacy section ──────────────────────────────────────────────────────────── +class PrivacySection extends StatelessWidget { + const PrivacySection({super.key, required this.settings}); + + final SettingsProvider settings; + + static const _color = AppColors.textSoft; @override Widget build(BuildContext context) { + final isFdroid = settings.isFdroidInstall; + final enabled = settings.telemetryAllowed; return _ProfileSection( - icon: Icons.sync_rounded, - iconColor: AppColors.warning, - title: 'Cloud Sync', - subtitle: 'Sync your data across devices', - trailing: const _ComingSoonBadge(), + icon: Icons.privacy_tip_outlined, + iconColor: _color, + title: 'Privacy', + subtitle: 'Control anonymous usage data', child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - const _SectionLabel('MONGODB CONNECTION STRING'), - const SizedBox(height: AppSpacing.sm), - Container( - decoration: BoxDecoration( - color: AppColors.glass, - borderRadius: BorderRadius.circular(AppRadius.sm), - border: Border.all(color: AppColors.glassBorder), - ), - child: TextField( - enabled: false, - style: TextStyle(fontFamily: 'GeistMono', - color: AppColors.textFaint, - fontSize: 12, - ), - decoration: InputDecoration( - hintText: 'mongodb+srv://user:pass@cluster.mongodb.net/db', - hintStyle: TextStyle(fontFamily: 'GeistMono', - color: AppColors.textFaint, - fontSize: 12, - ), - prefixIcon: const Icon( - Icons.link_rounded, - color: AppColors.textFaint, - size: 16, - ), - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm + 4, + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Share anonymous usage data', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + isFdroid + ? 'Always off for F-Droid installs' + : 'Install ID, platform, and workout counts — no personal data', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], ), ), - ), - ), - const SizedBox(height: AppSpacing.sm), - Text( - 'Cloud sync with custom MongoDB will be available in a future update.', - style: TextStyle(fontFamily: 'Geist', - color: AppColors.textFaint, - fontSize: 11, - fontStyle: FontStyle.italic, - ), + Switch( + value: enabled, + onChanged: isFdroid + ? null + : (v) => settings.setAnalyticsEnabled(v), + activeThumbColor: _color, + activeTrackColor: _color.withValues(alpha: 0.35), + ), + ], ), ], ), @@ -1154,28 +1140,3 @@ class _DebugLogSheet extends StatelessWidget { ); } } - -class _ComingSoonBadge extends StatelessWidget { - const _ComingSoonBadge(); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppColors.warning.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(AppRadius.full), - border: Border.all(color: AppColors.warning.withValues(alpha: 0.35)), - ), - child: Text( - 'Soon', - style: TextStyle(fontFamily: 'Geist', - color: AppColors.warning, - fontSize: 10, - fontWeight: FontWeight.w600, - letterSpacing: 0.3, - ), - ), - ); - } -} diff --git a/workout-logger/lib/services/settings_provider.dart b/workout-logger/lib/services/settings_provider.dart index 164df92..fbd9b8d 100644 --- a/workout-logger/lib/services/settings_provider.dart +++ b/workout-logger/lib/services/settings_provider.dart @@ -20,6 +20,8 @@ class SettingsProvider extends ChangeNotifier { String _weeklyInsights = ''; DateTime? _weeklyInsightsDate; bool _showAdvancedMetrics = false; + bool _analyticsEnabled = true; + bool _isFdroidInstall = false; WeightUnit get weightUnit => _weightUnit; double get weightIncrement => _weightIncrement; @@ -34,6 +36,22 @@ class SettingsProvider extends ChangeNotifier { DateTime? get weeklyInsightsDate => _weeklyInsightsDate; bool get showAdvancedMetrics => _showAdvancedMetrics; + /// Whether this install came from the F-Droid client (detected at runtime + /// via the Android installer package name — never baked in at compile + /// time, since a compile-time difference between the F-Droid build and + /// the GitHub release binary would break F-Droid's byte-for-byte + /// reproducible-build verification against `Binaries:` in fdroiddata). + bool get isFdroidInstall => _isFdroidInstall; + + /// User's analytics preference, as stored. F-Droid installs are always + /// telemetry-free regardless of this value — see [telemetryAllowed]. + bool get analyticsEnabled => _analyticsEnabled; + + /// Whether automatic telemetry (heartbeat/app-open/usage-report) may + /// fire. False for F-Droid installs unconditionally; otherwise follows + /// the user's setting. + bool get telemetryAllowed => _analyticsEnabled && !_isFdroidInstall; + SettingsProvider(this._storage); Future init() async { @@ -60,6 +78,16 @@ class SettingsProvider extends ChangeNotifier { _weeklyInsightsDate = dateStr != null ? DateTime.tryParse(dateStr) : null; final advMetrics = await _storage.getSetting('showAdvancedMetrics'); _showAdvancedMetrics = advMetrics == 'true'; + + final analytics = await _storage.getSetting('analyticsEnabled'); + _analyticsEnabled = analytics != 'false'; + + try { + final info = await PackageInfo.fromPlatform(); + _isFdroidInstall = info.installerStore == 'org.fdroid.fdroid'; + } catch (_) { + _isFdroidInstall = false; + } } Future setUserName(String name) async { @@ -130,6 +158,12 @@ class SettingsProvider extends ChangeNotifier { notifyListeners(); } + Future setAnalyticsEnabled(bool value) async { + _analyticsEnabled = value; + await _storage.saveSetting('analyticsEnabled', value.toString()); + notifyListeners(); + } + Future saveWeeklyInsights(String insights) async { _weeklyInsights = insights; _weeklyInsightsDate = DateTime.now();