This commit is contained in:
2026-03-26 13:46:23 +03:00
parent 87431a9fc9
commit 2d5eb92cba
7 changed files with 243 additions and 66 deletions
+51 -11
View File
@@ -6,7 +6,9 @@ enum GradientType { linear, linearReverse, radial, sweep, solid }
class CardColorService {
static const _key1 = 'card_color_primary';
static const _key2 = 'card_color_secondary';
static const _keyGradient = 'card_gradient_type';
static const _keyGradientLegacy = 'card_gradient_type';
static const _keyGradientLight = 'gradient_type_light';
static const _keyGradientDark = 'gradient_type_dark';
static const defaultPrimary = Color(0xFFBEF264);
static const defaultSecondary = Color(0xFF4D7C0F);
@@ -14,48 +16,86 @@ class CardColorService {
static const defaultPrimaryLight = Color(0xFF6A6482);
static const defaultSecondaryLight = Color(0xFF000000);
static const defaultGradient = GradientType.radial;
static const defaultGradientLight = GradientType.sweep;
static const defaultGradientDark = GradientType.radial;
static Future<(Color, Color, GradientType)> load({int? accountId}) async {
static Future<(Color, Color, GradientType, GradientType)> load({
int? accountId,
}) async {
final prefs = await SharedPreferences.getInstance();
final key1 = accountId != null ? '${_key1}_$accountId' : _key1;
final key2 = accountId != null ? '${_key2}_$accountId' : _key2;
final keyG = accountId != null ? '${_keyGradient}_$accountId' : _keyGradient;
final keyGLight = accountId != null
? '${_keyGradientLight}_$accountId'
: _keyGradientLight;
final keyGDark = accountId != null
? '${_keyGradientDark}_$accountId'
: _keyGradientDark;
final keyGLegacy = accountId != null
? '${_keyGradientLegacy}_$accountId'
: _keyGradientLegacy;
final c1 = prefs.getInt(key1);
final c2 = prefs.getInt(key2);
final g = prefs.getInt(keyG);
final legacyIndex = prefs.getInt(keyGLegacy);
GradientType parseWithDefault(int? index, GradientType fallback) {
if (index == null) return fallback;
final parsed = GradientType.values.elementAtOrNull(index);
return parsed ?? fallback;
}
final lightIndex = prefs.getInt(keyGLight);
final darkIndex = prefs.getInt(keyGDark);
return (
c1 != null ? Color(c1) : defaultPrimary,
c2 != null ? Color(c2) : defaultSecondary,
g != null ? GradientType.values[g] : defaultGradient,
parseWithDefault(lightIndex ?? legacyIndex, defaultGradientLight),
parseWithDefault(darkIndex ?? legacyIndex, defaultGradientDark),
);
}
static Future<void> save(
Color primary,
Color secondary,
GradientType gradient, {
GradientType lightGradient,
GradientType darkGradient, {
int? accountId,
}) async {
final prefs = await SharedPreferences.getInstance();
final key1 = accountId != null ? '${_key1}_$accountId' : _key1;
final key2 = accountId != null ? '${_key2}_$accountId' : _key2;
final keyG = accountId != null ? '${_keyGradient}_$accountId' : _keyGradient;
final keyGLight = accountId != null
? '${_keyGradientLight}_$accountId'
: _keyGradientLight;
final keyGDark = accountId != null
? '${_keyGradientDark}_$accountId'
: _keyGradientDark;
await prefs.setInt(key1, primary.value);
await prefs.setInt(key2, secondary.value);
await prefs.setInt(keyG, gradient.index);
await prefs.setInt(keyGLight, lightGradient.index);
await prefs.setInt(keyGDark, darkGradient.index);
}
static Future<void> reset(bool isDark, {int? accountId}) async {
final prefs = await SharedPreferences.getInstance();
final key1 = accountId != null ? '${_key1}_$accountId' : _key1;
final key2 = accountId != null ? '${_key2}_$accountId' : _key2;
final keyG = accountId != null ? '${_keyGradient}_$accountId' : _keyGradient;
final keyGLight = accountId != null
? '${_keyGradientLight}_$accountId'
: _keyGradientLight;
final keyGDark = accountId != null
? '${_keyGradientDark}_$accountId'
: _keyGradientDark;
final keyGLegacy = accountId != null
? '${_keyGradientLegacy}_$accountId'
: _keyGradientLegacy;
await prefs.remove(key1);
await prefs.remove(key2);
await prefs.remove(keyG);
await prefs.remove(keyGLight);
await prefs.remove(keyGDark);
await prefs.remove(keyGLegacy);
}
}
+52 -14
View File
@@ -11,6 +11,15 @@ import '../../shared/models/account.dart';
import '../../shared/services/storage_service.dart';
import '../settings/provider.dart';
// BUG FOUND: lib/features/dashboard/provider.dart
// Description: CardColorsNotifier calls an async `_load()` in the constructor without awaiting it.
// If the user triggers `save()` before `_load()` completes, the late `_load()` can
// overwrite the newly saved colors/gradient types.
// Reproduction: Open the app (cold start), open the card color editor immediately, press Apply
// before the initial load finishes.
// Suggested fix: Track a generation/token for in-flight loads and ignore stale load results
// after any state mutation (save/reset/theme-change).
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
throw UnimplementedError('Override in main');
});
@@ -334,9 +343,18 @@ final activeAccountProvider = Provider<Account?>((ref) {
class CardColors {
final Color primary;
final Color secondary;
final GradientType gradientType;
final GradientType lightGradientType;
final GradientType darkGradientType;
const CardColors(this.primary, this.secondary, this.gradientType);
const CardColors(
this.primary,
this.secondary,
this.lightGradientType,
this.darkGradientType,
);
GradientType gradientTypeForBrightness(Brightness brightness) =>
brightness == Brightness.dark ? darkGradientType : lightGradientType;
}
final cardColorsProvider =
@@ -365,12 +383,15 @@ class CardColorsNotifier extends StateNotifier<CardColors> {
const CardColors(
CardColorService.defaultPrimary,
CardColorService.defaultSecondary,
CardColorService.defaultGradient,
CardColorService.defaultGradientLight,
CardColorService.defaultGradientDark,
),
) {
_load();
}
int _loadGeneration = 0;
void setupThemeListener(Ref ref) {
ref.listen<ThemeMode>(themeProvider, (previous, next) {
if (previous != null) {
@@ -380,20 +401,27 @@ class CardColorsNotifier extends StateNotifier<CardColors> {
}
Future<void> _load() async {
final (c1, c2, g) = await CardColorService.load(accountId: accountId);
state = CardColors(c1, c2, g);
final currentGeneration = ++_loadGeneration;
final (c1, c2, lightG, darkG) =
await CardColorService.load(accountId: accountId);
if (currentGeneration != _loadGeneration) return; // stale
state = CardColors(c1, c2, lightG, darkG);
}
Future<void> save(
Color primary,
Color secondary,
GradientType gradient,
GradientType lightGradient,
GradientType darkGradient,
) async {
state = CardColors(primary, secondary, gradient);
// Invalidate any in-flight load so it can't overwrite this save.
_loadGeneration++;
state = CardColors(primary, secondary, lightGradient, darkGradient);
await CardColorService.save(
primary,
secondary,
gradient,
lightGradient,
darkGradient,
accountId: accountId,
);
}
@@ -405,11 +433,18 @@ class CardColorsNotifier extends StateNotifier<CardColors> {
final secondary = isDark
? CardColorService.defaultSecondary
: CardColorService.defaultSecondaryLight;
state = CardColors(primary, secondary, CardColorService.defaultGradient);
_loadGeneration++;
state = CardColors(
primary,
secondary,
CardColorService.defaultGradientLight,
CardColorService.defaultGradientDark,
);
await CardColorService.save(
primary,
secondary,
CardColorService.defaultGradient,
CardColorService.defaultGradientLight,
CardColorService.defaultGradientDark,
accountId: accountId,
);
}
@@ -428,14 +463,17 @@ class CardColorsNotifier extends StateNotifier<CardColors> {
final isUsingOldDefaults =
state.primary == oldDefaults.primary &&
state.secondary == oldDefaults.secondary &&
state.gradientType == oldDefaults.gradient;
state.gradientTypeForBrightness(previousBrightness) ==
oldDefaults.gradient;
// Only auto-switch if using default colors
if (isUsingOldDefaults) {
_loadGeneration++;
state = CardColors(
newDefaults.primary,
newDefaults.secondary,
newDefaults.gradient,
state.lightGradientType,
state.darkGradientType,
);
}
}
@@ -454,12 +492,12 @@ class CardColorsNotifier extends StateNotifier<CardColors> {
? (
primary: CardColorService.defaultPrimary,
secondary: CardColorService.defaultSecondary,
gradient: CardColorService.defaultGradient,
gradient: CardColorService.defaultGradientDark,
)
: (
primary: CardColorService.defaultPrimaryLight,
secondary: CardColorService.defaultSecondaryLight,
gradient: CardColorService.defaultGradient,
gradient: CardColorService.defaultGradientLight,
);
}
}
+39 -17
View File
@@ -44,8 +44,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
HSVColor savedSecondaryHSV = HSVColor.fromColor(
CardColorService.defaultSecondary,
);
GradientType tempGradientType = CardColorService.defaultGradient;
GradientType savedGradientType = CardColorService.defaultGradient;
// Per-theme gradient types (light/dark), persisted separately.
GradientType tempLightGradientType = CardColorService.defaultGradientLight;
GradientType tempDarkGradientType = CardColorService.defaultGradientDark;
GradientType savedLightGradientType = CardColorService.defaultGradientLight;
GradientType savedDarkGradientType = CardColorService.defaultGradientDark;
OverlayEntry? overlayEntry;
// Account editing state
@@ -60,13 +64,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
savedSecondary = colors.secondary;
savedPrimaryHSV = HSVColor.fromColor(colors.primary);
savedSecondaryHSV = HSVColor.fromColor(colors.secondary);
savedGradientType = colors.gradientType;
tempPrimary = colors.primary;
tempSecondary = colors.secondary;
tempPrimaryHSV = HSVColor.fromColor(colors.primary);
tempSecondaryHSV = HSVColor.fromColor(colors.secondary);
tempGradientType = colors.gradientType;
savedLightGradientType = colors.lightGradientType;
savedDarkGradientType = colors.darkGradientType;
tempLightGradientType = colors.lightGradientType;
tempDarkGradientType = colors.darkGradientType;
setState(() {
editingCard = true;
editingPrimary = true;
@@ -87,12 +92,18 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
HapticService.medium();
ref
.read(cardColorsProvider.notifier)
.save(tempPrimary, tempSecondary, tempGradientType);
.save(
tempPrimary,
tempSecondary,
tempLightGradientType,
tempDarkGradientType,
);
} else {
setState(() {
tempPrimary = savedPrimary;
tempSecondary = savedSecondary;
tempGradientType = savedGradientType;
tempLightGradientType = savedLightGradientType;
tempDarkGradientType = savedDarkGradientType;
});
}
overlayEntry?.remove();
@@ -106,13 +117,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
savedSecondary = colors.secondary;
savedPrimaryHSV = HSVColor.fromColor(colors.primary);
savedSecondaryHSV = HSVColor.fromColor(colors.secondary);
savedGradientType = colors.gradientType;
tempPrimary = colors.primary;
tempSecondary = colors.secondary;
tempPrimaryHSV = HSVColor.fromColor(colors.primary);
tempSecondaryHSV = HSVColor.fromColor(colors.secondary);
tempGradientType = colors.gradientType;
savedLightGradientType = colors.lightGradientType;
savedDarkGradientType = colors.darkGradientType;
tempLightGradientType = colors.lightGradientType;
tempDarkGradientType = colors.darkGradientType;
setState(() {
editingAccount = account;
tempAccountName = account.name;
@@ -137,13 +149,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
savedSecondary = colors.secondary;
savedPrimaryHSV = HSVColor.fromColor(colors.primary);
savedSecondaryHSV = HSVColor.fromColor(colors.secondary);
savedGradientType = colors.gradientType;
tempPrimary = colors.primary;
tempSecondary = colors.secondary;
tempPrimaryHSV = HSVColor.fromColor(colors.primary);
tempSecondaryHSV = HSVColor.fromColor(colors.secondary);
tempGradientType = colors.gradientType;
savedLightGradientType = colors.lightGradientType;
savedDarkGradientType = colors.darkGradientType;
tempLightGradientType = colors.lightGradientType;
tempDarkGradientType = colors.darkGradientType;
setState(() {
isAddingAccount = true;
editingAccount = null;
@@ -174,7 +187,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
await CardColorService.save(
tempPrimary,
tempSecondary,
tempGradientType,
tempLightGradientType,
tempDarkGradientType,
accountId: newId,
);
} else if (editingAccount != null) {
@@ -182,7 +196,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
// Save colors
await ref
.read(accountCardColorsProvider(editingAccount!.id).notifier)
.save(tempPrimary, tempSecondary, tempGradientType);
.save(
tempPrimary,
tempSecondary,
tempLightGradientType,
tempDarkGradientType,
);
// Update account name and currency
final updatedAccount = Account(
@@ -201,7 +220,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
setState(() {
tempPrimary = savedPrimary;
tempSecondary = savedSecondary;
tempGradientType = savedGradientType;
tempLightGradientType = savedLightGradientType;
tempDarkGradientType = savedDarkGradientType;
if (editingAccount != null) {
tempAccountName = editingAccount!.name;
tempAccountCurrency = editingAccount!.currency;
@@ -349,7 +369,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
previewPrimary: editingCard ? tempPrimary : null,
previewSecondary: editingCard ? tempSecondary : null,
previewGradientType: editingCard
? tempGradientType
? (Theme.of(context).brightness == Brightness.dark
? tempDarkGradientType
: tempLightGradientType)
: null,
),
const SizedBox(height: 16),
@@ -206,7 +206,10 @@ class _AccountEditorOverlayState extends State<AccountEditorOverlay> {
accountName: dash.tempAccountName,
previewPrimary: dash.tempPrimary,
previewSecondary: dash.tempSecondary,
previewGradientType: dash.tempGradientType,
previewGradientType:
Theme.of(widget.context).brightness == Brightness.dark
? dash.tempDarkGradientType
: dash.tempLightGradientType,
),
),
Positioned(
@@ -67,7 +67,11 @@ class AccountColorPanel extends StatelessWidget {
dashboardState.overlayEntry?.markNeedsBuild();
}
final isSolid = dashboardState.tempGradientType == GradientType.solid;
final activeGradientType =
Theme.of(dashboardContext).brightness == Brightness.dark
? dashboardState.tempDarkGradientType
: dashboardState.tempLightGradientType;
final isSolid = activeGradientType == GradientType.solid;
final currentHSV = (isSolid || dashboardState.editingPrimary)
? dashboardState.tempPrimaryHSV
: dashboardState.tempSecondaryHSV;
@@ -96,8 +100,14 @@ class AccountColorPanel extends StatelessWidget {
onTap: () {
dashboardState.setState(() {
if (isSolid)
dashboardState.tempGradientType =
CardColorService.defaultGradient;
if (Theme.of(dashboardContext).brightness ==
Brightness.dark) {
dashboardState.tempDarkGradientType =
CardColorService.defaultGradientDark;
} else {
dashboardState.tempLightGradientType =
CardColorService.defaultGradientLight;
}
dashboardState.editingPrimary = true;
});
setPanelState(() {});
@@ -116,8 +126,14 @@ class AccountColorPanel extends StatelessWidget {
onTap: () {
dashboardState.setState(() {
if (isSolid)
dashboardState.tempGradientType =
CardColorService.defaultGradient;
if (Theme.of(dashboardContext).brightness ==
Brightness.dark) {
dashboardState.tempDarkGradientType =
CardColorService.defaultGradientDark;
} else {
dashboardState.tempLightGradientType =
CardColorService.defaultGradientLight;
}
dashboardState.editingPrimary = false;
});
setPanelState(() {});
@@ -141,8 +157,14 @@ class AccountColorPanel extends StatelessWidget {
? null
: () {
dashboardState.setState(() {
dashboardState.tempGradientType =
if (Theme.of(dashboardContext).brightness ==
Brightness.dark) {
dashboardState.tempDarkGradientType =
GradientType.solid;
} else {
dashboardState.tempLightGradientType =
GradientType.solid;
}
dashboardState.editingPrimary = true;
});
setPanelState(() {});
@@ -399,8 +421,7 @@ class AccountColorPanel extends StatelessWidget {
children: GradientType.values
.where((t) => t != GradientType.solid)
.map((type) {
final isSelected =
dashboardState.tempGradientType == type;
final isSelected = activeGradientType == type;
final label = switch (type) {
GradientType.linear => s.gradientLinear,
GradientType.linearReverse => s.gradientReverse,
@@ -424,8 +445,17 @@ class AccountColorPanel extends StatelessWidget {
child: GestureDetector(
onTap: () {
dashboardState.setState(
() => dashboardState.tempGradientType =
type,
() {
if (Theme.of(dashboardContext)
.brightness ==
Brightness.dark) {
dashboardState.tempDarkGradientType =
type;
} else {
dashboardState.tempLightGradientType =
type;
}
},
);
setPanelState(() {});
dashboardState.overlayEntry
@@ -516,8 +546,10 @@ class AccountColorPanel extends StatelessWidget {
);
dashboardState.tempSecondaryHSV =
HSVColor.fromColor(defS);
dashboardState.tempGradientType =
CardColorService.defaultGradient;
dashboardState.tempLightGradientType =
CardColorService.defaultGradientLight;
dashboardState.tempDarkGradientType =
CardColorService.defaultGradientDark;
});
setPanelState(() {});
dashboardState.overlayEntry?.markNeedsBuild();
@@ -130,12 +130,15 @@ class BalanceCardState extends ConsumerState<BalanceCard>
final rates = ref.read(exchangeRateServiceProvider);
final fmt = ref.watch(amountFormatProvider);
final showConversions = ref.watch(showCurrencyConversionsProvider);
final brightness = Theme.of(context).brightness;
final globalColors = ref.watch(cardColorsProvider);
final savedColors = widget.accountColors ?? globalColors;
final primary = widget.previewPrimary ?? savedColors.primary;
final secondary = widget.previewSecondary ?? savedColors.secondary;
final gradientType = widget.previewGradientType ?? savedColors.gradientType;
final savedGradientType =
savedColors.gradientTypeForBrightness(brightness);
final gradientType = widget.previewGradientType ?? savedGradientType;
final others = kDisplayCurrencies
.where((c) => c.$1 != widget.currencyInfo.code)
@@ -67,7 +67,10 @@ class _FullScreenBlurOverlayState extends State<FullScreenBlurOverlay> {
onLongPress: null,
previewPrimary: dash.tempPrimary,
previewSecondary: dash.tempSecondary,
previewGradientType: dash.tempGradientType,
previewGradientType: Theme.of(widget.context).brightness ==
Brightness.dark
? dash.tempDarkGradientType
: dash.tempLightGradientType,
),
),
),
@@ -154,7 +157,11 @@ class _FullScreenBlurOverlayState extends State<FullScreenBlurOverlay> {
dash.overlayEntry?.markNeedsBuild();
}
final isSolid = dash.tempGradientType == GradientType.solid;
final activeGradientType =
Theme.of(widget.context).brightness == Brightness.dark
? dash.tempDarkGradientType
: dash.tempLightGradientType;
final isSolid = activeGradientType == GradientType.solid;
final currentHSV = (isSolid || dash.editingPrimary)
? dash.tempPrimaryHSV
: dash.tempSecondaryHSV;
@@ -181,7 +188,16 @@ class _FullScreenBlurOverlayState extends State<FullScreenBlurOverlay> {
isDimmed: isSolid,
onTap: () {
dash.setState(() {
if (isSolid) dash.tempGradientType = CardColorService.defaultGradient;
if (isSolid) {
if (Theme.of(widget.context).brightness ==
Brightness.dark) {
dash.tempDarkGradientType =
CardColorService.defaultGradientDark;
} else {
dash.tempLightGradientType =
CardColorService.defaultGradientLight;
}
}
dash.editingPrimary = true;
});
setPanelState(() {});
@@ -198,7 +214,16 @@ class _FullScreenBlurOverlayState extends State<FullScreenBlurOverlay> {
isDimmed: isSolid,
onTap: () {
dash.setState(() {
if (isSolid) dash.tempGradientType = CardColorService.defaultGradient;
if (isSolid) {
if (Theme.of(widget.context).brightness ==
Brightness.dark) {
dash.tempDarkGradientType =
CardColorService.defaultGradientDark;
} else {
dash.tempLightGradientType =
CardColorService.defaultGradientLight;
}
}
dash.editingPrimary = false;
});
setPanelState(() {});
@@ -220,7 +245,12 @@ class _FullScreenBlurOverlayState extends State<FullScreenBlurOverlay> {
GestureDetector(
onTap: isSolid ? null : () {
dash.setState(() {
dash.tempGradientType = GradientType.solid;
if (Theme.of(widget.context).brightness ==
Brightness.dark) {
dash.tempDarkGradientType = GradientType.solid;
} else {
dash.tempLightGradientType = GradientType.solid;
}
dash.editingPrimary = true;
});
setPanelState(() {});
@@ -454,7 +484,7 @@ class _FullScreenBlurOverlayState extends State<FullScreenBlurOverlay> {
children: GradientType.values
.where((t) => t != GradientType.solid)
.map((type) {
final isSelected = dash.tempGradientType == type;
final isSelected = activeGradientType == type;
final label = switch (type) {
GradientType.linear => s.gradientLinear,
GradientType.linearReverse => s.gradientReverse,
@@ -475,8 +505,14 @@ class _FullScreenBlurOverlayState extends State<FullScreenBlurOverlay> {
padding: const EdgeInsets.only(right: 6),
child: GestureDetector(
onTap: () {
dash.setState(
() => dash.tempGradientType = type);
dash.setState(() {
if (Theme.of(widget.context).brightness ==
Brightness.dark) {
dash.tempDarkGradientType = type;
} else {
dash.tempLightGradientType = type;
}
});
setPanelState(() {});
dash.overlayEntry?.markNeedsBuild();
},
@@ -557,7 +593,10 @@ class _FullScreenBlurOverlayState extends State<FullScreenBlurOverlay> {
dash.tempSecondary = defS;
dash.tempPrimaryHSV = HSVColor.fromColor(defP);
dash.tempSecondaryHSV = HSVColor.fromColor(defS);
dash.tempGradientType = CardColorService.defaultGradient;
dash.tempLightGradientType =
CardColorService.defaultGradientLight;
dash.tempDarkGradientType =
CardColorService.defaultGradientDark;
});
setPanelState(() {});
dash.overlayEntry?.markNeedsBuild();