From c90e6fe661025d0491a67603afb23b3d095669e0 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Fri, 22 Aug 2025 17:53:07 +0800 Subject: [PATCH] :sparkles: Experience records & refine leveling page --- assets/i18n/en-US.json | 5 +- assets/i18n/zh-CN.json | 5 +- lib/models/account.dart | 16 ++ lib/models/account.freezed.dart | 275 ++++++++++++++++++++++++++++ lib/models/account.g.dart | 25 +++ lib/screens/account/leveling.dart | 210 +++++++++++++++++---- lib/screens/account/leveling.g.dart | 21 +++ lib/widgets/post/post_list.g.dart | 2 +- 8 files changed, 518 insertions(+), 41 deletions(-) diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 71e01b3b..66eeaa4a 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -865,5 +865,8 @@ "fileSize": "File Size", "fileHash": "File Hash", "exifData": "EXIF Data", - "postShuffle": "Shuffle Posts" + "postShuffle": "Shuffle Posts", + "leveling": "Leveling", + "levelingHistory": "Leveling History", + "stellarProgram": "Stellar Program" } diff --git a/assets/i18n/zh-CN.json b/assets/i18n/zh-CN.json index 6266db1e..81e57f7d 100644 --- a/assets/i18n/zh-CN.json +++ b/assets/i18n/zh-CN.json @@ -833,5 +833,8 @@ "mimeType": "类型", "fileSize": "大小", "fileHash": "哈希", - "exifData": "EXIF 数据" + "exifData": "EXIF 数据", + "leveling": "等级", + "levelingHistory": "经验记录", + "stellarProgram": "恒星计划" } diff --git a/lib/models/account.dart b/lib/models/account.dart index 0ed1a09b..f4d837f0 100644 --- a/lib/models/account.dart +++ b/lib/models/account.dart @@ -208,3 +208,19 @@ sealed class SnAuthDeviceWithChallenge with _$SnAuthDeviceWithChallenge { factory SnAuthDeviceWithChallenge.fromJson(Map json) => _$SnAuthDeviceWithChallengeFromJson(json); } + +@freezed +sealed class SnExperienceRecord with _$SnExperienceRecord { + const factory SnExperienceRecord({ + required String id, + required int delta, + required String reasonType, + required String reason, + required DateTime createdAt, + required DateTime updatedAt, + required DateTime? deletedAt, + }) = _SnExperienceRecord; + + factory SnExperienceRecord.fromJson(Map json) => + _$SnExperienceRecordFromJson(json); +} diff --git a/lib/models/account.freezed.dart b/lib/models/account.freezed.dart index 42d6b900..b1c9548d 100644 --- a/lib/models/account.freezed.dart +++ b/lib/models/account.freezed.dart @@ -3018,6 +3018,281 @@ as bool, } +} + + +/// @nodoc +mixin _$SnExperienceRecord { + + String get id; int get delta; String get reasonType; String get reason; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; +/// Create a copy of SnExperienceRecord +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SnExperienceRecordCopyWith get copyWith => _$SnExperienceRecordCopyWithImpl(this as SnExperienceRecord, _$identity); + + /// Serializes this SnExperienceRecord to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnExperienceRecord&&(identical(other.id, id) || other.id == id)&&(identical(other.delta, delta) || other.delta == delta)&&(identical(other.reasonType, reasonType) || other.reasonType == reasonType)&&(identical(other.reason, reason) || other.reason == reason)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,delta,reasonType,reason,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnExperienceRecord(id: $id, delta: $delta, reasonType: $reasonType, reason: $reason, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $SnExperienceRecordCopyWith<$Res> { + factory $SnExperienceRecordCopyWith(SnExperienceRecord value, $Res Function(SnExperienceRecord) _then) = _$SnExperienceRecordCopyWithImpl; +@useResult +$Res call({ + String id, int delta, String reasonType, String reason, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + + + +} +/// @nodoc +class _$SnExperienceRecordCopyWithImpl<$Res> + implements $SnExperienceRecordCopyWith<$Res> { + _$SnExperienceRecordCopyWithImpl(this._self, this._then); + + final SnExperienceRecord _self; + final $Res Function(SnExperienceRecord) _then; + +/// Create a copy of SnExperienceRecord +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? delta = null,Object? reasonType = null,Object? reason = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,delta: null == delta ? _self.delta : delta // ignore: cast_nullable_to_non_nullable +as int,reasonType: null == reasonType ? _self.reasonType : reasonType // ignore: cast_nullable_to_non_nullable +as String,reason: null == reason ? _self.reason : reason // ignore: cast_nullable_to_non_nullable +as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable +as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [SnExperienceRecord]. +extension SnExperienceRecordPatterns on SnExperienceRecord { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _SnExperienceRecord value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SnExperienceRecord() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _SnExperienceRecord value) $default,){ +final _that = this; +switch (_that) { +case _SnExperienceRecord(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _SnExperienceRecord value)? $default,){ +final _that = this; +switch (_that) { +case _SnExperienceRecord() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, int delta, String reasonType, String reason, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SnExperienceRecord() when $default != null: +return $default(_that.id,_that.delta,_that.reasonType,_that.reason,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, int delta, String reasonType, String reason, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this; +switch (_that) { +case _SnExperienceRecord(): +return $default(_that.id,_that.delta,_that.reasonType,_that.reason,_that.createdAt,_that.updatedAt,_that.deletedAt);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, int delta, String reasonType, String reason, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this; +switch (_that) { +case _SnExperienceRecord() when $default != null: +return $default(_that.id,_that.delta,_that.reasonType,_that.reason,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _SnExperienceRecord implements SnExperienceRecord { + const _SnExperienceRecord({required this.id, required this.delta, required this.reasonType, required this.reason, required this.createdAt, required this.updatedAt, required this.deletedAt}); + factory _SnExperienceRecord.fromJson(Map json) => _$SnExperienceRecordFromJson(json); + +@override final String id; +@override final int delta; +@override final String reasonType; +@override final String reason; +@override final DateTime createdAt; +@override final DateTime updatedAt; +@override final DateTime? deletedAt; + +/// Create a copy of SnExperienceRecord +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SnExperienceRecordCopyWith<_SnExperienceRecord> get copyWith => __$SnExperienceRecordCopyWithImpl<_SnExperienceRecord>(this, _$identity); + +@override +Map toJson() { + return _$SnExperienceRecordToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnExperienceRecord&&(identical(other.id, id) || other.id == id)&&(identical(other.delta, delta) || other.delta == delta)&&(identical(other.reasonType, reasonType) || other.reasonType == reasonType)&&(identical(other.reason, reason) || other.reason == reason)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,delta,reasonType,reason,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnExperienceRecord(id: $id, delta: $delta, reasonType: $reasonType, reason: $reason, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$SnExperienceRecordCopyWith<$Res> implements $SnExperienceRecordCopyWith<$Res> { + factory _$SnExperienceRecordCopyWith(_SnExperienceRecord value, $Res Function(_SnExperienceRecord) _then) = __$SnExperienceRecordCopyWithImpl; +@override @useResult +$Res call({ + String id, int delta, String reasonType, String reason, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + + + +} +/// @nodoc +class __$SnExperienceRecordCopyWithImpl<$Res> + implements _$SnExperienceRecordCopyWith<$Res> { + __$SnExperienceRecordCopyWithImpl(this._self, this._then); + + final _SnExperienceRecord _self; + final $Res Function(_SnExperienceRecord) _then; + +/// Create a copy of SnExperienceRecord +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? delta = null,Object? reasonType = null,Object? reason = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { + return _then(_SnExperienceRecord( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,delta: null == delta ? _self.delta : delta // ignore: cast_nullable_to_non_nullable +as int,reasonType: null == reasonType ? _self.reasonType : reasonType // ignore: cast_nullable_to_non_nullable +as String,reason: null == reason ? _self.reason : reason // ignore: cast_nullable_to_non_nullable +as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable +as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + + } // dart format on diff --git a/lib/models/account.g.dart b/lib/models/account.g.dart index d14e51bd..9d1917b7 100644 --- a/lib/models/account.g.dart +++ b/lib/models/account.g.dart @@ -348,3 +348,28 @@ Map _$SnAuthDeviceWithChallengeeToJson( 'challenges': instance.challenges.map((e) => e.toJson()).toList(), 'is_current': instance.isCurrent, }; + +_SnExperienceRecord _$SnExperienceRecordFromJson(Map json) => + _SnExperienceRecord( + id: json['id'] as String, + delta: (json['delta'] as num).toInt(), + reasonType: json['reason_type'] as String, + reason: json['reason'] as String, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + deletedAt: + json['deleted_at'] == null + ? null + : DateTime.parse(json['deleted_at'] as String), + ); + +Map _$SnExperienceRecordToJson(_SnExperienceRecord instance) => + { + 'id': instance.id, + 'delta': instance.delta, + 'reason_type': instance.reasonType, + 'reason': instance.reason, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'deleted_at': instance.deletedAt?.toIso8601String(), + }; diff --git a/lib/screens/account/leveling.dart b/lib/screens/account/leveling.dart index 7f697ccb..685f8013 100644 --- a/lib/screens/account/leveling.dart +++ b/lib/screens/account/leveling.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/account.dart'; import 'package:island/models/wallet.dart'; import 'package:island/pods/network.dart'; import 'package:island/pods/userinfo.dart'; @@ -19,6 +20,7 @@ import 'package:island/widgets/payment/payment_overlay.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; import 'package:styled_widget/styled_widget.dart'; part 'leveling.g.dart'; @@ -35,13 +37,49 @@ Future accountStellarSubscription(Ref ref) async { } } +@riverpod +class LevelingHistoryNotifier extends _$LevelingHistoryNotifier + with CursorPagingNotifierMixin { + static const int _pageSize = 20; + + @override + Future> build() => fetch(cursor: null); + + @override + Future> fetch({ + required String? cursor, + }) async { + final client = ref.read(apiClientProvider); + final offset = cursor == null ? 0 : int.parse(cursor); + + final queryParams = {'offset': offset, 'take': _pageSize}; + + final response = await client.get( + '/id/accounts/me/leveling', + queryParameters: queryParams, + ); + final total = int.parse(response.headers.value('X-Total') ?? '0'); + final List data = response.data; + final records = + data.map((json) => SnExperienceRecord.fromJson(json)).toList(); + + final hasMore = offset + records.length < total; + final nextCursor = hasMore ? (offset + records.length).toString() : null; + + return CursorPagingData( + items: records, + hasMore: hasMore, + nextCursor: nextCursor, + ); + } +} + class LevelingScreen extends HookConsumerWidget { const LevelingScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final user = ref.watch(userInfoProvider); - final stellarSubscription = ref.watch(accountStellarSubscriptionProvider); if (user.value == null) { return AppScaffold( @@ -50,47 +88,143 @@ class LevelingScreen extends HookConsumerWidget { ); } - final currentLevel = user.value!.profile.level; - final currentExp = user.value!.profile.experience; - final progress = user.value!.profile.levelingProgress; + return DefaultTabController( + length: 2, + child: AppScaffold( + appBar: AppBar( + title: Text('levelingProgress'.tr()), + bottom: TabBar( + tabs: [ + Tab(text: 'leveling'.tr()), + Tab(text: 'stellarProgram'.tr()), + ], + ), + ), + body: TabBarView( + children: [ + _buildLevelingTab(context, ref, user.value!), + _buildStellarProgramTab(context, ref), + ], + ), + ), + ); + } - return AppScaffold( - appBar: AppBar(title: Text('levelingProgress'.tr())), - body: SingleChildScrollView( - padding: getTabbedPadding(context, horizontal: 20, vertical: 20), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 480), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Current Progress Card - LevelingProgressCard( - level: currentLevel, - experience: currentExp, - progress: progress, - ), - const Gap(24), + Widget _buildLevelingTab( + BuildContext context, + WidgetRef ref, + SnAccount user, + ) { + final currentLevel = user.profile.level; + final currentExp = user.profile.experience; + final progress = user.profile.levelingProgress; - // Level Stairs Graph - Text( - 'levelProgress'.tr(), - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const Gap(16), + return Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20), + constraints: const BoxConstraints(maxWidth: 480), + child: CustomScrollView( + slivers: [ + const SliverGap(20), - // Stairs visualization with fixed height and horizontal scroll - _buildLevelStairs(context, currentLevel), - - const Gap(24), - - // Membership section - _buildMembershipSection(context, ref, stellarSubscription), - const Gap(16), - ], + // Current Progress Card + SliverToBoxAdapter( + child: LevelingProgressCard( + level: currentLevel, + experience: currentExp, + progress: progress, + ), ), + const SliverGap(24), + + // Level Stairs Graph + SliverToBoxAdapter( + child: Text( + 'levelProgress'.tr(), + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + const SliverGap(16), + + // Stairs visualization with fixed height and horizontal scroll + SliverToBoxAdapter(child: _buildLevelStairs(context, currentLevel)), + const SliverGap(24), + + // Leveling History + SliverToBoxAdapter( + child: Text( + 'levelingHistory'.tr(), + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + const SliverGap(8), + PagingHelperSliverView( + provider: levelingHistoryNotifierProvider, + futureRefreshable: levelingHistoryNotifierProvider.future, + notifierRefreshable: levelingHistoryNotifierProvider.notifier, + contentBuilder: + (data, widgetCount, endItemView) => SliverList.builder( + itemCount: widgetCount, + itemBuilder: (context, index) { + if (index == widgetCount - 1) { + return endItemView; + } + final record = data.items[index]; + return ListTile( + title: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(record.reason), + Row( + spacing: 4, + children: [ + Text( + record.createdAt.formatRelative(context), + ).fontSize(13), + Text('·').fontSize(13).bold(), + Text( + record.createdAt.formatSystem(), + ).fontSize(13), + ], + ).opacity(0.8), + ], + ), + subtitle: Text( + '${record.delta > 0 ? '+' : ''}${record.delta} EXP', + ), + minTileHeight: 56, + contentPadding: EdgeInsets.symmetric(horizontal: 4), + ); + }, + ), + ), + + SliverGap(getTabbedPadding(context, vertical: 20).vertical), + ], + ), + ), + ); + } + + Widget _buildStellarProgramTab(BuildContext context, WidgetRef ref) { + final stellarSubscription = ref.watch(accountStellarSubscriptionProvider); + + return SingleChildScrollView( + padding: getTabbedPadding(context, horizontal: 20, vertical: 20), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildMembershipSection(context, ref, stellarSubscription), + const Gap(16), + ], ), ), ), diff --git a/lib/screens/account/leveling.g.dart b/lib/screens/account/leveling.g.dart index d457c503..86daa2b8 100644 --- a/lib/screens/account/leveling.g.dart +++ b/lib/screens/account/leveling.g.dart @@ -27,5 +27,26 @@ final accountStellarSubscriptionProvider = // ignore: unused_element typedef AccountStellarSubscriptionRef = AutoDisposeFutureProviderRef; +String _$levelingHistoryNotifierHash() => + r'e795f9b7911c9e50f15c095ea237cb0e87bf1e89'; + +/// See also [LevelingHistoryNotifier]. +@ProviderFor(LevelingHistoryNotifier) +final levelingHistoryNotifierProvider = AutoDisposeAsyncNotifierProvider< + LevelingHistoryNotifier, + CursorPagingData +>.internal( + LevelingHistoryNotifier.new, + name: r'levelingHistoryNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$levelingHistoryNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$LevelingHistoryNotifier = + AutoDisposeAsyncNotifier>; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/widgets/post/post_list.g.dart b/lib/widgets/post/post_list.g.dart index 3aa004bc..89050911 100644 --- a/lib/widgets/post/post_list.g.dart +++ b/lib/widgets/post/post_list.g.dart @@ -6,7 +6,7 @@ part of 'post_list.dart'; // RiverpodGenerator // ************************************************************************** -String _$postListNotifierHash() => r'27dc73b92a057b396e8ac026d4392508aedea4f5'; +String _$postListNotifierHash() => r'faa0b939fae56367ff120ce63d9deb17b1995c9c'; /// Copied from Dart SDK class _SystemHash {