diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..a86b896 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,36 @@ +# Release Notes - v1.0.0 + +## 🎉 What's New + +### Privacy Policy +- Added dedicated Privacy Policy page accessible from Settings +- Direct link support: `/privacy` route now works when accessed directly +- Web URL updates correctly when navigating to privacy page + +### UI Improvements +- Enhanced Settings screen with visual icons: + - 🎨 Palette icon for Theme selection + - 🌐 Globe icon for Language selection +- Improved visual consistency across settings options + +### Internationalization +- Complete language support with visual language indicators +- Data Management section fully translated +- Support for 9 languages: English, Spanish, French, German, Arabic, Hindi, Portuguese, Russian, and Chinese + +### Bug Fixes +- Fixed `/privacy` route navigation on web +- Fixed direct URL access to privacy page (no more 404 errors) +- Improved web deployment configuration for Vercel +- Fixed widget tests compatibility with localization + +### Technical Improvements +- Optimized Vercel deployment configuration for Flutter web +- Improved SPA routing support +- Better static file serving configuration + +--- + +**Version:** 1.0.0 +**Build Date:** $(date +%Y-%m-%d) + diff --git a/mobile/android/local.properties b/mobile/android/local.properties index 1e8726b..3f9e70f 100644 --- a/mobile/android/local.properties +++ b/mobile/android/local.properties @@ -1,5 +1,5 @@ sdk.dir=/Users/arsene/Library/Android/sdk flutter.sdk=/opt/homebrew/Caskroom/flutter/3.19.5/flutter -flutter.buildMode=debug -flutter.versionName=1.0.0 -flutter.versionCode=1 \ No newline at end of file +flutter.buildMode=release +flutter.versionName=1.0.2 +flutter.versionCode=4 \ No newline at end of file diff --git a/mobile/l10n.yaml b/mobile/l10n.yaml index 43fdb04..854868c 100644 --- a/mobile/l10n.yaml +++ b/mobile/l10n.yaml @@ -3,3 +3,4 @@ template-arb-file: app_en.arb output-localization-file: app_localizations.dart output-class: AppLocalizations use-escaping: true +untranslated-messages-file: untranslated_messages.txt diff --git a/mobile/lib/screens/home_screen.dart b/mobile/lib/screens/home_screen.dart index 625850e..5b8845d 100644 --- a/mobile/lib/screens/home_screen.dart +++ b/mobile/lib/screens/home_screen.dart @@ -30,6 +30,7 @@ class _HomeScreenState extends State { final Map _imageCache = {}; String? _lastHandledResultUrl; bool _hideSupportedPanel = false; + String _previousText = ''; @override void initState() { @@ -41,6 +42,7 @@ class _HomeScreenState extends State { context.read().checkServerStatus(); _loadUsageTipsFlag(); _loadHideSupportedPanelFlag(); + _checkAndShowFirstTimeMessage(); // Consume shared/pending URL from provider if any final pending = context.read().takePendingInputUrl(); if (pending != null && pending.isNotEmpty) { @@ -118,6 +120,23 @@ class _HomeScreenState extends State { }); } + Future _checkAndShowFirstTimeMessage() async { + final prefs = await SharedPreferences.getInstance(); + final hasSeen = prefs.getBool('first_time_message_shown') ?? false; + if (!hasSeen && mounted) { + // Wait a bit for the UI to settle + await Future.delayed(const Duration(milliseconds: 500)); + if (mounted) { + _showFirstTimeMessage(); + } + } + } + + Future _markFirstTimeMessageShown() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('first_time_message_shown', true); + } + Future _markHideSupportedPanel() async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool('hide_supported_panel', true); @@ -243,8 +262,8 @@ class _HomeScreenState extends State { const SizedBox(width: 16), ], ), - body: Consumer( - builder: (context, provider, child) { + body: Consumer2( + builder: (context, provider, settings, child) { // If a pending shared URL arrives while the screen is active, populate the input final pendingShared = provider.pendingInputUrl; if (pendingShared != null && @@ -259,7 +278,6 @@ class _HomeScreenState extends State { } // Auto-share and/or copy when result is received (if enabled) if (provider.result != null && !provider.isLoading) { - final settings = context.read(); final url = provider.result!.primary.url; final shouldHandle = (settings.autoShareOnSuccess || settings.autoCopyPrimary) && @@ -274,467 +292,512 @@ class _HomeScreenState extends State { } } - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Server Status Section - - // URL Input Section - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Consumer( - builder: (context, settings, child) { - return Column( + return Stack( + children: [ + Positioned( + left: 0, + right: 0, + top: 0, + height: 220, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.14), + Theme.of(context) + .colorScheme + .secondary + .withValues(alpha: 0.1), + Theme.of(context).colorScheme.surface, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + ), + ), + SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + physics: const BouncingScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 12), + _buildUrlInputCard(provider, settings), + const SizedBox(height: 16), + if (provider.error != null) _buildErrorCard(provider), + // Results Display + if (provider.result != null) ...[ + _buildPrimaryResult(provider.result!), + if ((provider.result!.primary.confidence) < 1.0) ...[ + const SizedBox(height: 8), + Card( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( children: [ - // Segmented Control Style Toggle - Container( - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - border: Border.all( + const Icon(Icons.info_outline, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + AppLocalizations.of(context)! + .notCertainReviewAlternatives, + style: TextStyle( + fontSize: 12, color: Theme.of(context) .colorScheme - .outline - .withValues(alpha: 0.2), + .onSurfaceVariant, ), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Local Option - GestureDetector( - onTap: () async { - if (settings.offlineMode) { - return; // Already local, no need to switch - } - await settings.setOfflineMode(true); - _updateOfflineMode(); - if (!context.mounted) return; - ScaffoldMessenger.of(context) - .showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context)! - .switchedToLocal), - duration: - const Duration(seconds: 2), - ), - ); - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: settings.offlineMode - ? Theme.of(context) - .colorScheme - .primary - : Colors.transparent, - borderRadius: - BorderRadius.circular(10), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.storage, - size: 16, - color: settings.offlineMode - ? Theme.of(context) - .colorScheme - .onPrimary - : Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - const SizedBox(width: 6), - Text( - AppLocalizations.of(context)! - .local, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: settings.offlineMode - ? Theme.of(context) - .colorScheme - .onPrimary - : Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ), - ], - ), - ), - ), - // Remote Option - GestureDetector( - onTap: () async { - if (!settings.offlineMode) { - return; // Already remote, no need to switch - } - await settings.setOfflineMode(false); - _updateOfflineMode(); - if (!context.mounted) return; - ScaffoldMessenger.of(context) - .showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context)! - .switchedToRemote), - duration: - const Duration(seconds: 2), - ), - ); - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: !settings.offlineMode - ? Theme.of(context) - .colorScheme - .primary - : Colors.transparent, - borderRadius: - BorderRadius.circular(10), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.cloud, - size: 16, - color: !settings.offlineMode - ? Theme.of(context) - .colorScheme - .onPrimary - : Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - const SizedBox(width: 6), - Text( - AppLocalizations.of(context)! - .remote, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: !settings.offlineMode - ? Theme.of(context) - .colorScheme - .onPrimary - : Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ), - ], - ), - ), - ), - ], - ), - ), - const SizedBox(height: 8), - // Info button - GestureDetector( - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => - const TutorialScreen(), - ), - ); - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - AppLocalizations.of(context)! - .processingMode, - style: TextStyle( - fontSize: 12, - color: Theme.of(context) - .colorScheme - .onSurface - .withValues(alpha: 0.6), - ), - ), - const SizedBox(width: 4), - Icon( - Icons.info_outline, - size: 16, - color: Theme.of(context) - .colorScheme - .primary, - ), - ], - ), - ), - ], - ); - }, - ), - const SizedBox(height: 16), - Text( - AppLocalizations.of(context)!.pasteLinkTitle, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 16), - TextField( - controller: _urlController, - focusNode: _urlFocusNode, - decoration: InputDecoration( - hintText: - AppLocalizations.of(context)!.inputHintHttp, - prefixIcon: const Icon(Icons.link), - border: const OutlineInputBorder(), - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - tooltip: - AppLocalizations.of(context)!.actionPaste, - icon: const Icon(Icons.paste), - onPressed: () async { - final data = - await Clipboard.getData('text/plain'); - final text = data?.text ?? ''; - if (text.isNotEmpty) { - if (!mounted) return; - - // Extract the first HTTP URL from the clipboard text - final String? extractedUrl = - UrlExtractor.extractFirstHttpUrl( - text); - if (extractedUrl != null) { - _urlController.text = extractedUrl; - } else { - // If no URL found, paste the original text - _urlController.text = text.trim(); - } - setState(() {}); - } - }, - ), - IconButton( - tooltip: - AppLocalizations.of(context)!.actionClear, - icon: const Icon(Icons.clear), - onPressed: () { - if (!mounted) return; - _urlController.clear(); - setState(() {}); - }, ), ], ), ), - keyboardType: TextInputType.url, - textInputAction: TextInputAction.go, - onChanged: (_) => setState(() {}), - onSubmitted: (_) => _cleanUrl(), - ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: provider.isLoading || - !_isValidHttpUrl(_urlController.text) - ? null - : _cleanUrl, - icon: provider.isLoading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon(Icons.cleaning_services), - label: Text(provider.isLoading - ? AppLocalizations.of(context)!.cleaning - : (_isValidHttpUrl(_urlController.text) - ? AppLocalizations.of(context)!.tabClean - : AppLocalizations.of(context)! - .enterValidUrl)), - ), ), ], - ), - ), - ), - - const SizedBox(height: 16), - - // Error Display - if (provider.error != null) - Card( - color: Theme.of(context).colorScheme.errorContainer, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - Row( - children: [ - Icon( - Icons.error_outline, - color: Theme.of( - context, - ).colorScheme.onErrorContainer, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - provider.error!, - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onErrorContainer, + const SizedBox(height: 16), + _buildAlternatives(provider.result!), + const SizedBox(height: 16), + ], + if (!_hideSupportedPanel) ...[ + const SizedBox(height: 12), + SupportedPlatformsPanel( + onHideForever: _markHideSupportedPanel, + onHideOnce: () => + setState(() => _hideSupportedPanel = true), + onRequestHideDialog: () async { + if (!mounted) return; + final choice = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(AppLocalizations.of(context)! + .hideSupportedTitle), + content: Text(AppLocalizations.of(context)! + .hideSupportedQuestion), + actions: [ + TextButton( + onPressed: () => + Navigator.of(context).pop('cancel'), + child: Text( + AppLocalizations.of(context)!.cancel), ), - ), - ), - IconButton( - onPressed: () => provider.clearError(), - icon: Icon( - Icons.close, - color: Theme.of( - context, - ).colorScheme.onErrorContainer, - ), - ), - ], - ), - // Show fallback option for remote processing failures - if (provider.error! - .contains('Remote processing failed')) - Padding( - padding: const EdgeInsets.only(top: 12), - child: Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: () => - _processLocally(provider), - icon: const Icon(Icons.storage), - label: Text(AppLocalizations.of(context)! - .processLocally), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context) - .colorScheme - .onErrorContainer, - foregroundColor: Theme.of(context) - .colorScheme - .errorContainer, - ), - ), + TextButton( + onPressed: () => + Navigator.of(context).pop('hide'), + child: Text( + AppLocalizations.of(context)!.hide), + ), + FilledButton( + onPressed: () => + Navigator.of(context).pop('forever'), + child: Text(AppLocalizations.of(context)! + .dontShowAgain), ), ], - ), - ), - ], + ); + }, + ); + if (choice == 'hide') { + if (!mounted) return; + setState(() => _hideSupportedPanel = true); + } else if (choice == 'forever') { + await _markHideSupportedPanel(); + } + }, + ), + ], + ], + ), + ), + ], + ); + }, + ), + ); + } + + + Widget _buildModeToggle(SettingsProvider settings) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.25), + ), + ), + child: Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () async { + if (settings.offlineMode) return; + await settings.setOfflineMode(true); + _updateOfflineMode(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text(AppLocalizations.of(context)!.switchedToLocal), + duration: const Duration(seconds: 2), + ), + ); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: settings.offlineMode + ? theme.colorScheme.primary + : Colors.transparent, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.storage_rounded, + size: 18, + color: settings.offlineMode + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + AppLocalizations.of(context)!.local, + style: TextStyle( + fontWeight: FontWeight.w600, + color: settings.offlineMode + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurfaceVariant, ), ), + ], + ), + ), + ), + ), + const SizedBox(width: 6), + Expanded( + child: GestureDetector( + onTap: () async { + if (!settings.offlineMode) return; + await settings.setOfflineMode(false); + _updateOfflineMode(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text(AppLocalizations.of(context)!.switchedToRemote), + duration: const Duration(seconds: 2), ), - - // Results Display - if (provider.result != null) ...[ - _buildPrimaryResult(provider.result!), - if ((provider.result!.primary.confidence) < 1.0) ...[ - const SizedBox(height: 8), - Card( - color: - Theme.of(context).colorScheme.surfaceContainerHighest, - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - const Icon(Icons.info_outline, size: 18), - const SizedBox(width: 8), - Expanded( - child: Text( - AppLocalizations.of(context)! - .notCertainReviewAlternatives, - style: TextStyle( - fontSize: 12, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ), - ), - ], - ), + ); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: !settings.offlineMode + ? theme.colorScheme.primary + : Colors.transparent, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.cloud_outlined, + size: 18, + color: !settings.offlineMode + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + AppLocalizations.of(context)!.remote, + style: TextStyle( + fontWeight: FontWeight.w600, + color: !settings.offlineMode + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurfaceVariant, ), ), ], - const SizedBox(height: 16), - _buildAlternatives(provider.result!), - const SizedBox(height: 16), - ], - if (!_hideSupportedPanel) ...[ - const SizedBox(height: 12), - SupportedPlatformsPanel( - onHideForever: _markHideSupportedPanel, - onHideOnce: () => - setState(() => _hideSupportedPanel = true), - onRequestHideDialog: () async { - if (!mounted) return; - final choice = await showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text(AppLocalizations.of(context)! - .hideSupportedTitle), - content: Text(AppLocalizations.of(context)! - .hideSupportedQuestion), - actions: [ - TextButton( - onPressed: () => - Navigator.of(context).pop('cancel'), - child: - Text(AppLocalizations.of(context)!.cancel), - ), - TextButton( - onPressed: () => - Navigator.of(context).pop('hide'), - child: Text(AppLocalizations.of(context)!.hide), - ), - FilledButton( - onPressed: () => - Navigator.of(context).pop('forever'), - child: Text(AppLocalizations.of(context)! - .dontShowAgain), - ), - ], - ); - }, - ); - if (choice == 'hide') { + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildUrlInputCard( + UrlCleanerProvider provider, SettingsProvider settings) { + final theme = Theme.of(context); + return Container( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.18), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 16, + offset: const Offset(0, 8), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.link, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Text( + AppLocalizations.of(context)!.pasteLinkTitle, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + tooltip: AppLocalizations.of(context)!.processingMode, + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const TutorialScreen(), + ), + ); + }, + icon: Icon(Icons.info_outline, + color: theme.colorScheme.onSurfaceVariant), + ), + ], + ), + const SizedBox(height: 10), + _buildModeToggle(settings), + const SizedBox(height: 14), + TextField( + controller: _urlController, + focusNode: _urlFocusNode, + decoration: InputDecoration( + hintText: AppLocalizations.of(context)!.inputHintHttp, + prefixIcon: const Icon(Icons.search_rounded), + filled: true, + fillColor: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.6), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide( + color: theme.colorScheme.outline.withValues(alpha: 0.2), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide( + color: theme.colorScheme.outline.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide( + color: theme.colorScheme.primary.withValues(alpha: 0.6), + ), + ), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: AppLocalizations.of(context)!.actionPaste, + icon: const Icon(Icons.content_paste_rounded), + onPressed: () async { + final data = await Clipboard.getData('text/plain'); + final text = data?.text ?? ''; + if (text.isNotEmpty) { + if (!mounted) return; + + final String? extractedUrl = + UrlExtractor.extractFirstHttpUrl(text); + if (extractedUrl != null) { + _urlController.text = extractedUrl; + _previousText = extractedUrl; + setState(() {}); + + if (settings.autoSubmitClipboard) { + _cleanUrl(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)! + .snackPastedAndCleaning), + duration: const Duration(seconds: 2), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)! + .snackPasted), + duration: const Duration(seconds: 2), + ), + ); + } + } else { + _urlController.text = text.trim(); + _previousText = text.trim(); + setState(() {}); + } + } + }, + ), + IconButton( + tooltip: AppLocalizations.of(context)!.actionClear, + icon: const Icon(Icons.close_rounded), + onPressed: () { if (!mounted) return; - setState(() => _hideSupportedPanel = true); - } else if (choice == 'forever') { - await _markHideSupportedPanel(); + _urlController.clear(); + setState(() {}); + }, + ), + ], + ), + ), + keyboardType: TextInputType.url, + textInputAction: TextInputAction.go, + onChanged: (text) { + setState(() {}); + if (text.length > _previousText.length + 10) { + final String? extractedUrl = + UrlExtractor.extractFirstHttpUrl(text); + if (extractedUrl != null && extractedUrl != text) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _urlController.value = TextEditingValue( + text: extractedUrl, + selection: TextSelection.collapsed( + offset: extractedUrl.length, + ), + ); + setState(() {}); } - }, + }); + } + } + _previousText = text; + }, + onSubmitted: (_) => _cleanUrl(), + ), + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: + provider.isLoading || !_isValidHttpUrl(_urlController.text) + ? null + : _cleanUrl, + icon: provider.isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.flash_on_rounded), + label: Text( + provider.isLoading + ? AppLocalizations.of(context)!.cleaning + : (_isValidHttpUrl(_urlController.text) + ? AppLocalizations.of(context)!.tabClean + : AppLocalizations.of(context)!.enterValidUrl), + ), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), ), - ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildErrorCard(UrlCleanerProvider provider) { + final theme = Theme.of(context); + return Card( + color: theme.colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + children: [ + Icon( + Icons.error_outline, + color: theme.colorScheme.onErrorContainer, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + provider.error!, + style: TextStyle( + color: theme.colorScheme.onErrorContainer, + ), + ), + ), + IconButton( + onPressed: () => provider.clearError(), + icon: Icon( + Icons.close, + color: theme.colorScheme.onErrorContainer, + ), + ), ], ), - ); - }, + if (provider.error!.contains('Remote processing failed')) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => _processLocally(provider), + icon: const Icon(Icons.storage), + label: + Text(AppLocalizations.of(context)!.processLocally), + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.onErrorContainer, + foregroundColor: theme.colorScheme.errorContainer, + ), + ), + ), + ], + ), + ), + ], + ), ), ); } @@ -809,21 +872,40 @@ class _HomeScreenState extends State { InkWell( onTap: () => _openUrl(result.primary.url), onLongPress: () => _copyToClipboard(result.primary.url), - borderRadius: BorderRadius.circular(4), - splashColor: Colors.blue.withValues(alpha: 0.1), - highlightColor: Colors.blue.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + splashColor: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.1), + highlightColor: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.05), child: Container( width: double.infinity, padding: const EdgeInsets.symmetric( - vertical: 8, horizontal: 4), + vertical: 12, horizontal: 12), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .primaryContainer + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.2), + width: 1, + ), + ), child: Text( result.primary.url, - style: const TextStyle( + style: TextStyle( fontFamily: 'monospace', fontSize: 16, fontWeight: FontWeight.w500, - decoration: TextDecoration.underline, - color: Colors.blue, + color: Theme.of(context).colorScheme.primary, ), ), ), @@ -1086,23 +1168,44 @@ class _HomeScreenState extends State { InkWell( onTap: () => _openUrl(alt.url), onLongPress: () => _copyToClipboard(alt.url), - borderRadius: BorderRadius.circular(4), - splashColor: Colors.blue.withValues(alpha: 0.1), - highlightColor: Colors.blue.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(6), + splashColor: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.1), + highlightColor: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.05), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Container( padding: const EdgeInsets.symmetric( - vertical: 4, horizontal: 2), + vertical: 8, horizontal: 8), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.2), + width: 1, + ), + ), child: Text( alt.url, - style: const TextStyle( + style: TextStyle( fontFamily: 'monospace', fontSize: 12, - decoration: TextDecoration.underline, - color: Colors.blue, + fontWeight: FontWeight.w500, + color: + Theme.of(context).colorScheme.onSurface, ), ), ), @@ -1211,6 +1314,123 @@ class _HomeScreenState extends State { } } + void _showFirstTimeMessage() { + final theme = Theme.of(context); + showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + title: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + theme.colorScheme.primary, + theme.colorScheme.secondary, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Icon( + Icons.clean_hands, + color: theme.colorScheme.onPrimary, + size: 24, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + AppLocalizations.of(context)!.appTitle, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, + ), + ), + ), + ], + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.whyCleanLinks, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 12), + Text( + AppLocalizations.of(context)!.whyCleanLinksDescription, + style: TextStyle( + fontSize: 14, + color: theme.colorScheme.onSurface.withValues(alpha: 0.8), + height: 1.5, + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.tips_and_updates_outlined, + color: theme.colorScheme.primary, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + AppLocalizations.of(context)!.pasteLinkTitle, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface, + ), + ), + ), + ], + ), + ), + ], + ), + ), + actions: [ + FilledButton.icon( + onPressed: () { + _markFirstTimeMessageShown(); + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.check), + label: Text(AppLocalizations.of(context)!.close), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(44), + padding: const EdgeInsets.symmetric(horizontal: 24), + ), + ), + ], + ); + }, + ); + } + Future _launchUrl(String url) async { try { final uri = Uri.parse(url); diff --git a/mobile/lib/services/database_service.dart b/mobile/lib/services/database_service.dart index 1961e78..c8f7f96 100644 --- a/mobile/lib/services/database_service.dart +++ b/mobile/lib/services/database_service.dart @@ -18,50 +18,97 @@ class DatabaseService { throw UnsupportedError('Database not supported on web'); } if (_database != null) return _database!; - _database = await _initDB(_dbFileName); - return _database!; + try { + _database = await _initDB(_dbFileName); + return _database!; + } catch (e) { + // ignore: avoid_print + print('[DB] Error initializing database: $e'); + // Try to reset and recreate database on error + try { + _database = null; + await resetDatabase(); + _database = await _initDB(_dbFileName); + return _database!; + } catch (resetError) { + // ignore: avoid_print + print('[DB] Error resetting database: $resetError'); + rethrow; + } + } } Future _initDB(String filePath) async { - final dbPath = await getDatabasesPath(); - final path = join(dbPath, filePath); + try { + final dbPath = await getDatabasesPath(); + final path = join(dbPath, filePath); + // ignore: avoid_print + print('[DB] Initializing database at: $path'); - return await openDatabase( - path, - version: 3, - onCreate: _createDB, - onUpgrade: (db, oldVersion, newVersion) async { - // ignore: avoid_print - print('[DB] onUpgrade old=$oldVersion new=$newVersion'); - if (oldVersion < 2) { - // Add optional metadata columns introduced in v2 - try { - await db.execute('ALTER TABLE history ADD COLUMN title TEXT'); - } catch (_) { - // Column may already exist - } - try { - await db - .execute('ALTER TABLE history ADD COLUMN thumbnailUrl TEXT'); - } catch (_) { - // Column may already exist - } + return await openDatabase( + path, + version: 3, + onCreate: _createDB, + onUpgrade: (db, oldVersion, newVersion) async { // ignore: avoid_print - print('[DB] Migration to v2 complete'); - } - if (oldVersion < 3) { - // Ensure isFavorite column exists for older databases - try { - await db.execute( - 'ALTER TABLE history ADD COLUMN isFavorite INTEGER NOT NULL DEFAULT 0'); - } catch (_) { - // Column may already exist; ignore + print('[DB] onUpgrade old=$oldVersion new=$newVersion'); + if (oldVersion < 2) { + // Add optional metadata columns introduced in v2 + try { + await db.execute('ALTER TABLE history ADD COLUMN title TEXT'); + } catch (_) { + // Column may already exist + } + try { + await db + .execute('ALTER TABLE history ADD COLUMN thumbnailUrl TEXT'); + } catch (_) { + // Column may already exist + } + // ignore: avoid_print + print('[DB] Migration to v2 complete'); + } + if (oldVersion < 3) { + // Ensure isFavorite column exists for older databases + try { + await db.execute( + 'ALTER TABLE history ADD COLUMN isFavorite INTEGER NOT NULL DEFAULT 0'); + } catch (_) { + // Column may already exist; ignore + } + // ignore: avoid_print + print('[DB] Migration to v3 complete (added isFavorite)'); } + }, + onOpen: (db) { // ignore: avoid_print - print('[DB] Migration to v3 complete (added isFavorite)'); - } - }, - ); + print('[DB] Database opened successfully'); + }, + ); + } catch (e) { + // ignore: avoid_print + print('[DB] Error in _initDB: $e'); + // If database file is corrupted, try to delete and recreate + try { + final dbPath = await getDatabasesPath(); + final path = join(dbPath, filePath); + // ignore: avoid_print + print('[DB] Attempting to delete corrupted database: $path'); + await deleteDatabase(path); + // Retry initialization + final dbPath2 = await getDatabasesPath(); + final path2 = join(dbPath2, filePath); + return await openDatabase( + path2, + version: 3, + onCreate: _createDB, + ); + } catch (deleteError) { + // ignore: avoid_print + print('[DB] Error deleting/recreating database: $deleteError'); + rethrow; + } + } } /// Destructively reset all stored history. @@ -123,10 +170,17 @@ class DatabaseService { print('[DB] Web platform detected - skipping SQLite init'); return; } - await database; - // Logging: database initialized - // ignore: avoid_print - print('[DB] Initialized history database'); + try { + await database; + // Logging: database initialized + // ignore: avoid_print + print('[DB] Initialized history database'); + } catch (e) { + // ignore: avoid_print + print('[DB] Failed to initialize database: $e'); + // Don't rethrow - allow app to continue with empty history + // The database will be retried on next access + } } Future insertHistoryItem(HistoryItem item) async { @@ -137,13 +191,29 @@ class DatabaseService { await _prefsSaveAll(updated); return item.id; } - final db = await database; - // Logging: inserting history item - // ignore: avoid_print - print( - '[DB] Inserting history item for domain=${item.domain} cleanedUrl=${item.cleanedUrl}'); - await db.insert('history', item.toJson()); - return item.id; + try { + final db = await database; + // Logging: inserting history item + // ignore: avoid_print + print( + '[DB] Inserting history item for domain=${item.domain} cleanedUrl=${item.cleanedUrl}'); + await db.insert('history', item.toJson()); + return item.id; + } catch (e) { + // ignore: avoid_print + print('[DB] Error inserting history item: $e'); + // Try to reset database and retry once + try { + _database = null; + final db = await database; + await db.insert('history', item.toJson()); + return item.id; + } catch (retryError) { + // ignore: avoid_print + print('[DB] Error on retry insert: $retryError'); + rethrow; + } + } } Future> getAllHistoryItems() async { @@ -152,13 +222,21 @@ class DatabaseService { list.sort((a, b) => b.createdAt.compareTo(a.createdAt)); return list; } - final db = await database; - final result = await db.query('history', orderBy: 'createdAt DESC'); - // Logging: fetched count - // ignore: avoid_print - print('[DB] Loaded all history items count=${result.length}'); + try { + final db = await database; + final result = await db.query('history', orderBy: 'createdAt DESC'); + final items = _decodeRows(result); + // ignore: avoid_print + print( + '[DB] Loaded history items count=${items.length} rawRows=${result.length}'); - return result.map((json) => HistoryItem.fromJson(json)).toList(); + return items; + } catch (e, stack) { + // ignore: avoid_print + print('[DB] Error loading history items: $e\n$stack'); + // Return empty list on error to prevent app crash + return []; + } } Future> getFavoriteHistoryItems() async { @@ -168,17 +246,26 @@ class DatabaseService { ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); return favs; } - final db = await database; - final result = await db.query( - 'history', - where: 'isFavorite = ?', - whereArgs: [1], - orderBy: 'createdAt DESC', - ); - // ignore: avoid_print - print('[DB] Loaded favorite history items count=${result.length}'); + try { + final db = await database; + final result = await db.query( + 'history', + where: 'isFavorite = ?', + whereArgs: [1], + orderBy: 'createdAt DESC', + ); + final items = _decodeRows(result); + // ignore: avoid_print + print( + '[DB] Loaded favorite history items count=${items.length} rawRows=${result.length}'); - return result.map((json) => HistoryItem.fromJson(json)).toList(); + return items; + } catch (e, stack) { + // ignore: avoid_print + print('[DB] Error loading favorite history items: $e\n$stack'); + // Return empty list on error to prevent app crash + return []; + } } Future getHistoryItem(String id) async { @@ -190,17 +277,24 @@ class DatabaseService { return null; } } - final db = await database; - final result = await db.query('history', where: 'id = ?', whereArgs: [id]); + try { + final db = await database; + final result = + await db.query('history', where: 'id = ?', whereArgs: [id]); - if (result.isNotEmpty) { + if (result.isNotEmpty) { + // ignore: avoid_print + print('[DB] Loaded history item id=$id'); + return HistoryItem.fromJson(result.first); + } + // ignore: avoid_print + print('[DB] History item not found id=$id'); + return null; + } catch (e) { // ignore: avoid_print - print('[DB] Loaded history item id=$id'); - return HistoryItem.fromJson(result.first); + print('[DB] Error getting history item: $e'); + return null; } - // ignore: avoid_print - print('[DB] History item not found id=$id'); - return null; } Future updateHistoryItem(HistoryItem item) async { @@ -213,15 +307,21 @@ class DatabaseService { } return; } - final db = await database; - // ignore: avoid_print - print('[DB] Updating history item id=${item.id}'); - await db.update( - 'history', - item.toJson(), - where: 'id = ?', - whereArgs: [item.id], - ); + try { + final db = await database; + // ignore: avoid_print + print('[DB] Updating history item id=${item.id}'); + await db.update( + 'history', + item.toJson(), + where: 'id = ?', + whereArgs: [item.id], + ); + } catch (e) { + // ignore: avoid_print + print('[DB] Error updating history item: $e'); + rethrow; + } } Future deleteHistoryItem(String id) async { @@ -231,10 +331,16 @@ class DatabaseService { await _prefsSaveAll(list); return; } - final db = await database; - // ignore: avoid_print - print('[DB] Deleting history item id=$id'); - await db.delete('history', where: 'id = ?', whereArgs: [id]); + try { + final db = await database; + // ignore: avoid_print + print('[DB] Deleting history item id=$id'); + await db.delete('history', where: 'id = ?', whereArgs: [id]); + } catch (e) { + // ignore: avoid_print + print('[DB] Error deleting history item: $e'); + rethrow; + } } Future clearAllHistory() async { @@ -242,18 +348,30 @@ class DatabaseService { await _prefsSaveAll([]); return; } - final db = await database; - // ignore: avoid_print - print('[DB] Clearing all history'); - await db.delete('history'); + try { + final db = await database; + // ignore: avoid_print + print('[DB] Clearing all history'); + await db.delete('history'); + } catch (e) { + // ignore: avoid_print + print('[DB] Error clearing history: $e'); + rethrow; + } } Future toggleFavorite(String id) async { - final item = await getHistoryItem(id); - if (item != null) { + try { + final item = await getHistoryItem(id); + if (item != null) { + // ignore: avoid_print + print('[DB] Toggling favorite id=$id -> ${!item.isFavorite}'); + await updateHistoryItem(item.copyWith(isFavorite: !item.isFavorite)); + } + } catch (e) { // ignore: avoid_print - print('[DB] Toggling favorite id=$id -> ${!item.isFavorite}'); - await updateHistoryItem(item.copyWith(isFavorite: !item.isFavorite)); + print('[DB] Error toggling favorite: $e'); + rethrow; } } @@ -269,17 +387,24 @@ class DatabaseService { ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); return filtered; } - final db = await database; - final result = await db.query( - 'history', - where: 'originalUrl LIKE ? OR cleanedUrl LIKE ? OR domain LIKE ?', - whereArgs: ['%$query%', '%$query%', '%$query%'], - orderBy: 'createdAt DESC', - ); - // ignore: avoid_print - print('[DB] Search "$query" results=${result.length}'); + try { + final db = await database; + final result = await db.query( + 'history', + where: 'originalUrl LIKE ? OR cleanedUrl LIKE ? OR domain LIKE ?', + whereArgs: ['%$query%', '%$query%', '%$query%'], + orderBy: 'createdAt DESC', + ); + // ignore: avoid_print + print('[DB] Search "$query" results=${result.length}'); - return result.map((json) => HistoryItem.fromJson(json)).toList(); + return result.map((json) => HistoryItem.fromJson(json)).toList(); + } catch (e) { + // ignore: avoid_print + print('[DB] Error searching history: $e'); + // Return empty list on error to prevent app crash + return []; + } } Future close() async { @@ -294,11 +419,23 @@ class DatabaseService { final raw = prefs.getString(_prefsHistoryKey); if (raw == null || raw.isEmpty) return []; try { - final list = (jsonDecode(raw) as List) - .map((e) => HistoryItem.fromJson(Map.from(e))) - .toList(); + final decoded = jsonDecode(raw); + if (decoded is! List) return []; + final list = []; + for (final entry in decoded) { + try { + list.add( + HistoryItem.fromJson(Map.from(entry)), + ); + } catch (e) { + // ignore: avoid_print + print('[DB] Skipping malformed cached history entry: $e entry=$entry'); + } + } return list; - } catch (_) { + } catch (e) { + // ignore: avoid_print + print('[DB] Error decoding cached history: $e'); return []; } } @@ -308,4 +445,19 @@ class DatabaseService { final encoded = jsonEncode(items.map((e) => e.toJson()).toList()); await prefs.setString(_prefsHistoryKey, encoded); } + + List _decodeRows(List> rows) { + final items = []; + for (final row in rows) { + try { + items.add( + HistoryItem.fromJson(Map.from(row)), + ); + } catch (e) { + // ignore: avoid_print + print('[DB] Skipping malformed history row: $e row=$row'); + } + } + return items; + } } diff --git a/mobile/lib/utils/url_extractor.dart b/mobile/lib/utils/url_extractor.dart index 98dd645..9b8cb9b 100644 --- a/mobile/lib/utils/url_extractor.dart +++ b/mobile/lib/utils/url_extractor.dart @@ -5,6 +5,7 @@ class UrlExtractor { /// Extracts the first continuous link string starting with http from the given text /// /// Returns the first valid HTTP/HTTPS URL found in the text, or null if none found. + /// Also detects URLs without protocol prefixes (e.g., "bit.ly/4rvJwRn") and prepends "https://". /// /// Example: /// Input: "I'm using Gboard to type in English (US) (QWERTY). You can try it at: https://gboard.app.goo.gl?utm_campaign=user_referral&amv=26830000&apn=com.google.android.inputmethod.latin&ibi=com.google.keyboard&isi=1091700242&link=https%3A%2F%2Fdeeplink.com.google.android.inputmethod.latin%2F%3FdeeplinkInfo%3DH4sIAAAAAAAAAOPi52JNzdMNDRZiKyxPLSqplPi2aL4%252BABCf%252FHsWAAAA&utm_medium=deeplink&utm_source=access_point&ofl=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dcom.google.android.inputmethod.latin" @@ -12,34 +13,78 @@ class UrlExtractor { static String? extractFirstHttpUrl(String text) { if (text.isEmpty) return null; - // Regular expression to match HTTP/HTTPS URLs - // This pattern matches: - // - http:// or https:// - // - followed by valid URL characters (including query parameters, fragments, etc.) - // - stops at whitespace or common sentence-ending punctuation - final RegExp urlPattern = RegExp( + // First, try to match HTTP/HTTPS URLs (existing behavior) + final RegExp httpUrlPattern = RegExp( r'https?://[^\s<>"{}|\\^`\[\]]+', caseSensitive: false, ); - final Match? match = urlPattern.firstMatch(text); - if (match == null) return null; + Match? match = httpUrlPattern.firstMatch(text); + if (match != null) { + String url = match.group(0)!; + // Trim trailing punctuation that might have been included + url = url.replaceAll(RegExp(r'[.,!?;:)]+$'), ''); - String url = match.group(0)!; + // Validate that the extracted URL is actually valid + try { + final uri = Uri.parse(url); + if (uri.hasScheme && + (uri.scheme == 'http' || uri.scheme == 'https') && + uri.hasAuthority) { + return url; + } + } catch (_) { + // Invalid URI, continue to check for URLs without protocol + } + } - // Trim trailing punctuation that might have been included - url = url.replaceAll(RegExp(r'[.,!?;:)]+$'), ''); + // If no HTTP/HTTPS URL found, try to match URLs without protocol + // Pattern matches: + // - Domain (with optional www. or subdomain) + // - Followed by optional path, query params, or fragment + // - Must have at least a domain and TLD (e.g., "bit.ly", "example.com") + // - Avoids matching email addresses (no @ before domain) + // - Avoids matching if preceded by :// (already handled above) + // - Must start at word boundary or beginning of string + final RegExp noProtocolUrlPattern = RegExp( + r'(?:^|[\s>])(?"{}|\\^`\[\]]*)?', + caseSensitive: false, + ); - // Validate that the extracted URL is actually valid - try { - final uri = Uri.parse(url); - if (uri.hasScheme && - (uri.scheme == 'http' || uri.scheme == 'https') && - uri.hasAuthority) { - return url; + match = noProtocolUrlPattern.firstMatch(text); + if (match != null) { + String url = match.group(0)!.trim(); + + // Skip if it's just whitespace or empty + if (url.isEmpty) return null; + + // Trim trailing punctuation that might have been included + url = url.replaceAll(RegExp(r'[.,!?;:)]+$'), ''); + + // Validate that it looks like a URL (has domain and TLD) + // Check if it's not just a single word or email-like pattern + if (url.contains('/') || + RegExp(r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.[a-z]{2,}', caseSensitive: false).hasMatch(url)) { + try { + // Prepend https:// and validate + final fullUrl = 'https://$url'; + final uri = Uri.parse(fullUrl); + if (uri.hasAuthority && uri.host.isNotEmpty) { + // Additional validation: ensure it's not an email address + // Check if there's an @ before the domain in the original text + final matchStart = match.start; + final beforeMatch = text.substring(0, matchStart); + // Check if there's @ before, or if it's part of a protocol (://) + if (!beforeMatch.endsWith('@') && + !beforeMatch.endsWith('@ ') && + !beforeMatch.contains('://')) { + return fullUrl; + } + } + } catch (_) { + // Invalid URI, return null + } } - } catch (_) { - // Invalid URI, return null } return null; @@ -48,24 +93,23 @@ class UrlExtractor { /// Extracts all HTTP/HTTPS URLs from the given text /// /// Returns a list of all valid HTTP/HTTPS URLs found in the text. + /// Also detects URLs without protocol prefixes and prepends "https://". static List extractAllHttpUrls(String text) { if (text.isEmpty) return []; - final RegExp urlPattern = RegExp( + final List urls = []; + + // First, try to match HTTP/HTTPS URLs + final RegExp httpUrlPattern = RegExp( r'https?://[^\s<>"{}|\\^`\[\]]+', caseSensitive: false, ); - final List urls = []; - final Iterable matches = urlPattern.allMatches(text); - - for (final Match match in matches) { + final Iterable httpMatches = httpUrlPattern.allMatches(text); + for (final Match match in httpMatches) { String url = match.group(0)!; - - // Trim trailing punctuation that might have been included url = url.replaceAll(RegExp(r'[.,!?;:)]+$'), ''); - // Validate that the extracted URL is actually valid try { final uri = Uri.parse(url); if (uri.hasScheme && @@ -78,6 +122,39 @@ class UrlExtractor { } } + // If we found HTTP/HTTPS URLs, return them (prioritize explicit protocols) + if (urls.isNotEmpty) return urls; + + // Otherwise, try to match URLs without protocol + final RegExp noProtocolUrlPattern = RegExp( + r'(?"{}|\\^`\[\]]*)?', + caseSensitive: false, + ); + + final Iterable noProtocolMatches = noProtocolUrlPattern.allMatches(text); + for (final Match match in noProtocolMatches) { + String url = match.group(0)!; + url = url.replaceAll(RegExp(r'[.,!?;:)]+$'), ''); + + if (url.contains('/') || + RegExp(r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.[a-z]{2,}', caseSensitive: false).hasMatch(url)) { + try { + final fullUrl = 'https://$url'; + final uri = Uri.parse(fullUrl); + if (uri.hasAuthority && uri.host.isNotEmpty) { + // Additional validation: ensure it's not an email address + final matchStart = match.start; + final beforeMatch = text.substring(0, matchStart); + if (!beforeMatch.endsWith('@') && !beforeMatch.endsWith('@ ')) { + urls.add(fullUrl); + } + } + } catch (_) { + // Invalid URI, skip it + } + } + } + return urls; } } diff --git a/mobile/lib/widgets/supported_platforms_panel.dart b/mobile/lib/widgets/supported_platforms_panel.dart index 2954bb9..d1d62c9 100644 --- a/mobile/lib/widgets/supported_platforms_panel.dart +++ b/mobile/lib/widgets/supported_platforms_panel.dart @@ -17,57 +17,94 @@ class SupportedPlatformsPanel extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide( + color: colorScheme.outline.withValues(alpha: 0.1), + ), + ), child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - Icon(Icons.verified_user, color: colorScheme.primary), - const SizedBox(width: 8), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + colorScheme.primary, + colorScheme.secondary, + ], + ), + ), + child: Icon( + Icons.verified_user, + color: colorScheme.onPrimary, + size: 20, + ), + ), + const SizedBox(width: 12), Expanded( child: Text( AppLocalizations.of(context)! .supportedPlatformsAndWhatWeClean, - maxLines: 1, - overflow: TextOverflow.ellipsis, style: const TextStyle( - fontSize: 18, fontWeight: FontWeight.bold), + fontSize: 18, + fontWeight: FontWeight.bold, + ), ), ), - TextButton( + IconButton( onPressed: () async { if (onRequestHideDialog != null) { await onRequestHideDialog!.call(); } }, - child: Text(AppLocalizations.of(context)!.hide), + icon: const Icon(Icons.close), + tooltip: AppLocalizations.of(context)!.hide, ), ], ), - const SizedBox(height: 12), + const SizedBox(height: 16), // Privacy-first explanation Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), + gradient: LinearGradient( + colors: [ + colorScheme.primaryContainer.withValues(alpha: 0.3), + colorScheme.secondaryContainer.withValues(alpha: 0.2), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12), border: Border.all( - color: colorScheme.outline.withValues(alpha: 0.2)), + color: colorScheme.outline.withValues(alpha: 0.15), + ), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.shield, size: 20), - const SizedBox(width: 8), + Icon( + Icons.shield, + size: 22, + color: colorScheme.primary, + ), + const SizedBox(width: 12), Expanded( child: Text( AppLocalizations.of(context)!.whyCleanLinksDescription, style: TextStyle( - fontSize: 12, - color: colorScheme.onSurfaceVariant, + fontSize: 13, + color: colorScheme.onSurface, + height: 1.4, ), ), ), @@ -75,7 +112,7 @@ class SupportedPlatformsPanel extends StatelessWidget { ), ), - const SizedBox(height: 16), + const SizedBox(height: 20), // Platforms grid (responsive) LayoutBuilder( @@ -85,13 +122,13 @@ class SupportedPlatformsPanel extends StatelessWidget { double aspect; if (width < 380) { crossAxisCount = 1; - aspect = 2.2; // single column can be wider + aspect = 2.5; } else if (width < 700) { crossAxisCount = 2; - aspect = 1.9; // taller tiles to avoid overflow + aspect = 2.2; } else { crossAxisCount = 3; - aspect = 1.6; // make tiles taller on wide screens + aspect = 1.9; } return _PlatformGrid( items: const [ @@ -119,7 +156,7 @@ class SupportedPlatformsPanel extends StatelessWidget { name: 'TikTok', icon: Icons.play_circle_fill, exampleIn: - 'tiktok.com/@user/video/123?is_from_webapp=1&sender_device=pc&share_app_id=1233', + 'tiktok.com/@user/video/123?is_from_webapp=1&sender_device=pc', exampleOut: 'tiktok.com/@user/video/123', ), _PlatformItem( @@ -140,7 +177,7 @@ class SupportedPlatformsPanel extends StatelessWidget { name: 'Reddit', icon: Icons.reddit, exampleIn: - 'reddit.com/r/sub/comments/abc?ref=share&context=3&rdt=12345', + 'reddit.com/r/sub/comments/abc?ref=share&context=3', exampleOut: 'reddit.com/r/sub/comments/abc', ), _PlatformItem( @@ -170,11 +207,22 @@ class SupportedPlatformsPanel extends StatelessWidget { }, ), - const SizedBox(height: 8), - Text( - AppLocalizations.of(context)!.privacyNotesDescription, - style: - TextStyle(fontSize: 12, color: colorScheme.onSurfaceVariant), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: + colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + AppLocalizations.of(context)!.privacyNotesDescription, + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + height: 1.4, + ), + ), ), ], ), @@ -194,77 +242,307 @@ class _PlatformGrid extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; return GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: crossAxisCount, - crossAxisSpacing: 12, - mainAxisSpacing: 12, + crossAxisSpacing: 10, + mainAxisSpacing: 10, childAspectRatio: childAspectRatio, ), itemCount: items.length, itemBuilder: (_, i) { final it = items[i]; - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - border: - Border.all(color: colorScheme.outline.withValues(alpha: 0.2)), + return _AnimatedPlatformCard(item: it, index: i); + }, + ); + } +} + +class _AnimatedPlatformCard extends StatefulWidget { + const _AnimatedPlatformCard({required this.item, required this.index}); + final _PlatformItem item; + final int index; + + @override + State<_AnimatedPlatformCard> createState() => _AnimatedPlatformCardState(); +} + +class _AnimatedPlatformCardState extends State<_AnimatedPlatformCard> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + bool _isHovered = false; + bool _hasStopped = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2500), // Faster animation + ); + // Start animation with delay based on index + Future.delayed(Duration(milliseconds: widget.index * 100), () { + if (mounted && !_hasStopped) { + _controller.repeat(reverse: true); + } + }); + // Stop animation after 20 seconds + Future.delayed(const Duration(seconds: 20), () { + if (mounted && !_hasStopped) { + setState(() { + _hasStopped = true; + }); + _controller.stop(); + // Animate to the clean state (final state) + _controller.animateTo(1.0, duration: const Duration(milliseconds: 500)); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final animation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ), + ); + + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: _isHovered + ? [ + colorScheme.primaryContainer.withValues(alpha: 0.4), + colorScheme.secondaryContainer.withValues(alpha: 0.3), + ] + : [ + colorScheme.surfaceContainerHighest.withValues(alpha: 0.6), + colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(it.icon, size: 20), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - it.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.w600), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: _isHovered + ? colorScheme.primary.withValues(alpha: 0.3) + : colorScheme.outline.withValues(alpha: 0.15), + width: _isHovered ? 1.5 : 1, + ), + boxShadow: _isHovered + ? [ + BoxShadow( + color: colorScheme.primary.withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + widget.item.icon, + size: 16, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + widget.item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: colorScheme.onSurface, ), - const SizedBox(height: 4), - _Example(inText: it.exampleIn, outText: it.exampleOut), - ], + ), ), + ], + ), + const SizedBox(height: 8), + Flexible( + child: _AnimatedExample( + inText: widget.item.exampleIn, + outText: widget.item.exampleOut, + animation: animation, ), - ], - ), - ); - }, + ), + ], + ), + ), ); } } -class _Example extends StatelessWidget { - const _Example({required this.inText, required this.outText}); +class _AnimatedExample extends StatelessWidget { + const _AnimatedExample({ + required this.inText, + required this.outText, + required this.animation, + }); final String inText; final String outText; + final Animation animation; @override Widget build(BuildContext context) { - const mono = TextStyle(fontFamily: 'monospace', fontSize: 11); - final faded = Theme.of(context) - .textTheme - .bodySmall - ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(AppLocalizations.of(context)!.example, style: faded), - const SizedBox(height: 2), - Text(inText, maxLines: 1, overflow: TextOverflow.ellipsis, style: mono), - const Icon(Icons.arrow_downward, size: 12), - Text(outText, - maxLines: 1, overflow: TextOverflow.ellipsis, style: mono), - ], + final colorScheme = Theme.of(context).colorScheme; + const mono = TextStyle(fontFamily: 'monospace', fontSize: 9); + + return AnimatedBuilder( + animation: animation, + builder: (context, child) { + // When animation is stopped at 1.0, show both links fully visible + final isStopped = animation.value == 1.0; + final showClean = animation.value > 0.5 || isStopped; + final dirtyOpacity = isStopped + ? 1.0 + : (showClean + ? 1.0 - ((animation.value - 0.5) * 2) + : 1.0 - (animation.value * 2)); + final cleanOpacity = + isStopped ? 1.0 : (showClean ? (animation.value - 0.5) * 2 : 0.0); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Dirty link (fades out) + AnimatedOpacity( + opacity: dirtyOpacity, + duration: const Duration(milliseconds: 300), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.errorContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: colorScheme.error.withValues(alpha: 0.2), + width: 1, + ), + ), + child: Row( + children: [ + Icon( + Icons.link_off, + size: 10, + color: colorScheme.error, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + inText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: mono.copyWith( + color: colorScheme.onErrorContainer, + ), + ), + ), + ], + ), + ), + ), + // Animated arrow + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + transitionBuilder: (child, animation) { + return ScaleTransition( + scale: animation, + child: FadeTransition( + opacity: animation, + child: child, + ), + ); + }, + child: Icon( + Icons.arrow_downward_rounded, + key: ValueKey(showClean), + size: 16, + color: showClean + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + ), + // Clean link (fades in) + AnimatedOpacity( + opacity: cleanOpacity, + duration: const Duration(milliseconds: 300), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + colorScheme.primaryContainer.withValues(alpha: 0.4), + colorScheme.secondaryContainer.withValues(alpha: 0.3), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: colorScheme.primary.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + children: [ + Icon( + Icons.check_circle_outline, + size: 10, + color: colorScheme.primary, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + outText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: mono.copyWith( + color: colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ], + ); + }, ); } } diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 0ac2199..7e501c7 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -1,6 +1,6 @@ name: traceoff_mobile description: TraceOff Mobile App - Share links without trackers -version: 1.0.0+1 +version: 1.0.2+4 environment: sdk: ">=3.1.0 <4.0.0" diff --git a/mobile/test/url_extractor_test.dart b/mobile/test/url_extractor_test.dart index 7859578..b7ee2c5 100644 --- a/mobile/test/url_extractor_test.dart +++ b/mobile/test/url_extractor_test.dart @@ -304,6 +304,46 @@ void main() { equals( 'https://example.com/post?utm_source=blog&utm_medium=markdown')); }); + + test('extracts URL without protocol prefix (bit.ly)', () { + const String input = 'Check this out: bit.ly/4rvJwRn'; + + final String? result = UrlExtractor.extractFirstHttpUrl(input); + + expect(result, equals('https://bit.ly/4rvJwRn')); + }); + + test('extracts URL without protocol prefix (example.com)', () { + const String input = 'Visit example.com/path for more info'; + + final String? result = UrlExtractor.extractFirstHttpUrl(input); + + expect(result, equals('https://example.com/path')); + }); + + test('prioritizes URLs with protocol over those without', () { + const String input = 'See https://example.com and bit.ly/abc'; + + final String? result = UrlExtractor.extractFirstHttpUrl(input); + + expect(result, equals('https://example.com')); + }); + + test('does not extract email addresses as URLs', () { + const String input = 'Contact me at user@example.com'; + + final String? result = UrlExtractor.extractFirstHttpUrl(input); + + expect(result, isNull); + }); + + test('extracts URL without protocol when no protocol URL exists', () { + const String input = 'Short link: t.co/xyz123'; + + final String? result = UrlExtractor.extractFirstHttpUrl(input); + + expect(result, equals('https://t.co/xyz123')); + }); }); }); } diff --git a/mobile/untranslated_messages.txt b/mobile/untranslated_messages.txt new file mode 100644 index 0000000..150fee9 --- /dev/null +++ b/mobile/untranslated_messages.txt @@ -0,0 +1,381 @@ +{ + "ar": [ + "switchedToLocal", + "local", + "switchedToRemote", + "remote", + "copiedToClipboard", + "urlCleanedCopiedAndShared", + "couldNotLaunch", + "errorLaunchingUrl", + "switchedToLocalProcessingAndCleaned", + "whyCleanLinks", + "privacyNotes", + "chooseTheme", + "light", + "dark", + "system", + "debugInfo", + "originalInput", + "localRemote", + "supportedPlatformsAndWhatWeClean", + "whyCleanLinksDescription", + "privacyNotesDescription", + "example", + "general", + "autoCopyPrimaryResult", + "autoCopyPrimaryResultDesc", + "showConfirmation", + "showConfirmationDesc", + "showCleanLinkPreviews", + "showCleanLinkPreviewsDesc", + "localProcessing", + "localProcessingDesc", + "localProcessingWebDesc", + "manageLocalStrategies", + "manageLocalStrategiesDesc", + "manageLocalStrategiesWebDesc", + "theme", + "language", + "serverUrl", + "serverUrlDesc", + "environment", + "baseUrl", + "apiUrl", + "clearHistory", + "clearHistoryDesc", + "exportHistory", + "exportHistoryDesc", + "version", + "openSource", + "openSourceDesc", + "githubLinkNotImplemented", + "privacyPolicyDesc", + "resetToDefaults", + "resetToDefaultsDesc", + "localOfflineNotAvailableWeb", + "chooseLanguage", + "clearHistoryTitle", + "clearHistoryConfirm", + "clear", + "historyCleared", + "noHistoryToExport", + "localCleaningStrategies", + "defaultOfflineCleaner", + "defaultOfflineCleanerDesc", + "addStrategy", + "close", + "newStrategy", + "editStrategy", + "steps", + "addStep", + "redirect", + "removeQuery", + "stripFragment", + "save", + "redirectStep", + "removeQueryKeys", + "resetSettings", + "resetSettingsConfirm", + "reset", + "dataManagement", + "about" + ], + + "hi": [ + "whyCleanLinks", + "privacyNotes" + ], + + "ru": [ + "tutorialProcessingModes", + "tutorialLocalProcessing", + "tutorialLocalDescription", + "tutorialLocalPros1", + "tutorialLocalPros2", + "tutorialLocalPros3", + "tutorialLocalPros4", + "tutorialLocalCons1", + "tutorialLocalCons2", + "tutorialLocalCons3", + "tutorialLocalCons4", + "tutorialLocalWhenToUse", + "tutorialRemoteProcessing", + "tutorialRemoteDescription", + "tutorialRemotePros1", + "tutorialRemotePros2", + "tutorialRemotePros3", + "tutorialRemotePros4", + "tutorialRemotePros5", + "tutorialRemoteCons1", + "tutorialRemoteCons2", + "tutorialRemoteCons3", + "tutorialRemoteCons4", + "tutorialRemoteWhenToUse", + "tutorialSecurityPrivacy", + "tutorialSec1", + "tutorialSec2", + "tutorialSec3", + "tutorialSec4", + "tutorialSec5", + "tutorialRecommendations", + "tutorialRec1", + "tutorialRec2", + "tutorialRec3", + "tutorialRec4", + "tutorialAdvantages", + "tutorialLimitations", + "tutorialWhenToUseLabel", + "historyTitle", + "historySearchHint", + "historyShowAll", + "historyShowFavoritesOnly", + "historyExport", + "historyClearAll", + "historyNoFavoritesYet", + "historyNoItemsYet", + "historyFavoritesHint", + "historyCleanSomeUrls", + "historyOriginal", + "historyCleaned", + "historyConfidence", + "historyCopyOriginal", + "historyCopyCleaned", + "historyShare", + "historyOpen", + "historyReclean", + "historyAddToFavorites", + "historyRemoveFromFavorites", + "historyDelete", + "historyDeleteItem", + "historyDeleteConfirm", + "historyClearAllTitle", + "historyClearAllConfirm", + "historyClearAllAction", + "historyOriginalCopied", + "historyCleanedCopied", + "historyCouldNotLaunch", + "historyErrorLaunching", + "historyJustNow", + "historyDaysAgo", + "historyHoursAgo", + "historyMinutesAgo", + "settingsGeneral", + "settingsCleaningStrategies", + "settingsAppearance", + "settingsServer", + "settingsDataManagement", + "settingsAbout", + "settingsAutoCopyPrimary", + "settingsAutoCopyPrimaryDesc", + "settingsShowConfirmation", + "settingsShowConfirmationDesc", + "settingsShowCleanLinkPreviews", + "settingsShowCleanLinkPreviewsDesc", + "settingsLocalProcessing", + "settingsLocalProcessingDesc", + "settingsLocalProcessingWebDesc", + "settingsManageLocalStrategies", + "settingsManageLocalStrategiesDesc", + "settingsManageLocalStrategiesWebDesc", + "settingsTheme", + "settingsLanguage", + "settingsServerUrl", + "settingsServerUrlDesc", + "settingsClearHistory", + "settingsClearHistoryDesc", + "settingsExportHistory", + "settingsExportHistoryDesc", + "settingsVersion", + "settingsOpenSource", + "settingsOpenSourceDesc", + "settingsGitHubNotImplemented", + "settingsResetToDefaultsDesc", + "settingsSystemDefault", + "settingsLight", + "settingsDark", + "settingsChooseTheme", + "settingsChooseLanguage", + "settingsClearHistoryTitle", + "settingsClearHistoryConfirm", + "settingsHistoryCleared", + "settingsNoHistoryToExport", + "settingsHistoryExported", + "settingsResetSettings", + "settingsResetSettingsConfirm", + "settingsReset", + "settingsSettingsResetToDefaults", + "settingsLocalStrategiesTitle", + "settingsDefaultOfflineCleaner", + "settingsDefaultOfflineCleanerDesc", + "settingsAddStrategy", + "settingsNewStrategy", + "settingsEditStrategy", + "settingsStrategyName", + "settingsSteps", + "settingsAddStep", + "settingsRedirectStep", + "settingsTimes", + "settingsRemoveQueryKeys", + "settingsCommaSeparatedKeys", + "settingsCommaSeparatedKeysHint", + "settingsRedirect", + "settingsRemoveQuery", + "settingsStripFragment", + "settingsClose", + "settingsSave", + "settingsCancel", + "settingsRedirectTimes", + "settingsRemoveNoQueryKeys", + "settingsRemoveKeys", + "settingsStripUrlFragment", + "whyCleanLinks", + "privacyNotes" + ], + + "zh": [ + "tutorialProcessingModes", + "tutorialLocalProcessing", + "tutorialLocalDescription", + "tutorialLocalPros1", + "tutorialLocalPros2", + "tutorialLocalPros3", + "tutorialLocalPros4", + "tutorialLocalCons1", + "tutorialLocalCons2", + "tutorialLocalCons3", + "tutorialLocalCons4", + "tutorialLocalWhenToUse", + "tutorialRemoteProcessing", + "tutorialRemoteDescription", + "tutorialRemotePros1", + "tutorialRemotePros2", + "tutorialRemotePros3", + "tutorialRemotePros4", + "tutorialRemotePros5", + "tutorialRemoteCons1", + "tutorialRemoteCons2", + "tutorialRemoteCons3", + "tutorialRemoteCons4", + "tutorialRemoteWhenToUse", + "tutorialSecurityPrivacy", + "tutorialSec1", + "tutorialSec2", + "tutorialSec3", + "tutorialSec4", + "tutorialSec5", + "tutorialRecommendations", + "tutorialRec1", + "tutorialRec2", + "tutorialRec3", + "tutorialRec4", + "tutorialAdvantages", + "tutorialLimitations", + "tutorialWhenToUseLabel", + "historyTitle", + "historySearchHint", + "historyShowAll", + "historyShowFavoritesOnly", + "historyExport", + "historyClearAll", + "historyNoFavoritesYet", + "historyNoItemsYet", + "historyFavoritesHint", + "historyCleanSomeUrls", + "historyOriginal", + "historyCleaned", + "historyConfidence", + "historyCopyOriginal", + "historyCopyCleaned", + "historyShare", + "historyOpen", + "historyReclean", + "historyAddToFavorites", + "historyRemoveFromFavorites", + "historyDelete", + "historyDeleteItem", + "historyDeleteConfirm", + "historyClearAllTitle", + "historyClearAllConfirm", + "historyClearAllAction", + "historyOriginalCopied", + "historyCleanedCopied", + "historyCouldNotLaunch", + "historyErrorLaunching", + "historyJustNow", + "historyDaysAgo", + "historyHoursAgo", + "historyMinutesAgo", + "settingsGeneral", + "settingsCleaningStrategies", + "settingsAppearance", + "settingsServer", + "settingsDataManagement", + "settingsAbout", + "settingsAutoCopyPrimary", + "settingsAutoCopyPrimaryDesc", + "settingsShowConfirmation", + "settingsShowConfirmationDesc", + "settingsShowCleanLinkPreviews", + "settingsShowCleanLinkPreviewsDesc", + "settingsLocalProcessing", + "settingsLocalProcessingDesc", + "settingsLocalProcessingWebDesc", + "settingsManageLocalStrategies", + "settingsManageLocalStrategiesDesc", + "settingsManageLocalStrategiesWebDesc", + "settingsTheme", + "settingsLanguage", + "settingsServerUrl", + "settingsServerUrlDesc", + "settingsClearHistory", + "settingsClearHistoryDesc", + "settingsExportHistory", + "settingsExportHistoryDesc", + "settingsVersion", + "settingsOpenSource", + "settingsOpenSourceDesc", + "settingsGitHubNotImplemented", + "settingsResetToDefaultsDesc", + "settingsSystemDefault", + "settingsLight", + "settingsDark", + "settingsChooseTheme", + "settingsChooseLanguage", + "settingsClearHistoryTitle", + "settingsClearHistoryConfirm", + "settingsHistoryCleared", + "settingsNoHistoryToExport", + "settingsHistoryExported", + "settingsResetSettings", + "settingsResetSettingsConfirm", + "settingsReset", + "settingsSettingsResetToDefaults", + "settingsLocalStrategiesTitle", + "settingsDefaultOfflineCleaner", + "settingsDefaultOfflineCleanerDesc", + "settingsAddStrategy", + "settingsNewStrategy", + "settingsEditStrategy", + "settingsStrategyName", + "settingsSteps", + "settingsAddStep", + "settingsRedirectStep", + "settingsTimes", + "settingsRemoveQueryKeys", + "settingsCommaSeparatedKeys", + "settingsCommaSeparatedKeysHint", + "settingsRedirect", + "settingsRemoveQuery", + "settingsStripFragment", + "settingsClose", + "settingsSave", + "settingsCancel", + "settingsRedirectTimes", + "settingsRemoveNoQueryKeys", + "settingsRemoveKeys", + "settingsStripUrlFragment", + "whyCleanLinks", + "privacyNotes" + ] +} diff --git a/mobile/vercel.json b/mobile/vercel.json index 526640c..041ad00 100644 --- a/mobile/vercel.json +++ b/mobile/vercel.json @@ -1,8 +1,8 @@ { "version": 2, - "buildCommand": "", - "installCommand": "", - "outputDirectory": "build/web", + "builds": [ + { "src": "build/web/**", "use": "@vercel/static" } + ], "rewrites": [ { "source": "/(.*)", diff --git a/server/src/engine/strategies/FacebookStrategy.ts b/server/src/engine/strategies/FacebookStrategy.ts index 7e73bf6..5b3fbc6 100644 --- a/server/src/engine/strategies/FacebookStrategy.ts +++ b/server/src/engine/strategies/FacebookStrategy.ts @@ -30,6 +30,8 @@ export class FacebookStrategy { { name: 'gclid', action: 'deny', reason: 'Google click ID' }, { name: 'igshid', action: 'deny', reason: 'Instagram tracking parameter' }, { name: 'si', action: 'deny', reason: 'Session tracking' }, + { name: 'rdid', action: 'deny', reason: 'Facebook redirect ID tracking' }, + { name: 'share_url', action: 'deny', reason: 'Facebook share URL tracking' }, { name: 'locale', action: 'allow', reason: 'Locale setting' }, { name: 'language', action: 'allow', reason: 'Language preference' }, { name: 'hl', action: 'allow', reason: 'Language preference' },