From 1f2a5c107d222868bb8431341eca267507f2c3be Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Tue, 20 May 2025 01:51:24 +0800 Subject: [PATCH] :sparkles: Tour and introduce --- lib/database/message_repository.dart | 1 - lib/screens/explore.dart | 134 ++++++++--------- lib/screens/notification.g.dart | 2 +- lib/services/tour.dart | 68 +++++++++ lib/services/tour.freezed.dart | 145 +++++++++++++++++++ lib/services/tour.g.dart | 28 ++++ lib/widgets/tour/techincal_review_intro.dart | 63 ++++++++ lib/widgets/tour/tour.dart | 36 +++++ 8 files changed, 410 insertions(+), 67 deletions(-) create mode 100644 lib/services/tour.dart create mode 100644 lib/services/tour.freezed.dart create mode 100644 lib/services/tour.g.dart create mode 100644 lib/widgets/tour/techincal_review_intro.dart create mode 100644 lib/widgets/tour/tour.dart diff --git a/lib/database/message_repository.dart b/lib/database/message_repository.dart index 7ae52da..1c7d363 100644 --- a/lib/database/message_repository.dart +++ b/lib/database/message_repository.dart @@ -146,7 +146,6 @@ class MessageRepository { queryParameters: {'offset': offset, 'take': take}, ); - final total = int.parse(response.headers.value('X-Total') ?? '0'); final List data = response.data; final messages = diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index 2a8bbd3..bc15bfa 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -11,6 +11,7 @@ import 'package:island/widgets/app_scaffold.dart'; import 'package:island/models/post.dart'; import 'package:island/widgets/check_in.dart'; import 'package:island/widgets/post/post_item.dart'; +import 'package:island/widgets/tour/tour.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; @@ -27,74 +28,77 @@ class ExploreScreen extends ConsumerWidget { final user = ref.watch(userInfoProvider); final activitiesNotifier = ref.watch(activityListNotifierProvider.notifier); - return AppScaffold( - appBar: AppBar(title: const Text('explore').tr()), - floatingActionButton: FloatingActionButton( - heroTag: Key("explore-page-fab"), - onPressed: () { - context.router.push(PostComposeRoute()).then((value) { - if (value != null) { - activitiesNotifier.forceRefresh(); - } - }); - }, - child: const Icon(Symbols.edit), - ), - floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, - body: RefreshIndicator( - onRefresh: () => Future.sync(activitiesNotifier.forceRefresh), - child: PagingHelperView( - provider: activityListNotifierProvider, - futureRefreshable: activityListNotifierProvider.future, - notifierRefreshable: activityListNotifierProvider.notifier, - contentBuilder: - (data, widgetCount, endItemView) => CustomScrollView( - slivers: [ - if (user.hasValue) SliverToBoxAdapter(child: CheckInWidget()), - SliverList.builder( - itemCount: widgetCount, - itemBuilder: (context, index) { - if (index == widgetCount - 1) { - return endItemView; - } + return TourTriggerWidget( + child: AppScaffold( + appBar: AppBar(title: const Text('explore').tr()), + floatingActionButton: FloatingActionButton( + heroTag: Key("explore-page-fab"), + onPressed: () { + context.router.push(PostComposeRoute()).then((value) { + if (value != null) { + activitiesNotifier.forceRefresh(); + } + }); + }, + child: const Icon(Symbols.edit), + ), + floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, + body: RefreshIndicator( + onRefresh: () => Future.sync(activitiesNotifier.forceRefresh), + child: PagingHelperView( + provider: activityListNotifierProvider, + futureRefreshable: activityListNotifierProvider.future, + notifierRefreshable: activityListNotifierProvider.notifier, + contentBuilder: + (data, widgetCount, endItemView) => CustomScrollView( + slivers: [ + if (user.hasValue) + SliverToBoxAdapter(child: CheckInWidget()), + SliverList.builder( + itemCount: widgetCount, + itemBuilder: (context, index) { + if (index == widgetCount - 1) { + return endItemView; + } - final item = data.items[index]; - if (item.data == null) return const SizedBox.shrink(); - Widget itemWidget; + final item = data.items[index]; + if (item.data == null) return const SizedBox.shrink(); + Widget itemWidget; - switch (item.type) { - case 'posts.new': - itemWidget = PostItem( - item: SnPost.fromJson(item.data), - onRefresh: (_) { - activitiesNotifier.forceRefresh(); - }, - onUpdate: (post) { - activitiesNotifier.updateOne( - index, - item.copyWith(data: post.toJson()), - ); - }, - ); - break; - case 'accounts.check-in': - itemWidget = CheckInActivityWidget(item: item); - break; - case 'accounts.status': - itemWidget = StatusActivityWidget(item: item); - break; - default: - itemWidget = const Placeholder(); - } + switch (item.type) { + case 'posts.new': + itemWidget = PostItem( + item: SnPost.fromJson(item.data), + onRefresh: (_) { + activitiesNotifier.forceRefresh(); + }, + onUpdate: (post) { + activitiesNotifier.updateOne( + index, + item.copyWith(data: post.toJson()), + ); + }, + ); + break; + case 'accounts.check-in': + itemWidget = CheckInActivityWidget(item: item); + break; + case 'accounts.status': + itemWidget = StatusActivityWidget(item: item); + break; + default: + itemWidget = const Placeholder(); + } - return Column( - children: [itemWidget, const Divider(height: 1)], - ); - }, - ), - SliverGap(MediaQuery.of(context).padding.bottom + 16), - ], - ), + return Column( + children: [itemWidget, const Divider(height: 1)], + ); + }, + ), + SliverGap(MediaQuery.of(context).padding.bottom + 16), + ], + ), + ), ), ), ); diff --git a/lib/screens/notification.g.dart b/lib/screens/notification.g.dart index 2026f43..3db91f6 100644 --- a/lib/screens/notification.g.dart +++ b/lib/screens/notification.g.dart @@ -7,7 +7,7 @@ part of 'notification.dart'; // ************************************************************************** String _$notificationUnreadCountNotifierHash() => - r'ddec25e8e693b8feb800c085ef87d65f0d172341'; + r'074143cf208a3afe1495be405198532a23ef77c8'; /// See also [NotificationUnreadCountNotifier]. @ProviderFor(NotificationUnreadCountNotifier) diff --git a/lib/services/tour.dart b/lib/services/tour.dart new file mode 100644 index 0000000..47f2b25 --- /dev/null +++ b/lib/services/tour.dart @@ -0,0 +1,68 @@ +import 'dart:convert'; + +import 'package:flutter/widgets.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:island/pods/config.dart'; +import 'package:island/widgets/tour/techincal_review_intro.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'tour.g.dart'; +part 'tour.freezed.dart'; + +const kAppTourStatusKey = "app_tour_statuses"; + +const List kAllTours = [ + Tour(id: 'technical_review_intro', isStartup: true), +]; + +@freezed +abstract class Tour with _$Tour { + const Tour._(); + + const factory Tour({required String id, required bool isStartup}) = _Tour; + + Widget get widget => switch (id) { + 'technical_review_intro' => const TechicalReviewIntroWidget(), + _ => throw UnimplementedError(), + }; +} + +@riverpod +class TourStatusNotifier extends _$TourStatusNotifier { + @override + Map build() { + final prefs = ref.watch(sharedPreferencesProvider); + final storedJson = prefs.getString(kAppTourStatusKey); + if (storedJson != null) { + try { + final Map stored = jsonDecode(storedJson); + return Map.from(stored); + } catch (_) { + return {for (final id in kAllTours.map((e) => e.id)) id: false}; + } + } + return {for (final id in kAllTours.map((e) => e.id)) id: false}; + } + + bool isTourShown(String tourId) => state[tourId] ?? false; + + Future _saveState(Map newState) async { + state = newState; + final prefs = ref.read(sharedPreferencesProvider); + await prefs.setString(kAppTourStatusKey, jsonEncode(newState)); + } + + Future showTour(String tourId) async { + if (!isTourShown(tourId) || true) { + final newState = {...state, tourId: true}; + await _saveState(newState); + return kAllTours.firstWhere((e) => e.id == tourId).widget; + } + return null; + } + + Future resetTours() async { + final newState = {for (final id in kAllTours.map((e) => e.id)) id: false}; + await _saveState(newState); + } +} diff --git a/lib/services/tour.freezed.dart b/lib/services/tour.freezed.dart new file mode 100644 index 0000000..f689f63 --- /dev/null +++ b/lib/services/tour.freezed.dart @@ -0,0 +1,145 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'tour.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$Tour { + + String get id; bool get isStartup; +/// Create a copy of Tour +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TourCopyWith get copyWith => _$TourCopyWithImpl(this as Tour, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Tour&&(identical(other.id, id) || other.id == id)&&(identical(other.isStartup, isStartup) || other.isStartup == isStartup)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,isStartup); + +@override +String toString() { + return 'Tour(id: $id, isStartup: $isStartup)'; +} + + +} + +/// @nodoc +abstract mixin class $TourCopyWith<$Res> { + factory $TourCopyWith(Tour value, $Res Function(Tour) _then) = _$TourCopyWithImpl; +@useResult +$Res call({ + String id, bool isStartup +}); + + + + +} +/// @nodoc +class _$TourCopyWithImpl<$Res> + implements $TourCopyWith<$Res> { + _$TourCopyWithImpl(this._self, this._then); + + final Tour _self; + final $Res Function(Tour) _then; + +/// Create a copy of Tour +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? isStartup = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,isStartup: null == isStartup ? _self.isStartup : isStartup // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// @nodoc + + +class _Tour extends Tour { + const _Tour({required this.id, required this.isStartup}): super._(); + + +@override final String id; +@override final bool isStartup; + +/// Create a copy of Tour +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TourCopyWith<_Tour> get copyWith => __$TourCopyWithImpl<_Tour>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Tour&&(identical(other.id, id) || other.id == id)&&(identical(other.isStartup, isStartup) || other.isStartup == isStartup)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,isStartup); + +@override +String toString() { + return 'Tour(id: $id, isStartup: $isStartup)'; +} + + +} + +/// @nodoc +abstract mixin class _$TourCopyWith<$Res> implements $TourCopyWith<$Res> { + factory _$TourCopyWith(_Tour value, $Res Function(_Tour) _then) = __$TourCopyWithImpl; +@override @useResult +$Res call({ + String id, bool isStartup +}); + + + + +} +/// @nodoc +class __$TourCopyWithImpl<$Res> + implements _$TourCopyWith<$Res> { + __$TourCopyWithImpl(this._self, this._then); + + final _Tour _self; + final $Res Function(_Tour) _then; + +/// Create a copy of Tour +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? isStartup = null,}) { + return _then(_Tour( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,isStartup: null == isStartup ? _self.isStartup : isStartup // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/lib/services/tour.g.dart b/lib/services/tour.g.dart new file mode 100644 index 0000000..e99b714 --- /dev/null +++ b/lib/services/tour.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tour.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$tourStatusNotifierHash() => + r'040aac2d7cf6d14e539c1b04cf311421ee133ed3'; + +/// See also [TourStatusNotifier]. +@ProviderFor(TourStatusNotifier) +final tourStatusNotifierProvider = + AutoDisposeNotifierProvider>.internal( + TourStatusNotifier.new, + name: r'tourStatusNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$tourStatusNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$TourStatusNotifier = AutoDisposeNotifier>; +// 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/tour/techincal_review_intro.dart b/lib/widgets/tour/techincal_review_intro.dart new file mode 100644 index 0000000..22bc67f --- /dev/null +++ b/lib/widgets/tour/techincal_review_intro.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class TechicalReviewIntroWidget extends StatelessWidget { + const TechicalReviewIntroWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.8, + ), + child: Column( + children: [ + Padding( + padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12), + child: Row( + children: [ + Text( + '技术性预览', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + letterSpacing: -0.5, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Symbols.close), + onPressed: () => Navigator.pop(context), + style: IconButton.styleFrom(minimumSize: const Size(36, 36)), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('👋').fontSize(32), + Text('你好呀~').fontSize(24), + Text('欢迎来使用 Solar Network 3.0 的技术性预览版。'), + const Gap(24), + Text('技术性预览的初衷是让我们更顺滑的将 3.0 发布出来,帮助我们一点一点的迁移数据。'), + const Gap(24), + Text('同时,既然是测试版,肯定有一系列的 Bug 和问题,请多多包涵,也欢迎积极反馈到 GitHub 上。'), + Text('目前帐号数据已经迁移完毕,其他数据将在未来逐渐迁移。还请耐心等待,不要重复创建以免未来数据冲突。'), + const Gap(24), + Text('最后,感谢你愿意参与技术性预览,祝你使用愉快!'), + const Gap(16), + Text('关掉这个对话框就开始探索吧!').fontSize(11), + ], + ).padding(horizontal: 20, vertical: 24), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/tour/tour.dart b/lib/widgets/tour/tour.dart new file mode 100644 index 0000000..f9d4ae6 --- /dev/null +++ b/lib/widgets/tour/tour.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/services/tour.dart'; + +const List kStartTours = ['technical_review_intro']; + +class TourTriggerWidget extends HookConsumerWidget { + final Widget child; + const TourTriggerWidget({super.key, required this.child}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tourStatus = ref.watch(tourStatusNotifierProvider.notifier); + + useEffect(() { + Future(() async { + for (final tour in kStartTours) { + final widget = await tourStatus.showTour(tour); + if (widget != null) { + if (!context.mounted) return; + await showModalBottomSheet( + isScrollControlled: true, + useRootNavigator: true, + context: context, + builder: (context) => widget, + ); + } + } + }); + return null; + }, [tourStatus]); + + return child; + } +}