Skip to main content

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:

TypeEnum ValueString Constant (DB)Use CaseExample Value (Dart)fromJson Example (jsonData as Type)
Texttext'text'Simple strings"User Name"jsonData as String?
Integerinteger'integer'Whole numbers123jsonData as int?
Numbernumber'number'Decimal/floating-point3.14159(jsonData as num?)?.toDouble()
Booleanboolean'boolean'True/false valuestruejsonData as bool?
DateTimedatetime'datetime'Date and time values"2023-10-27T10:00:00Z"jsonData == null ? null : DateTime.parse(jsonData as String)
Text ArraystringList'stringList'Lists of strings['apple', 'banana']jsonData == null ? null : List<String>.from(jsonData as List)
Integer ArrayintegerArray'integerArray'Lists of whole numbers[1, 2, 3]jsonData == null ? null : List<int>.from(jsonData as List)
Number ArraynumberArray'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 ObjectjsonObject'jsonObject'Complex structured objects{'key': 'value', 'count': 1}jsonData == null ? null : Map<String, dynamic>.from(jsonData as Map)
JSON ArrayjsonArray'jsonArray'Lists of complex objects[{'id':1}, {'id':2}]jsonData == null ? null : List<dynamic>.from(jsonData as List)
Text ArraytextArray'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 and textArray both represent an array of strings. stringList might be present for historical reasons or specific use cases, while textArray is a more explicit name. Choose one consistently or as per your project's convention. The generated userPreferenceValueTypeToString 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

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();
}