diff --git a/mobile-app/lib/app/app.dart b/mobile-app/lib/app/app.dart index e48ccd570..aac8311c2 100644 --- a/mobile-app/lib/app/app.dart +++ b/mobile-app/lib/app/app.dart @@ -6,6 +6,7 @@ import 'package:freecodecamp/service/firebase/remote_config_service.dart'; import 'package:freecodecamp/service/learn/learn_file_service.dart'; import 'package:freecodecamp/service/learn/learn_offline_service.dart'; import 'package:freecodecamp/service/learn/learn_service.dart'; +import 'package:freecodecamp/service/symbol_bar_service.dart'; import 'package:freecodecamp/service/learn/daily_challenge_service.dart'; import 'package:freecodecamp/service/learn/daily_challenge_notification_service.dart'; import 'package:freecodecamp/service/locale_service.dart'; @@ -85,6 +86,7 @@ import 'package:stacked_services/stacked_services.dart'; LazySingleton(classType: LocaleService), LazySingleton(classType: DioService), LazySingleton(classType: NewsApiService), + LazySingleton(classType: SymbolBarService), ], logger: StackedLogger(), ) diff --git a/mobile-app/lib/main.dart b/mobile-app/lib/main.dart index bc7a7b893..358ec9fed 100644 --- a/mobile-app/lib/main.dart +++ b/mobile-app/lib/main.dart @@ -19,8 +19,10 @@ import 'package:freecodecamp/service/locale_service.dart'; import 'package:freecodecamp/service/navigation/quick_actions_service.dart'; import 'package:freecodecamp/service/news/api_service.dart'; import 'package:freecodecamp/service/podcast/notification_service.dart'; +import 'package:freecodecamp/service/symbol_bar_service.dart'; import 'package:freecodecamp/ui/theme/fcc_theme.dart'; import 'package:freecodecamp/utils/upgrade_controller.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:stacked_services/stacked_services.dart'; import 'package:upgrader/upgrader.dart'; @@ -33,6 +35,9 @@ Future main({bool testing = false}) async { await AppAudioService().init(); await AuthenticationService().init(); await NewsApiService().init(); + await locator().init( + await SharedPreferences.getInstance(), + ); var fbApp = await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); diff --git a/mobile-app/lib/models/symbol_set_model.dart b/mobile-app/lib/models/symbol_set_model.dart new file mode 100644 index 000000000..b9df9096e --- /dev/null +++ b/mobile-app/lib/models/symbol_set_model.dart @@ -0,0 +1,256 @@ +/// Symbol Set Models for the customizable quick-access symbol bar +/// +/// Architecture: +/// - [SymbolSetType]: Enum for predefined sets (Python, JavaScript, HTML/CSS) +/// - [SymbolSet]: Base model for any symbol set (predefined or custom) +/// - [PredefinedSymbolSet]: Sealed implementation of predefined sets +/// - [CustomSymbolSet]: User-defined symbol set +/// - [SymbolBarState]: Complete state including active set and enabled custom symbols + +enum SymbolSetType { + python('Python'), + javascript('JavaScript'), + htmlCss('HTML/CSS'); + + final String displayName; + const SymbolSetType(this.displayName); + + /// Convert string to enum value (case-insensitive) + static SymbolSetType fromValue(String value) { + return SymbolSetType.values.firstWhere( + (type) => type.name.toLowerCase() == value.toLowerCase(), + orElse: () => SymbolSetType.python, // Default fallback + ); + } +} + +/// Abstract base class for symbol sets +/// Enables type safety and future extensibility +abstract class SymbolSet { + String get name; + List get symbols; + bool get isCustom; + + /// Create a copy with modified values + SymbolSet copyWith({List? symbols}); + + /// Convert to JSON for storage + Map toJson(); + + /// Create from JSON + factory SymbolSet.fromJson(Map json) { + if (json['isCustom'] == true) { + return CustomSymbolSet.fromJson(json); + } + return PredefinedSymbolSet.fromJson(json); + } +} + +/// Predefined symbol sets (Python, JavaScript, HTML/CSS) +/// Immutable and generated from constants +class PredefinedSymbolSet implements SymbolSet { + final SymbolSetType type; + + PredefinedSymbolSet(this.type); + + @override + String get name => type.displayName; + + @override + List get symbols { + switch (type) { + case SymbolSetType.python: + return _pythonSymbols; + case SymbolSetType.javascript: + return _javascriptSymbols; + case SymbolSetType.htmlCss: + return _htmlCssSymbols; + } + } + + @override + bool get isCustom => false; + + @override + SymbolSet copyWith({List? symbols}) { + // Predefined sets are immutable, return self + return this; + } + + @override + Map toJson() { + return { + 'type': type.name, + 'isCustom': false, + 'symbols': symbols, + }; + } + + factory PredefinedSymbolSet.fromJson(Map json) { + final type = SymbolSetType.fromValue(json['type'] as String? ?? 'python'); + return PredefinedSymbolSet(type); + } + + // Python symbol set - ideal for Python, Ruby, and other interpreted languages + static const List _pythonSymbols = [ + 'Tab', + '(', + ')', + '[', + ']', + '{', + '}', + ':', + '#', + 'def', + 'class', + '=', + ]; + + // JavaScript symbol set - ideal for JavaScript, TypeScript, and modern web development + static const List _javascriptSymbols = [ + '(', + ')', + '{', + '}', + '[', + ']', + ';', + '=>', + 'const', + 'let', + 'var', + '=', + ]; + + // HTML/CSS symbol set - ideal for web markup and styling + static const List _htmlCssSymbols = [ + '<', + '>', + '/', + 'class=', + 'id=', + 'div', + 'p', + 'span', + ';', + '{', + '}', + ':', + ]; +} + +/// User-defined custom symbol set +/// Mutable and persisted to SharedPreferences +class CustomSymbolSet implements SymbolSet { + @override + final String name; + + @override + final List symbols; + + CustomSymbolSet({ + required this.name, + required this.symbols, + }); + + @override + bool get isCustom => true; + + @override + SymbolSet copyWith({List? symbols}) { + return CustomSymbolSet( + name: name, + symbols: symbols ?? this.symbols, + ); + } + + @override + Map toJson() { + return { + 'name': name, + 'symbols': symbols, + 'isCustom': true, + }; + } + + factory CustomSymbolSet.fromJson(Map json) { + return CustomSymbolSet( + name: json['name'] as String? ?? 'Custom', + symbols: List.from(json['symbols'] as List? ?? []), + ); + } +} + +/// Complete symbol bar state +/// Used for persistence and reactive state management +class SymbolBarState { + /// Currently active symbol set (predefined or custom) + final SymbolSet activeSet; + + /// Whether custom symbols are enabled + final bool customSymbolsEnabled; + + /// All user-defined custom symbol sets + final List customSymbolSets; + + SymbolBarState({ + required this.activeSet, + this.customSymbolsEnabled = false, + this.customSymbolSets = const [], + }); + + /// Get the currently displayed symbols + List get currentSymbols => activeSet.symbols; + + /// Create a copy with modified values + SymbolBarState copyWith({ + SymbolSet? activeSet, + bool? customSymbolsEnabled, + List? customSymbolSets, + }) { + return SymbolBarState( + activeSet: activeSet ?? this.activeSet, + customSymbolsEnabled: customSymbolsEnabled ?? this.customSymbolsEnabled, + customSymbolSets: customSymbolSets ?? this.customSymbolSets, + ); + } + + /// Convert to JSON for storage + Map toJson() { + return { + 'activeSet': activeSet.toJson(), + 'customSymbolsEnabled': customSymbolsEnabled, + 'customSymbolSets': customSymbolSets.map((s) => s.toJson()).toList(), + }; + } + + /// Create from JSON + factory SymbolBarState.fromJson(Map json) { + final activeSetJson = json['activeSet'] as Map?; + final activeSet = activeSetJson != null + ? SymbolSet.fromJson(activeSetJson) + : PredefinedSymbolSet(SymbolSetType.python); + + final customSymbolSetsJson = json['customSymbolSets'] as List?; + final customSymbolSets = (customSymbolSetsJson ?? []) + .cast>() + .map((s) => CustomSymbolSet.fromJson(s)) + .toList(); + + return SymbolBarState( + activeSet: activeSet, + customSymbolsEnabled: json['customSymbolsEnabled'] as bool? ?? false, + customSymbolSets: customSymbolSets, + ); + } + + /// Create default state (Python set active) + factory SymbolBarState.defaultState() { + return SymbolBarState( + activeSet: PredefinedSymbolSet(SymbolSetType.python), + customSymbolsEnabled: false, + customSymbolSets: [], + ); + } +} diff --git a/mobile-app/lib/service/symbol_bar_service.dart b/mobile-app/lib/service/symbol_bar_service.dart new file mode 100644 index 000000000..2dceb99ae --- /dev/null +++ b/mobile-app/lib/service/symbol_bar_service.dart @@ -0,0 +1,265 @@ +/// Symbol Bar Service +/// +/// Responsibilities: +/// - Load and save symbol bar state to SharedPreferences +/// - Switch between predefined symbol sets +/// - Create, update, delete custom symbol sets +/// - Provide reactive updates via listeners +/// - Handle backward compatibility with legacy hardcoded symbols + +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:freecodecamp/models/symbol_set_model.dart'; + +class SymbolBarService { + static const String _activeSetKey = 'symbol_bar_active_set'; + static const String _customSetKey = 'symbol_bar_custom_sets'; + static const String _customEnabledKey = 'symbol_bar_custom_enabled'; + + late SharedPreferences _prefs; + late SymbolBarState _state; + + /// List of listeners called when state changes + final List _listeners = []; + + /// Whether the service has been initialized + bool _initialized = false; + + /// Initialize the service and load persisted state + Future init(SharedPreferences prefs) async { + _prefs = prefs; + await _loadState(); + _initialized = true; + } + + /// Get current state + SymbolBarState get state => _state; + + /// Check if initialized + bool get isInitialized => _initialized; + + /// Add listener for state changes + void addListener(Function(SymbolBarState) listener) { + _listeners.add(listener); + } + + /// Remove listener + void removeListener(Function(SymbolBarState) listener) { + _listeners.remove(listener); + } + + /// Notify all listeners of state change + void _notifyListeners() { + for (var listener in _listeners) { + listener(_state); + } + } + + /// Load state from SharedPreferences + Future _loadState() async { + try { + final activeSetJson = _prefs.getString(_activeSetKey); + final customSetsJson = _prefs.getString(_customSetKey); + final customEnabled = _prefs.getBool(_customEnabledKey) ?? false; + + if (activeSetJson != null) { + // Deserialize from JSON + final activeSet = SymbolSet.fromJson( + jsonDecode(activeSetJson) as Map, + ); + + final customSymbolSets = []; + if (customSetsJson != null) { + final customList = jsonDecode(customSetsJson) as List; + customSymbolSets.addAll( + customList.cast>().map( + (json) => CustomSymbolSet.fromJson(json), + ), + ); + } + + _state = SymbolBarState( + activeSet: activeSet, + customSymbolsEnabled: customEnabled, + customSymbolSets: customSymbolSets, + ); + } else { + // First launch: use default state (Python set) + _state = SymbolBarState.defaultState(); + await _saveState(); + } + } catch (e) { + // If deserialization fails, reset to default + print('Error loading symbol bar state: $e. Resetting to default.'); + _state = SymbolBarState.defaultState(); + await _saveState(); + } + } + + /// Save state to SharedPreferences + Future _saveState() async { + try { + await _prefs.setString(_activeSetKey, jsonEncode(_state.activeSet.toJson())); + await _prefs.setString( + _customSetKey, + jsonEncode(_state.customSymbolSets.map((s) => s.toJson()).toList()), + ); + await _prefs.setBool(_customEnabledKey, _state.customSymbolsEnabled); + _notifyListeners(); + } catch (e) { + print('Error saving symbol bar state: $e'); + } + } + + /// Switch to a predefined symbol set + Future switchToPredefinedSet(SymbolSetType type) async { + final newSet = PredefinedSymbolSet(type); + _state = _state.copyWith( + activeSet: newSet, + customSymbolsEnabled: false, + ); + await _saveState(); + } + + /// Switch to a custom symbol set by name + Future switchToCustomSet(String setName) async { + final customSet = _state.customSymbolSets.firstWhere( + (set) => set.name == setName, + orElse: () => throw Exception('Custom set not found: $setName'), + ); + _state = _state.copyWith( + activeSet: customSet, + customSymbolsEnabled: true, + ); + await _saveState(); + } + + /// Create a new custom symbol set + Future createCustomSet({ + required String name, + required List symbols, + }) async { + // Validate inputs + if (name.trim().isEmpty) { + throw ArgumentError('Custom set name cannot be empty'); + } + if (symbols.isEmpty) { + throw ArgumentError('Custom set must contain at least one symbol'); + } + if (symbols.length > 50) { + throw ArgumentError('Custom set cannot exceed 50 symbols'); + } + + // Check for duplicate names + if (_state.customSymbolSets.any((set) => set.name == name)) { + throw ArgumentError('Custom set with name "$name" already exists'); + } + + final newSet = CustomSymbolSet(name: name, symbols: symbols); + final newCustomSets = [..._state.customSymbolSets, newSet]; + + _state = _state.copyWith(customSymbolSets: newCustomSets); + await _saveState(); + } + + /// Update an existing custom symbol set + Future updateCustomSet({ + required String currentName, + String? newName, + List? newSymbols, + }) async { + final index = _state.customSymbolSets.indexWhere((set) => set.name == currentName); + if (index == -1) { + throw Exception('Custom set not found: $currentName'); + } + + final currentSet = _state.customSymbolSets[index]; + final updatedName = newName ?? currentSet.name; + final updatedSymbols = newSymbols ?? currentSet.symbols; + + // Validate + if (updatedName.trim().isEmpty) { + throw ArgumentError('Custom set name cannot be empty'); + } + if (updatedSymbols.isEmpty) { + throw ArgumentError('Custom set must contain at least one symbol'); + } + if (updatedSymbols.length > 50) { + throw ArgumentError('Custom set cannot exceed 50 symbols'); + } + + // Check for duplicate names (excluding current set) + if (updatedName != currentName && + _state.customSymbolSets.any((set) => set.name == updatedName)) { + throw ArgumentError('Custom set with name "$updatedName" already exists'); + } + + final updatedSet = CustomSymbolSet( + name: updatedName, + symbols: updatedSymbols, + ); + + final newCustomSets = [..._state.customSymbolSets]; + newCustomSets[index] = updatedSet; + + // If this was the active set, update the active set reference + SymbolSet activeSet = _state.activeSet; + if (activeSet is CustomSymbolSet && activeSet.name == currentName) { + activeSet = updatedSet; + } + + _state = _state.copyWith( + activeSet: activeSet, + customSymbolSets: newCustomSets, + ); + await _saveState(); + } + + /// Delete a custom symbol set + Future deleteCustomSet(String setName) async { + final index = _state.customSymbolSets.indexWhere((set) => set.name == setName); + if (index == -1) { + throw Exception('Custom set not found: $setName'); + } + + final newCustomSets = [..._state.customSymbolSets]; + newCustomSets.removeAt(index); + + // If this was the active set, switch back to Python + SymbolSet activeSet = _state.activeSet; + if (activeSet is CustomSymbolSet && activeSet.name == setName) { + activeSet = PredefinedSymbolSet(SymbolSetType.python); + } + + _state = _state.copyWith( + activeSet: activeSet, + customSymbolSets: newCustomSets, + ); + await _saveState(); + } + + /// Get all available symbol sets (predefined + custom) + List getAllSymbolSets() { + return [ + PredefinedSymbolSet(SymbolSetType.python), + PredefinedSymbolSet(SymbolSetType.javascript), + PredefinedSymbolSet(SymbolSetType.htmlCss), + ..._state.customSymbolSets, + ]; + } + + /// Reset custom symbol sets and switch to Python + Future resetToDefaults() async { + _state = SymbolBarState.defaultState(); + await _saveState(); + } + + /// Clear all data (useful for testing or factory reset) + Future clearAll() async { + await _prefs.remove(_activeSetKey); + await _prefs.remove(_customSetKey); + await _prefs.remove(_customEnabledKey); + _state = SymbolBarState.defaultState(); + _notifyListeners(); + } +} diff --git a/mobile-app/lib/ui/viewmodels/symbol_bar_viewmodel.dart b/mobile-app/lib/ui/viewmodels/symbol_bar_viewmodel.dart new file mode 100644 index 000000000..8f3d38465 --- /dev/null +++ b/mobile-app/lib/ui/viewmodels/symbol_bar_viewmodel.dart @@ -0,0 +1,165 @@ +/// Symbol Bar ViewModel +/// +/// Responsible for managing the symbol bar UI state and interactions. +/// Uses Stacked MVVM pattern for reactive updates. +/// +/// Key Responsibilities: +/// - Reactive state management for symbol sets and custom symbols +/// - Handle user interactions (switching sets, creating/editing symbols) +/// - Provide getters for the view layer to observe +/// - Integrate with SymbolBarService for persistence + +import 'package:freecodecamp/app/app.locator.dart'; +import 'package:freecodecamp/models/symbol_set_model.dart'; +import 'package:freecodecamp/service/symbol_bar_service.dart'; +import 'package:stacked/stacked.dart'; +import 'package:stacked_services/stacked_services.dart'; + +class SymbolBarViewModel extends BaseViewModel { + final SymbolBarService _symbolBarService = locator(); + final DialogService _dialogService = locator(); + + // State getters + SymbolBarState get state => _symbolBarService.state; + List get currentSymbols => _symbolBarService.state.currentSymbols; + SymbolSet get activeSet => _symbolBarService.state.activeSet; + List get customSymbolSets => _symbolBarService.state.customSymbolSets; + bool get customSymbolsEnabled => _symbolBarService.state.customSymbolsEnabled; + + // All available symbol sets (predefined + custom) + List get allSymbolSets => _symbolBarService.getAllSymbolSets(); + + // Predefined symbol sets + List get predefinedSets => [ + PredefinedSymbolSet(SymbolSetType.python), + PredefinedSymbolSet(SymbolSetType.javascript), + PredefinedSymbolSet(SymbolSetType.htmlCss), + ]; + + /// Initialize the ViewModel and set up listeners + void init() { + _symbolBarService.addListener(_onSymbolBarStateChanged); + } + + /// Clean up resources + @override + void dispose() { + _symbolBarService.removeListener(_onSymbolBarStateChanged); + super.dispose(); + } + + /// Called whenever the symbol bar state changes + /// Triggers UI rebuild via notifyListeners() + void _onSymbolBarStateChanged(SymbolBarState newState) { + notifyListeners(); + } + + // ==================== PREDEFINED SETS ==================== + + /// Switch to a predefined symbol set + Future switchToPredefinedSet(SymbolSetType type) async { + await _symbolBarService.switchToPredefinedSet(type); + } + + // ==================== CUSTOM SETS ==================== + + /// Create a new custom symbol set + /// + /// Throws ArgumentError if: + /// - name is empty + /// - symbols list is empty or > 50 + /// - name already exists + Future createCustomSet({ + required String name, + required List symbols, + }) async { + try { + await _symbolBarService.createCustomSet( + name: name, + symbols: symbols, + ); + } catch (e) { + rethrow; + } + } + + /// Update an existing custom symbol set + /// + /// Throws Exception if set not found + Future updateCustomSet({ + required String currentName, + String? newName, + List? newSymbols, + }) async { + try { + await _symbolBarService.updateCustomSet( + currentName: currentName, + newName: newName, + newSymbols: newSymbols, + ); + } catch (e) { + rethrow; + } + } + + /// Delete a custom symbol set + /// + /// If it's currently active, switches to Python set + Future deleteCustomSet(String setName) async { + try { + await _symbolBarService.deleteCustomSet(setName); + } catch (e) { + rethrow; + } + } + + /// Switch to a custom symbol set by name + Future switchToCustomSet(String setName) async { + try { + await _symbolBarService.switchToCustomSet(setName); + } catch (e) { + rethrow; + } + } + + // ==================== UTILITIES ==================== + + /// Check if a custom set name already exists + bool customSetNameExists(String name) { + return customSymbolSets.any((set) => set.name == name); + } + + /// Get a custom set by name + CustomSymbolSet? getCustomSetByName(String name) { + try { + return customSymbolSets.firstWhere((set) => set.name == name); + } catch (e) { + return null; + } + } + + /// Reset all custom symbol sets and switch to Python + Future resetToDefaults() async { + await _symbolBarService.resetToDefaults(); + } + + /// Get display name for the active set + String getActiveSetDisplayName() { + if (activeSet is PredefinedSymbolSet) { + return (activeSet as PredefinedSymbolSet).type.displayName; + } else if (activeSet is CustomSymbolSet) { + return (activeSet as CustomSymbolSet).name; + } + return 'Unknown'; + } + + /// Validate symbol (not empty, reasonable length) + bool isValidSymbol(String symbol) { + return symbol.isNotEmpty && symbol.length <= 50; + } + + /// Validate symbol list + bool isValidSymbolList(List symbols) { + return symbols.isNotEmpty && symbols.length <= 50 && symbols.every(isValidSymbol); + } +} diff --git a/mobile-app/lib/ui/views/learn/challenge/challenge_viewmodel.dart b/mobile-app/lib/ui/views/learn/challenge/challenge_viewmodel.dart index a65f87b64..24cdbb22b 100644 --- a/mobile-app/lib/ui/views/learn/challenge/challenge_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/challenge/challenge_viewmodel.dart @@ -513,7 +513,18 @@ class ChallengeViewModel extends BaseViewModel { }); } - // This function allows the symbols to be insterted into the text controllers + /// Insert a symbol at the current cursor position in the editor + /// + /// This method is called by the SymbolBar widget when a user clicks a symbol. + /// The SymbolBar widget displays symbols from SymbolBarService, which manages + /// predefined and custom symbol sets. This method handles the actual text insertion. + /// + /// Architecture: SymbolBarService (state/persistence) → SymbolBar widget (UI) → + /// insertSymbol (insertion logic) + /// + /// Parameters: + /// - [symbol]: The symbol string to insert (can be multi-character like "=>") + /// - [editor]: The Editor instance where the symbol should be inserted void insertSymbol(String symbol, Editor editor) async { final TextEditingControllerIDE focused = textFieldData!.controller; final RegionPosition position = textFieldData!.position; diff --git a/mobile-app/lib/ui/views/learn/widgets/challenge_widgets/symbol_bar.dart b/mobile-app/lib/ui/views/learn/widgets/challenge_widgets/symbol_bar.dart index 0e2dd1ada..826558abd 100644 --- a/mobile-app/lib/ui/views/learn/widgets/challenge_widgets/symbol_bar.dart +++ b/mobile-app/lib/ui/views/learn/widgets/challenge_widgets/symbol_bar.dart @@ -1,65 +1,73 @@ import 'package:flutter/material.dart'; import 'package:flutter_scroll_shadow/flutter_scroll_shadow.dart'; import 'package:freecodecamp/ui/views/learn/challenge/challenge_viewmodel.dart'; +import 'package:freecodecamp/ui/viewmodels/symbol_bar_viewmodel.dart'; import 'package:phone_ide/phone_ide.dart'; +import 'package:stacked/stacked.dart'; +/// Symbol Bar Widget +/// +/// Displays a horizontal scrollable bar of quick-access symbols. +/// Supports both predefined and user-defined custom symbol sets. +/// +/// The symbol bar: +/// - Automatically adapts to the currently selected symbol set +/// - Persists user preferences via SymbolBarService +/// - Inserts symbols at the cursor position in the code editor class SymbolBar extends StatelessWidget { const SymbolBar({ super.key, required this.editor, - required this.model, + required this.challengeModel, }); + /// The code editor instance where symbols will be inserted final Editor editor; - final ChallengeViewModel model; - static const List symbols = [ - 'Tab', - '<', - '/', - '>', - '\\', - '\'', - '"', - '=', - '{', - '}' - ]; + /// The challenge view model that handles symbol insertion + final ChallengeViewModel challengeModel; @override Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.only(bottom: 8), - height: 50, - color: const Color(0xFF1b1b32), - child: ScrollShadow( - size: 12, - child: ListView.builder( - scrollDirection: Axis.horizontal, - controller: model.symbolBarScrollController, - padding: const EdgeInsets.symmetric(horizontal: 8), - itemCount: symbols.length, - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.symmetric( - vertical: 4, - horizontal: 1, - ), - child: TextButton( - onPressed: () { - model.insertSymbol(symbols[index], editor); - }, - style: TextButton.styleFrom( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.zero), + return ViewModelBuilder.reactive( + viewModelBuilder: () => SymbolBarViewModel(), + onViewModelReady: (model) => model.init(), + builder: (context, model, child) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + height: 50, + color: const Color(0xFF1b1b32), + child: ScrollShadow( + size: 12, + child: ListView.builder( + scrollDirection: Axis.horizontal, + controller: challengeModel.symbolBarScrollController, + padding: const EdgeInsets.symmetric(horizontal: 8), + itemCount: model.currentSymbols.length, + itemBuilder: (context, index) { + final symbol = model.currentSymbols[index]; + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 1, ), - ), - child: Text(symbols[index]), - ), - ); - }, - ), - ), + child: TextButton( + onPressed: () { + challengeModel.insertSymbol(symbol, editor); + }, + style: TextButton.styleFrom( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.zero), + ), + ), + child: Text(symbol), + ), + ); + }, + ), + ), + ); + }, ); } } diff --git a/mobile-app/lib/ui/views/settings/settings_view.dart b/mobile-app/lib/ui/views/settings/settings_view.dart index 2a28d073f..7d59adab5 100644 --- a/mobile-app/lib/ui/views/settings/settings_view.dart +++ b/mobile-app/lib/ui/views/settings/settings_view.dart @@ -3,6 +3,7 @@ import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/service/authentication/authentication_service.dart'; import 'package:freecodecamp/ui/views/settings/delete-account/delete_account_view.dart'; import 'package:freecodecamp/ui/views/settings/settings_viewmodel.dart'; +import 'package:freecodecamp/ui/views/settings/widgets/symbol_settings_widget.dart'; import 'package:freecodecamp/ui/widgets/drawer_widget/drawer_widget_view.dart'; import 'package:stacked/stacked.dart'; @@ -51,6 +52,21 @@ class SettingsView extends StatelessWidget { onTap: () => model.resetCache(context), ), buildDivider(), + ListTile( + leading: const Icon(Icons.functions), + title: const Text('Symbol Bar Settings'), + subtitle: const Text( + 'Customize quick-access symbols', + ), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SymbolSettingsWidget(), + settings: const RouteSettings(name: '/symbol-settings'), + ), + ), + ), + buildDivider(), ListTile( leading: const Icon(Icons.privacy_tip), title: Text( diff --git a/mobile-app/lib/ui/views/settings/widgets/symbol_settings_widget.dart b/mobile-app/lib/ui/views/settings/widgets/symbol_settings_widget.dart new file mode 100644 index 000000000..b98dc7f47 --- /dev/null +++ b/mobile-app/lib/ui/views/settings/widgets/symbol_settings_widget.dart @@ -0,0 +1,468 @@ +/// Symbol Settings Widget +/// +/// Provides a comprehensive UI for managing symbol sets: +/// - View and switch between predefined symbol sets +/// - Create new custom symbol sets +/// - Edit and delete custom symbol sets +/// - Reset to defaults +/// +/// This widget is embedded in the Settings view to give users +/// full control over their symbol bar customization. + +import 'package:flutter/material.dart'; +import 'package:freecodecamp/models/symbol_set_model.dart'; +import 'package:freecodecamp/ui/viewmodels/symbol_bar_viewmodel.dart'; +import 'package:stacked/stacked.dart'; + +class SymbolSettingsWidget extends StatelessWidget { + const SymbolSettingsWidget({super.key}); + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => SymbolBarViewModel(), + onViewModelReady: (model) => model.init(), + builder: (context, model, child) { + return Scaffold( + appBar: AppBar( + title: const Text('Symbol Bar Settings'), + ), + body: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Predefined Sets Section + _buildSectionHeader(context, 'Predefined Symbol Sets'), + ...model.predefinedSets + .map((set) => _buildPredefinedSetTile(context, model, set)) + .toList(), + const Divider(), + + // Custom Sets Section + _buildSectionHeader(context, 'Custom Symbol Sets'), + if (model.customSymbolSets.isEmpty) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), + child: Text( + 'No custom symbol sets created yet', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey, + ), + ), + ) + else + ...model.customSymbolSets + .map((set) => _buildCustomSetTile(context, model, set)) + .toList(), + + // Create Custom Set Button + Padding( + padding: const EdgeInsets.all(16), + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => _showCreateCustomSetDialog(context, model), + icon: const Icon(Icons.add), + label: const Text('Create Custom Set'), + ), + ), + ), + + const Divider(), + + // Reset to Defaults Button + if (model.customSymbolSets.isNotEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => _showResetConfirmDialog(context, model), + icon: const Icon(Icons.refresh), + label: const Text('Reset to Defaults'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + + /// Build a predefined set ListTile + Widget _buildPredefinedSetTile( + BuildContext context, + SymbolBarViewModel model, + PredefinedSymbolSet set, + ) { + final isActive = model.activeSet is PredefinedSymbolSet && + (model.activeSet as PredefinedSymbolSet).type == set.type; + + return ListTile( + leading: Icon( + isActive ? Icons.check_circle : Icons.radio_button_unchecked, + color: isActive ? Colors.green : Colors.grey, + ), + title: Text(set.type.displayName), + subtitle: Text( + '${set.symbols.length} symbols', + style: Theme.of(context).textTheme.bodySmall, + ), + trailing: _buildSymbolPreview(set.symbols), + onTap: () => model.switchToPredefinedSet(set.type), + ); + } + + /// Build a custom set ListTile with edit/delete options + Widget _buildCustomSetTile( + BuildContext context, + SymbolBarViewModel model, + CustomSymbolSet set, + ) { + final isActive = model.activeSet is CustomSymbolSet && + (model.activeSet as CustomSymbolSet).name == set.name; + + return ListTile( + leading: Icon( + isActive ? Icons.check_circle : Icons.radio_button_unchecked, + color: isActive ? Colors.green : Colors.grey, + ), + title: Text(set.name), + subtitle: Text( + '${set.symbols.length} symbols', + style: Theme.of(context).textTheme.bodySmall, + ), + trailing: SizedBox( + width: 120, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + icon: const Icon(Icons.edit, size: 20), + tooltip: 'Edit', + onPressed: () => _showEditCustomSetDialog(context, model, set), + ), + IconButton( + icon: const Icon(Icons.delete, size: 20, color: Colors.red), + tooltip: 'Delete', + onPressed: () => _showDeleteConfirmDialog(context, model, set), + ), + ], + ), + ), + onTap: () => model.switchToCustomSet(set.name), + ); + } + + /// Build a small preview of symbols + Widget _buildSymbolPreview(List symbols) { + final preview = symbols.take(3).join(' '); + return Tooltip( + message: symbols.join(', '), + child: Text( + preview + (symbols.length > 3 ? '...' : ''), + style: const TextStyle( + fontSize: 11, + fontFamily: 'monospace', + ), + ), + ); + } + + /// Build a section header + Widget _buildSectionHeader(BuildContext context, String title) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ); + } + + // ==================== DIALOGS ==================== + + /// Show dialog to create a new custom symbol set + void _showCreateCustomSetDialog( + BuildContext context, + SymbolBarViewModel model, + ) { + final nameController = TextEditingController(); + final symbolsController = TextEditingController(); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Create Custom Symbol Set'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: 'Set Name', + hintText: 'e.g., My Custom Symbols', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + TextField( + controller: symbolsController, + maxLines: 4, + decoration: const InputDecoration( + labelText: 'Symbols (comma-separated)', + hintText: 'e.g., (, ), {, }, [, ]', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + Text( + 'Separate symbols with commas. Maximum 50 symbols.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + final name = nameController.text.trim(); + final symbolsText = symbolsController.text.trim(); + + if (name.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter a set name')), + ); + return; + } + + final symbols = symbolsText + .split(',') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + + if (symbols.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter at least one symbol')), + ); + return; + } + + if (symbols.length > 50) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Maximum 50 symbols allowed')), + ); + return; + } + + try { + model.createCustomSet(name: name, symbols: symbols); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Custom set "$name" created')), + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + }, + child: const Text('Create'), + ), + ], + ), + ); + } + + /// Show dialog to edit a custom symbol set + void _showEditCustomSetDialog( + BuildContext context, + SymbolBarViewModel model, + CustomSymbolSet set, + ) { + final nameController = TextEditingController(text: set.name); + final symbolsController = TextEditingController(text: set.symbols.join(', ')); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Edit "${set.name}"'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: 'Set Name', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + TextField( + controller: symbolsController, + maxLines: 4, + decoration: const InputDecoration( + labelText: 'Symbols (comma-separated)', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + Text( + 'Separate symbols with commas. Maximum 50 symbols.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + final newName = nameController.text.trim(); + final symbolsText = symbolsController.text.trim(); + + if (newName.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter a set name')), + ); + return; + } + + final symbols = symbolsText + .split(',') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + + if (symbols.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter at least one symbol')), + ); + return; + } + + if (symbols.length > 50) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Maximum 50 symbols allowed')), + ); + return; + } + + try { + model.updateCustomSet( + currentName: set.name, + newName: newName, + newSymbols: symbols, + ); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Custom set updated')), + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + }, + child: const Text('Save'), + ), + ], + ), + ); + } + + /// Show confirmation dialog to delete a custom set + void _showDeleteConfirmDialog( + BuildContext context, + SymbolBarViewModel model, + CustomSymbolSet set, + ) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Custom Set?'), + content: Text( + 'Are you sure you want to delete "${set.name}"? This action cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + model.deleteCustomSet(set.name); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Custom set "${set.name}" deleted')), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + ), + child: const Text('Delete'), + ), + ], + ), + ); + } + + /// Show confirmation dialog to reset all to defaults + void _showResetConfirmDialog( + BuildContext context, + SymbolBarViewModel model, + ) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Reset to Defaults?'), + content: const Text( + 'This will delete all custom symbol sets and reset to the predefined sets. This action cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + model.resetToDefaults(); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Reset to defaults')), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + ), + child: const Text('Reset'), + ), + ], + ), + ); + } +} diff --git a/mobile-app/test/services/symbol_bar_service_test.dart b/mobile-app/test/services/symbol_bar_service_test.dart new file mode 100644 index 000000000..15bf40c3a --- /dev/null +++ b/mobile-app/test/services/symbol_bar_service_test.dart @@ -0,0 +1,393 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:freecodecamp/models/symbol_set_model.dart'; +import 'package:freecodecamp/service/symbol_bar_service.dart'; +import 'package:mockito/mockito.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// Mock SharedPreferences +class MockSharedPreferences extends Mock implements SharedPreferences {} + +void main() { + group('SymbolSetModel Tests', () { + test('SymbolSetType.fromValue should parse string correctly', () { + expect(SymbolSetType.fromValue('python'), SymbolSetType.python); + expect(SymbolSetType.fromValue('PYTHON'), SymbolSetType.python); + expect(SymbolSetType.fromValue('javascript'), SymbolSetType.javascript); + expect(SymbolSetType.fromValue('htmlCss'), SymbolSetType.htmlCss); + expect(SymbolSetType.fromValue('invalid'), SymbolSetType.python); // Default fallback + }); + + test('PredefinedSymbolSet should return correct symbols', () { + final pythonSet = PredefinedSymbolSet(SymbolSetType.python); + expect(pythonSet.symbols.length, 12); + expect(pythonSet.symbols.contains('def'), true); + expect(pythonSet.symbols.contains('class'), true); + + final jsSet = PredefinedSymbolSet(SymbolSetType.javascript); + expect(jsSet.symbols.length, 12); + expect(jsSet.symbols.contains('=>'), true); + expect(jsSet.symbols.contains('const'), true); + + final htmlCssSet = PredefinedSymbolSet(SymbolSetType.htmlCss); + expect(htmlCssSet.symbols.length, 12); + expect(htmlCssSet.symbols.contains('div'), true); + expect(htmlCssSet.symbols.contains('class='), true); + }); + + test('PredefinedSymbolSet should not be custom', () { + final set = PredefinedSymbolSet(SymbolSetType.python); + expect(set.isCustom, false); + }); + + test('PredefinedSymbolSet.copyWith should return self', () { + final set = PredefinedSymbolSet(SymbolSetType.python); + final copied = set.copyWith(symbols: ['new']); + expect(identical(set, copied), true); + }); + + test('PredefinedSymbolSet.toJson should serialize correctly', () { + final set = PredefinedSymbolSet(SymbolSetType.python); + final json = set.toJson(); + expect(json['type'], 'python'); + expect(json['isCustom'], false); + expect(json['symbols'], isA()); + }); + + test('CustomSymbolSet should be custom', () { + final set = CustomSymbolSet(name: 'My Set', symbols: ['a', 'b']); + expect(set.isCustom, true); + }); + + test('CustomSymbolSet.copyWith should create new instance', () { + final set = CustomSymbolSet(name: 'My Set', symbols: ['a', 'b']); + final copied = set.copyWith(symbols: ['c', 'd']); + expect(identical(set, copied), false); + expect(copied.name, 'My Set'); + expect(copied.symbols, ['c', 'd']); + }); + + test('CustomSymbolSet.toJson should serialize correctly', () { + final set = CustomSymbolSet(name: 'My Set', symbols: ['a', 'b']); + final json = set.toJson(); + expect(json['name'], 'My Set'); + expect(json['isCustom'], true); + expect(json['symbols'], ['a', 'b']); + }); + + test('CustomSymbolSet.fromJson should deserialize correctly', () { + final json = { + 'name': 'My Set', + 'symbols': ['a', 'b', 'c'], + 'isCustom': true, + }; + final set = CustomSymbolSet.fromJson(json); + expect(set.name, 'My Set'); + expect(set.symbols, ['a', 'b', 'c']); + }); + + test('SymbolSet.fromJson should create correct instance', () { + final predefinedJson = { + 'type': 'python', + 'isCustom': false, + }; + final predefined = SymbolSet.fromJson(predefinedJson); + expect(predefined is PredefinedSymbolSet, true); + + final customJson = { + 'name': 'Custom', + 'symbols': ['x', 'y'], + 'isCustom': true, + }; + final custom = SymbolSet.fromJson(customJson); + expect(custom is CustomSymbolSet, true); + }); + + test('SymbolBarState should have correct currentSymbols', () { + final set = PredefinedSymbolSet(SymbolSetType.python); + final state = SymbolBarState(activeSet: set); + expect(state.currentSymbols, set.symbols); + }); + + test('SymbolBarState.copyWith should create new instance', () { + final set1 = PredefinedSymbolSet(SymbolSetType.python); + final set2 = PredefinedSymbolSet(SymbolSetType.javascript); + final state1 = SymbolBarState(activeSet: set1); + final state2 = state1.copyWith(activeSet: set2); + + expect(identical(state1, state2), false); + expect(state1.activeSet, set1); + expect(state2.activeSet, set2); + }); + + test('SymbolBarState.defaultState should return Python set', () { + final state = SymbolBarState.defaultState(); + expect(state.activeSet is PredefinedSymbolSet, true); + expect( + (state.activeSet as PredefinedSymbolSet).type, + SymbolSetType.python, + ); + expect(state.customSymbolsEnabled, false); + expect(state.customSymbolSets.isEmpty, true); + }); + + test('SymbolBarState.toJson and fromJson should round-trip', () { + final customSet = + CustomSymbolSet(name: 'Test Set', symbols: ['a', 'b']); + final originalState = SymbolBarState( + activeSet: customSet, + customSymbolsEnabled: true, + customSymbolSets: [customSet], + ); + + final json = originalState.toJson(); + final restoredState = SymbolBarState.fromJson(json); + + expect(restoredState.activeSet is CustomSymbolSet, true); + expect((restoredState.activeSet as CustomSymbolSet).name, 'Test Set'); + expect(restoredState.customSymbolsEnabled, true); + expect(restoredState.customSymbolSets.length, 1); + }); + }); + + group('SymbolBarService Tests', () { + late MockSharedPreferences mockPrefs; + late SymbolBarService service; + + setUp(() { + mockPrefs = MockSharedPreferences(); + service = SymbolBarService(); + }); + + test('init should load default state when no saved state exists', () async { + // Setup mock to return null (no saved state) + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + + await service.init(mockPrefs); + + expect(service.state.activeSet is PredefinedSymbolSet, true); + expect( + (service.state.activeSet as PredefinedSymbolSet).type, + SymbolSetType.python, + ); + }); + + test('switchToPredefinedSet should change active set', () async { + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + when(mockPrefs.setString(any, any)).thenAnswer((_) async => true); + when(mockPrefs.setBool(any, any)).thenAnswer((_) async => true); + + await service.init(mockPrefs); + + await service.switchToPredefinedSet(SymbolSetType.javascript); + + expect(service.state.activeSet is PredefinedSymbolSet, true); + expect( + (service.state.activeSet as PredefinedSymbolSet).type, + SymbolSetType.javascript, + ); + }); + + test('createCustomSet should add new custom set', () async { + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + when(mockPrefs.setString(any, any)).thenAnswer((_) async => true); + when(mockPrefs.setBool(any, any)).thenAnswer((_) async => true); + + await service.init(mockPrefs); + + await service.createCustomSet( + name: 'Test Set', + symbols: ['a', 'b', 'c'], + ); + + expect(service.state.customSymbolSets.length, 1); + expect(service.state.customSymbolSets[0].name, 'Test Set'); + expect(service.state.customSymbolSets[0].symbols, ['a', 'b', 'c']); + }); + + test('createCustomSet should reject empty name', () async { + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + + await service.init(mockPrefs); + + expect( + () => service.createCustomSet( + name: '', + symbols: ['a'], + ), + throwsA(isA()), + ); + }); + + test('createCustomSet should reject empty symbols', () async { + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + + await service.init(mockPrefs); + + expect( + () => service.createCustomSet( + name: 'Test', + symbols: [], + ), + throwsA(isA()), + ); + }); + + test('createCustomSet should reject duplicate names', () async { + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + when(mockPrefs.setString(any, any)).thenAnswer((_) async => true); + when(mockPrefs.setBool(any, any)).thenAnswer((_) async => true); + + await service.init(mockPrefs); + + await service.createCustomSet( + name: 'Test Set', + symbols: ['a', 'b'], + ); + + expect( + () => service.createCustomSet( + name: 'Test Set', + symbols: ['c', 'd'], + ), + throwsA(isA()), + ); + }); + + test('updateCustomSet should modify existing set', () async { + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + when(mockPrefs.setString(any, any)).thenAnswer((_) async => true); + when(mockPrefs.setBool(any, any)).thenAnswer((_) async => true); + + await service.init(mockPrefs); + + await service.createCustomSet( + name: 'Original', + symbols: ['a', 'b'], + ); + + await service.updateCustomSet( + currentName: 'Original', + newName: 'Updated', + newSymbols: ['c', 'd', 'e'], + ); + + expect(service.state.customSymbolSets[0].name, 'Updated'); + expect(service.state.customSymbolSets[0].symbols, ['c', 'd', 'e']); + }); + + test('deleteCustomSet should remove set', () async { + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + when(mockPrefs.setString(any, any)).thenAnswer((_) async => true); + when(mockPrefs.setBool(any, any)).thenAnswer((_) async => true); + + await service.init(mockPrefs); + + await service.createCustomSet( + name: 'Test Set', + symbols: ['a', 'b'], + ); + + await service.deleteCustomSet('Test Set'); + + expect(service.state.customSymbolSets.isEmpty, true); + }); + + test('deleteCustomSet should switch to Python if active', () async { + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + when(mockPrefs.setString(any, any)).thenAnswer((_) async => true); + when(mockPrefs.setBool(any, any)).thenAnswer((_) async => true); + + await service.init(mockPrefs); + + final customSet = CustomSymbolSet( + name: 'Active Set', + symbols: ['a', 'b'], + ); + final newState = service.state.copyWith(activeSet: customSet); + service.state == newState; + + await service.deleteCustomSet('Active Set'); + + expect(service.state.activeSet is PredefinedSymbolSet, true); + expect( + (service.state.activeSet as PredefinedSymbolSet).type, + SymbolSetType.python, + ); + }); + + test('getAllSymbolSets should return all sets', () async { + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + when(mockPrefs.setString(any, any)).thenAnswer((_) async => true); + when(mockPrefs.setBool(any, any)).thenAnswer((_) async => true); + + await service.init(mockPrefs); + + await service.createCustomSet( + name: 'Custom 1', + symbols: ['a'], + ); + + final allSets = service.getAllSymbolSets(); + + // 3 predefined + 1 custom + expect(allSets.length, 4); + expect( + allSets.where((s) => s is PredefinedSymbolSet).length, + 3, + ); + expect( + allSets.where((s) => s is CustomSymbolSet).length, + 1, + ); + }); + + test('resetToDefaults should clear all custom sets', () async { + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + when(mockPrefs.setString(any, any)).thenAnswer((_) async => true); + when(mockPrefs.setBool(any, any)).thenAnswer((_) async => true); + + await service.init(mockPrefs); + + await service.createCustomSet( + name: 'Custom Set', + symbols: ['a'], + ); + + await service.resetToDefaults(); + + expect(service.state.customSymbolSets.isEmpty, true); + expect(service.state.activeSet is PredefinedSymbolSet, true); + expect( + (service.state.activeSet as PredefinedSymbolSet).type, + SymbolSetType.python, + ); + }); + + test('listeners should be notified on state change', () async { + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + when(mockPrefs.setString(any, any)).thenAnswer((_) async => true); + when(mockPrefs.setBool(any, any)).thenAnswer((_) async => true); + + await service.init(mockPrefs); + + var callCount = 0; + service.addListener((_) => callCount++); + + await service.switchToPredefinedSet(SymbolSetType.javascript); + + expect(callCount, greaterThan(0)); + }); + }); +}