diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 53eb24c..16300fe 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -9,6 +9,7 @@ import '../../../core/storage/token_storage.dart'; import '../../../core/utils/haptics.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; +import '../../widgets/info_action_sheet.dart'; import '../auth/login_screen.dart'; import '../auth/proxy_settings_sheet.dart'; import 'customization_screen.dart'; @@ -98,6 +99,38 @@ class _SettingsTabState extends State { if (mounted) setState(() => _hapticsEnabled = value); } + Future _openCloudStorage(BuildContext context) async { + final cs = Theme.of(context).colorScheme; + await showInfoActionSheet( + context, + headerIcon: Symbols.cloud, + title: 'Облачное хранилище', + subtitle: 'Через МАХ', + items: [ + const InfoActionSheetItem( + icon: Symbols.cloud_done, + title: 'Работает при белых списках', + body: 'Вы сможете передать файл даже при ограниченном интернете.', + ), + const InfoActionSheetItem( + icon: Symbols.inventory_2, + title: 'Файлы до 4ГБ, безлимитное количество.', + body: 'Можете хранить массивный обьем информации.', + ), + InfoActionSheetItem( + icon: Symbols.gpp_maybe, + title: 'Не обеспечивается конфединциальность файлов', + body: + 'Облачное хранилище работает через ваш аккаунт на сервере МАХ, ' + 'нужные люди всё равно могут его посмотреть.', + titleColor: cs.error, + ), + ], + confirmLabel: 'ОК', + confirmDelay: const Duration(seconds: 3), + ); + } + Future _confirmLogout() async { final cs = Theme.of(context).colorScheme; final confirmed = await showModalBottomSheet( @@ -277,6 +310,11 @@ child: _buildSection( toggleValue: _hapticsEnabled, onToggle: _setHaptics, ), + _SettingsItem( + icon: Symbols.cloud, + label: 'Облачное хранилище [BETA]', + onTap: () => _openCloudStorage(context), + ), _SettingsItem( icon: Symbols.vpn_lock, label: 'Прокси', diff --git a/lib/frontend/screens/profile/spoof_screen.dart b/lib/frontend/screens/profile/spoof_screen.dart index 1a1f724..9eef76d 100644 --- a/lib/frontend/screens/profile/spoof_screen.dart +++ b/lib/frontend/screens/profile/spoof_screen.dart @@ -8,10 +8,12 @@ import 'package:flutter_timezone/flutter_timezone.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../../core/config/device_presets.dart'; +import '../../../core/storage/device_identity.dart'; import '../../../core/storage/spoofing_service.dart'; import '../../../core/storage/token_storage.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; +import '../../widgets/info_action_sheet.dart'; import '../auth/login_screen.dart'; enum SpoofingMethod { partial, full } @@ -36,6 +38,10 @@ class _SpoofScreenState extends State { final _deviceIdController = TextEditingController(); final _appVersionController = TextEditingController(); final _buildNumberController = TextEditingController(); + final _instanceIdController = TextEditingController(); + final _clientSessionIdController = TextEditingController(); + final _deviceLocaleController = TextEditingController(); + final _pushDeviceTypeController = TextEditingController(text: 'GCM'); String _selectedDeviceType = 'ANDROID'; String _selectedArch = 'arm64-v8a'; @@ -45,9 +51,34 @@ class _SpoofScreenState extends State { @override void initState() { super.initState(); + _localeController.addListener(_syncDeviceLocale); _loadInitialData(); } + void _syncDeviceLocale() { + if (_selectedMethod == SpoofingMethod.full) return; + final derived = _localeController.text.split(RegExp(r'[-_]')).first; + if (_deviceLocaleController.text != derived) { + _deviceLocaleController.text = derived; + } + } + + Future _confirmFullSpoofing() { + return showInfoActionSheet( + context, + headerIcon: Icons.warning_amber_rounded, + title: 'Могут быть последствия.', + subtitle: 'Меняй, только если знаешь что делаешь.', + confirmLabel: 'ОК', + confirmDelay: const Duration(seconds: 3), + ); + } + + Future _loadSessionIdentifiers() async { + _instanceIdController.text = await DeviceIdentity.instanceId(); + _clientSessionIdController.text = '${DeviceIdentity.clientSessionId}'; + } + String _generateDeviceId() { final bytes = List.generate(8, (_) => _random.nextInt(256)); return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); @@ -55,6 +86,7 @@ class _SpoofScreenState extends State { Future _loadInitialData() async { setState(() => _isLoading = true); + await _loadSessionIdentifiers(); final prefs = await SharedPreferences.getInstance(); final isSpoofingEnabled = prefs.getBool('spoofing_enabled') ?? false; @@ -336,6 +368,7 @@ class _SpoofScreenState extends State { @override void dispose() { + _localeController.removeListener(_syncDeviceLocale); _deviceNameController.dispose(); _osVersionController.dispose(); _screenController.dispose(); @@ -344,6 +377,10 @@ class _SpoofScreenState extends State { _deviceIdController.dispose(); _appVersionController.dispose(); _buildNumberController.dispose(); + _instanceIdController.dispose(); + _clientSessionIdController.dispose(); + _deviceLocaleController.dispose(); + _pushDeviceTypeController.dispose(); super.dispose(); } @@ -453,8 +490,16 @@ class _SpoofScreenState extends State { ), ], selected: {_selectedMethod}, - onSelectionChanged: (s) => - setState(() => _selectedMethod = s.first), + onSelectionChanged: (s) async { + final next = s.first; + if (next == _selectedMethod) return; + if (next == SpoofingMethod.full) { + final confirmed = await _confirmFullSpoofing(); + if (!confirmed || !mounted) return; + } + setState(() => _selectedMethod = next); + _syncDeviceLocale(); + }, ), const SizedBox(height: 12), descriptionWidget, @@ -604,6 +649,15 @@ class _SpoofScreenState extends State { Icons.language_outlined, ), ), + const SizedBox(height: 16), + TextField( + controller: _deviceLocaleController, + enabled: _selectedMethod == SpoofingMethod.full, + decoration: _inputDecoration( + l10n.spoofFieldDeviceLocale, + Icons.translate_outlined, + ), + ), ], ), ), @@ -625,6 +679,24 @@ class _SpoofScreenState extends State { text: l10n.spoofIdentifiersDescription, ), const SizedBox(height: 12), + TextField( + controller: _instanceIdController, + enabled: _selectedMethod == SpoofingMethod.full, + decoration: _inputDecoration( + l10n.spoofFieldInstanceId, + Icons.fingerprint_outlined, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _clientSessionIdController, + enabled: _selectedMethod == SpoofingMethod.full, + decoration: _inputDecoration( + l10n.spoofFieldClientSessionId, + Icons.vpn_key_outlined, + ), + ), + const SizedBox(height: 16), TextField( controller: _deviceIdController, decoration: @@ -642,7 +714,7 @@ class _SpoofScreenState extends State { const SizedBox(height: 16), TextField( controller: _appVersionController, - enabled: false, + enabled: _selectedMethod == SpoofingMethod.full, decoration: _inputDecoration( l10n.spoofFieldAppVersion, Icons.info_outline_rounded, @@ -651,7 +723,7 @@ class _SpoofScreenState extends State { const SizedBox(height: 16), TextField( controller: _buildNumberController, - enabled: false, + enabled: _selectedMethod == SpoofingMethod.full, keyboardType: TextInputType.number, decoration: _inputDecoration( l10n.spoofFieldBuildNumber, @@ -659,6 +731,15 @@ class _SpoofScreenState extends State { ), ), const SizedBox(height: 16), + TextField( + controller: _pushDeviceTypeController, + enabled: _selectedMethod == SpoofingMethod.full, + decoration: _inputDecoration( + l10n.spoofFieldPushDeviceType, + Icons.notifications_outlined, + ), + ), + const SizedBox(height: 16), Padding( padding: const EdgeInsets.only(left: 4, bottom: 8), child: Text( diff --git a/lib/frontend/widgets/info_action_sheet.dart b/lib/frontend/widgets/info_action_sheet.dart new file mode 100644 index 0000000..d6c6dce --- /dev/null +++ b/lib/frontend/widgets/info_action_sheet.dart @@ -0,0 +1,275 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class InfoActionSheetItem { + final IconData icon; + final String title; + final String body; + final Color? titleColor; + final Color? iconColor; + + const InfoActionSheetItem({ + required this.icon, + required this.title, + required this.body, + this.titleColor, + this.iconColor, + }); +} + +Future showInfoActionSheet( + BuildContext context, { + String? headerEmoji, + IconData? headerIcon, + required String title, + String? subtitle, + List items = const [], + String confirmLabel = 'ОК', + Duration confirmDelay = Duration.zero, + String? seenKey, +}) async { + assert( + headerEmoji == null || headerIcon == null, + 'Use either headerEmoji or headerIcon, not both.', + ); + + if (seenKey != null) { + final prefs = await SharedPreferences.getInstance(); + if (prefs.getBool(seenKey) ?? false) return true; + } + + if (!context.mounted) return false; + + final cs = Theme.of(context).colorScheme; + final confirmed = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => _InfoActionSheet( + headerEmoji: headerEmoji, + headerIcon: headerIcon, + title: title, + subtitle: subtitle, + items: items, + confirmLabel: confirmLabel, + confirmDelay: confirmDelay, + ), + ); + + final ok = confirmed == true; + if (ok && seenKey != null) { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(seenKey, true); + } + return ok; +} + +class _InfoActionSheet extends StatefulWidget { + final String? headerEmoji; + final IconData? headerIcon; + final String title; + final String? subtitle; + final List items; + final String confirmLabel; + final Duration confirmDelay; + + const _InfoActionSheet({ + this.headerEmoji, + this.headerIcon, + required this.title, + this.subtitle, + this.items = const [], + required this.confirmLabel, + required this.confirmDelay, + }); + + @override + State<_InfoActionSheet> createState() => _InfoActionSheetState(); +} + +class _InfoActionSheetState extends State<_InfoActionSheet> { + Timer? _ticker; + int _remaining = 0; + + @override + void initState() { + super.initState(); + _remaining = widget.confirmDelay.inSeconds; + if (_remaining > 0) { + _ticker = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() { + _remaining--; + if (_remaining == 0) { + _ticker?.cancel(); + _ticker = null; + } + }); + }); + } + } + + @override + void dispose() { + _ticker?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final enabled = _remaining == 0; + final buttonText = enabled + ? widget.confirmLabel + : '${widget.confirmLabel}($_remaining)'; + final hasHeader = widget.headerEmoji != null || widget.headerIcon != null; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 28, 24, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Flexible( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (hasHeader) ...[ + _buildHeader(cs), + const SizedBox(height: 18), + ], + Text( + widget.title, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 22, + fontWeight: FontWeight.w700, + ), + ), + if (widget.subtitle != null) ...[ + const SizedBox(height: 6), + Text( + widget.subtitle!, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + color: cs.onSurfaceVariant, + fontSize: 13, + fontWeight: FontWeight.w400, + ), + ), + ], + if (widget.items.isNotEmpty) ...[ + const SizedBox(height: 22), + for (int i = 0; i < widget.items.length; i++) ...[ + _buildItem(cs, widget.items[i]), + if (i < widget.items.length - 1) + const SizedBox(height: 18), + ], + ], + ], + ), + ), + ), + const SizedBox(height: 28), + FilledButton( + onPressed: enabled ? () => Navigator.pop(context, true) : null, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + disabledBackgroundColor: cs.primary.withValues(alpha: 0.45), + disabledForegroundColor: cs.onPrimary.withValues(alpha: 0.85), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(28), + ), + ), + child: Text( + buttonText, + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildHeader(ColorScheme cs) { + if (widget.headerEmoji != null) { + return Center( + child: Text( + widget.headerEmoji!, + style: const TextStyle(fontSize: 72, height: 1.0), + ), + ); + } + return Center( + child: Icon( + widget.headerIcon, + size: 72, + color: cs.primary, + weight: 400, + ), + ); + } + + Widget _buildItem(ColorScheme cs, InfoActionSheetItem item) { + final titleColor = item.titleColor ?? cs.onSurface; + final iconColor = item.iconColor ?? cs.onSurfaceVariant; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + item.icon, + size: 26, + color: iconColor, + weight: 400, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + style: GoogleFonts.inter( + color: titleColor, + fontSize: 16, + fontWeight: FontWeight.w700, + height: 1.25, + ), + ), + const SizedBox(height: 4), + Text( + item.body, + style: GoogleFonts.inter( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1.35, + ), + ), + ], + ), + ), + ], + ); + } +} + diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c7b227b..963fb5c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -73,8 +73,12 @@ "spoofFieldScreen": "Screen resolution", "spoofFieldTimezone": "Timezone", "spoofFieldLocale": "Locale", + "spoofFieldDeviceLocale": "Device locale (derived)", "spoofIdentifiersSectionTitle": "Identifiers", "spoofIdentifiersDescription": "mt_instanceid and clientSessionId are generated automatically on every app launch. Only the Device ID can be changed.", + "spoofFieldInstanceId": "mt_instanceid", + "spoofFieldClientSessionId": "clientSessionId", + "spoofFieldPushDeviceType": "Push device type", "spoofFieldDeviceId": "Device ID", "spoofRegenerateIdTooltip": "Generate a new ID", "spoofFieldAppVersion": "App version", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ce14093..ec39c9d 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -482,6 +482,12 @@ abstract class AppLocalizations { /// **'Locale'** String get spoofFieldLocale; + /// No description provided for @spoofFieldDeviceLocale. + /// + /// In en, this message translates to: + /// **'Device locale (derived)'** + String get spoofFieldDeviceLocale; + /// No description provided for @spoofIdentifiersSectionTitle. /// /// In en, this message translates to: @@ -494,6 +500,24 @@ abstract class AppLocalizations { /// **'mt_instanceid and clientSessionId are generated automatically on every app launch. Only the Device ID can be changed.'** String get spoofIdentifiersDescription; + /// No description provided for @spoofFieldInstanceId. + /// + /// In en, this message translates to: + /// **'mt_instanceid'** + String get spoofFieldInstanceId; + + /// No description provided for @spoofFieldClientSessionId. + /// + /// In en, this message translates to: + /// **'clientSessionId'** + String get spoofFieldClientSessionId; + + /// No description provided for @spoofFieldPushDeviceType. + /// + /// In en, this message translates to: + /// **'Push device type'** + String get spoofFieldPushDeviceType; + /// No description provided for @spoofFieldDeviceId. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6f4c3c6..62d18b3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -209,6 +209,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get spoofFieldLocale => 'Locale'; + @override + String get spoofFieldDeviceLocale => 'Device locale (derived)'; + @override String get spoofIdentifiersSectionTitle => 'Identifiers'; @@ -216,6 +219,15 @@ class AppLocalizationsEn extends AppLocalizations { String get spoofIdentifiersDescription => 'mt_instanceid and clientSessionId are generated automatically on every app launch. Only the Device ID can be changed.'; + @override + String get spoofFieldInstanceId => 'mt_instanceid'; + + @override + String get spoofFieldClientSessionId => 'clientSessionId'; + + @override + String get spoofFieldPushDeviceType => 'Push device type'; + @override String get spoofFieldDeviceId => 'Device ID'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 999ea90..7b439e7 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -211,6 +211,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get spoofFieldLocale => 'Локаль'; + @override + String get spoofFieldDeviceLocale => 'Системная локаль (производная)'; + @override String get spoofIdentifiersSectionTitle => 'Идентификаторы'; @@ -218,6 +221,15 @@ class AppLocalizationsRu extends AppLocalizations { String get spoofIdentifiersDescription => 'mt_instanceid и clientSessionId генерируются автоматически при каждом запуске приложения. Изменить можно только Device ID.'; + @override + String get spoofFieldInstanceId => 'mt_instanceid'; + + @override + String get spoofFieldClientSessionId => 'clientSessionId'; + + @override + String get spoofFieldPushDeviceType => 'Тип push-уведомлений'; + @override String get spoofFieldDeviceId => 'ID Устройства'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 3d134a9..7b3739e 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -73,8 +73,12 @@ "spoofFieldScreen": "Разрешение экрана", "spoofFieldTimezone": "Часовой пояс", "spoofFieldLocale": "Локаль", + "spoofFieldDeviceLocale": "Системная локаль (производная)", "spoofIdentifiersSectionTitle": "Идентификаторы", "spoofIdentifiersDescription": "mt_instanceid и clientSessionId генерируются автоматически при каждом запуске приложения. Изменить можно только Device ID.", + "spoofFieldInstanceId": "mt_instanceid", + "spoofFieldClientSessionId": "clientSessionId", + "spoofFieldPushDeviceType": "Тип push-уведомлений", "spoofFieldDeviceId": "ID Устройства", "spoofRegenerateIdTooltip": "Сгенерировать новый ID", "spoofFieldAppVersion": "Версия приложения",