Compare commits

...

4 Commits

Author SHA1 Message Date
3d15c0b9f9 User fortune history 2024-12-22 19:37:44 +08:00
67a29b4305 Show user level 2024-12-22 19:11:53 +08:00
594f57e0d3 Tappable label tags 2024-12-22 17:37:37 +08:00
d1eb51c596 💄 Optimize styling 2024-12-22 17:30:41 +08:00
11 changed files with 269 additions and 25 deletions

View File

@ -0,0 +1,41 @@
import 'package:intl/intl.dart';
const List<int> kExperienceToLevelRequirements = [
0, // Level 0
1000, // Level 1
4000, // Level 2
9000, // Level 3
16000, // Level 4
25000, // Level 5
36000, // Level 6
49000, // Level 7
64000, // Level 8
81000, // Level 9
100000, // Level 10
121000, // Level 11
144000, // Level 12
368000 // Level 13
];
int getLevelFromExp(int experience) {
final exp = kExperienceToLevelRequirements.reversed.firstWhere((x) => x <= experience);
final idx = kExperienceToLevelRequirements.indexOf(exp);
return idx;
}
double calcLevelUpProgress(int experience) {
final exp = kExperienceToLevelRequirements.reversed.firstWhere((x) => x <= experience);
final idx = kExperienceToLevelRequirements.indexOf(exp);
if (idx + 1 >= kExperienceToLevelRequirements.length) return 1;
final nextExp = kExperienceToLevelRequirements[idx + 1];
return (experience - exp).abs() / (exp - nextExp).abs();
}
String calcLevelUpProgressLevel(int experience) {
final exp = kExperienceToLevelRequirements.reversed.firstWhere((x) => x <= experience);
final idx = kExperienceToLevelRequirements.indexOf(exp);
if (idx + 1 >= kExperienceToLevelRequirements.length) return 'Infinity';
final nextExp = exp - kExperienceToLevelRequirements[idx + 1];
final formatter = NumberFormat.compactCurrency(symbol: '', decimalDigits: 1);
return '${formatter.format((exp - experience).abs())}/${formatter.format(nextExp.abs())}';
}

View File

@ -77,8 +77,11 @@ final _appRoutes = [
GoRoute( GoRoute(
path: '/search', path: '/search',
name: 'postSearch', name: 'postSearch',
builder: (context, state) => const AppBackground( builder: (context, state) => AppBackground(
child: PostSearchScreen(), child: PostSearchScreen(
initialTags: state.uri.queryParameters['tags']?.split(','),
initialCategories: state.uri.queryParameters['categories']?.split(','),
),
), ),
), ),
GoRoute( GoRoute(

View File

@ -1,6 +1,7 @@
import 'dart:ui'; import 'dart:ui';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@ -9,10 +10,12 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart'; import 'package:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/experience.dart';
import 'package:surface/providers/relationship.dart'; import 'package:surface/providers/relationship.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/screens/abuse_report.dart'; import 'package:surface/screens/abuse_report.dart';
import 'package:surface/types/account.dart'; import 'package:surface/types/account.dart';
import 'package:surface/types/check_in.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
@ -61,6 +64,19 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
} }
} }
Future<List<SnCheckInRecord>> _getCheckInRecords() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14');
return List.from(
resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [],
);
} catch (err) {
if (mounted) context.showErrorDialog(err);
rethrow;
}
}
SnAccountStatusInfo? _status; SnAccountStatusInfo? _status;
Future<void> _fetchStatus() async { Future<void> _fetchStatus() async {
@ -437,6 +453,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
Column( Column(
children: [ children: [
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
const Icon(Symbols.calendar_add_on), const Icon(Symbols.calendar_add_on),
const Gap(8), const Gap(8),
@ -444,6 +461,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
], ],
), ),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
const Icon(Symbols.cake), const Icon(Symbols.cake),
const Gap(8), const Gap(8),
@ -457,6 +475,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
], ],
), ),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
const Icon(Symbols.identity_platform), const Icon(Symbols.identity_platform),
const Gap(8), const Gap(8),
@ -466,6 +485,26 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
).opacity(0.8), ).opacity(0.8),
], ],
), ),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.star),
const Gap(8),
Text('Lv${getLevelFromExp(_account?.profile?.experience ?? 0)}'),
const Gap(8),
Text(calcLevelUpProgressLevel(_account?.profile?.experience ?? 0)).fontSize(11).opacity(0.5),
const Gap(8),
Container(
width: double.infinity,
constraints: const BoxConstraints(maxWidth: 160),
child: LinearProgressIndicator(
value: calcLevelUpProgress(_account?.profile?.experience ?? 0),
borderRadius: BorderRadius.circular(8),
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
).alignment(Alignment.centerLeft),
),
],
),
], ],
).padding(horizontal: 8), ).padding(horizontal: 8),
], ],
@ -473,6 +512,27 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
), ),
SliverToBoxAdapter(child: const Divider()), SliverToBoxAdapter(child: const Divider()),
const SliverGap(12), const SliverGap(12),
SliverToBoxAdapter(
child: FutureBuilder<List<SnCheckInRecord>>(
future: _getCheckInRecords(),
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox.shrink();
final records = snapshot.data!;
return SizedBox(
width: double.infinity,
height: 240,
child: CheckInRecordChart(records: records),
).padding(
right: 24,
left: 16,
top: 12,
);
},
),
),
const SliverGap(12),
SliverToBoxAdapter(child: const Divider()),
const SliverGap(12),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -541,3 +601,105 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
); );
} }
} }
class CheckInRecordChart extends StatelessWidget {
const CheckInRecordChart({
super.key,
required this.records,
});
final List<SnCheckInRecord> records;
@override
Widget build(BuildContext context) {
return LineChart(
LineChartData(
lineBarsData: [
LineChartBarData(
color: Theme.of(context).colorScheme.primary,
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: List.filled(
records.length,
Theme.of(context).colorScheme.primary.withOpacity(0.3),
).toList(),
),
),
spots: records
.map(
(x) => FlSpot(
x.createdAt
.copyWith(
hour: 0,
minute: 0,
second: 0,
millisecond: 0,
microsecond: 0,
)
.millisecondsSinceEpoch
.toDouble(),
x.resultTier.toDouble(),
),
)
.toList(),
)
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (spots) => spots
.map(
(spot) => LineTooltipItem(
'${kCheckInResultTierSymbols[spot.y.toInt()]}\n${DateFormat('MM/dd').format(DateTime.fromMillisecondsSinceEpoch(spot.x.toInt()))}',
TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
)
.toList(),
getTooltipColor: (_) => Theme.of(context).colorScheme.surfaceContainerHigh,
),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: 1,
getTitlesWidget: (value, _) => Align(
alignment: Alignment.centerRight,
child: Text(
kCheckInResultTierSymbols[value.toInt()],
textAlign: TextAlign.right,
).padding(right: 8),
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
interval: 86400000,
getTitlesWidget: (value, _) => Text(
DateFormat('dd').format(
DateTime.fromMillisecondsSinceEpoch(
value.toInt(),
),
),
textAlign: TextAlign.center,
).padding(top: 8),
),
),
),
gridData: const FlGridData(show: false),
borderData: FlBorderData(show: false),
),
);
}
}

