app state fixed

This commit is contained in:
2026-02-25 18:37:17 +01:00
parent 4b4eb807d7
commit 0c93440f9b
54 changed files with 941 additions and 500 deletions

View File

@@ -435,7 +435,7 @@ class ProfileSettingsScreen extends ConsumerWidget {
style: const ButtonStyle(
padding: WidgetStatePropertyAll(EdgeInsets.all(0)),
),
onPressed: () {},
onPressed: () => _logout(context, ref),
child: Row(
spacing: 4,
children: [

View File

@@ -9,36 +9,31 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:profile/src/presentation/state/profile_view_model.dart';
import 'package:profile/src/presentation/state/profile_view_state.dart';
class ProfileScreen extends ConsumerWidget {
class ProfileScreen extends ConsumerStatefulWidget {
final NavigationContract navigationContract;
const ProfileScreen({super.key, required this.navigationContract});
List<num> _expensesByDay(List<WalletTransactionEntity> transactions) {
final result = List<num>.filled(7, 0);
for (final tx in transactions) {
final date = DateTime.tryParse(tx.createdDate);
if (date == null) continue;
final amount = double.tryParse(tx.amount) ?? 0;
result[date.weekday - 1] += amount.abs();
}
return result;
}
@override
ConsumerState<ProfileScreen> createState() => _ProfileScreenState();
}
class _ProfileScreenState extends ConsumerState<ProfileScreen> {
NavigationContract get navigationContract => widget.navigationContract;
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final theme = ref.watch(themePortProvider);
final viewState = ref.watch(profileViewModelProvider);
return Scaffold(
backgroundColor: theme.getColorFor(ThemeCode.backgroundSecondary),
body: _buildBody(context, ref, theme, viewState),
body: _buildBody(context, theme, viewState),
);
}
Widget _buildBody(
BuildContext context,
WidgetRef ref,
ThemePort theme,
ProfileViewState viewState,
) {
@@ -47,12 +42,14 @@ class ProfileScreen extends ConsumerWidget {
}
if (viewState.errorMessage.isNotEmpty) {
return _buildError(context, ref, theme, viewState.errorMessage);
return _buildError(context, theme, viewState.errorMessage);
}
final balance = viewState.walletBalance;
final balance = ref.watch(parentWalletBalanceProvider);
final accountTotalBalance =
ref.watch(accountTotalBalanceProvider).value ?? balance?.totalBalance ?? 0;
final content = <Widget>[
final header = <Widget>[
Row(
children: [
Text(
@@ -78,16 +75,13 @@ class ProfileScreen extends ConsumerWidget {
),
if (balance != null)
WalletBalanceBlock(
value: balance.availableBalance,
savings: balance.allocatedBalance,
availableBalance: balance.availableBalance,
allocatedBalance: balance.allocatedBalance,
totalBalance: balance.totalBalance,
),
LineGraph(
lines: [_expensesByDay(viewState.transactions)],
lineLabels: [context.translate(I18n.profileMyAccount)],
),
if (balance != null)
DepositBlock(
max: 150 - balance.totalBalance,
max: 150 - accountTotalBalance,
onDeposit: (amount) async {
final result = await showPayinBottomSheet(
context,
@@ -100,8 +94,10 @@ class ProfileScreen extends ConsumerWidget {
message: context.translate(I18n.payinSuccess),
type: MessageType.success,
);
ref.read(profileViewModelProvider.notifier).load();
ref.read(parentWalletBalanceProvider.notifier).applyOptimisticPayin(amount);
return true;
}
return false;
},
),
Container(
@@ -133,9 +129,15 @@ class ProfileScreen extends ConsumerWidget {
),
),
),
_buildTransactions(context, theme, viewState),
];
final transactions = viewState.transactions;
final hasTransactions = transactions.isNotEmpty;
final itemCount = header.length
+ 1 // transactions title or empty message
+ (hasTransactions ? transactions.length : 0);
return Stack(
children: [
DecoratedBox(
@@ -158,13 +160,43 @@ class ProfileScreen extends ConsumerWidget {
ref.read(profileViewModelProvider.notifier).load(),
child: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
itemBuilder: (BuildContext context, int index) {
return content[index];
itemCount: itemCount,
separatorBuilder: (_, __) =>
const Divider(color: Colors.transparent, height: 20),
itemBuilder: (context, index) {
// Header items
if (index < header.length) {
return header[index];
}
final offset = index - header.length;
// Transactions title or empty message
if (offset == 0) {
if (!hasTransactions) {
return Center(
child: Text(
context.translate(I18n.profileNoRecentTransactions),
style: TextStyle(
fontSize: 14,
color: theme.getColorFor(ThemeCode.textPrimary),
),
),
);
}
return Text(
context.translate(I18n.profileRecentTransactions),
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 18,
color: theme.getColorFor(ThemeCode.textPrimary),
),
);
}
final txIndex = offset - 1;
return TransactionTile(transaction: transactions[txIndex]);
},
separatorBuilder: (BuildContext context, int index) {
return Divider(color: Colors.transparent, height: 20);
},
itemCount: content.length,
),
),
),
@@ -175,55 +207,11 @@ class ProfileScreen extends ConsumerWidget {
);
}
Widget _buildTransactions(
BuildContext context,
ThemePort theme,
ProfileViewState viewState,
) {
final transactions = viewState.transactions;
if (transactions.isEmpty) {
return Center(
child: Text(
context.translate(I18n.profileNoRecentTransactions),
style: TextStyle(
fontSize: 14,
color: theme.getColorFor(ThemeCode.textPrimary),
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.translate(I18n.profileRecentTransactions),
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 18,
color: theme.getColorFor(ThemeCode.textPrimary),
),
),
const SizedBox(height: 12),
...transactions.map(
(transaction) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TransactionTile(transaction: transaction),
),
),
],
);
}
Widget _buildError(
BuildContext context,
WidgetRef ref,
ThemePort theme,
String message,
) {
final viewModel = ref.read(profileViewModelProvider.notifier);
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
@@ -246,7 +234,8 @@ class ProfileScreen extends ConsumerWidget {
),
const SizedBox(height: 16),
TextButton(
onPressed: () => viewModel.retry(),
onPressed: () =>
ref.read(profileViewModelProvider.notifier).retry(),
child: Text(context.translate(I18n.retry)),
),
],

View File

@@ -1,8 +1,5 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:activity/activity.dart';
import 'profile_view_state.dart';
final profileViewModelProvider =
@@ -11,18 +8,9 @@ final profileViewModelProvider =
);
class ProfileViewModel extends Notifier<ProfileViewState> {
late final GetWalletBalanceUseCase _getBalanceUseCase;
late final GetWalletTransactionsUseCase _getTransactionsUseCase;
late final GetPaymentProfileUseCase _getPaymentProfileUseCase;
late final GetUserInfoUseCase _getUserInfoUseCase;
@override
ProfileViewState build() {
_getBalanceUseCase = ref.read(getWalletBalanceUseCaseProvider);
_getTransactionsUseCase = ref.read(getWalletTransactionsUseCaseProvider);
_getPaymentProfileUseCase = ref.read(getPaymentProfileUseCaseProvider);
_getUserInfoUseCase = ref.read(getUserInfoUseCaseProvider);
ref.watch(walletRefreshProvider);
Future.microtask(() => load());
return const ProfileViewState(isLoading: true);
}
@@ -31,7 +19,7 @@ class ProfileViewModel extends Notifier<ProfileViewState> {
state = state.copyWith(isLoading: true, errorMessage: '');
try {
final user = await _getUserInfoUseCase.getUserInfo();
final user = await ref.read(getUserInfoUseCaseProvider).getUserInfo();
if (!ref.mounted) return;
final rawName = user.firstName;
@@ -39,7 +27,7 @@ class ProfileViewModel extends Notifier<ProfileViewState> {
? ''
: rawName[0].toUpperCase() + rawName.substring(1).toLowerCase();
final paymentProfile = await _getPaymentProfileUseCase.getPaymentProfile(
final paymentProfile = await ref.read(getPaymentProfileUseCaseProvider).getPaymentProfile(
userId: user.id,
);
if (!ref.mounted) return;
@@ -50,18 +38,21 @@ class ProfileViewModel extends Notifier<ProfileViewState> {
return;
}
final results = await Future.wait([
_getBalanceUseCase(walletId: walletId),
_loadTransactions(walletId),
]);
final query = TransactionsQuery(
walletId: walletId,
dateFilter: DateFilter.lastWeek,
);
final transactions = await ref
.read(walletTransactionsProvider(query).future);
if (!ref.mounted) return;
state = state.copyWith(
isLoading: false,
userName: name,
walletBalance: results[0] as WalletBalanceEntity,
transactions: results[1] as List<WalletTransactionEntity>,
walletId: walletId,
transactions: transactions,
);
} catch (e) {
if (!ref.mounted) return;
@@ -69,24 +60,6 @@ class ProfileViewModel extends Notifier<ProfileViewState> {
}
}
Future<List<WalletTransactionEntity>> _loadTransactions(
String walletId,
) async {
final filtersJson = jsonEncode({
'createdDate': {
'gte': DateFilter.lastWeek.startDate.toIso8601String(),
},
});
final filtersBase64 = base64Encode(utf8.encode(filtersJson));
final response = await _getTransactionsUseCase(
walletId: walletId,
queryParameters: {'filters': filtersBase64},
);
return response.items;
}
Future<void> retry() async {
await load();
}

View File

@@ -8,7 +8,7 @@ abstract class ProfileViewState with _$ProfileViewState {
const factory ProfileViewState({
@Default(false) bool isLoading,
@Default('') String userName,
WalletBalanceEntity? walletBalance,
@Default('') String walletId,
@Default([]) List<WalletTransactionEntity> transactions,
@Default('') String errorMessage,
}) = _ProfileViewState;

View File

@@ -14,7 +14,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ProfileViewState {
bool get isLoading; String get userName; WalletBalanceEntity? get walletBalance; List<WalletTransactionEntity> get transactions; String get errorMessage;
bool get isLoading; String get userName; String get walletId; List<WalletTransactionEntity> get transactions; String get errorMessage;
/// Create a copy of ProfileViewState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -25,16 +25,16 @@ $ProfileViewStateCopyWith<ProfileViewState> get copyWith => _$ProfileViewStateCo
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ProfileViewState&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.userName, userName) || other.userName == userName)&&(identical(other.walletBalance, walletBalance) || other.walletBalance == walletBalance)&&const DeepCollectionEquality().equals(other.transactions, transactions)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage));
return identical(this, other) || (other.runtimeType == runtimeType&&other is ProfileViewState&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.userName, userName) || other.userName == userName)&&(identical(other.walletId, walletId) || other.walletId == walletId)&&const DeepCollectionEquality().equals(other.transactions, transactions)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage));
}
@override
int get hashCode => Object.hash(runtimeType,isLoading,userName,walletBalance,const DeepCollectionEquality().hash(transactions),errorMessage);
int get hashCode => Object.hash(runtimeType,isLoading,userName,walletId,const DeepCollectionEquality().hash(transactions),errorMessage);
@override
String toString() {
return 'ProfileViewState(isLoading: $isLoading, userName: $userName, walletBalance: $walletBalance, transactions: $transactions, errorMessage: $errorMessage)';
return 'ProfileViewState(isLoading: $isLoading, userName: $userName, walletId: $walletId, transactions: $transactions, errorMessage: $errorMessage)';
}
@@ -45,11 +45,11 @@ abstract mixin class $ProfileViewStateCopyWith<$Res> {
factory $ProfileViewStateCopyWith(ProfileViewState value, $Res Function(ProfileViewState) _then) = _$ProfileViewStateCopyWithImpl;
@useResult
$Res call({
bool isLoading, String userName, WalletBalanceEntity? walletBalance, List<WalletTransactionEntity> transactions, String errorMessage
bool isLoading, String userName, String walletId, List<WalletTransactionEntity> transactions, String errorMessage
});
$WalletBalanceEntityCopyWith<$Res>? get walletBalance;
}
/// @nodoc
@@ -62,29 +62,17 @@ class _$ProfileViewStateCopyWithImpl<$Res>
/// Create a copy of ProfileViewState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? isLoading = null,Object? userName = null,Object? walletBalance = freezed,Object? transactions = null,Object? errorMessage = null,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? isLoading = null,Object? userName = null,Object? walletId = null,Object? transactions = null,Object? errorMessage = null,}) {
return _then(_self.copyWith(
isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
as bool,userName: null == userName ? _self.userName : userName // ignore: cast_nullable_to_non_nullable
as String,walletBalance: freezed == walletBalance ? _self.walletBalance : walletBalance // ignore: cast_nullable_to_non_nullable
as WalletBalanceEntity?,transactions: null == transactions ? _self.transactions : transactions // ignore: cast_nullable_to_non_nullable
as String,walletId: null == walletId ? _self.walletId : walletId // ignore: cast_nullable_to_non_nullable
as String,transactions: null == transactions ? _self.transactions : transactions // ignore: cast_nullable_to_non_nullable
as List<WalletTransactionEntity>,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
as String,
));
}
/// Create a copy of ProfileViewState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$WalletBalanceEntityCopyWith<$Res>? get walletBalance {
if (_self.walletBalance == null) {
return null;
}
return $WalletBalanceEntityCopyWith<$Res>(_self.walletBalance!, (value) {
return _then(_self.copyWith(walletBalance: value));
});
}
}
@@ -166,10 +154,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool isLoading, String userName, WalletBalanceEntity? walletBalance, List<WalletTransactionEntity> transactions, String errorMessage)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool isLoading, String userName, String walletId, List<WalletTransactionEntity> transactions, String errorMessage)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ProfileViewState() when $default != null:
return $default(_that.isLoading,_that.userName,_that.walletBalance,_that.transactions,_that.errorMessage);case _:
return $default(_that.isLoading,_that.userName,_that.walletId,_that.transactions,_that.errorMessage);case _:
return orElse();
}
@@ -187,10 +175,10 @@ return $default(_that.isLoading,_that.userName,_that.walletBalance,_that.transac
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool isLoading, String userName, WalletBalanceEntity? walletBalance, List<WalletTransactionEntity> transactions, String errorMessage) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool isLoading, String userName, String walletId, List<WalletTransactionEntity> transactions, String errorMessage) $default,) {final _that = this;
switch (_that) {
case _ProfileViewState():
return $default(_that.isLoading,_that.userName,_that.walletBalance,_that.transactions,_that.errorMessage);case _:
return $default(_that.isLoading,_that.userName,_that.walletId,_that.transactions,_that.errorMessage);case _:
throw StateError('Unexpected subclass');
}
@@ -207,10 +195,10 @@ return $default(_that.isLoading,_that.userName,_that.walletBalance,_that.transac
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool isLoading, String userName, WalletBalanceEntity? walletBalance, List<WalletTransactionEntity> transactions, String errorMessage)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool isLoading, String userName, String walletId, List<WalletTransactionEntity> transactions, String errorMessage)? $default,) {final _that = this;
switch (_that) {
case _ProfileViewState() when $default != null:
return $default(_that.isLoading,_that.userName,_that.walletBalance,_that.transactions,_that.errorMessage);case _:
return $default(_that.isLoading,_that.userName,_that.walletId,_that.transactions,_that.errorMessage);case _:
return null;
}
@@ -222,12 +210,12 @@ return $default(_that.isLoading,_that.userName,_that.walletBalance,_that.transac
class _ProfileViewState implements ProfileViewState {
const _ProfileViewState({this.isLoading = false, this.userName = '', this.walletBalance, final List<WalletTransactionEntity> transactions = const [], this.errorMessage = ''}): _transactions = transactions;
const _ProfileViewState({this.isLoading = false, this.userName = '', this.walletId = '', final List<WalletTransactionEntity> transactions = const [], this.errorMessage = ''}): _transactions = transactions;
@override@JsonKey() final bool isLoading;
@override@JsonKey() final String userName;
@override final WalletBalanceEntity? walletBalance;
@override@JsonKey() final String walletId;
final List<WalletTransactionEntity> _transactions;
@override@JsonKey() List<WalletTransactionEntity> get transactions {
if (_transactions is EqualUnmodifiableListView) return _transactions;
@@ -247,16 +235,16 @@ _$ProfileViewStateCopyWith<_ProfileViewState> get copyWith => __$ProfileViewStat
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ProfileViewState&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.userName, userName) || other.userName == userName)&&(identical(other.walletBalance, walletBalance) || other.walletBalance == walletBalance)&&const DeepCollectionEquality().equals(other._transactions, _transactions)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ProfileViewState&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.userName, userName) || other.userName == userName)&&(identical(other.walletId, walletId) || other.walletId == walletId)&&const DeepCollectionEquality().equals(other._transactions, _transactions)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage));
}
@override
int get hashCode => Object.hash(runtimeType,isLoading,userName,walletBalance,const DeepCollectionEquality().hash(_transactions),errorMessage);
int get hashCode => Object.hash(runtimeType,isLoading,userName,walletId,const DeepCollectionEquality().hash(_transactions),errorMessage);
@override
String toString() {
return 'ProfileViewState(isLoading: $isLoading, userName: $userName, walletBalance: $walletBalance, transactions: $transactions, errorMessage: $errorMessage)';
return 'ProfileViewState(isLoading: $isLoading, userName: $userName, walletId: $walletId, transactions: $transactions, errorMessage: $errorMessage)';
}
@@ -267,11 +255,11 @@ abstract mixin class _$ProfileViewStateCopyWith<$Res> implements $ProfileViewSta
factory _$ProfileViewStateCopyWith(_ProfileViewState value, $Res Function(_ProfileViewState) _then) = __$ProfileViewStateCopyWithImpl;
@override @useResult
$Res call({
bool isLoading, String userName, WalletBalanceEntity? walletBalance, List<WalletTransactionEntity> transactions, String errorMessage
bool isLoading, String userName, String walletId, List<WalletTransactionEntity> transactions, String errorMessage
});
@override $WalletBalanceEntityCopyWith<$Res>? get walletBalance;
}
/// @nodoc
@@ -284,30 +272,18 @@ class __$ProfileViewStateCopyWithImpl<$Res>
/// Create a copy of ProfileViewState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? isLoading = null,Object? userName = null,Object? walletBalance = freezed,Object? transactions = null,Object? errorMessage = null,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? isLoading = null,Object? userName = null,Object? walletId = null,Object? transactions = null,Object? errorMessage = null,}) {
return _then(_ProfileViewState(
isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
as bool,userName: null == userName ? _self.userName : userName // ignore: cast_nullable_to_non_nullable
as String,walletBalance: freezed == walletBalance ? _self.walletBalance : walletBalance // ignore: cast_nullable_to_non_nullable
as WalletBalanceEntity?,transactions: null == transactions ? _self._transactions : transactions // ignore: cast_nullable_to_non_nullable
as String,walletId: null == walletId ? _self.walletId : walletId // ignore: cast_nullable_to_non_nullable
as String,transactions: null == transactions ? _self._transactions : transactions // ignore: cast_nullable_to_non_nullable
as List<WalletTransactionEntity>,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
as String,
));
}
/// Create a copy of ProfileViewState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$WalletBalanceEntityCopyWith<$Res>? get walletBalance {
if (_self.walletBalance == null) {
return null;
}
return $WalletBalanceEntityCopyWith<$Res>(_self.walletBalance!, (value) {
return _then(_self.copyWith(walletBalance: value));
});
}
}
// dart format on

