Tour and introduce

This commit is contained in:
LittleSheep 2025-05-20 01:51:24 +08:00
parent 9e609b8fe4
commit 1f2a5c107d
8 changed files with 410 additions and 67 deletions

View File

@ -146,7 +146,6 @@ class MessageRepository {
queryParameters: {'offset': offset, 'take': take}, queryParameters: {'offset': offset, 'take': take},
); );
final total = int.parse(response.headers.value('X-Total') ?? '0');
final List<dynamic> data = response.data; final List<dynamic> data = response.data;
final messages = final messages =

View File

@ -11,6 +11,7 @@ import 'package:island/widgets/app_scaffold.dart';
import 'package:island/models/post.dart'; import 'package:island/models/post.dart';
import 'package:island/widgets/check_in.dart'; import 'package:island/widgets/check_in.dart';
import 'package:island/widgets/post/post_item.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:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
@ -27,74 +28,77 @@ class ExploreScreen extends ConsumerWidget {
final user = ref.watch(userInfoProvider); final user = ref.watch(userInfoProvider);
final activitiesNotifier = ref.watch(activityListNotifierProvider.notifier); final activitiesNotifier = ref.watch(activityListNotifierProvider.notifier);
return AppScaffold( return TourTriggerWidget(
appBar: AppBar(title: const Text('explore').tr()), child: AppScaffold(
floatingActionButton: FloatingActionButton( appBar: AppBar(title: const Text('explore').tr()),
heroTag: Key("explore-page-fab"), floatingActionButton: FloatingActionButton(
onPressed: () { heroTag: Key("explore-page-fab"),
context.router.push(PostComposeRoute()).then((value) { onPressed: () {
if (value != null) { context.router.push(PostComposeRoute()).then((value) {
activitiesNotifier.forceRefresh(); if (value != null) {
} activitiesNotifier.forceRefresh();
}); }
}, });
child: const Icon(Symbols.edit), },
), child: const Icon(Symbols.edit),
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, ),
body: RefreshIndicator( floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
onRefresh: () => Future.sync(activitiesNotifier.forceRefresh), body: RefreshIndicator(
child: PagingHelperView( onRefresh: () => Future.sync(activitiesNotifier.forceRefresh),
provider: activityListNotifierProvider, child: PagingHelperView(
futureRefreshable: activityListNotifierProvider.future, provider: activityListNotifierProvider,
notifierRefreshable: activityListNotifierProvider.notifier, futureRefreshable: activityListNotifierProvider.future,
contentBuilder: notifierRefreshable: activityListNotifierProvider.notifier,
(data, widgetCount, endItemView) => CustomScrollView( contentBuilder:
slivers: [ (data, widgetCount, endItemView) => CustomScrollView(
if (user.hasValue) SliverToBoxAdapter(child: CheckInWidget()), slivers: [
SliverList.builder( if (user.hasValue)
itemCount: widgetCount, SliverToBoxAdapter(child: CheckInWidget()),
itemBuilder: (context, index) { SliverList.builder(
if (index == widgetCount - 1) { itemCount: widgetCount,
return endItemView; itemBuilder: (context, index) {
} if (index == widgetCount - 1) {
return endItemView;
}
final item = data.items[index]; final item = data.items[index];
if (item.data == null) return const SizedBox.shrink(); if (item.data == null) return const SizedBox.shrink();
Widget itemWidget; Widget itemWidget;
switch (item.type) { switch (item.type) {
case 'posts.new': case 'posts.new':
itemWidget = PostItem( itemWidget = PostItem(
item: SnPost.fromJson(item.data), item: SnPost.fromJson(item.data),
onRefresh: (_) { onRefresh: (_) {
activitiesNotifier.forceRefresh(); activitiesNotifier.forceRefresh();
}, },
onUpdate: (post) { onUpdate: (post) {
activitiesNotifier.updateOne( activitiesNotifier.updateOne(
index, index,
item.copyWith(data: post.toJson()), item.copyWith(data: post.toJson()),
); );
}, },
); );
break; break;
case 'accounts.check-in': case 'accounts.check-in':
itemWidget = CheckInActivityWidget(item: item); itemWidget = CheckInActivityWidget(item: item);
break; break;
case 'accounts.status': case 'accounts.status':
itemWidget = StatusActivityWidget(item: item); itemWidget = StatusActivityWidget(item: item);
break; break;
default: default:
itemWidget = const Placeholder(); itemWidget = const Placeholder();
} }
return Column( return Column(
children: [itemWidget, const Divider(height: 1)], children: [itemWidget, const Divider(height: 1)],
); );
}, },
), ),
SliverGap(MediaQuery.of(context).padding.bottom + 16), SliverGap(MediaQuery.of(context).padding.bottom + 16),
], ],
), ),
),
), ),
), ),
); );

View File

@ -7,7 +7,7 @@ part of 'notification.dart';
// ************************************************************************** // **************************************************************************
String _$notificationUnreadCountNotifierHash() => String _$notificationUnreadCountNotifierHash() =>
r'ddec25e8e693b8feb800c085ef87d65f0d172341'; r'074143cf208a3afe1495be405198532a23ef77c8';
/// See also [NotificationUnreadCountNotifier]. /// See also [NotificationUnreadCountNotifier].
@ProviderFor(NotificationUnreadCountNotifier) @ProviderFor(NotificationUnreadCountNotifier)

68
lib/services/tour.dart Normal file
View File

@ -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<Tour> 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<String, bool> build() {
final prefs = ref.watch(sharedPreferencesProvider);
final storedJson = prefs.getString(kAppTourStatusKey);
if (storedJson != null) {
try {
final Map<String, dynamic> stored = jsonDecode(storedJson);
return Map<String, bool>.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<void> _saveState(Map<String, bool> newState) async {
state = newState;
final prefs = ref.read(sharedPreferencesProvider);
await prefs.setString(kAppTourStatusKey, jsonEncode(newState));
}
Future<Widget?> 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<void> resetTours() async {
final newState = {for (final id in kAllTours.map((e) => e.id)) id: false};
await _saveState(newState);
}
}

View File

@ -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>(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<Tour> get copyWith => _$TourCopyWithImpl<Tour>(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

28
lib/services/tour.g.dart Normal file
View File

@ -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<TourStatusNotifier, Map<String, bool>>.internal(
TourStatusNotifier.new,
name: r'tourStatusNotifierProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$tourStatusNotifierHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$TourStatusNotifier = AutoDisposeNotifier<Map<String, bool>>;
// 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

View File

@ -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),
),
),
],
),
);
}
}

View File

@ -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<String> 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;
}
}