View File

@ -181,11 +181,11 @@ class _ExploreScreenState extends State<ExploreScreen> {
const Gap(8), const Gap(8),
], ],
bottom: PreferredSize( bottom: PreferredSize(
preferredSize: const Size.fromHeight(40), preferredSize: const Size.fromHeight(50),
child: SizedBox( child: SizedBox(
height: 40, height: 50,
child: ListView.builder( child: ListView.builder(
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8), padding: const EdgeInsets.only(left: 8, right: 8, bottom: 12),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: _categories.length, itemCount: _categories.length,
itemBuilder: (context, idx) { itemBuilder: (context, idx) {

View File

@ -13,7 +13,10 @@ import 'package:surface/widgets/post/post_tags_field.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class PostSearchScreen extends StatefulWidget { class PostSearchScreen extends StatefulWidget {
const PostSearchScreen({super.key}); final Iterable<String>? initialTags;
final Iterable<String>? initialCategories;
const PostSearchScreen({super.key, this.initialTags, this.initialCategories});
@override @override
State<PostSearchScreen> createState() => _PostSearchScreenState(); State<PostSearchScreen> createState() => _PostSearchScreenState();
@ -31,6 +34,16 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
String _searchTerm = ''; String _searchTerm = '';
Duration? _lastTook; Duration? _lastTook;
@override
void initState() {
super.initState();
_searchTags.addAll(widget.initialTags ?? []);
_searchCategories.addAll(widget.initialCategories ?? []);
if (_searchTags.isNotEmpty || _searchCategories.isNotEmpty) {
_fetchPosts();
}
}
Future<void> _fetchPosts() async { Future<void> _fetchPosts() async {
if (_searchTerm.isEmpty && _searchCategories.isEmpty && _searchTags.isEmpty) return; if (_searchTerm.isEmpty && _searchCategories.isEmpty && _searchTags.isEmpty) return;
if (_postCount != null && _posts.length >= _postCount!) return; if (_postCount != null && _posts.length >= _postCount!) return;

View File

@ -45,17 +45,9 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
Future<void> _fetchPublisher() async { Future<void> _fetchPublisher() async {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final ud = context.read<UserDirectoryProvider>();
final rel = context.read<SnRelationshipProvider>();
final resp = await sn.client.get('/cgi/co/publishers/${widget.name}'); final resp = await sn.client.get('/cgi/co/publishers/${widget.name}');
if (!mounted) return; if (!mounted) return;
_publisher = SnPublisher.fromJson(resp.data); _publisher = SnPublisher.fromJson(resp.data);
_account = await ud.getAccount(_publisher?.accountId);
_accountRelationship = await rel.getRelationship(_account!.id);
if (_publisher?.realmId != null && _publisher!.realmId != 0) {
final resp = await sn.client.get('/cgi/id/realms/${_publisher!.realmId}');
_realm = SnRealm.fromJson(resp.data);
}
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err).then((_) { context.showErrorDialog(err).then((_) {
@ -65,6 +57,20 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
} finally { } finally {
setState(() {}); setState(() {});
} }
try {
final sn = context.read<SnNetworkProvider>();
final ud = context.read<UserDirectoryProvider>();
final rel = context.read<SnRelationshipProvider>();
_account = await ud.getAccount(_publisher?.accountId);
_accountRelationship = await rel.getRelationship(_account!.id);
if (_publisher?.realmId != null && _publisher!.realmId != 0) {
final resp = await sn.client.get('/cgi/id/realms/${_publisher!.realmId}');
_realm = SnRealm.fromJson(resp.data);
}
} catch (_) {
// ignore
}
} }
bool _isSubscribing = false; bool _isSubscribing = false;

View File

@ -3,6 +3,8 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'check_in.freezed.dart'; part 'check_in.freezed.dart';
part 'check_in.g.dart'; part 'check_in.g.dart';
const List<String> kCheckInResultTierSymbols = ['大凶', '', '中平', '', '大吉'];
@freezed @freezed
class SnCheckInRecord with _$SnCheckInRecord { class SnCheckInRecord with _$SnCheckInRecord {
const SnCheckInRecord._(); const SnCheckInRecord._();
@ -21,11 +23,5 @@ class SnCheckInRecord with _$SnCheckInRecord {
factory SnCheckInRecord.fromJson(Map<String, dynamic> json) => factory SnCheckInRecord.fromJson(Map<String, dynamic> json) =>
_$SnCheckInRecordFromJson(json); _$SnCheckInRecordFromJson(json);
String get symbol => switch (resultTier) { String get symbol => kCheckInResultTierSymbols[resultTier];
0 => '大凶',
1 => '',
2 => '中平',
3 => '',
_ => '大吉',
};
} }

View File

@ -142,7 +142,7 @@ class ChatMessage extends StatelessWidget {
onEdit: onEdit, onEdit: onEdit,
onDelete: onDelete, onDelete: onDelete,
), ),
)).padding(bottom: 4, top: isMerged ? 4 : 2), )).padding(bottom: 4, top: 4),
switch (data.type) { switch (data.type) {
'messages.new' => _ChatMessageText(data: data), 'messages.new' => _ChatMessageText(data: data),
_ => _ChatMessageSystemNotify(data: data), _ => _ChatMessageSystemNotify(data: data),

View File

@ -989,7 +989,14 @@ class _PostTagsList extends StatelessWidget {
), ),
], ],
), ),
onTap: () {}, onTap: () {
GoRouter.of(context).pushNamed(
'postSearch',
queryParameters: {
'categories': ele.alias,
},
);
},
), ),
) )
.toList(), .toList(),
@ -1008,7 +1015,14 @@ class _PostTagsList extends StatelessWidget {
Text(ele.alias, style: GoogleFonts.robotoMono()), Text(ele.alias, style: GoogleFonts.robotoMono()),
], ],
), ),
onTap: () {}, onTap: () {
GoRouter.of(context).pushNamed(
'postSearch',
queryParameters: {
'tags': ele.alias,
},
);
},
), ),
) )
.toList(), .toList(),

View File

@ -614,6 +614,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
fl_chart:
dependency: "direct main"
description:
name: fl_chart
sha256: c724234b05e378383e958f3e82ca84a3e1e3c06a0898462044dd8a24b1ee9864
url: "https://pub.dev"
source: hosted
version: "0.70.0"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -2145,4 +2153,4 @@ packages:
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.6.0 <4.0.0" dart: ">=3.6.0 <4.0.0"
flutter: ">=3.24.0" flutter: ">=3.27.0"

View File

@ -112,6 +112,7 @@ dependencies:
in_app_review: ^2.0.10 in_app_review: ^2.0.10
version: ^3.0.2 version: ^3.0.2
flutter_colorpicker: ^1.1.0 flutter_colorpicker: ^1.1.0
fl_chart: ^0.70.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: