import 'dart:async'; import 'package:flutter/foundation.dart'; import '../../data/models/app_entry.dart'; import '../../data/models/slot_assignment.dart'; import '../../data/models/slot_type.dart'; import '../../data/repositories/settings_repository.dart'; import '../../data/services/native_bridge.dart'; /// Owns installed apps, shortcut assignments and the default-home state. class LauncherController extends ChangeNotifier { LauncherController(this._platform, this._settings); final LauncherPlatform _platform; final SettingsRepository _settings; static const maxHomeButtons = 16; final Map _slots = {}; final Map _slotEnabled = {}; final List _pinnedApps = []; List _apps = const []; bool _appsLoaded = false; bool _loadingApps = false; Object? _appsError; DateTime? _lastAppsLoad; bool _defaultHome = false; bool _defaultHomeKnown = false; Timer? _stateTimer; bool _disposed = false; List get apps => _apps; bool get isLoadingApps => _loadingApps; bool get appsLoaded => _appsLoaded; Object? get appsError => _appsError; bool get isDefaultHome => _defaultHome; bool get defaultHomeKnown => _defaultHomeKnown; SlotAssignment? assignmentFor(SlotType slot) => _slots[slot]; bool isSlotEnabled(SlotType slot) => _slotEnabled[slot] ?? true; /// Settings and All apps are always present; the three fixed slots count only /// while enabled, and each pinned app adds one. int get homeButtonCount => 2 + SlotType.values.where(isSlotEnabled).length + _pinnedApps.length; int get enabledSlotCount => SlotType.values.where(isSlotEnabled).length; List get pinnedApps => List.unmodifiable(_pinnedApps); bool isPinned(SlotAssignment assignment) => _pinnedApps.contains(assignment); AppEntry? appForAssignment(SlotAssignment assignment) { for (final app in _apps) { if (app.matches(assignment)) return app; } return null; } /// Loads persisted choices synchronously, then refreshes platform state in /// the background so the first frame is not blocked by package enumeration. void initialize() { for (final slot in SlotType.values) { _slotEnabled[slot] = _settings.slotEnabled(slot); final assignment = _settings.slot(slot); if (assignment != null) _slots[slot] = assignment; } _pinnedApps.addAll(_settings.pinnedApps); final loadedCount = homeButtonCount; var trimmed = false; while (homeButtonCount > maxHomeButtons && _pinnedApps.isNotEmpty) { _pinnedApps.removeLast(); trimmed = true; } if (trimmed || loadedCount != homeButtonCount) { unawaited(_settings.setPinnedApps(_pinnedApps)); } _notify(); unawaited(refreshDefaultHome()); unawaited(loadApps()); } Future loadApps({bool force = false}) async { if (_loadingApps) return; if (_appsLoaded && !force) return; _loadingApps = true; _appsError = null; _notify(); try { final loaded = await _platform.listApps(); _apps = List.unmodifiable(loaded); _appsLoaded = true; _lastAppsLoad = DateTime.now(); await _validateAssignments(); } catch (error) { _appsError = error; } finally { _loadingApps = false; _notify(); } } /// Called when the launcher returns to the foreground. The package list is /// refreshed at most once a minute unless [forceApps] is set. Future refresh({bool forceApps = false}) async { await refreshDefaultHome(); final last = _lastAppsLoad; if (forceApps || last == null || DateTime.now().difference(last).inSeconds > 60) { await loadApps(force: true); } } Future _validateAssignments() async { final installed = _apps .map((app) => '${app.packageName}/${app.activityName}') .toSet(); final missing = []; _slots.forEach((slot, assignment) { if (!installed.contains( '${assignment.packageName}/${assignment.activityName}', )) { missing.add(slot); } }); for (final slot in missing) { _slots.remove(slot); await _settings.clearSlot(slot); } final missingPinned = _pinnedApps .where( (assignment) => !installed.contains( '${assignment.packageName}/${assignment.activityName}', ), ) .toList(growable: false); if (missingPinned.isNotEmpty) { _pinnedApps.removeWhere(missingPinned.contains); await _settings.setPinnedApps(_pinnedApps); } } Future assign(SlotType slot, AppEntry app) async { final assignment = app.toAssignment(); _slots[slot] = assignment; await _settings.setSlot(slot, assignment); _notify(); } Future clearSlot(SlotType slot) async { _slots.remove(slot); await _settings.clearSlot(slot); _notify(); } /// Hides or shows a predefined home button. Disabled slots keep their app /// assignment, so re-enabling restores the same shortcut. Future setSlotEnabled(SlotType slot, bool enabled) async { if (isSlotEnabled(slot) == enabled) return null; if (enabled && homeButtonCount >= maxHomeButtons) { return 'Home screen is full ($maxHomeButtons buttons). ' 'Remove an app or turn off another button first.'; } _slotEnabled[slot] = enabled; await _settings.setSlotEnabled(slot, enabled); _notify(); return null; } /// Pins an app from All Apps to the home screen. Returns an error message /// when the app is already present or the 16-button limit is reached. Future addPinnedApp(AppEntry app) async { final assignment = app.toAssignment(); final alreadyPinned = _pinnedApps.contains(assignment); final alreadyInVisibleSlot = SlotType.values.any( (slot) => isSlotEnabled(slot) && _slots[slot] == assignment, ); if (alreadyPinned || alreadyInVisibleSlot) { return '${app.label} is already on the Home screen.'; } if (homeButtonCount >= maxHomeButtons) { return 'Home screen is full ($maxHomeButtons buttons). ' 'Remove a button first.'; } _pinnedApps.add(assignment); await _settings.setPinnedApps(_pinnedApps); _notify(); return null; } Future removePinnedApp(SlotAssignment assignment) async { if (!_pinnedApps.remove(assignment)) return; await _settings.setPinnedApps(_pinnedApps); _notify(); } Future launchSlot(SlotType slot) async { final assignment = _slots[slot]; if (assignment == null) return null; return launchAssignment(assignment); } Future launchAssignment(SlotAssignment assignment) async { try { await _platform.launchApp( packageName: assignment.packageName, activityName: assignment.activityName, ); return null; } on LauncherPlatformException catch (error) { return error.message; } catch (_) { return 'Could not open ${assignment.label.isEmpty ? 'this app' : assignment.label}.'; } } Future launchAppEntry(AppEntry app) async { try { await _platform.launchApp( packageName: app.packageName, activityName: app.activityName, ); return null; } on LauncherPlatformException catch (error) { return error.message; } catch (_) { return 'Could not open ${app.label}.'; } } Future refreshDefaultHome() async { try { _defaultHome = await _platform.isDefaultHome(); } catch (_) { _defaultHome = false; } _defaultHomeKnown = true; _notify(); } Future requestDefaultHome() async { final launched = await _platform.requestDefaultHome(); if (launched) { // The system dialog will pause us; refresh again after it closes. _stateTimer?.cancel(); _stateTimer = Timer(const Duration(seconds: 2), () { unawaited(refreshDefaultHome()); }); } return launched; } void _notify() { if (!_disposed) notifyListeners(); } @override void dispose() { _disposed = true; _stateTimer?.cancel(); super.dispose(); } }