diff --git a/lib/app/app.dart b/lib/app/app.dart index 85e23e7..9eea33a 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -26,6 +26,7 @@ import 'package:fula_files/features/settings/providers/settings_provider.dart'; import 'package:fula_files/features/onboarding/screens/terms_of_service_screen.dart'; import 'package:fula_files/features/sharing/widgets/create_collaboration_dialog.dart'; import 'package:fula_files/features/sharing/widgets/create_share_dialog.dart'; +import 'package:fula_files/shared/widgets/beta_upload_dialog.dart'; import 'package:fula_files/shared/widgets/keyboard_shortcuts.dart'; import 'package:fula_files/shared/widgets/mini_player.dart'; @@ -373,6 +374,12 @@ class _FulaFilesAppState extends ConsumerState filePath: filePath, ); if (!confirmed) return; + final betaCtx = walletNavigatorKey.currentContext; + if (betaCtx == null || + !betaCtx.mounted || + !await showBetaUploadDialog(betaCtx)) { + return; + } final name = filePath.split(Platform.pathSeparator).last; @@ -763,7 +770,10 @@ class _FulaFilesAppState extends ConsumerState builder: (context, child) { // Show ToS screen if not accepted if (!tosAccepted) { - return TermsOfServiceScreen(onAccepted: _onTosAccepted); + return TermsOfServiceScreen( + onAccepted: _onTosAccepted, + isUpdate: settings.tosAcceptedBefore, + ); } return DesktopKeyboardShortcuts( diff --git a/lib/features/apps/screens/whatsapp_backup_screen.dart b/lib/features/apps/screens/whatsapp_backup_screen.dart index 20a151b..62ab65f 100644 --- a/lib/features/apps/screens/whatsapp_backup_screen.dart +++ b/lib/features/apps/screens/whatsapp_backup_screen.dart @@ -10,6 +10,7 @@ import 'package:fula_files/core/services/whatsapp_backup_service.dart'; import 'package:fula_files/features/apps/providers/app_provider.dart'; import 'package:fula_files/features/apps/widgets/password_setup_dialog.dart'; import 'package:fula_files/shared/utils/adaptive_ui.dart'; +import 'package:fula_files/shared/widgets/beta_upload_dialog.dart'; String _formatTimeAgo(DateTime dateTime) { final diff = DateTime.now().difference(dateTime); @@ -223,6 +224,10 @@ class _WhatsAppBackupScreenState extends ConsumerState { overrideDir = Directory(activated!.iosFolderPath!); } + // A backup uploads the chats and their media. + if (!context.mounted || !await showBetaUploadDialog(context)) return; + if (!context.mounted) return; + ref.read(appProvider.notifier).startBackup( widget.appId, overrideDir: overrideDir, diff --git a/lib/features/automate/screens/automate_task_detail_screen.dart b/lib/features/automate/screens/automate_task_detail_screen.dart index 9f2b90e..3fff49b 100644 --- a/lib/features/automate/screens/automate_task_detail_screen.dart +++ b/lib/features/automate/screens/automate_task_detail_screen.dart @@ -986,7 +986,12 @@ class _AutomateTaskDetailScreenState // (the same dialog the website-generation flow shows when // pinning files to IPFS) — different from the bulk-send // click-to-chat disclaimer we just showed above. - final ipfsOk = await ipfs_warning.showLegalDisclaimerDialog(context); + // The user's own attachment is being published, not generated + // content — so the beta box carries the upload wording. + final ipfsOk = await ipfs_warning.showLegalDisclaimerDialog( + context, + betaAcknowledgement: ipfs_warning.kBetaUploadAcknowledgement, + ); if (ipfsOk != true) { // User declined the IPFS warning — abort run; don't send // ANY rows (the attachment is required for the {File} diff --git a/lib/features/browser/screens/file_browser_screen.dart b/lib/features/browser/screens/file_browser_screen.dart index 39f75b0..c331264 100644 --- a/lib/features/browser/screens/file_browser_screen.dart +++ b/lib/features/browser/screens/file_browser_screen.dart @@ -30,6 +30,7 @@ import 'package:fula_files/core/services/legacy_listing_cache.dart'; import 'package:fula_files/core/utils/user_id.dart'; import 'package:fula_files/core/utils/safe_path.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:fula_files/shared/widgets/beta_upload_dialog.dart'; import 'package:fula_files/shared/widgets/master_health_banner.dart'; import 'package:fula_files/core/models/file_tag.dart'; import 'package:fula_files/core/models/local_file.dart'; @@ -2150,8 +2151,9 @@ class _FileBrowserScreenState extends ConsumerState { leading: Icon(LucideIcons.upload, color: isLoggedIn ? null : Colors.grey), title: Text('Upload to Cloud', style: TextStyle(color: isLoggedIn ? null : Colors.grey)), subtitle: isLoggedIn ? null : const Text('Sign in required', style: TextStyle(fontSize: 12)), - onTap: isLoggedIn ? () { + onTap: isLoggedIn ? () async { Navigator.pop(ctx); + if (!await showBetaUploadDialog(context) || !mounted) return; _uploadFile(file); } : null, ), @@ -2396,6 +2398,8 @@ class _FileBrowserScreenState extends ConsumerState { } Future _uploadSelected() async { + // One notice for the whole selection, not one per file. + if (!await showBetaUploadDialog(context) || !mounted) return; for (final path in _selectedFiles) { final file = _files.firstWhere((f) => f.path == path); await _uploadFile(file); @@ -2539,6 +2543,8 @@ class _FileBrowserScreenState extends ConsumerState { } Future _enableCategorySync(FileCategory category) async { + // Turning sync on starts uploads the user will not confirm one by one. + if (!await showBetaUploadDialog(context) || !mounted) return; try { // Check battery optimization for background sync (Android) if (Platform.isAndroid) { @@ -2590,6 +2596,7 @@ class _FileBrowserScreenState extends ConsumerState { } Future _syncCategoryNow(FileCategory category) async { + if (!await showBetaUploadDialog(context) || !mounted) return; try { final syncPath = 'category:${widget.category}'; await FolderWatchService.instance.syncFolder(syncPath); @@ -2697,6 +2704,8 @@ class _FileBrowserScreenState extends ConsumerState { } Future _enableFolderSync(LocalFile folder) async { + // Turning sync on starts uploads the user will not confirm one by one. + if (!await showBetaUploadDialog(context) || !mounted) return; try { // Check battery optimization for background sync (Android) if (Platform.isAndroid) { @@ -2772,6 +2781,7 @@ class _FileBrowserScreenState extends ConsumerState { } Future _syncFolderNow(LocalFile folder) async { + if (!await showBetaUploadDialog(context) || !mounted) return; try { await FolderWatchService.instance.syncFolder(folder.path); if (mounted) { @@ -2827,6 +2837,7 @@ class _FileBrowserScreenState extends ConsumerState { ); if (confirmed != true || !mounted) return; + if (!await showBetaUploadDialog(context) || !mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/lib/features/nft/screens/nft_detail_screen.dart b/lib/features/nft/screens/nft_detail_screen.dart index 7557565..d5f4a01 100644 --- a/lib/features/nft/screens/nft_detail_screen.dart +++ b/lib/features/nft/screens/nft_detail_screen.dart @@ -8,6 +8,7 @@ import 'package:lucide_icons/lucide_icons.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:fula_files/core/models/file_tag.dart'; +import 'package:fula_files/shared/widgets/beta_upload_dialog.dart'; import 'package:fula_files/core/models/local_file.dart'; import 'package:fula_files/core/models/nft_token.dart'; import 'package:fula_files/features/nft/providers/nft_provider.dart'; @@ -413,10 +414,15 @@ class _NftDetailScreenState extends ConsumerState { return; } + // Minting uploads the image and its metadata. + if (!await showBetaUploadDialog(context)) return; + final previewPath = await _resolveFilePath(localPath); + if (!mounted) return; + // Show mint config sheet (image preview + stepped config) final config = await showMintConfigDialog( context, - previewPath: await _resolveFilePath(localPath), + previewPath: previewPath, defaultEventName: p.basenameWithoutExtension(firstFile.fileName), ); if (config == null || !mounted) return; diff --git a/lib/features/onboarding/screens/terms_of_service_screen.dart b/lib/features/onboarding/screens/terms_of_service_screen.dart index 5240ab6..4bf8d50 100644 --- a/lib/features/onboarding/screens/terms_of_service_screen.dart +++ b/lib/features/onboarding/screens/terms_of_service_screen.dart @@ -1,300 +1,31 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fula_files/features/settings/providers/settings_provider.dart'; +import 'package:fula_files/shared/widgets/terms_of_service_view.dart'; -class TermsOfServiceScreen extends ConsumerStatefulWidget { +/// Native first-run (and Terms-update) gate. The text and the scroll-to-read +/// rule live in the shared [TermsAcceptanceScreen], so native and web show the +/// same Terms. +class TermsOfServiceScreen extends ConsumerWidget { final VoidCallback onAccepted; - const TermsOfServiceScreen({super.key, required this.onAccepted}); + /// An earlier version was accepted; present the Terms as an update. + final bool isUpdate; - @override - ConsumerState createState() => _TermsOfServiceScreenState(); -} - -class _TermsOfServiceScreenState extends ConsumerState { - bool _hasScrolledToBottom = false; - final ScrollController _scrollController = ScrollController(); + const TermsOfServiceScreen({ + super.key, + required this.onAccepted, + this.isUpdate = false, + }); @override - void initState() { - super.initState(); - _scrollController.addListener(_onScroll); - } - - @override - void dispose() { - _scrollController.removeListener(_onScroll); - _scrollController.dispose(); - super.dispose(); - } - - void _onScroll() { - if (_scrollController.position.pixels >= - _scrollController.position.maxScrollExtent - 50) { - if (!_hasScrolledToBottom) { - setState(() => _hasScrolledToBottom = true); - } - } - } - - Future _acceptTerms() async { - await ref.read(settingsProvider.notifier).setTosAccepted(true); - widget.onAccepted(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Scaffold( - body: SafeArea( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - children: [ - Icon( - Icons.description_outlined, - size: 48, - color: theme.colorScheme.primary, - ), - const SizedBox(height: 16), - Text( - 'Terms of Service', - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - 'Please read and accept our terms to continue', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - Expanded( - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - border: Border.all(color: theme.dividerColor), - borderRadius: BorderRadius.circular(12), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: SingleChildScrollView( - controller: _scrollController, - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSection( - 'Welcome to FxFiles', - 'FxFiles ("the App") is free and open-source software released under the MIT License. ' - 'It is maintained by independent open-source contributors who are not affiliated with, ' - 'or acting on behalf of, any party or entity.\n\n' - 'These Terms take effect on September 9, 2026. Development carried out before that date ' - 'was done as part of Functionland.\n\n' - 'By using the App, ' - 'you agree to be bound by these Terms of Service. If you do not agree to these terms, ' - 'please do not use the App.', - ), - _buildSection( - '1. Service Description', - 'FxFiles is a file management application that provides cloud storage and synchronization services. ' - 'The service is provided on an "as is" and "as available" basis without warranties of any kind.', - ), - _buildSection( - '2. Backup Storage Classification', - 'IMPORTANT: At this point, the Fula network and FxFiles backup storage should be considered either:\n\n' - '- ARCHIVAL and SECONDARY backup, OR\n' - '- SHORT-TERM and TEMPORARY backup\n\n' - 'until these terms are updated to mention otherwise.\n\n' - 'You should NOT rely on this service as your sole or primary backup solution. ' - 'Always maintain independent backups of your important data.', - ), - _buildSection( - '3. Service Termination', - 'The service may be terminated, suspended, or modified at any time. ' - 'In the event of service termination, we will provide a minimum of TWO (2) WEEKS advance notice ' - 'via email or in-app notification.\n\n' - 'IT IS YOUR SOLE RESPONSIBILITY to download and migrate your data before the termination date. ' - 'No entity or contributor shall be liable for any data loss resulting from service termination.', - ), - _buildSection( - '4. Use at Your Own Risk', - 'You use this App entirely AT YOUR OWN RISK. No entity or contributor shall be liable for any direct, ' - 'indirect, incidental, special, consequential, or exemplary damages, including but not limited to:\n\n' - '- Loss of data or files\n' - '- Loss of profits or business opportunities\n' - '- Service interruptions\n' - '- Device damage or malfunction\n' - '- Any other damages arising from your use of the App', - ), - _buildSection( - '5. Encryption and Security', - 'The App employs industry-standard encryption algorithms to protect your data. However, ' - 'NO ENCRYPTION IS ABSOLUTELY SECURE.\n\n' - 'You acknowledge and agree that:\n\n' - '- Encryption technology may become vulnerable due to technological advances, newly discovered vulnerabilities, ' - 'or unforeseen bugs\n' - '- If at any point encrypted files become decryptable due to technological advances, security vulnerabilities, ' - 'or any other reason, no entity or contributor shall be held responsible\n' - '- This is an edge technology and security guarantees cannot be absolute\n' - '- You should not store extremely sensitive information solely relying on this encryption', - ), - _buildSection( - '6. Private Keys and Account Access', - 'IMPORTANT: No copies of your private encryption keys are stored.\n\n' - 'You acknowledge and understand that:\n\n' - '- Your encryption key is derived from your sign-in credentials (email/Google account)\n' - '- If you lose access to the email address used to sign in, you may PERMANENTLY LOSE access to your encrypted data\n' - '- If Google or other authentication providers change their signature creation methods, your key derivation may change, ' - 'potentially resulting in loss of access to previously encrypted data\n' - '- IT IS YOUR RESPONSIBILITY to back up your private key and store it securely\n' - '- Your private key can be viewed and copied in the App Settings\n' - '- Your data cannot be recovered if you lose your private key', - ), - _buildSection( - '7. Data Ownership and Responsibility', - 'You retain ownership of all data you upload to the service. You are solely responsible for:\n\n' - '- Maintaining backups of your important data\n' - '- Ensuring you have legal rights to upload and store your content\n' - '- Any consequences of sharing your data with others', - ), - _buildSection( - '8. Limitation of Liability', - 'TO THE MAXIMUM EXTENT PERMITTED BY LAW, no entity, contributor, or their affiliates, officers, directors, ' - 'employees, and agents shall not be liable for any claims, damages, losses, or expenses arising ' - 'from or related to:\n\n' - '- Your use or inability to use the App\n' - '- Unauthorized access to your data\n' - '- Data loss, corruption, or encryption failures\n' - '- Service interruptions or termination\n' - '- Third-party actions or services\n' - '- Any other matter relating to the service', - ), - _buildSection( - '9. Internal NFT Wallet Disclaimer', - 'IMPORTANT: This App is NOT a wallet application. The App includes an internal NFT wallet solely ' - 'for the purpose of holding NFTs generated by the App.\n\n' - 'You acknowledge and agree that:\n\n' - '- The internal NFT wallet should ONLY be used to hold NFTs generated by the App and nothing else\n' - '- You must NOT transfer tokens (cryptocurrency, ERC-20 tokens, or any other digital assets) to the internal wallet\n' - '- If you transfer tokens to the internal wallet, any and all responsibility is solely yours\n' - '- No entity or contributor is responsible for any loss as a cause of keeping, ' - 'transferring, or holding tokens in the internal wallet\n' - '- The internal wallet is not designed, audited, or intended for general-purpose asset storage', - ), - _buildSection( - '10. No Warranty', - 'THE APP IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ' - 'WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.\n\n' - 'We do not warrant that:\n' - '- The App will meet your requirements\n' - '- The App will be uninterrupted, timely, secure, or error-free\n' - '- Any errors will be corrected', - ), - _buildSection( - '11. Indemnification', - 'You agree that no entity, contributor, or maintainer bears liability for any claims, ' - 'damages, losses, or expenses arising from your use of the App or violation of these terms.', - ), - _buildSection( - '12. Changes to Terms', - 'We reserve the right to modify these terms at any time. Continued use of the App after changes ' - 'constitutes acceptance of the modified terms.', - ), - _buildSection( - '13. Contact', - 'For questions about these Terms of Service, please open a GitHub issue or discussion at github.com/functionland/FxFiles/issues', - ), - const SizedBox(height: 16), - Text( - 'Last updated: March 2026', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 32), - ], - ), - ), - ), - ), - ), - if (!_hasScrolledToBottom) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.arrow_downward, - size: 16, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 8), - Text( - 'Scroll to read all terms', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: _hasScrolledToBottom ? _acceptTerms : null, - child: const Text('I Accept the Terms of Service'), - ), - ), - const SizedBox(height: 8), - Text( - 'By clicking "Accept", you acknowledge that you have read, understood, and agree to be bound by these terms.', - textAlign: TextAlign.center, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildSection(String title, String content) { - final theme = Theme.of(context); - return Padding( - padding: const EdgeInsets.only(bottom: 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - content, - style: theme.textTheme.bodyMedium, - ), - ], - ), + Widget build(BuildContext context, WidgetRef ref) { + return TermsAcceptanceScreen( + isUpdate: isUpdate, + onAccept: () async { + await ref.read(settingsProvider.notifier).setTosAccepted(true); + onAccepted(); + }, ); } } diff --git a/lib/features/settings/providers/settings_provider.dart b/lib/features/settings/providers/settings_provider.dart index 4eb0aa3..c5108f1 100644 --- a/lib/features/settings/providers/settings_provider.dart +++ b/lib/features/settings/providers/settings_provider.dart @@ -1,13 +1,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fula_files/core/services/local_storage_service.dart'; +import 'package:fula_files/shared/legal/terms_content.dart'; class AppSettings { final ThemeMode themeMode; final bool autoSync; final bool wifiOnly; final bool thumbScrollEnabled; + + /// The CURRENT Terms version ([kTermsVersion]) has been accepted. final bool tosAccepted; + + /// Some earlier version was accepted — the gate then presents the Terms as + /// an update rather than as a first-time request. + final bool tosAcceptedBefore; final String? orgName; AppSettings({ @@ -16,6 +23,7 @@ class AppSettings { this.wifiOnly = true, this.thumbScrollEnabled = true, this.tosAccepted = false, + this.tosAcceptedBefore = false, this.orgName, }); @@ -25,6 +33,7 @@ class AppSettings { bool? wifiOnly, bool? thumbScrollEnabled, bool? tosAccepted, + bool? tosAcceptedBefore, String? orgName, bool clearOrgName = false, }) { @@ -34,6 +43,7 @@ class AppSettings { wifiOnly: wifiOnly ?? this.wifiOnly, thumbScrollEnabled: thumbScrollEnabled ?? this.thumbScrollEnabled, tosAccepted: tosAccepted ?? this.tosAccepted, + tosAcceptedBefore: tosAcceptedBefore ?? this.tosAcceptedBefore, orgName: clearOrgName ? null : (orgName ?? this.orgName), ); } @@ -52,7 +62,10 @@ class SettingsNotifier extends Notifier { final autoSync = LocalStorageService.instance.getSetting('autoSync'); final wifiOnly = LocalStorageService.instance.getSetting('wifiOnly'); final thumbScrollEnabled = LocalStorageService.instance.getSetting('thumbScrollEnabled'); - final tosAccepted = LocalStorageService.instance.getSetting('tosAccepted'); + final legacyTosAccepted = + LocalStorageService.instance.getSetting('tosAccepted') ?? false; + final tosAcceptedVersion = + LocalStorageService.instance.getSetting('tosAcceptedVersion'); final storedOrgName = LocalStorageService.instance.getSetting('orgName'); final orgName = (storedOrgName != null && storedOrgName.isNotEmpty) ? storedOrgName : null; @@ -63,7 +76,11 @@ class SettingsNotifier extends Notifier { autoSync: autoSync ?? true, wifiOnly: wifiOnly ?? true, thumbScrollEnabled: thumbScrollEnabled ?? true, - tosAccepted: tosAccepted ?? false, + tosAccepted: !termsAcceptanceRequired( + acceptedVersion: tosAcceptedVersion, + legacyAccepted: legacyTosAccepted, + ), + tosAcceptedBefore: legacyTosAccepted || tosAcceptedVersion != null, orgName: orgName, ); } @@ -88,9 +105,20 @@ class SettingsNotifier extends Notifier { await LocalStorageService.instance.saveSetting('thumbScrollEnabled', value); } + /// Record acceptance of the CURRENT Terms: which version, and when (UTC). + /// The legacy boolean is kept for older builds reading the same box. Future setTosAccepted(bool value) async { - state = state.copyWith(tosAccepted: value); + state = state.copyWith( + tosAccepted: value, + tosAcceptedBefore: value ? true : null, + ); await LocalStorageService.instance.saveSetting('tosAccepted', value); + if (value) { + await LocalStorageService.instance + .saveSetting('tosAcceptedVersion', kTermsVersion); + await LocalStorageService.instance.saveSetting( + 'tosAcceptedAt', DateTime.now().toUtc().toIso8601String()); + } } Future setOrgName(String? value) async { diff --git a/lib/features/settings/screens/settings_screen.dart b/lib/features/settings/screens/settings_screen.dart index afdf2a8..05dd1a9 100644 --- a/lib/features/settings/screens/settings_screen.dart +++ b/lib/features/settings/screens/settings_screen.dart @@ -29,6 +29,8 @@ import 'package:fula_files/core/services/nft_wallet_service.dart'; import 'package:fula_files/core/services/deep_link_service.dart'; import 'package:fula_files/core/utils/platform_capabilities.dart'; import 'package:fula_files/shared/utils/error_messages.dart'; +import 'package:fula_files/shared/legal/terms_content.dart'; +import 'package:fula_files/shared/widgets/terms_of_service_view.dart'; class SettingsScreen extends ConsumerStatefulWidget { const SettingsScreen({super.key}); @@ -202,6 +204,17 @@ class _SettingsScreenState extends ConsumerState { ); }, ), + ListTile( + leading: const Icon(LucideIcons.fileText), + title: const Text('Terms of Service'), + subtitle: const Text('Last updated: $kTermsLastUpdated'), + trailing: const Icon(LucideIcons.chevronRight), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const TermsOfServicePage(), + ), + ), + ), ], ), ], diff --git a/lib/features/shelf/screens/shelf_add_note_screen.dart b/lib/features/shelf/screens/shelf_add_note_screen.dart index f6940fc..00624a6 100644 --- a/lib/features/shelf/screens/shelf_add_note_screen.dart +++ b/lib/features/shelf/screens/shelf_add_note_screen.dart @@ -14,6 +14,7 @@ import 'package:fula_files/core/models/file_tag.dart'; import 'package:fula_files/core/services/shelf_service.dart'; import 'package:fula_files/features/tags/providers/tag_provider.dart'; import 'package:fula_files/features/tags/widgets/tag_chip.dart'; +import 'package:fula_files/shared/widgets/beta_upload_dialog.dart'; /// Manual "Add note" flow. The user enters multi-line text + optional /// tags; on Save the text is staged as a `.txt` file and pushed @@ -73,6 +74,8 @@ class _ShelfAddNoteScreenState extends ConsumerState { Future _save() async { final text = _controller.text; if (text.trim().isEmpty) return; + // Saving uploads the note to the cloud. + if (!await showBetaUploadDialog(context) || !mounted) return; setState(() => _saving = true); try { diff --git a/lib/features/shelf/screens/shelf_doodle_screen.dart b/lib/features/shelf/screens/shelf_doodle_screen.dart index c7252f8..e3ae214 100644 --- a/lib/features/shelf/screens/shelf_doodle_screen.dart +++ b/lib/features/shelf/screens/shelf_doodle_screen.dart @@ -19,6 +19,7 @@ import 'package:fula_files/core/services/shelf_service.dart'; import 'package:fula_files/features/shelf/painters/shelf_doodle_painter.dart'; import 'package:fula_files/features/tags/providers/tag_provider.dart'; import 'package:fula_files/features/tags/widgets/tag_chip.dart'; +import 'package:fula_files/shared/widgets/beta_upload_dialog.dart'; /// Camera-capture annotation editor. Receives the path of a freshly /// captured photo (from `image_picker`) and lets the user overlay @@ -230,6 +231,8 @@ class _ShelfDoodleScreenState extends ConsumerState { Future _save() async { if (_workingImagePath == null) return; + // Saving uploads the photo to the cloud. + if (!await showBetaUploadDialog(context) || !mounted) return; setState(() => _saving = true); try { diff --git a/lib/features/shelf/widgets/shelf_add_sheet.dart b/lib/features/shelf/widgets/shelf_add_sheet.dart index a5e34c5..4d42605 100644 --- a/lib/features/shelf/widgets/shelf_add_sheet.dart +++ b/lib/features/shelf/widgets/shelf_add_sheet.dart @@ -15,6 +15,7 @@ import 'package:fula_files/core/models/file_tag.dart'; import 'package:fula_files/core/services/shelf_service.dart'; import 'package:fula_files/features/tags/providers/tag_provider.dart'; import 'package:fula_files/features/tags/widgets/tag_chip.dart'; +import 'package:fula_files/shared/widgets/beta_upload_dialog.dart'; /// Modal bottom sheet shown from `ShelfScreen`'s FAB. Three actions: /// - Add note → push `/dump/add/note` @@ -93,6 +94,9 @@ class ShelfAddSheet extends ConsumerWidget { } Future _importFile(BuildContext context, WidgetRef ref) async { + // The sheet's own context is gone once it pops; the navigator it was + // shown on stays mounted and can host the upload notice. + final hostContext = Navigator.of(context).context; Navigator.of(context).pop(); FilePickerResult? result; try { @@ -113,6 +117,9 @@ class ShelfAddSheet extends ConsumerWidget { if (result == null) return; // cancelled final picked = result.files.where((f) => f.path != null).toList(); if (picked.isEmpty) return; + if (!hostContext.mounted || !await showBetaUploadDialog(hostContext)) { + return; + } final stagedPaths = []; final mimeTypes = []; diff --git a/lib/features/websites/screens/website_detail_screen.dart b/lib/features/websites/screens/website_detail_screen.dart index ed03f59..5ebf4a5 100644 --- a/lib/features/websites/screens/website_detail_screen.dart +++ b/lib/features/websites/screens/website_detail_screen.dart @@ -743,6 +743,9 @@ class _WebsiteDetailScreenState extends ConsumerState { } void _retryGeneration(WebsiteGeneration gen, FileTag? currentTag) async { + // A retry is a new generation: same notice as Create and Recreate. + final accepted = await showLegalDisclaimerDialog(context); + if (accepted != true || !mounted) return; final files = await ref.read(taggedFilesProvider(widget.tagId).future); if (!mounted) return; diff --git a/lib/features/websites/widgets/legal_disclaimer_dialog.dart b/lib/features/websites/widgets/legal_disclaimer_dialog.dart index 16db666..83afb0d 100644 --- a/lib/features/websites/widgets/legal_disclaimer_dialog.dart +++ b/lib/features/websites/widgets/legal_disclaimer_dialog.dart @@ -6,11 +6,20 @@ import 'package:flutter/material.dart'; +import 'package:fula_files/shared/legal/terms_content.dart'; import 'package:fula_files/shared/widgets/ipfs_public_disclaimer_dialog.dart'; +export 'package:fula_files/shared/legal/terms_content.dart' + show kBetaGenerateAcknowledgement, kBetaUploadAcknowledgement; export 'package:fula_files/shared/widgets/ipfs_public_disclaimer_dialog.dart'; /// Back-compat alias. Prefer `showIpfsPublicDisclaimerDialog` directly. -Future showLegalDisclaimerDialog(BuildContext context) => - showIpfsPublicDisclaimerDialog(context, - variant: PublicDisclaimerVariant.website); +Future showLegalDisclaimerDialog( + BuildContext context, { + String betaAcknowledgement = kBetaGenerateAcknowledgement, +}) => + showIpfsPublicDisclaimerDialog( + context, + variant: PublicDisclaimerVariant.website, + betaAcknowledgement: betaAcknowledgement, + ); diff --git a/lib/shared/legal/terms_content.dart b/lib/shared/legal/terms_content.dart new file mode 100644 index 0000000..50e56ca --- /dev/null +++ b/lib/shared/legal/terms_content.dart @@ -0,0 +1,197 @@ +/// The FxFiles Terms of Service and the acknowledgements that point back to +/// them — ONE source, rendered by the native first-run screen, the web shell, +/// and the settings links on both. +/// +/// Pure Dart (no Flutter import) so it is unit-testable and shared with the +/// web compile graph. +library; + +/// Bump whenever the Terms change in a way every user must see and accept +/// again. Anyone whose recorded acceptance is older is shown the Terms before +/// they can use the app. +/// +/// 1 the original terms (a plain "accepted" flag, no version recorded) +/// 2 2026-09-16: adds "Beta Software and FULA Token" +const int kTermsVersion = 2; + +const String kTermsLastUpdated = 'September 16, 2026'; + +/// Required tick in the popup shown before generating a website or social +/// post. Wording approved by the project owner. +const String kBetaGenerateAcknowledgement = + 'I understand this app is in beta testing, generated content may not be ' + 'reliable or accessible, and integration with Fula token is for testing ' + 'and demo use.'; + +/// Required tick in the popup shown before every user-initiated upload. +const String kBetaUploadAcknowledgement = + 'I understand this app is in beta testing, uploaded files may not be ' + 'reliably stored or remain accessible (I will keep my own copies of ' + 'anything important), and integration with Fula token is for testing and ' + 'demo use.'; + +class TermsSection { + const TermsSection(this.title, this.body); + final String title; + final String body; +} + +const List kTermsSections = [ + TermsSection( + 'Welcome to FxFiles', + 'FxFiles ("the App") is free and open-source software released under the MIT License. ' + 'It is maintained by independent open-source contributors who are not affiliated with, ' + 'or acting on behalf of, any party or entity.\n\n' + 'These Terms take effect on September 9, 2026. Development carried out before that date ' + 'was done as part of Functionland.\n\n' + 'By using the App, ' + 'you agree to be bound by these Terms of Service. If you do not agree to these terms, ' + 'please do not use the App.', + ), + TermsSection( + '1. Service Description', + 'FxFiles is a file management application that provides cloud storage and synchronization services. ' + 'The service is provided on an "as is" and "as available" basis without warranties of any kind.', + ), + TermsSection( + '2. Beta Software and FULA Token', + 'FxFiles is beta software under active testing. Features may change, fail or be discontinued ' + 'without notice.\n\n' + 'Content you upload or generate - including files, websites, links, previews and NFTs - may be ' + 'unreliable, incomplete, inaccessible or permanently lost, and the third-party networks and ' + 'gateways that serve it are outside our control. Do not rely on FxFiles as your only copy of ' + 'anything.\n\n' + 'Any integration with the FULA token, including credits, payments, wallets and NFTs, is for ' + 'testing and demonstration purposes only, carries no guarantee of value, and is not an ' + 'investment or financial product.\n\n' + 'You use FxFiles and any FULA feature at your own risk; the Limitation of Liability, No Warranty ' + 'and Indemnification sections of these Terms apply in full.', + ), + TermsSection( + '3. Backup Storage Classification', + 'IMPORTANT: At this point, the Fula network and FxFiles backup storage should be considered either:\n\n' + '- ARCHIVAL and SECONDARY backup, OR\n' + '- SHORT-TERM and TEMPORARY backup\n\n' + 'until these terms are updated to mention otherwise.\n\n' + 'You should NOT rely on this service as your sole or primary backup solution. ' + 'Always maintain independent backups of your important data.', + ), + TermsSection( + '4. Service Termination', + 'The service may be terminated, suspended, or modified at any time. ' + 'In the event of service termination, we will provide a minimum of TWO (2) WEEKS advance notice ' + 'via email or in-app notification.\n\n' + 'IT IS YOUR SOLE RESPONSIBILITY to download and migrate your data before the termination date. ' + 'No entity or contributor shall be liable for any data loss resulting from service termination.', + ), + TermsSection( + '5. Use at Your Own Risk', + 'You use this App entirely AT YOUR OWN RISK. No entity or contributor shall be liable for any direct, ' + 'indirect, incidental, special, consequential, or exemplary damages, including but not limited to:\n\n' + '- Loss of data or files\n' + '- Loss of profits or business opportunities\n' + '- Service interruptions\n' + '- Device damage or malfunction\n' + '- Any other damages arising from your use of the App', + ), + TermsSection( + '6. Encryption and Security', + 'The App employs industry-standard encryption algorithms to protect your data. However, ' + 'NO ENCRYPTION IS ABSOLUTELY SECURE.\n\n' + 'You acknowledge and agree that:\n\n' + '- Encryption technology may become vulnerable due to technological advances, newly discovered vulnerabilities, ' + 'or unforeseen bugs\n' + '- If at any point encrypted files become decryptable due to technological advances, security vulnerabilities, ' + 'or any other reason, no entity or contributor shall be held responsible\n' + '- This is an edge technology and security guarantees cannot be absolute\n' + '- You should not store extremely sensitive information solely relying on this encryption', + ), + TermsSection( + '7. Private Keys and Account Access', + 'IMPORTANT: No copies of your private encryption keys are stored.\n\n' + 'You acknowledge and understand that:\n\n' + '- Your encryption key is derived from your sign-in credentials (email/Google account)\n' + '- If you lose access to the email address used to sign in, you may PERMANENTLY LOSE access to your encrypted data\n' + '- If Google or other authentication providers change their signature creation methods, your key derivation may change, ' + 'potentially resulting in loss of access to previously encrypted data\n' + '- IT IS YOUR RESPONSIBILITY to back up your private key and store it securely\n' + '- Your private key can be viewed and copied in the App Settings\n' + '- Your data cannot be recovered if you lose your private key', + ), + TermsSection( + '8. Data Ownership and Responsibility', + 'You retain ownership of all data you upload to the service. You are solely responsible for:\n\n' + '- Maintaining backups of your important data\n' + '- Ensuring you have legal rights to upload and store your content\n' + '- Any consequences of sharing your data with others', + ), + TermsSection( + '9. Limitation of Liability', + 'TO THE MAXIMUM EXTENT PERMITTED BY LAW, no entity, contributor, or their affiliates, officers, directors, ' + 'employees, and agents shall not be liable for any claims, damages, losses, or expenses arising ' + 'from or related to:\n\n' + '- Your use or inability to use the App\n' + '- Unauthorized access to your data\n' + '- Data loss, corruption, or encryption failures\n' + '- Service interruptions or termination\n' + '- Third-party actions or services\n' + '- Any other matter relating to the service', + ), + TermsSection( + '10. Internal NFT Wallet Disclaimer', + 'IMPORTANT: This App is NOT a wallet application. The App includes an internal NFT wallet solely ' + 'for the purpose of holding NFTs generated by the App.\n\n' + 'You acknowledge and agree that:\n\n' + '- The internal NFT wallet should ONLY be used to hold NFTs generated by the App and nothing else\n' + '- You must NOT transfer tokens (cryptocurrency, ERC-20 tokens, or any other digital assets) to the internal wallet\n' + '- If you transfer tokens to the internal wallet, any and all responsibility is solely yours\n' + '- No entity or contributor is responsible for any loss as a cause of keeping, ' + 'transferring, or holding tokens in the internal wallet\n' + '- The internal wallet is not designed, audited, or intended for general-purpose asset storage', + ), + TermsSection( + '11. No Warranty', + 'THE APP IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ' + 'WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.\n\n' + 'We do not warrant that:\n' + '- The App will meet your requirements\n' + '- The App will be uninterrupted, timely, secure, or error-free\n' + '- Any errors will be corrected', + ), + TermsSection( + '12. Indemnification', + 'You agree that no entity, contributor, or maintainer bears liability for any claims, ' + 'damages, losses, or expenses arising from your use of the App or violation of these terms.', + ), + TermsSection( + '13. Changes to Terms', + 'We reserve the right to modify these terms at any time. Continued use of the App after changes ' + 'constitutes acceptance of the modified terms.', + ), + TermsSection( + '14. Contact', + 'For questions about these Terms of Service, please open a GitHub issue or discussion at github.com/functionland/FxFiles/issues', + ), +]; + +/// An accepted Terms version as stored as text (web localStorage), or null +/// when absent or not a valid version. +int? parseAcceptedTermsVersion(String? raw) { + if (raw == null) return null; + final version = int.tryParse(raw.trim()); + return (version == null || version < 1) ? null : version; +} + +/// Whether the user must (re)accept the Terms before using the app. +/// +/// [acceptedVersion] is the version recorded at acceptance, or null when none +/// was recorded. [legacyAccepted] is the pre-versioning boolean: acceptance +/// recorded that way was of version 1, so it no longer satisfies a newer +/// version. +bool termsAcceptanceRequired({ + required int? acceptedVersion, + required bool legacyAccepted, +}) { + final effective = acceptedVersion ?? (legacyAccepted ? 1 : 0); + return effective < kTermsVersion; +} diff --git a/lib/shared/widgets/beta_upload_dialog.dart b/lib/shared/widgets/beta_upload_dialog.dart new file mode 100644 index 0000000..4f8ae0c --- /dev/null +++ b/lib/shared/widgets/beta_upload_dialog.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; + +import 'package:fula_files/shared/legal/terms_content.dart'; + +/// Checkbox-gated beta notice shown before EVERY user-initiated upload — +/// once per action (a batch of picked or dropped files is one action), never +/// once per file. The owner chose every time over once-per-version. +/// +/// Same shape as the website-generation notice +/// (showIpfsPublicDisclaimerDialog): an unticked required box, Agree disabled +/// until it is ticked, and no barrier dismissal. +/// +/// Returns true only when the user ticks the box and agrees. +/// +/// On the WEB, call this only AFTER the file picker has returned. A browser +/// opens a picker only from inside the user's tap, and awaiting a dialog +/// first moves the picker out of that tap — on iOS Safari it then silently +/// never opens. +Future showBetaUploadDialog(BuildContext context) async { + final agreed = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const _BetaUploadDialog(), + ); + return agreed == true; +} + +class _BetaUploadDialog extends StatefulWidget { + const _BetaUploadDialog(); + + @override + State<_BetaUploadDialog> createState() => _BetaUploadDialogState(); +} + +class _BetaUploadDialogState extends State<_BetaUploadDialog> { + bool _accepted = false; + + static const String _uploadTerms = + '1. FxFiles is in beta testing. Uploads may fail or be delayed, and ' + 'stored files may not remain accessible.\n\n' + '2. Do NOT rely on FxFiles as your only copy. Keep your own copies of ' + 'anything important.\n\n' + '3. You are solely responsible for the files you upload and must have ' + 'the right to store them.\n\n' + '4. Any integration with the FULA token is for testing and ' + 'demonstration purposes only.'; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Row( + children: [ + Icon(Icons.warning_amber_rounded, color: Colors.orange), + SizedBox(width: 8), + Text('Important Notice'), + ], + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'By proceeding, you acknowledge and agree to the following:', + style: TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 12), + const Text(_uploadTerms), + const SizedBox(height: 16), + CheckboxListTile( + value: _accepted, + onChanged: (value) => setState(() => _accepted = value ?? false), + title: const Text( + kBetaUploadAcknowledgement, + style: TextStyle(fontSize: 14), + ), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _accepted ? () => Navigator.of(context).pop(true) : null, + child: const Text('Agree'), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/ipfs_public_disclaimer_dialog.dart b/lib/shared/widgets/ipfs_public_disclaimer_dialog.dart index c8f6d4c..232f689 100644 --- a/lib/shared/widgets/ipfs_public_disclaimer_dialog.dart +++ b/lib/shared/widgets/ipfs_public_disclaimer_dialog.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:fula_files/shared/legal/terms_content.dart'; + /// What is about to be published without encryption. enum PublicDisclaimerVariant { website, social } /// Checkbox-gated warning that assets and the generated result will be /// PUBLIC on IPFS. Shown before every website generation (native parity — /// no "don't ask again") and before every social-post generation. -/// Returns true only when the user ticks the box and agrees. +/// Returns true only when the user ticks BOTH boxes — the terms above and +/// the beta acknowledgement — and agrees. /// /// Moved here from lib/features/websites/widgets/legal_disclaimer_dialog.dart /// (which remains as a re-export shim) so the web screens can share it — @@ -33,6 +36,11 @@ Future showIpfsPublicDisclaimerDialog( /// `Future` contract intact for the call sites that don't need /// this. ValueNotifier? directoryOptIn, + + /// The required beta acknowledgement. Generation by default; a caller that + /// is publishing a user's own file rather than generated content passes + /// [kBetaUploadAcknowledgement]. + String betaAcknowledgement = kBetaGenerateAcknowledgement, }) { return showDialog( context: context, @@ -41,6 +49,7 @@ Future showIpfsPublicDisclaimerDialog( variant: variant, footnote: footnote, directoryOptIn: directoryOptIn, + betaAcknowledgement: betaAcknowledgement, ), ); } @@ -49,10 +58,12 @@ class _IpfsPublicDisclaimerDialog extends StatefulWidget { final PublicDisclaimerVariant variant; final String? footnote; final ValueNotifier? directoryOptIn; + final String betaAcknowledgement; const _IpfsPublicDisclaimerDialog({ required this.variant, this.footnote, this.directoryOptIn, + required this.betaAcknowledgement, }); @override @@ -63,6 +74,7 @@ class _IpfsPublicDisclaimerDialog extends StatefulWidget { class _IpfsPublicDisclaimerDialogState extends State<_IpfsPublicDisclaimerDialog> { bool _accepted = false; + bool _betaAccepted = false; static const String _websiteTerms = '1. Files will be uploaded WITHOUT encryption to IPFS, ' @@ -158,6 +170,19 @@ class _IpfsPublicDisclaimerDialogState controlAffinity: ListTileControlAffinity.leading, contentPadding: EdgeInsets.zero, ), + // Separate and required: acknowledging the beta status is its own + // affirmative act, never folded into, or implied by, the box above. + CheckboxListTile( + value: _betaAccepted, + onChanged: (value) => + setState(() => _betaAccepted = value ?? false), + title: Text( + widget.betaAcknowledgement, + style: const TextStyle(fontSize: 14), + ), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), ], ), ), @@ -167,8 +192,9 @@ class _IpfsPublicDisclaimerDialogState child: const Text('Cancel'), ), FilledButton( - onPressed: - _accepted ? () => Navigator.of(context).pop(true) : null, + onPressed: _accepted && _betaAccepted + ? () => Navigator.of(context).pop(true) + : null, child: const Text('Agree'), ), ], diff --git a/lib/shared/widgets/terms_of_service_view.dart b/lib/shared/widgets/terms_of_service_view.dart new file mode 100644 index 0000000..5c273f3 --- /dev/null +++ b/lib/shared/widgets/terms_of_service_view.dart @@ -0,0 +1,235 @@ +import 'package:flutter/material.dart'; + +import 'package:fula_files/shared/legal/terms_content.dart'; + +/// The Terms text itself: every section of [kTermsSections] plus the +/// last-updated line. Shared by the native first-run gate, the web gate and +/// the read-only screens both settings pages link to. +class TermsOfServiceBody extends StatelessWidget { + const TermsOfServiceBody({super.key, this.controller}); + + final ScrollController? controller; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SingleChildScrollView( + controller: controller, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final section in kTermsSections) + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + section.title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text(section.body, style: theme.textTheme.bodyMedium), + ], + ), + ), + const SizedBox(height: 16), + Text( + 'Last updated: $kTermsLastUpdated', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 32), + ], + ), + ); + } +} + +/// Full-screen Terms gate: the Accept button unlocks once the reader has +/// reached the end of the text. [onAccept] records the acceptance. +class TermsAcceptanceScreen extends StatefulWidget { + const TermsAcceptanceScreen({ + super.key, + required this.onAccept, + this.isUpdate = false, + }); + + final Future Function() onAccept; + + /// True when the user accepted an earlier version: the heading says the + /// Terms changed rather than asking as if for the first time. + final bool isUpdate; + + @override + State createState() => _TermsAcceptanceScreenState(); +} + +class _TermsAcceptanceScreenState extends State { + final ScrollController _scrollController = ScrollController(); + bool _hasScrolledToBottom = false; + bool _saving = false; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_checkScrolledToBottom); + // A window tall enough to show everything never scrolls, so the listener + // would never fire and Accept would stay disabled for good. + WidgetsBinding.instance.addPostFrameCallback((_) => _checkScrolledToBottom()); + } + + @override + void dispose() { + _scrollController.removeListener(_checkScrolledToBottom); + _scrollController.dispose(); + super.dispose(); + } + + void _checkScrolledToBottom() { + if (_hasScrolledToBottom || !_scrollController.hasClients) return; + final position = _scrollController.position; + if (position.pixels >= position.maxScrollExtent - 50) { + setState(() => _hasScrolledToBottom = true); + } + } + + Future _accept() async { + setState(() => _saving = true); + try { + await widget.onAccept(); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 820), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + children: [ + Icon( + Icons.description_outlined, + size: 48, + color: theme.colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + widget.isUpdate + ? 'Updated Terms of Service' + : 'Terms of Service', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + widget.isUpdate + ? 'Our terms have changed. Please read and accept them to continue.' + : 'Please read and accept our terms to continue', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Expanded( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(12), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: TermsOfServiceBody(controller: _scrollController), + ), + ), + ), + if (!_hasScrolledToBottom) + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.arrow_downward, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + 'Scroll to read all terms', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _hasScrolledToBottom && !_saving + ? _accept + : null, + child: const Text('I Accept the Terms of Service'), + ), + ), + const SizedBox(height: 8), + Text( + 'By clicking "Accept", you acknowledge that you have read, understood, and agree to be bound by these terms.', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Read-only Terms, opened from the settings screens. +class TermsOfServicePage extends StatelessWidget { + const TermsOfServicePage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Terms of Service')), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 820), + child: const TermsOfServiceBody(), + ), + ), + ); + } +} diff --git a/lib/web/app_web.dart b/lib/web/app_web.dart index 4120cba..50c97a4 100644 --- a/lib/web/app_web.dart +++ b/lib/web/app_web.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:fula_files/app/theme/app_theme.dart'; +import 'package:fula_files/shared/widgets/terms_of_service_view.dart'; import 'package:fula_files/web/router_web.dart'; +import 'package:fula_files/web/services/web_terms_store.dart'; import 'package:fula_files/web/widgets/web_mini_audio_player.dart'; import 'package:fula_files/web/widgets/web_upload_tray.dart'; @@ -16,6 +18,13 @@ class FxFilesWebApp extends StatefulWidget { class _FxFilesWebAppState extends State { late final router = buildWebRouter(); + /// Same gate as the native app: nothing past the Terms until the CURRENT + /// version is accepted on this browser. Read once, synchronously, so the + /// first frame is already the right one. + late bool _termsRequired = WebTermsStore.instance.acceptanceRequired; + late final bool _termsAcceptedBefore = + WebTermsStore.instance.acceptedVersion() != null; + @override Widget build(BuildContext context) { return MaterialApp.router( @@ -30,6 +39,15 @@ class _FxFilesWebAppState extends State { // (and, being a thin strip at the very bottom, only minimally over a // centred dialog); it renders nothing when no upload is queued. builder: (context, child) { + if (_termsRequired) { + return TermsAcceptanceScreen( + isUpdate: _termsAcceptedBefore, + onAccept: () async { + WebTermsStore.instance.recordAcceptance(); + setState(() => _termsRequired = false); + }, + ); + } return Stack( children: [ if (child != null) Positioned.fill(child: child), diff --git a/lib/web/screens/web_automate_task_detail_screen.dart b/lib/web/screens/web_automate_task_detail_screen.dart index 82f07b3..98aca90 100644 --- a/lib/web/screens/web_automate_task_detail_screen.dart +++ b/lib/web/screens/web_automate_task_detail_screen.dart @@ -15,6 +15,7 @@ import 'package:fula_files/core/services/tabular_parser.dart'; import 'package:fula_files/core/utils/target_uri_builder.dart'; import 'package:fula_files/core/utils/template_renderer.dart'; import 'package:fula_files/features/automate/widgets/placeholder_chip_bar.dart'; +import 'package:fula_files/shared/widgets/beta_upload_dialog.dart'; import 'package:fula_files/shared/widgets/legal_disclaimer_dialog.dart'; import 'package:fula_files/web/services/web_automate_csv_store.dart'; import 'package:fula_files/web/services/web_tag_service.dart'; @@ -790,7 +791,8 @@ class _WebAutomateTaskDetailScreenState } if (!mounted) return; final ipfsOk = await _confirmIpfsUpload(); - if (ipfsOk != true) return; + if (ipfsOk != true || !mounted) return; + if (!await showBetaUploadDialog(context)) return; setState(() => _uploadStatus = 'Uploading attachment to IPFS…'); final result = await IpfsPublicService.instance.pinBytes( attachment.bytes, diff --git a/lib/web/screens/web_bucket_screen.dart b/lib/web/screens/web_bucket_screen.dart index 62d6960..f064ba6 100644 --- a/lib/web/screens/web_bucket_screen.dart +++ b/lib/web/screens/web_bucket_screen.dart @@ -22,6 +22,7 @@ import 'package:fula_files/web/services/web_foreground_activity.dart'; import 'package:fula_files/web/services/web_listing_cache.dart'; import 'package:fula_files/web/services/web_listing_swr.dart'; import 'package:fula_files/web/services/web_share_service.dart'; +import 'package:fula_files/shared/widgets/beta_upload_dialog.dart'; import 'package:fula_files/web/services/web_streaming_file.dart'; import 'package:fula_files/web/services/web_tag_service.dart'; import 'package:fula_files/web/services/web_upload_manager.dart'; @@ -334,7 +335,9 @@ class _WebBucketScreenState extends State { // there, and on completion the manager refreshes this tab's cache + pings // us via onBucketCompleted (own-write path). final picked = await pickFilesForUpload(accept: _pickerAccept); - if (picked.isEmpty) return; + if (picked.isEmpty || !mounted) return; + // After the picker, never before it: it must open inside the tap. + if (!await showBetaUploadDialog(context) || !mounted) return; WebUploadManager.instance.enqueue( base: widget.base, bucket: BucketVersionResolver.writeBucket(widget.base), @@ -492,6 +495,7 @@ class _WebBucketScreenState extends State { ), ); if (confirmed != true || !mounted) return; + if (!await showBetaUploadDialog(context) || !mounted) return; // The whole file is held in memory here, and `pinBytes` layers a // multipart copy on top — the same reason the Cloud Files screen caps // rename/move. Without this a big file OOM-kills a low-RAM phone tab, diff --git a/lib/web/screens/web_cloud_files_screen.dart b/lib/web/screens/web_cloud_files_screen.dart index e65a11e..aeb8478 100644 --- a/lib/web/screens/web_cloud_files_screen.dart +++ b/lib/web/screens/web_cloud_files_screen.dart @@ -16,6 +16,7 @@ import 'package:fula_files/web/services/web_device_class.dart'; import 'package:fula_files/web/services/web_file_view_mode.dart'; import 'package:fula_files/web/services/web_foreground_activity.dart'; import 'package:fula_files/web/services/web_save.dart'; +import 'package:fula_files/shared/widgets/beta_upload_dialog.dart'; import 'package:fula_files/web/services/web_streaming_file.dart'; import 'package:fula_files/web/services/web_tag_service.dart'; import 'package:fula_files/web/services/web_text_viewer_logic.dart'; @@ -246,6 +247,8 @@ class _WebCloudFilesScreenState extends State { // user gesture (iOS Safari), then resolves with the picked files. final picked = await pickFilesForUpload(); if (picked.isEmpty || !mounted) return; + // After the picker, never before it: it must open inside the tap. + if (!await showBetaUploadDialog(context) || !mounted) return; WebUploadManager.instance.enqueue( base: bucket, bucket: bucket, @@ -581,6 +584,7 @@ class _WebCloudFilesScreenState extends State { confirmLabel: 'Share publicly')) { return; } + if (!mounted || !await showBetaUploadDialog(context)) return; if (!_guardCopySize(o, 'shared publicly')) return; final bucket = o.sourceBucket ?? _bucket!; _snack('Uploading "${o.name}" to IPFS…'); diff --git a/lib/web/screens/web_collab_detail_screen.dart b/lib/web/screens/web_collab_detail_screen.dart index d81046d..fd96c1b 100644 --- a/lib/web/screens/web_collab_detail_screen.dart +++ b/lib/web/screens/web_collab_detail_screen.dart @@ -9,6 +9,7 @@ import 'package:fula_files/core/models/collaboration_group.dart'; import 'package:fula_files/core/services/collaboration_service.dart'; import 'package:fula_files/features/sharing/utils/collab_folder_tree.dart'; import 'package:fula_files/features/sharing/widgets/share_with_ai_dialog.dart'; +import 'package:fula_files/shared/widgets/beta_upload_dialog.dart'; import 'package:fula_files/web/services/web_foreground_activity.dart'; import 'package:fula_files/web/services/web_save.dart'; @@ -107,6 +108,7 @@ class _WebCollabDetailScreenState extends State { final file = picked?.files.firstOrNull; final bytes = file?.bytes; if (file == null || bytes == null || !mounted) return; + if (!await showBetaUploadDialog(context) || !mounted) return; // Encryption copies the buffer, so a big file spikes tab memory at // 2×+ its size on the main thread. Confirm before committing. diff --git a/lib/web/screens/web_nft_detail_screen.dart b/lib/web/screens/web_nft_detail_screen.dart index a5037ae..49f3984 100644 --- a/lib/web/screens/web_nft_detail_screen.dart +++ b/lib/web/screens/web_nft_detail_screen.dart @@ -10,6 +10,7 @@ import 'package:fula_files/app/theme/app_colors.dart'; import 'package:fula_files/core/models/billing/supported_chain.dart'; import 'package:fula_files/core/models/nft_token.dart'; import 'package:fula_files/core/services/wallet_service.dart'; +import 'package:fula_files/shared/widgets/beta_upload_dialog.dart'; import 'package:fula_files/web/services/web_nft_gas_logic.dart'; import 'package:fula_files/web/services/web_nft_service.dart'; import 'package:fula_files/web/services/web_nft_status_logic.dart'; @@ -158,6 +159,9 @@ class _WebNftDetailScreenState extends State { return; } + // Minting uploads the image and its metadata. + if (!await showBetaUploadDialog(context) || !mounted) return; + // Config BEFORE the wallet (native ordering) so a wallet modal isn't // sitting open while the user fills in the fields. final config = await _showMintConfigDialog(name); diff --git a/lib/web/screens/web_settings_screen.dart b/lib/web/screens/web_settings_screen.dart index 8b3a8d5..6ef99fb 100644 --- a/lib/web/screens/web_settings_screen.dart +++ b/lib/web/screens/web_settings_screen.dart @@ -14,6 +14,8 @@ import 'package:fula_files/core/services/ipfs_gateway_helper.dart'; import 'package:fula_files/core/services/nft_wallet_service.dart'; import 'package:fula_files/core/services/secure_storage_service.dart'; import 'package:fula_files/core/services/share_link_builder.dart'; +import 'package:fula_files/shared/legal/terms_content.dart'; +import 'package:fula_files/shared/widgets/terms_of_service_view.dart'; import 'package:fula_files/web/services/web_session.dart'; /// App version label shown in About + the home footer. Kept in one place @@ -715,6 +717,15 @@ class _WebSettingsScreenState extends State { title: const Text('FxFiles'), subtitle: Text(kWebAppVersion), ), + ListTile( + leading: const Icon(Icons.description_outlined), + title: const Text('Terms of Service'), + subtitle: const Text('Last updated: $kTermsLastUpdated'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const TermsOfServicePage()), + ), + ), ], ); } diff --git a/lib/web/screens/web_shelf_screen.dart b/lib/web/screens/web_shelf_screen.dart index 0b3b909..0e117aa 100644 --- a/lib/web/screens/web_shelf_screen.dart +++ b/lib/web/screens/web_shelf_screen.dart @@ -18,6 +18,7 @@ import 'package:fula_files/web/widgets/media_preview_dialog.dart'; import 'package:fula_files/web/widgets/web_tag_dialogs.dart'; import 'package:fula_files/features/shelf/widgets/shelf_ask_ai_sheet.dart'; import 'package:fula_files/shared/utils/adaptive_ui.dart'; +import 'package:fula_files/shared/widgets/beta_upload_dialog.dart'; import 'package:lucide_icons/lucide_icons.dart'; /// Mirror of lib/features/shelf/screens/shelf_screen.dart (view-only): @@ -290,6 +291,8 @@ class _WebShelfScreenState extends State { _snack('Enter a valid http(s) link.'); return; } + // Saved to cloud storage like any other Shelf item. + if (!mounted || !await showBetaUploadDialog(context) || !mounted) return; await _runAdd(() => WebShelfService.instance.addLink(clean), 'link'); } @@ -326,6 +329,8 @@ class _WebShelfScreenState extends State { ); controller.dispose(); if (text == null || text.trim().isEmpty) return; + // Saved to cloud storage like any other Shelf item. + if (!mounted || !await showBetaUploadDialog(context) || !mounted) return; await _runAdd(() => WebShelfService.instance.addNote(text), 'note'); } @@ -338,6 +343,7 @@ class _WebShelfScreenState extends State { _snack('Could not read that file.'); return; } + if (!mounted || !await showBetaUploadDialog(context) || !mounted) return; await _runAdd( () => WebShelfService.instance.addBytes(bytes: data, name: f.name), 'file', @@ -354,6 +360,8 @@ class _WebShelfScreenState extends State { } final c = cap; if (c == null) return; + // After the capture: the camera input must open inside the tap. + if (!mounted || !await showBetaUploadDialog(context) || !mounted) return; await _runAdd( () => WebShelfService.instance .addBytes(bytes: c.bytes, name: c.name, mime: c.mime), @@ -498,6 +506,7 @@ class _WebShelfScreenState extends State { builder: (ctx) => const _RecordAudioDialog(), ); if (cap == null) return; + if (!mounted || !await showBetaUploadDialog(context) || !mounted) return; await _runAdd( () => WebShelfService.instance .addBytes(bytes: cap.bytes, name: cap.name, mime: cap.mime), diff --git a/lib/web/screens/web_signin_screen.dart b/lib/web/screens/web_signin_screen.dart index 00ee538..b7e6980 100644 --- a/lib/web/screens/web_signin_screen.dart +++ b/lib/web/screens/web_signin_screen.dart @@ -4,6 +4,7 @@ import 'package:google_sign_in_web/web_only.dart' as gsi_web; import 'package:sign_in_with_apple/sign_in_with_apple.dart'; import 'package:fula_files/core/services/issuer_client.dart'; +import 'package:fula_files/shared/widgets/terms_of_service_view.dart'; import 'package:fula_files/web/services/web_session.dart'; /// Web sign-in mirroring the native onboarding flow @@ -473,6 +474,15 @@ class _WebSignInScreenState extends State { 'I agree to the FxFiles Terms of Service.', style: TextStyle(fontSize: 13), ), + // Agreeing to terms one cannot open is not informed consent. + secondary: TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const TermsOfServicePage(), + ), + ), + child: const Text('Read'), + ), ), FilledButton( onPressed: (_storedConfirmed && _tosAccepted && !_working) diff --git a/lib/web/screens/web_website_detail_screen.dart b/lib/web/screens/web_website_detail_screen.dart index 433dead..dbf6509 100644 --- a/lib/web/screens/web_website_detail_screen.dart +++ b/lib/web/screens/web_website_detail_screen.dart @@ -14,6 +14,7 @@ import 'package:fula_files/core/models/website_generation.dart'; import 'package:fula_files/core/models/website_group_pointer.dart'; import 'package:fula_files/core/services/ipfs_gateway_helper.dart'; import 'package:fula_files/core/services/website_prompt_builder.dart'; +import 'package:fula_files/shared/widgets/beta_upload_dialog.dart'; import 'package:fula_files/shared/widgets/ipfs_public_disclaimer_dialog.dart'; import 'package:fula_files/shared/widgets/step_row.dart'; import 'package:fula_files/web/screens/web_generate_website_screen.dart'; @@ -371,6 +372,10 @@ class _WebWebsiteDetailScreenState extends State { Future _importAssets() async { final files = await pickFilesForUpload(); if (files.isEmpty || !mounted) return; + // These upload the moment they are picked, well before the generation + // notice — so they get the upload notice. After the picker: it must open + // inside the tap. + if (!await showBetaUploadDialog(context) || !mounted) return; final r = WebWebsiteAssetUploader.instance.enqueue( tagId: widget.tagId, websiteName: _displayName, diff --git a/lib/web/services/web_terms_store.dart b/lib/web/services/web_terms_store.dart new file mode 100644 index 0000000..a091569 --- /dev/null +++ b/lib/web/services/web_terms_store.dart @@ -0,0 +1,46 @@ +import 'package:web/web.dart' as web; + +import 'package:fula_files/shared/legal/terms_content.dart'; + +/// localStorage keys for the web shell's Terms acceptance (the stored version +/// is parsed by [parseAcceptedTermsVersion]). Kept apart from the +/// session keys sign-out wipes: accepting the Terms belongs to the visitor on +/// this browser, not to one signed-in account. +const String webTermsVersionKey = 'fx_terms_accepted_version'; +const String webTermsAcceptedAtKey = 'fx_terms_accepted_at'; + +/// Persistence for web Terms acceptance. SYNCHRONOUS localStorage, like the +/// view-mode store, so the gate decides on the very first frame, and fail-soft: +/// Safari private mode and blocked storage throw on access — the visitor is +/// then asked again next visit rather than locked out now. +class WebTermsStore { + WebTermsStore._(); + static final WebTermsStore instance = WebTermsStore._(); + + /// Version recorded as accepted, or null. + int? acceptedVersion() { + try { + return parseAcceptedTermsVersion( + web.window.localStorage.getItem(webTermsVersionKey)); + } catch (_) { + return null; + } + } + + bool get acceptanceRequired => termsAcceptanceRequired( + acceptedVersion: acceptedVersion(), + legacyAccepted: false, + ); + + /// Record acceptance of the CURRENT Terms, with when (UTC). + void recordAcceptance() { + try { + web.window.localStorage + .setItem(webTermsVersionKey, kTermsVersion.toString()); + web.window.localStorage.setItem( + webTermsAcceptedAtKey, DateTime.now().toUtc().toIso8601String()); + } catch (_) { + // Storage denied — acceptance still holds for this visit. + } + } +} diff --git a/lib/web/widgets/web_recent_files_section.dart b/lib/web/widgets/web_recent_files_section.dart index aa1282b..f93bb38 100644 --- a/lib/web/widgets/web_recent_files_section.dart +++ b/lib/web/widgets/web_recent_files_section.dart @@ -8,6 +8,7 @@ import 'package:web/web.dart' as web; import 'package:fula_files/core/services/bucket_version_resolver.dart'; import 'package:fula_files/core/utils/file_type_utils.dart'; +import 'package:fula_files/shared/widgets/beta_upload_dialog.dart'; import 'package:fula_files/web/services/web_recent_files_service.dart'; import 'package:fula_files/web/services/web_session.dart'; import 'package:fula_files/web/services/web_streaming_file.dart'; @@ -80,7 +81,16 @@ class _WebRecentFilesSectionState extends State { Future _pickAndUpload() async { // No accept filter — this tile takes ANY file and routes it by type. final picked = await pickFilesForUpload(); - _enqueueByCategory(picked); + await _confirmAndEnqueue(picked); + } + + /// The beta notice comes AFTER the files are in hand: the picker must open + /// inside the tap (iOS Safari), and a drop's files are read synchronously + /// in the event, then stay valid. + Future _confirmAndEnqueue(List files) async { + if (files.isEmpty || !mounted) return; + if (!await showBetaUploadDialog(context) || !mounted) return; + _enqueueByCategory(files); } /// Group files by their auto-detected category and enqueue one batch per @@ -127,7 +137,7 @@ class _WebRecentFilesSectionState extends State { final isOver = _pointerOverAddTile(de.clientX.toDouble(), de.clientY.toDouble()); if (_dragOverAddTile) setState(() => _dragOverAddTile = false); - if (isOver) _enqueueByCategory(filesFromDataTransfer(de.dataTransfer)); + if (isOver) _confirmAndEnqueue(filesFromDataTransfer(de.dataTransfer)); }).toJS; final leave = ((web.Event e) { if (!mounted) return; diff --git a/test/unit/shared/legal/terms_content_test.dart b/test/unit/shared/legal/terms_content_test.dart new file mode 100644 index 0000000..1c85a0b --- /dev/null +++ b/test/unit/shared/legal/terms_content_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fula_files/shared/legal/terms_content.dart'; + +void main() { + group('Terms content', () { + test('carries the beta and FULA token section', () { + final beta = kTermsSections.firstWhere( + (s) => s.title.contains('Beta Software and FULA Token'), + ); + expect(beta.body, contains('beta software under active testing')); + expect(beta.body, contains('Do not rely on FxFiles as your only copy')); + expect(beta.body, + contains('for testing and demonstration purposes only')); + expect(beta.body, contains('not an investment or financial product')); + expect(beta.body, contains('Limitation of Liability')); + }); + + test('numbered sections run 1..N without gaps or repeats', () { + final numbers = kTermsSections + .map((s) => RegExp(r'^(\d+)\. ').firstMatch(s.title)?.group(1)) + .whereType() + .map(int.parse) + .toList(); + expect(numbers, List.generate(numbers.length, (i) => i + 1)); + }); + + // The existing clauses were moved, not rewritten — spot-check the ones + // that carry the most weight. + test('keeps the existing clauses verbatim', () { + final all = kTermsSections.map((s) => s.body).join('\n'); + expect(all, contains('IMPORTANT: No copies of your private encryption keys are stored.')); + expect(all, contains('THE APP IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND')); + expect(all, contains('TO THE MAXIMUM EXTENT PERMITTED BY LAW')); + expect(all, contains('You must NOT transfer tokens')); + expect(all, contains('These Terms take effect on September 9, 2026.')); + }); + + test('the acknowledgements say what the owner approved', () { + expect(kBetaGenerateAcknowledgement, + 'I understand this app is in beta testing, generated content may not be ' + 'reliable or accessible, and integration with Fula token is for testing ' + 'and demo use.'); + expect(kBetaUploadAcknowledgement, contains('uploaded files may not be reliably stored')); + expect(kBetaUploadAcknowledgement, contains('keep my own copies')); + expect(kBetaUploadAcknowledgement, + contains('integration with Fula token is for testing and demo use')); + }); + }); + + group('termsAcceptanceRequired', () { + test('nothing recorded -> required', () { + expect(termsAcceptanceRequired(acceptedVersion: null, legacyAccepted: false), isTrue); + }); + + // Every existing native user holds only the pre-versioning boolean: they + // accepted version 1 and must see the new section. + test('legacy acceptance counts as version 1 and is not enough now', () { + expect(kTermsVersion, greaterThan(1)); + expect(termsAcceptanceRequired(acceptedVersion: null, legacyAccepted: true), isTrue); + }); + + test('the current version satisfies it', () { + expect(termsAcceptanceRequired(acceptedVersion: kTermsVersion, legacyAccepted: false), isFalse); + expect(termsAcceptanceRequired(acceptedVersion: kTermsVersion, legacyAccepted: true), isFalse); + }); + + test('an older recorded version does not', () { + expect(termsAcceptanceRequired(acceptedVersion: kTermsVersion - 1, legacyAccepted: true), isTrue); + }); + + test('a newer recorded version (a later build) is accepted', () { + expect(termsAcceptanceRequired(acceptedVersion: kTermsVersion + 1, legacyAccepted: false), isFalse); + }); + }); + + group('parseAcceptedTermsVersion', () { + test('reads stored versions and rejects garbage', () { + expect(parseAcceptedTermsVersion('2'), 2); + expect(parseAcceptedTermsVersion(' 3 '), 3); + expect(parseAcceptedTermsVersion(null), isNull); + expect(parseAcceptedTermsVersion(''), isNull); + expect(parseAcceptedTermsVersion('true'), isNull); + expect(parseAcceptedTermsVersion('0'), isNull); + expect(parseAcceptedTermsVersion('-1'), isNull); + }); + }); +} diff --git a/test/widget/shared/consent_dialogs_test.dart b/test/widget/shared/consent_dialogs_test.dart new file mode 100644 index 0000000..297c8f2 --- /dev/null +++ b/test/widget/shared/consent_dialogs_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fula_files/shared/legal/terms_content.dart'; +import 'package:fula_files/shared/widgets/beta_upload_dialog.dart'; +import 'package:fula_files/shared/widgets/ipfs_public_disclaimer_dialog.dart'; + +/// Pumps a button that opens [open] and records what the dialog returned. +Future> _host( + WidgetTester tester, + Future Function(BuildContext) open, +) async { + final results = []; + await tester.pumpWidget(MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () async => results.add(await open(context)), + child: const Text('open'), + ), + ), + ), + ), + )); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + return results; +} + +FilledButton _agree(WidgetTester tester) => + tester.widget(find.widgetWithText(FilledButton, 'Agree')); + +/// Tick a box the way a user would: the dialog scrolls on a small window, so +/// bring the box into view first. +Future _tick(WidgetTester tester, String label) async { + await tester.ensureVisible(find.text(label)); + await tester.pumpAndSettle(); + await tester.tap(find.text(label)); + await tester.pump(); +} + +void main() { + group('beta upload dialog', () { + testWidgets('Agree stays disabled until the acknowledgement is ticked', + (tester) async { + final results = await _host(tester, (c) => showBetaUploadDialog(c)); + + expect(find.text(kBetaUploadAcknowledgement), findsOneWidget); + expect(_agree(tester).onPressed, isNull); + + await _tick(tester, kBetaUploadAcknowledgement); + expect(_agree(tester).onPressed, isNotNull); + + await tester.tap(find.text('Agree')); + await tester.pumpAndSettle(); + expect(results, [true]); + }); + + testWidgets('Cancel means no upload', (tester) async { + final results = await _host(tester, (c) => showBetaUploadDialog(c)); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(results, [false]); + }); + + testWidgets('the barrier does not dismiss it', (tester) async { + final results = await _host(tester, (c) => showBetaUploadDialog(c)); + await tester.tapAt(const Offset(5, 5)); + await tester.pumpAndSettle(); + expect(find.text(kBetaUploadAcknowledgement), findsOneWidget); + expect(results, isEmpty); + }); + }); + + group('generation disclaimer', () { + testWidgets('needs BOTH the terms box and the beta box', (tester) async { + final results = + await _host(tester, (c) => showIpfsPublicDisclaimerDialog(c)); + + expect(find.text(kBetaGenerateAcknowledgement), findsOneWidget); + expect(_agree(tester).onPressed, isNull); + + await _tick(tester, 'I understand and accept these terms'); + expect(_agree(tester).onPressed, isNull, + reason: 'the terms box alone must not be enough'); + + await _tick(tester, kBetaGenerateAcknowledgement); + expect(_agree(tester).onPressed, isNotNull); + + await tester.tap(find.text('Agree')); + await tester.pumpAndSettle(); + expect(results, [true]); + }); + + testWidgets('the beta box alone is not enough either', (tester) async { + await _host(tester, (c) => showIpfsPublicDisclaimerDialog(c)); + await _tick(tester, kBetaGenerateAcknowledgement); + expect(_agree(tester).onPressed, isNull); + }); + + testWidgets('a caller publishing a user file shows the upload wording', + (tester) async { + await _host( + tester, + (c) => showIpfsPublicDisclaimerDialog( + c, + betaAcknowledgement: kBetaUploadAcknowledgement, + ), + ); + expect(find.text(kBetaUploadAcknowledgement), findsOneWidget); + expect(find.text(kBetaGenerateAcknowledgement), findsNothing); + }); + + testWidgets('the social variant carries the beta box too', (tester) async { + await _host( + tester, + (c) => showIpfsPublicDisclaimerDialog( + c, + variant: PublicDisclaimerVariant.social, + ), + ); + expect(find.text(kBetaGenerateAcknowledgement), findsOneWidget); + }); + }); +} diff --git a/test/widget/shared/terms_of_service_view_test.dart b/test/widget/shared/terms_of_service_view_test.dart new file mode 100644 index 0000000..b15873f --- /dev/null +++ b/test/widget/shared/terms_of_service_view_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fula_files/shared/legal/terms_content.dart'; +import 'package:fula_files/shared/widgets/terms_of_service_view.dart'; + +FilledButton _accept(WidgetTester tester) => tester.widget( + find.widgetWithText(FilledButton, 'I Accept the Terms of Service')); + +void main() { + testWidgets('Accept unlocks only after reading to the end', (tester) async { + var accepted = 0; + await tester.pumpWidget(MaterialApp( + home: TermsAcceptanceScreen(onAccept: () async => accepted++), + )); + await tester.pump(); + + expect(find.text('Terms of Service'), findsOneWidget); + expect(_accept(tester).onPressed, isNull); + + await tester.dragUntilVisible( + find.text('Last updated: $kTermsLastUpdated'), + find.byType(SingleChildScrollView), + const Offset(0, -400), + ); + await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -4000)); + await tester.pumpAndSettle(); + expect(_accept(tester).onPressed, isNotNull); + + await tester.tap(find.text('I Accept the Terms of Service')); + await tester.pumpAndSettle(); + expect(accepted, 1); + }); + + testWidgets('shows the new beta section', (tester) async { + await tester.pumpWidget(const MaterialApp(home: TermsOfServicePage())); + await tester.dragUntilVisible( + find.text('2. Beta Software and FULA Token'), + find.byType(SingleChildScrollView), + const Offset(0, -200), + ); + expect(find.text('2. Beta Software and FULA Token'), findsOneWidget); + }); + + // A window tall enough to show everything never scrolls: the button must + // not stay disabled for good. + testWidgets('a window that shows everything unlocks without scrolling', + (tester) async { + tester.view.physicalSize = const Size(1600, 40000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget(MaterialApp( + home: TermsAcceptanceScreen(onAccept: () async {}), + )); + await tester.pumpAndSettle(); + expect(_accept(tester).onPressed, isNotNull); + }); + + testWidgets('an update is labelled as one', (tester) async { + await tester.pumpWidget(MaterialApp( + home: TermsAcceptanceScreen(isUpdate: true, onAccept: () async {}), + )); + expect(find.text('Updated Terms of Service'), findsOneWidget); + }); +}