User Preferences Manager
The UserPreferencesManager
is a powerful utility class generated by Tether
that provides a type-safe, reactive way to store and manage user preferences in
your Flutter application. It uses a local SQLite database to persist key-value
pairs with automatic JSON serialization/deserialization and supports streaming
updates for reactive UI components.
Overview
UserPreferencesManager
simplifies user preference management by:
- Type-Safe Storage: Store any JSON-serializable data with the help of the
UserPreferenceValueType
enum. - Automatic Serialization: Handles JSON encoding/decoding automatically.
- Reactive Streams: Watch preferences for real-time UI updates using
watchPreference
. - Default Values: Easily set up default preferences on first app launch with
ensureDefaultPreferences
. - Rich Data Types: Support for primitives, arrays, and complex objects.
- Upsert Operations:
setPreference
automatically creates or updates preferences.
Supported Data Types
The manager supports various data types through the UserPreferenceValueType
enum:
Type | Enum Value | String Constant (DB) | Use Case | Example Value (Dart) | fromJson Example (jsonData as Type) |
---|---|---|---|---|---|
Text | text | 'text' | Simple strings | "User Name" | jsonData as String? |
Integer | integer | 'integer' | Whole numbers | 123 | jsonData as int? |
Number | number | 'number' | Decimal/floating-point | 3.14159 | (jsonData as num?)?.toDouble() |
Boolean | boolean | 'boolean' | True/false values | true | jsonData as bool? |
DateTime | datetime | 'datetime' | Date and time values | "2023-10-27T10:00:00Z" | jsonData == null ? null : DateTime.parse(jsonData as String) |
Text Array | stringList | 'stringList' | Lists of strings | ['apple', 'banana'] | jsonData == null ? null : List<String>.from(jsonData as List) |
Integer Array | integerArray | 'integerArray' | Lists of whole numbers | [1, 2, 3] | jsonData == null ? null : List<int>.from(jsonData as List) |
Number Array | numberArray | 'numberArray' | Lists of decimal numbers | [1.1, 2.2, 3.3] | jsonData == null ? null : List<double>.from((jsonData as List).map((e) => (e as num).toDouble())) |
JSON Object | jsonObject | 'jsonObject' | Complex structured objects | {'key': 'value', 'count': 1} | jsonData == null ? null : Map<String, dynamic>.from(jsonData as Map) |
JSON Array | jsonArray | 'jsonArray' | Lists of complex objects | [{'id':1}, {'id':2}] | jsonData == null ? null : List<dynamic>.from(jsonData as List) |
Text Array | textArray | 'textArray' | Lists of strings (alternative to stringList ) | ['itemA', 'itemB'] | jsonData == null ? null : List<String>.from(jsonData as List) |
Note:
- The "Enum Value" column shows the actual enum case (e.g.,
UserPreferenceValueType.text
). - The "String Constant (DB)" column shows the string representation used for
storage in the database (e.g.,
'text'
). stringList
andtextArray
both represent an array of strings.stringList
might be present for historical reasons or specific use cases, whiletextArray
is a more explicit name. Choose one consistently or as per your project's convention. The generateduserPreferenceValueTypeToString
function might map both to the same string (e.g., "textArray") for database storage.
Setup & Configuration
1. Enable in Configuration
Ensure User Preferences are enabled in your tether.yaml
:
# tether.yaml
generation:
# ... other settings
user_preferences:
enabled: true # Enables generation of UserPreferencesManager
# If using Riverpod, ensure providers are enabled:
client_managers:
enabled: true
use_riverpod: true
providers:
enabled: true
2. Generated Files
Tether generates:
lib/database/managers/user_preferences_manager.g.dart
: The core manager class- Database migration: Creates the
user_preferences
table automatically
3. Database Schema
The manager uses this SQLite table structure:
CREATE TABLE user_preferences (
preference_key TEXT PRIMARY KEY,
preference_value TEXT NOT NULL, -- JSON-encoded value
value_type TEXT NOT NULL, -- One of UserPreferenceValueType
created_at INTEGER DEFAULT (unixepoch()),
updated_at INTEGER DEFAULT (unixepoch())
);
Basic Usage
Accessing the Manager
// With Riverpod (recommended)
final prefsManager = ref.watch(userPreferencesManagerProvider);
// Direct instantiation
final prefsManager = UserPreferencesManager(yourSqliteDatabase);
Setting Preferences
Simple Values
// Store a string
await prefsManager.setPreference(
'username',
'john_doe',
valueType: UserPreferenceValueType.text,
);
// Store a boolean
await prefsManager.setPreference(
'notifications_enabled',
true,
valueType: UserPreferenceValueType.boolean,
);
// Store a number (float/double)
await prefsManager.setPreference(
'user_score',
85.5,
valueType: UserPreferenceValueType.number,
);
// Store a DateTime (as ISO8601 string)
await prefsManager.setPreference(
'last_login',
DateTime.now().toIso8601String(),
valueType: UserPreferenceValueType.datetime,
);
Complex Objects
// Store an object
class UserSettings {
final String theme;
final bool darkMode;
final List<String> languages;
UserSettings({
required this.theme,
required this.darkMode,
required this.languages,
});
Map<String, dynamic> toJson() => {
'theme': theme,
'darkMode': darkMode,
'languages': languages,
};
factory UserSettings.fromJson(Map<String, dynamic> json) => UserSettings(
theme: json['theme'] as String,
darkMode: json['darkMode'] as bool,
languages: List<String>.from(json['languages'] as List),
);
}
// Store the settings object
final settings = UserSettings(
theme: 'blue',
darkMode: true,
languages: ['en', 'es'],
);
await prefsManager.setPreference<Map<String, dynamic>>( // Explicitly type T for complex objects
'user_settings',
settings.toJson(), // Value must be JSON encodable (e.g., Map<String, dynamic>)
valueType: UserPreferenceValueType.jsonObject,
);
Arrays
// Store a list of strings
await prefsManager.setPreference<List<String>>(
'favorite_categories',
['technology', 'sports', 'music'],
valueType: UserPreferenceValueType.stringList, // or .textArray
);
// Store a list of numbers
await prefsManager.setPreference<List<double>>(
'graph_points',
[10.2, 15.5, 12.0],
valueType: UserPreferenceValueType.numberArray,
);
// Store a list of objects (maps)
final recentSearches = [
{'query': 'flutter', 'timestamp': DateTime.now().millisecondsSinceEpoch},
{'query': 'dart', 'timestamp': DateTime.now().millisecondsSinceEpoch},
];
await prefsManager.setPreference<List<Map<String, dynamic>>>(
'recent_searches',
recentSearches,
valueType: UserPreferenceValueType.jsonArray,
);
Getting Preferences
Simple Values
// Get a string, providing a default if null
final username = await prefsManager.getPreference<String>(
'username',
fromJson: (jsonData) => jsonData as String? ?? 'Guest',
);
// Get a boolean, providing a default
final notificationsEnabled = await prefsManager.getPreference<bool>(
'notifications_enabled',
fromJson: (jsonData) => jsonData as bool? ?? true,
);
// Get a number (double), providing a default
final userScore = await prefsManager.getPreference<double>(
'user_score',
fromJson: (jsonData) => (jsonData as num?)?.toDouble() ?? 0.0,
);
// Get a DateTime (parsed from ISO8601 string)
final lastLogin = await prefsManager.getPreference<DateTime?>(
'last_login',
fromJson: (jsonData) => jsonData == null ? null : DateTime.tryParse(jsonData as String),
);
Complex Objects
// Get an object
final settings = await prefsManager.getPreference<UserSettings?>(
'user_settings',
fromJson: (jsonData) {
if (jsonData == null) return null;
return UserSettings.fromJson(jsonData as Map<String, dynamic>);
},
);
// Get with fallback to a default instance
final settingsWithDefault = await prefsManager.getPreference<UserSettings>(
'user_settings',
fromJson: (jsonData) {
if (jsonData == null) {
return UserSettings(theme: 'default', darkMode: false, languages: ['en']); // Default instance
}
return UserSettings.fromJson(jsonData as Map<String, dynamic>);
},
);
Arrays
// Get a list of strings
final categories = await prefsManager.getPreference<List<String>>(
'favorite_categories',
fromJson: (jsonData) => jsonData != null
? List<String>.from(jsonData as List)
: <String>[], // Default to empty list
);
// Get a list of maps (representing objects)
final searches = await prefsManager.getPreference<List<Map<String, dynamic>>>(
'recent_searches',
fromJson: (jsonData) => jsonData != null
? List<Map<String, dynamic>>.from(jsonData as List)
: <Map<String, dynamic>>[], // Default to empty list
);
Reactive UI with Riverpod Providers
For the most seamless integration with your app's state management, create Riverpod providers that wrap your preference streams:
Simple Preference Providers
// Create providers for commonly used preferences
final notificationsEnabledProvider = StreamProvider<bool>((ref) {
final prefsManager = ref.watch(userPreferencesManagerProvider);
return prefsManager.watchPreference<bool>(
'notifications_enabled',
fromJson: (json) => json as bool? ?? true,
);
});
final userThemeProvider = StreamProvider<String>((ref) {
final prefsManager = ref.watch(userPreferencesManagerProvider);
return prefsManager.watchPreference<String>(
'theme',
fromJson: (json) => json as String? ?? 'default',
);
});
final favoriteCategories = StreamProvider<List<String>>((ref) {
final prefsManager = ref.watch(userPreferencesManagerProvider);
return prefsManager.watchPreference<List<String>>(
'favorite_categories',
fromJson: (json) => json != null
? List<String>.from(json as List)
: <String>[],
);
});
Complex Object Providers
final userSettingsProvider = StreamProvider<UserSettings>((ref) {
final prefsManager = ref.watch(userPreferencesManagerProvider);
return prefsManager.watchPreference<UserSettings>(
'user_settings',
fromJson: (json) {
if (json == null) {
return UserSettings(
theme: 'default',
darkMode: false,
languages: ['en'],
);
}
return UserSettings.fromJson(json as Map<String, dynamic>);
},
);
});
Using Providers in Widgets
class NotificationToggle extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final notificationsAsync = ref.watch(notificationsEnabledProvider);
final prefsManager = ref.watch(userPreferencesManagerProvider);
return notificationsAsync.when(
data: (isEnabled) => SwitchListTile(
title: Text('Enable Notifications'),
value: isEnabled,
onChanged: (value) async {
await prefsManager.setPreference(
'notifications_enabled',
value,
valueType: UserPreferenceValueType.boolean,
);
},
),
loading: () => ListTile(
title: Text('Enable Notifications'),
trailing: CircularProgressIndicator(strokeWidth: 2),
),
error: (error, stack) => ListTile(
title: Text('Enable Notifications'),
subtitle: Text('Error: $error'),
),
);
}
}
class ThemeSelector extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final settingsAsync = ref.watch(userSettingsProvider);
final prefsManager = ref.watch(userPreferencesManagerProvider);
return settingsAsync.when(
data: (settings) => Column(
children: [
ListTile(
title: Text('Theme'),
trailing: DropdownButton<String>(
value: settings.theme,
items: ['default', 'blue', 'green'].map((theme) =>
DropdownMenuItem(value: theme, child: Text(theme))
).toList(),
onChanged: (newTheme) async {
if (newTheme != null) {
final updatedSettings = UserSettings(
theme: newTheme,
darkMode: settings.darkMode,
languages: settings.languages,
);
await prefsManager.setPreference(
'user_settings',
updatedSettings.toJson(),
valueType: UserPreferenceValueType.jsonObject,
);
}
},
),
),
SwitchListTile(
title: Text('Dark Mode'),
value: settings.darkMode,
onChanged: (value) async {
final updatedSettings = UserSettings(
theme: settings.theme,
darkMode: value,
languages: settings.languages,
);
await prefsManager.setPreference(
'user_settings',
updatedSettings.toJson(),
valueType: UserPreferenceValueType.jsonObject,
);
},
),
],
),
loading: () => Column(
children: [
ListTile(
title: Text('Theme'),
trailing: CircularProgressIndicator(strokeWidth: 2),
),
ListTile(
title: Text('Dark Mode'),
trailing: CircularProgressIndicator(strokeWidth: 2),
),
],
),
error: (error, stack) => ListTile(
title: Text('Settings Error'),
subtitle: Text(error.toString()),
),
);
}
}
Provider Family for Dynamic Keys
For preferences with dynamic keys, use a provider family:
final dynamicPreferenceProvider = StreamProvider.family<String, String>((ref, key) {
final prefsManager = ref.watch(userPreferencesManagerProvider);
return prefsManager.watchPreference<String>(
key,
fromJson: (json) => json as String? ?? '',
);
});
// Usage
class DynamicPreferenceWidget extends ConsumerWidget {
final String preferenceKey;
const DynamicPreferenceWidget({required this.preferenceKey});
@override
Widget build(BuildContext context, WidgetRef ref) {
final valueAsync = ref.watch(dynamicPreferenceProvider(preferenceKey));
return valueAsync.when(
data: (value) => Text('$preferenceKey: $value'),
loading: () => CircularProgressIndicator(),
error: (error, stack) => Text('Error: $error'),
);
}
}
Reactive UI with StreamBuilder (Alternative)
If you prefer using StreamBuilder directly instead of Riverpod providers:
Watching Simple Preferences
class NotificationToggle extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final prefsManager = ref.watch(userPreferencesManagerProvider);
return StreamBuilder<bool>(
stream: prefsManager.watchPreference<bool>(
'notifications_enabled',
fromJson: (json) => json as bool? ?? true,
),
builder: (context, snapshot) {
final isEnabled = snapshot.data ?? true;
return SwitchListTile(
title: Text('Enable Notifications'),
value: isEnabled,
onChanged: (value) async {
await prefsManager.setPreference(
'notifications_enabled',
value,
valueType: UserPreferenceValueType.boolean,
);
},
);
},
);
}
}
Watching Complex Objects
class ThemeSelector extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final prefsManager = ref.watch(userPreferencesManagerProvider);
return StreamBuilder<UserSettings?>(
stream: prefsManager.watchPreference<UserSettings?>(
'user_settings',
fromJson: (json) {
if (json == null) return null;
return UserSettings.fromJson(json as Map<String, dynamic>);
},
),
builder: (context, snapshot) {
final settings = snapshot.data ?? UserSettings(
theme: 'default',
darkMode: false,
languages: ['en'],
);
return Column(
children: [
ListTile(
title: Text('Theme: ${settings.theme}'),
trailing: DropdownButton<String>(
value: settings.theme,
items: ['default', 'blue', 'green'].map((theme) =>
DropdownMenuItem(value: theme, child: Text(theme))
).toList(),
onChanged: (newTheme) async {
if (newTheme != null) {
final updatedSettings = UserSettings(
theme: newTheme,
darkMode: settings.darkMode,
languages: settings.languages,
);
await prefsManager.setPreference(
'user_settings',
updatedSettings.toJson(),
valueType: UserPreferenceValueType.jsonObject,
);
}
},
),
),
SwitchListTile(
title: Text('Dark Mode'),
value: settings.darkMode,
onChanged: (value) async {
final updatedSettings = UserSettings(
theme: settings.theme,
darkMode: value,
languages: settings.languages,
);
await prefsManager.setPreference(
'user_settings',
updatedSettings.toJson(),
valueType: UserPreferenceValueType.jsonObject,
);
},
),
],
);
},
);
}
}
Default Preferences
Setting Up Defaults
class PreferencesService {
final UserPreferencesManager _prefsManager;
PreferencesService(this._prefsManager);
Future<void> initializeDefaults() async {
await _prefsManager.ensureDefaultPreferences({
'theme': (value: 'default', valueType: UserPreferenceValueType.text),
'notifications_enabled': (value: true, valueType: UserPreferenceValueType.boolean),
'user_score': (value: 0.0, valueType: UserPreferenceValueType.real),
'favorite_categories': (
value: ['general'],
valueType: UserPreferenceValueType.textArray
),
'user_settings': (
value: {
'theme': 'default',
'darkMode': false,
'languages': ['en'],
},
valueType: UserPreferenceValueType.jsonObject
),
});
}
}
App Initialization
class MyApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return FutureBuilder(
future: _initializeApp(ref),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return MaterialApp(
home: Scaffold(
body: Center(child: CircularProgressIndicator()),
),
);
}
return MaterialApp(
title: 'My App',
home: HomeScreen(),
);
},
);
}
Future<void> _initializeApp(WidgetRef ref) async {
final prefsManager = ref.read(userPreferencesManagerProvider);
final preferencesService = PreferencesService(prefsManager);
await preferencesService.initializeDefaults();
}
}
Advanced Usage
Raw Preference Access
For debugging or advanced use cases, you can access raw preference data:
// Get raw preference data
final rawData = await prefsManager.getRawPreference('user_settings');
print('Key: ${rawData?['preference_key']}');
print('Value: ${rawData?['preference_value']}');
print('Type: ${rawData?['value_type']}');
// Watch raw preference changes
prefsManager.watchRawPreference('user_settings').listen((rawData) {
if (rawData != null) {
print('Raw preference updated: $rawData');
}
});
Preference Deletion
// Delete a specific preference
await prefsManager.deletePreference('old_setting');
// Check if preference exists
final exists = await prefsManager.getRawPreference('some_key') != null;
Error Handling
// Robust preference retrieval with error handling
Future<UserSettings> getUserSettings() async {
try {
final settings = await prefsManager.getPreference<UserSettings?>(
'user_settings',
fromJson: (json) {
if (json == null) return null;
return UserSettings.fromJson(json as Map<String, dynamic>);
},
);
return settings ?? UserSettings.defaultSettings();
} catch (e) {
print('Error loading user settings: $e');
return UserSettings.defaultSettings();
}
}
Best Practices
1. Use Riverpod Providers (Recommended)
Create dedicated providers for your preferences rather than accessing the manager directly in widgets:
// Good - Declarative and reactive
final themeProvider = StreamProvider<String>((ref) {
final prefsManager = ref.watch(userPreferencesManagerProvider);
return prefsManager.watchPreference<String>(
'theme',
fromJson: (json) => json as String? ?? 'default',
);
});
// Less ideal - Direct access in widget
class SomeWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final prefsManager = ref.watch(userPreferencesManagerProvider);
return StreamBuilder<String>(
stream: prefsManager.watchPreference<String>('theme', fromJson: ...),
// ...
);
}
}
2. Type Safety
Always use specific types and proper fromJson functions:
// Good
final count = await prefsManager.getPreference<int>(
'item_count',
fromJson: (json) => json as int? ?? 0,
);
// Avoid
final count = await prefsManager.getPreference<dynamic>(
'item_count',
fromJson: (json) => json,
);
3. Consistent Value Types
Use the appropriate UserPreferenceValueType
for your data:
// Good - matches the actual data type
await prefsManager.setPreference(
'categories',
['tech', 'sports'],
valueType: UserPreferenceValueType.textArray,
);
// Avoid - inconsistent with data structure
await prefsManager.setPreference(
'categories',
['tech', 'sports'],
valueType: UserPreferenceValueType.text, // Wrong type
);
4. Default Values
Always provide sensible defaults in your fromJson functions:
// Good - handles null case
final theme = await prefsManager.getPreference<String>(
'theme',
fromJson: (json) => json as String? ?? 'default',
);
// Risky - could return null unexpectedly
final theme = await prefsManager.getPreference<String?>(
'theme',
fromJson: (json) => json as String?,
);
5. Performance Considerations
- Use Riverpod providers for UI that needs real-time updates
- Use one-time calls (
getPreference
) for initialization or infrequent access - Consider batching multiple preference updates when possible
6. Migration Strategy
When changing preference structures, handle migration gracefully:
Future<UserSettings> migrateUserSettings() async {
final settings = await prefsManager.getPreference<UserSettings?>(
'user_settings',
fromJson: (json) {
if (json == null) return null;
// Handle old format
if (json is String) {
return UserSettings(theme: json, darkMode: false, languages: ['en']);
}
// Handle new format
return UserSettings.fromJson(json as Map<String, dynamic>);
},
);
// Save in new format if migration occurred
if (settings != null) {
await prefsManager.setPreference(
'user_settings',
settings.toJson(),
valueType: UserPreferenceValueType.jsonObject,
);
}
return settings ?? UserSettings.defaultSettings();
}