Image library: - "Clean up duplicates" finds byte-identical images by md5, keeps one file per group (preferring the most-used, then the oldest), merges the tags/descriptions and captions of the copies, repoints slides in open decks and in .md presentations on disk, and deletes the copies after a confirmation that lists every group. - A header toggle filters to images without tags/description, so it is easy to see which ones still need attention. - The delete warning now also lists presentations on disk that still reference the image (marked "not open"), next to the open decks. Editor and accessibility (already in tree): - Interface text scaling up to 200%, keyboard-operable panel divider, keyboard-first add-slide dialog, and screen-reader improvements. - Paste a spreadsheet/CSV/markdown selection into a table cell to fill the whole grid. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
322 lines
11 KiB
Dart
322 lines
11 KiB
Dart
import 'dart:convert';
|
|
import 'package:flutter_riverpod/legacy.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import '../models/settings.dart';
|
|
|
|
class SettingsNotifier extends StateNotifier<AppSettings> {
|
|
SettingsNotifier() : super(const AppSettings()) {
|
|
_load();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final themeJson = prefs.getString('themeProfile');
|
|
final profilesJson = prefs.getString('themeProfiles');
|
|
final loadedProfiles = profilesJson == null
|
|
? [
|
|
themeJson == null
|
|
? const ThemeProfile()
|
|
: ThemeProfile.fromJson(
|
|
Map<String, Object?>.from(jsonDecode(themeJson) as Map),
|
|
),
|
|
]
|
|
: (jsonDecode(profilesJson) as List)
|
|
.map(
|
|
(item) => ThemeProfile.fromJson(
|
|
Map<String, Object?>.from(item as Map),
|
|
),
|
|
)
|
|
.toList();
|
|
final profiles = _uniqueProfiles(loadedProfiles);
|
|
final appearanceJson = prefs.getString('appAppearanceProfiles');
|
|
final loadedAppearances = appearanceJson == null
|
|
? const <AppAppearanceProfile>[]
|
|
: (jsonDecode(appearanceJson) as List)
|
|
.map(
|
|
(item) => AppAppearanceProfile.fromJson(
|
|
Map<String, Object?>.from(item as Map),
|
|
),
|
|
)
|
|
.toList();
|
|
final appearances = _mergeAppearanceProfiles(loadedAppearances);
|
|
final selectedAppearance =
|
|
prefs.getString('selectedAppAppearanceProfileName') ?? 'Basic';
|
|
state = AppSettings(
|
|
languageCode: prefs.getString('languageCode') ?? 'nl',
|
|
homeDirectory: prefs.getString('homeDirectory'),
|
|
exportDirectory: prefs.getString('exportDirectory'),
|
|
themeProfiles: profiles.isEmpty ? const [ThemeProfile()] : profiles,
|
|
selectedThemeProfileName:
|
|
prefs.getString('selectedThemeProfileName') ?? profiles.first.name,
|
|
appAppearanceProfiles: appearances,
|
|
selectedAppAppearanceProfileName:
|
|
appearances.any((profile) => profile.name == selectedAppearance)
|
|
? selectedAppearance
|
|
: 'Basic',
|
|
recentFiles: prefs.getStringList('recentFiles') ?? [],
|
|
uiTextScale: (prefs.getDouble('uiTextScale') ?? 1.0).clamp(1.0, 2.0),
|
|
);
|
|
}
|
|
|
|
Future<void> setUiTextScale(double scale) async {
|
|
final clamped = scale.clamp(1.0, 2.0).toDouble();
|
|
state = state.copyWith(uiTextScale: clamped);
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setDouble('uiTextScale', clamped);
|
|
}
|
|
|
|
Future<void> addRecentFile(String path) async {
|
|
final updated = [
|
|
path,
|
|
...state.recentFiles.where((f) => f != path),
|
|
].take(10).toList();
|
|
state = state.copyWith(recentFiles: updated);
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setStringList('recentFiles', updated);
|
|
}
|
|
|
|
Future<void> setLanguageCode(String code) async {
|
|
state = state.copyWith(languageCode: code);
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString('languageCode', code);
|
|
}
|
|
|
|
Future<void> setHomeDirectory(String? path) async {
|
|
state = path == null
|
|
? state.copyWith(clearHomeDirectory: true)
|
|
: state.copyWith(homeDirectory: path);
|
|
final prefs = await SharedPreferences.getInstance();
|
|
if (path == null) {
|
|
await prefs.remove('homeDirectory');
|
|
} else {
|
|
await prefs.setString('homeDirectory', path);
|
|
}
|
|
}
|
|
|
|
Future<void> setExportDirectory(String? path) async {
|
|
state = path == null
|
|
? state.copyWith(clearExportDirectory: true)
|
|
: state.copyWith(exportDirectory: path);
|
|
final prefs = await SharedPreferences.getInstance();
|
|
if (path == null) {
|
|
await prefs.remove('exportDirectory');
|
|
} else {
|
|
await prefs.setString('exportDirectory', path);
|
|
}
|
|
}
|
|
|
|
/// Persist edits to the profile currently identified by [previousName],
|
|
/// renaming it in place when the name changed. When no profile matches
|
|
/// [previousName] (e.g. a freshly created one) the profile is added. The
|
|
/// edited profile is selected afterwards.
|
|
Future<void> saveThemeProfile(
|
|
ThemeProfile profile, {
|
|
required String previousName,
|
|
}) async {
|
|
final profileName = _uniqueName(profile.name, exceptName: previousName);
|
|
final renamed = profile.copyWith(name: profileName);
|
|
final exists = state.themeProfiles.any((p) => p.name == previousName);
|
|
final profiles = exists
|
|
? [
|
|
for (final p in state.themeProfiles)
|
|
if (p.name == previousName) renamed else p,
|
|
]
|
|
: [...state.themeProfiles, renamed];
|
|
state = state.copyWith(
|
|
themeProfiles: profiles,
|
|
selectedThemeProfileName: profileName,
|
|
);
|
|
await _saveProfiles();
|
|
}
|
|
|
|
Future<void> selectThemeProfile(String name) async {
|
|
state = state.copyWith(selectedThemeProfileName: name);
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString('selectedThemeProfileName', name);
|
|
}
|
|
|
|
/// Create a brand-new profile (optionally based on [base]), add it to the
|
|
/// list, select it and persist. Returns the created profile (its name may
|
|
/// have been made unique).
|
|
Future<ThemeProfile> createThemeProfile({ThemeProfile? base}) async {
|
|
final source = base ?? state.themeProfile;
|
|
final name = _uniqueName('Nieuw profiel');
|
|
final created = source.copyWith(name: name);
|
|
state = state.copyWith(
|
|
themeProfiles: [...state.themeProfiles, created],
|
|
selectedThemeProfileName: name,
|
|
);
|
|
await _saveProfiles();
|
|
return created;
|
|
}
|
|
|
|
Future<void> deleteThemeProfile(String name) async {
|
|
if (state.themeProfiles.length <= 1) return;
|
|
final profiles = state.themeProfiles.where((p) => p.name != name).toList();
|
|
state = state.copyWith(
|
|
themeProfiles: profiles,
|
|
selectedThemeProfileName: profiles.first.name,
|
|
);
|
|
await _saveProfiles();
|
|
}
|
|
|
|
Future<void> selectAppAppearanceProfile(String name) async {
|
|
if (!state.appAppearanceProfiles.any((profile) => profile.name == name)) {
|
|
return;
|
|
}
|
|
state = state.copyWith(selectedAppAppearanceProfileName: name);
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString('selectedAppAppearanceProfileName', name);
|
|
}
|
|
|
|
Future<AppAppearanceProfile> createAppAppearanceProfile({
|
|
AppAppearanceProfile? base,
|
|
}) async {
|
|
final source = base ?? state.appAppearanceProfile;
|
|
final created = source.copyWith(
|
|
name: _uniqueAppearanceName('Eigen thema'),
|
|
isBuiltIn: false,
|
|
);
|
|
state = state.copyWith(
|
|
appAppearanceProfiles: [...state.appAppearanceProfiles, created],
|
|
selectedAppAppearanceProfileName: created.name,
|
|
);
|
|
await _saveAppearanceProfiles();
|
|
return created;
|
|
}
|
|
|
|
Future<void> saveAppAppearanceProfile(
|
|
AppAppearanceProfile profile, {
|
|
required String previousName,
|
|
}) async {
|
|
final existing = state.appAppearanceProfiles.firstWhere(
|
|
(item) => item.name == previousName,
|
|
orElse: () => profile,
|
|
);
|
|
if (existing.isBuiltIn) return;
|
|
final name = _uniqueAppearanceName(profile.name, exceptName: previousName);
|
|
final saved = profile.copyWith(name: name, isBuiltIn: false);
|
|
final profiles = [
|
|
for (final item in state.appAppearanceProfiles)
|
|
if (item.name == previousName) saved else item,
|
|
];
|
|
state = state.copyWith(
|
|
appAppearanceProfiles: profiles,
|
|
selectedAppAppearanceProfileName: name,
|
|
);
|
|
await _saveAppearanceProfiles();
|
|
}
|
|
|
|
Future<void> deleteAppAppearanceProfile(String name) async {
|
|
final profile = state.appAppearanceProfiles.firstWhere(
|
|
(item) => item.name == name,
|
|
orElse: () => AppAppearanceProfile.basic,
|
|
);
|
|
if (profile.isBuiltIn) return;
|
|
final profiles = state.appAppearanceProfiles
|
|
.where((item) => item.name != name)
|
|
.toList();
|
|
state = state.copyWith(
|
|
appAppearanceProfiles: profiles,
|
|
selectedAppAppearanceProfileName: 'Basic',
|
|
);
|
|
await _saveAppearanceProfiles();
|
|
}
|
|
|
|
Future<void> _saveAppearanceProfiles() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final customProfiles = state.appAppearanceProfiles
|
|
.where((profile) => !profile.isBuiltIn)
|
|
.map((profile) => profile.toJson())
|
|
.toList();
|
|
await prefs.setString('appAppearanceProfiles', jsonEncode(customProfiles));
|
|
await prefs.setString(
|
|
'selectedAppAppearanceProfileName',
|
|
state.selectedAppAppearanceProfileName,
|
|
);
|
|
}
|
|
|
|
Future<void> _saveProfiles() async {
|
|
state = state.copyWith(themeProfiles: _uniqueProfiles(state.themeProfiles));
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(
|
|
'themeProfiles',
|
|
jsonEncode(state.themeProfiles.map((p) => p.toJson()).toList()),
|
|
);
|
|
await prefs.setString(
|
|
'selectedThemeProfileName',
|
|
state.selectedThemeProfileName,
|
|
);
|
|
await prefs.setString(
|
|
'themeProfile',
|
|
jsonEncode(state.themeProfile.toJson()),
|
|
);
|
|
}
|
|
|
|
List<ThemeProfile> _uniqueProfiles(List<ThemeProfile> profiles) {
|
|
final result = <ThemeProfile>[];
|
|
for (final profile in profiles) {
|
|
result.add(
|
|
profile.copyWith(name: _uniqueName(profile.name, profiles: result)),
|
|
);
|
|
}
|
|
return result.isEmpty ? const [ThemeProfile()] : result;
|
|
}
|
|
|
|
String _uniqueName(
|
|
String rawName, {
|
|
List<ThemeProfile>? profiles,
|
|
String? exceptName,
|
|
}) {
|
|
final existingProfiles = profiles ?? state.themeProfiles;
|
|
final base = rawName.trim().isEmpty ? 'Stijlprofiel' : rawName.trim();
|
|
final used = existingProfiles
|
|
.map((p) => p.name)
|
|
.where((name) => name != exceptName)
|
|
.toSet();
|
|
if (!used.contains(base)) return base;
|
|
var index = 2;
|
|
while (used.contains('$base $index')) {
|
|
index++;
|
|
}
|
|
return '$base $index';
|
|
}
|
|
|
|
List<AppAppearanceProfile> _mergeAppearanceProfiles(
|
|
List<AppAppearanceProfile> loaded,
|
|
) {
|
|
final result = [...AppAppearanceProfile.builtIns];
|
|
for (final profile in loaded.where((profile) => !profile.isBuiltIn)) {
|
|
result.add(
|
|
profile.copyWith(
|
|
name: _uniqueAppearanceName(profile.name, profiles: result),
|
|
isBuiltIn: false,
|
|
),
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
String _uniqueAppearanceName(
|
|
String rawName, {
|
|
List<AppAppearanceProfile>? profiles,
|
|
String? exceptName,
|
|
}) {
|
|
final existingProfiles = profiles ?? state.appAppearanceProfiles;
|
|
final base = rawName.trim().isEmpty ? 'Eigen thema' : rawName.trim();
|
|
final used = existingProfiles
|
|
.map((profile) => profile.name)
|
|
.where((name) => name != exceptName)
|
|
.toSet();
|
|
if (!used.contains(base)) return base;
|
|
var index = 2;
|
|
while (used.contains('$base $index')) {
|
|
index++;
|
|
}
|
|
return '$base $index';
|
|
}
|
|
}
|
|
|
|
final settingsProvider = StateNotifierProvider<SettingsNotifier, AppSettings>(
|
|
(_) => SettingsNotifier(),
|
|
);
|