/ app / lib / services / user_profile_service.dart
user_profile_service.dart
  1  import 'dart:convert';
  2  import 'dart:io';
  3  
  4  import 'package:path_provider/path_provider.dart';
  5  import 'package:shared_preferences/shared_preferences.dart';
  6  
  7  /// Service for managing the user's own profile (picture, display name, sharing preferences).
  8  /// Singleton backed by SharedPreferences + file on disk.
  9  /// Picture paths are stored RELATIVE to the documents directory to survive
 10  /// iOS container path changes across app restarts.
 11  class UserProfileService {
 12    static UserProfileService? _instance;
 13    static const _prefsKey = 'user_profile';
 14  
 15    SharedPreferences? _prefs;
 16    String? _picturePath; // Relative path (e.g. "user_profile/avatar.png")
 17    String? _displayName;
 18    Set<String> _shareProfileContactIds = {};
 19    bool _shareWithAll = false;
 20  
 21    UserProfileService._();
 22  
 23    static UserProfileService get instance {
 24      _instance ??= UserProfileService._();
 25      return _instance!;
 26    }
 27  
 28    /// Returns the resolved absolute path, or null if no picture or file missing.
 29    Future<String?> get picturePath async {
 30      if (_picturePath == null) return null;
 31      return _resolveAbsolutePath(_picturePath!);
 32    }
 33  
 34    String? get displayName => _displayName;
 35    Set<String> get shareProfileContactIds => Set.unmodifiable(_shareProfileContactIds);
 36    bool get shareWithAll => _shareWithAll;
 37  
 38    Future<void> _ensureInitialized() async {
 39      _prefs ??= await SharedPreferences.getInstance();
 40      _loadFromStorage();
 41    }
 42  
 43    void _loadFromStorage() {
 44      final stored = _prefs?.getString(_prefsKey);
 45      if (stored != null) {
 46        try {
 47          final data = json.decode(stored) as Map<String, dynamic>;
 48          _picturePath = data['picturePath'] as String?;
 49          _displayName = data['displayName'] as String?;
 50          final ids = data['shareProfileContactIds'] as List<dynamic>?;
 51          _shareProfileContactIds = ids?.map((e) => e as String).toSet() ?? {};
 52          _shareWithAll = data['shareWithAll'] as bool? ?? false;
 53        } catch (_) {
 54          // Ignore corrupt data
 55        }
 56      }
 57    }
 58  
 59    Future<void> _saveToStorage() async {
 60      final data = {
 61        'picturePath': _picturePath,
 62        'displayName': _displayName,
 63        'shareProfileContactIds': _shareProfileContactIds.toList(),
 64        'shareWithAll': _shareWithAll,
 65      };
 66      await _prefs?.setString(_prefsKey, json.encode(data));
 67    }
 68  
 69    /// Resolve a relative (or legacy absolute) path to an absolute path.
 70    Future<String?> _resolveAbsolutePath(String path) async {
 71      // If already absolute (legacy data), check if it exists
 72      if (path.startsWith('/')) {
 73        if (await File(path).exists()) return path;
 74        // Try to extract relative part from legacy absolute path
 75        final idx = path.indexOf('user_profile/');
 76        if (idx >= 0) {
 77          final relPart = path.substring(idx);
 78          final appDir = await getApplicationDocumentsDirectory();
 79          final absPath = '${appDir.path}/$relPart';
 80          if (await File(absPath).exists()) return absPath;
 81        }
 82        return null;
 83      }
 84      final appDir = await getApplicationDocumentsDirectory();
 85      final absPath = '${appDir.path}/$path';
 86      if (await File(absPath).exists()) return absPath;
 87      return null;
 88    }
 89  
 90    /// Initialize the service (call once at app startup).
 91    Future<void> init() async {
 92      await _ensureInitialized();
 93    }
 94  
 95    /// Set the user's profile picture from a file.
 96    /// Returns the stored absolute path.
 97    Future<String> setPicture(File imageFile) async {
 98      await _ensureInitialized();
 99  
100      final appDir = await getApplicationDocumentsDirectory();
101      final profileDir = Directory('${appDir.path}/user_profile');
102      if (!await profileDir.exists()) {
103        await profileDir.create(recursive: true);
104      }
105  
106      // Delete old picture if it exists
107      if (_picturePath != null) {
108        try {
109          final oldAbsPath = await _resolveAbsolutePath(_picturePath!);
110          if (oldAbsPath != null) await File(oldAbsPath).delete();
111        } catch (_) {}
112      }
113  
114      final extension = imageFile.path.split('.').last;
115      final timestamp = DateTime.now().millisecondsSinceEpoch;
116      final relativePath = 'user_profile/avatar_$timestamp.$extension';
117      final absolutePath = '${appDir.path}/$relativePath';
118      await imageFile.copy(absolutePath);
119  
120      _picturePath = relativePath;
121      await _saveToStorage();
122      return absolutePath;
123    }
124  
125    /// Remove the user's profile picture.
126    Future<void> removePicture() async {
127      await _ensureInitialized();
128  
129      if (_picturePath != null) {
130        try {
131          final absPath = await _resolveAbsolutePath(_picturePath!);
132          if (absPath != null) await File(absPath).delete();
133        } catch (_) {}
134        _picturePath = null;
135        await _saveToStorage();
136      }
137    }
138  
139    /// Set the user's display name.
140    Future<void> setDisplayName(String? name) async {
141      await _ensureInitialized();
142      _displayName = (name != null && name.isEmpty) ? null : name;
143      await _saveToStorage();
144    }
145  
146    /// Check if profile is shared with a specific contact.
147    bool isSharedWith(String contactIdHex) {
148      return _shareProfileContactIds.contains(contactIdHex);
149    }
150  
151    /// Toggle sharing with a specific contact. Returns the new sharing state.
152    Future<bool> toggleShareWith(String contactIdHex) async {
153      await _ensureInitialized();
154      final nowShared = !_shareProfileContactIds.contains(contactIdHex);
155      if (nowShared) {
156        _shareProfileContactIds.add(contactIdHex);
157      } else {
158        _shareProfileContactIds.remove(contactIdHex);
159      }
160      await _saveToStorage();
161      return nowShared;
162    }
163  
164    /// Explicitly set sharing state for a contact.
165    Future<void> setShareWith(String contactIdHex, bool share) async {
166      await _ensureInitialized();
167      if (share) {
168        _shareProfileContactIds.add(contactIdHex);
169      } else {
170        _shareProfileContactIds.remove(contactIdHex);
171      }
172      await _saveToStorage();
173    }
174  
175    /// Set the global share-with-all flag.
176    Future<void> setShareWithAll(bool value) async {
177      await _ensureInitialized();
178      _shareWithAll = value;
179      await _saveToStorage();
180    }
181  
182    /// Ensure all given contact IDs are in the shared set.
183    /// Returns the list of hex IDs that were newly added.
184    Future<List<String>> ensureAllContactsShared(List<String> allContactHexIds) async {
185      await _ensureInitialized();
186      final newlyAdded = <String>[];
187      for (final hexId in allContactHexIds) {
188        if (!_shareProfileContactIds.contains(hexId)) {
189          _shareProfileContactIds.add(hexId);
190          newlyAdded.add(hexId);
191        }
192      }
193      if (newlyAdded.isNotEmpty) {
194        await _saveToStorage();
195      }
196      return newlyAdded;
197    }
198  
199    /// Get the profile picture as bytes (for sending to contacts).
200    Future<List<int>?> getPictureBytes() async {
201      if (_picturePath == null) return null;
202      try {
203        final absPath = await _resolveAbsolutePath(_picturePath!);
204        if (absPath == null) return null;
205        return await File(absPath).readAsBytes();
206      } catch (_) {
207        return null;
208      }
209    }
210  }