View File

@@ -1,27 +1,33 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sca_treezor/sca_treezor.dart'
show TreezorWalletConnectionService;
import 'package:sf_infrastructure/sf_infrastructure.dart';
// import 'package:sf_shared/sf_shared.dart' show treezorRepositoryProvider;
import 'package:sf_shared/sf_shared.dart' show treezorRepositoryProvider;
final logoutProvider = FutureProvider.autoDispose<void>((ref) async {
final repository = getIt<QuestiaRepository>();
// final treezorRepository = ref.read(treezorRepositoryProvider);
// // Delete SCA wallet on the server before clearing local data
// try {
// final cachedWallets = await treezorRepository.scaWallets();
// final scaWalletId = cachedWallets.item.id;
// if (scaWalletId.isNotEmpty) {
// await treezorRepository.deleteScaWallet(scaWalletId: scaWalletId);
// }
// } catch (_) {
// // Don't block logout if SCA wallet deletion fails
// }
// 1. Logout from Treezor SDK (disconnect active wallet session)
try {
final connectionService = getIt<TreezorWalletConnectionService>();
await connectionService.logout();
} catch (_) {}
// 2. Clear treezor repository in-memory cache
try {
final treezorRepository = ref.read(treezorRepositoryProvider);
await treezorRepository.resetScaWallets();
} catch (_) {}
// 3. Backend logout
try {
await repository.post<void>('/auth/logout');
} on DioException catch (error) {
throw Exception(error.message ?? 'Error in logout');
}
// 4. Clear local data (stops heartbeat via onBeforeSessionCleared,
// then clears cookies + SharedPreferences)
await clearSessionData();
});