diff --git a/assets/translations/en.json b/assets/translations/en.json index 2378daa..4bae1aa 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -238,5 +238,9 @@ "callScreenOff": "Turn off screen share", "callScreenOn": "Turn on screen share", "callMessageEnded": "Call lasted {}", - "callMessageStarted": "Call started" + "callMessageStarted": "Call started", + "dailyCheckIn": "Check In", + "dailyCheckInNone": "You haven't checked in today", + "dailyCheckAction": "Check in right now!", + "dailyCheckDetail": "Can't understand the talisman? Master, help me understand it!" } diff --git a/assets/translations/zh.json b/assets/translations/zh.json index 997fe5d..f899f95 100644 --- a/assets/translations/zh.json +++ b/assets/translations/zh.json @@ -238,5 +238,9 @@ "callScreenOff": "关闭屏幕共享", "callScreenOn": "开启屏幕共享", "callMessageEnded": "通话持续了 {}", - "callMessageStarted": "通话开始了" + "callMessageStarted": "通话开始了", + "dailyCheckIn": "每日签到", + "dailyCheckInNone": "今日尚未签到", + "dailyCheckAction": "现在签到", + "dailyCheckDetail": "看不懂符?大师帮我解惑!" } diff --git a/lib/router.dart b/lib/router.dart index 14f56ca..7bef268 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -70,6 +70,7 @@ final _appRoutes = [ path: '/search', name: 'postSearch', builder: (context, state) => const AppBackground( + isLessOptimization: true, child: PostSearchScreen(), ), ), diff --git a/lib/screens/home.dart b/lib/screens/home.dart index 9b34022..5c9a7dc 100644 --- a/lib/screens/home.dart +++ b/lib/screens/home.dart @@ -1,7 +1,26 @@ import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:flutter/material.dart'; +import 'package:surface/providers/sn_network.dart'; +import 'package:surface/types/check_in.dart'; +import 'package:surface/widgets/dialog.dart'; + +class HomeScreenDashEntry { + final String name; + final Widget child; + final int rows, cols; + + const HomeScreenDashEntry({ + required this.name, + required this.child, + this.rows = 1, + this.cols = 1, + }); +} class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -11,29 +30,234 @@ class HomeScreen extends StatefulWidget { } class _HomeScreenState extends State { + static const List kCards = [ + HomeScreenDashEntry( + name: 'dashEntryCheckIn', + child: _HomeDashCheckInWidget(), + ), + ]; + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("screenHome").tr(), ), - body: Column( - children: [ - MaterialBanner( - leading: const Icon(Symbols.construction), - content: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('nextVersionAlert').tr().bold(), - Text('nextVersionNotice').tr(), + body: SingleChildScrollView( + child: Column( + children: [ + MaterialBanner( + leading: const Icon(Symbols.construction), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('nextVersionAlert').tr().bold(), + Text('nextVersionNotice').tr(), + ], + ).padding(vertical: 16), + actions: [ + const SizedBox(), ], - ).padding(vertical: 16), - actions: [ - const SizedBox(), - ], - ), - ], + ), + StaggeredGrid.count( + crossAxisCount: 2, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + children: kCards.map((card) { + return StaggeredGridTile.count( + crossAxisCellCount: card.cols, + mainAxisCellCount: card.rows, + child: card.child, + ); + }).toList(), + ).padding(all: 8), + ], + ), ), ); } } + +class _HomeDashCheckInWidget extends StatefulWidget { + const _HomeDashCheckInWidget({super.key}); + + @override + State<_HomeDashCheckInWidget> createState() => _HomeDashCheckInWidgetState(); +} + +class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> { + bool _isBusy = false; + + SnCheckInRecord? _todayRecord; + + Future _pullCheckIn() async { + setState(() => _isBusy = true); + try { + final sn = context.read(); + final resp = await sn.client.get('/cgi/id/check-in/today'); + _todayRecord = SnCheckInRecord.fromJson(resp.data); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } finally { + setState(() => _isBusy = false); + } + } + + Future _doCheckIn() async { + setState(() => _isBusy = true); + try { + final sn = context.read(); + final resp = await sn.client.post('/cgi/id/check-in'); + _todayRecord = SnCheckInRecord.fromJson(resp.data); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } finally { + setState(() => _isBusy = false); + } + } + + @override + void initState() { + super.initState(); + _pullCheckIn(); + } + + @override + Widget build(BuildContext context) { + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: AnimatedSwitcher( + switchInCurve: Curves.fastOutSlowIn, + switchOutCurve: Curves.fastOutSlowIn, + duration: const Duration(milliseconds: 300), + transitionBuilder: (child, animation) { + return ScaleTransition( + scale: animation, + child: child, + ); + }, + child: _todayRecord == null + ? Column( + key: Key('daily-check-in-overview-none'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'dailyCheckIn', + style: Theme.of(context).textTheme.titleLarge, + ).tr(), + Text( + 'dailyCheckInNone', + style: Theme.of(context).textTheme.bodyLarge, + ).tr(), + ], + ) + : Column( + key: Key('daily-check-in-overview-has'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _todayRecord!.symbol, + style: GoogleFonts.notoSerifHk( + textStyle: Theme.of(context).textTheme.titleLarge, + ), + ), + Text( + '+${_todayRecord!.resultExperience} EXP', + style: Theme.of(context).textTheme.bodyLarge, + ).tr(), + ], + ), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + DateFormat('EEE\nMM/dd').format(DateTime.now().toUtc()), + ).fontSize(13).opacity(0.75), + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.surfaceContainer, + ), + child: AnimatedSwitcher( + switchInCurve: Curves.fastOutSlowIn, + switchOutCurve: Curves.fastOutSlowIn, + duration: const Duration(milliseconds: 300), + child: _todayRecord == null + ? IconButton( + key: UniqueKey(), + tooltip: 'dailyCheckAction'.tr(), + icon: const Icon(Symbols.local_fire_department), + onPressed: _isBusy ? null : _doCheckIn, + ) + : IconButton( + key: UniqueKey(), + tooltip: 'dailyCheckDetail'.tr(), + icon: const Icon(Symbols.help), + onPressed: _isBusy ? null : _doCheckIn, + ), + ), + ), + ], + ), + ], + ).padding(all: 24), + ); + } +} + +class _HomeDashLinkWidget extends StatelessWidget { + final String title; + final String subtitle; + const _HomeDashLinkWidget({ + super.key, + required this.title, + required this.subtitle, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleLarge, + ), + Text( + subtitle, + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + Align( + alignment: Alignment.centerRight, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.surfaceContainer, + ), + child: IconButton( + icon: const Icon(Symbols.arrow_right_alt), + onPressed: () {}, + ), + ), + ) + ], + ).padding(all: 24), + ); + } +} diff --git a/lib/types/check_in.dart b/lib/types/check_in.dart new file mode 100644 index 0000000..7d3033a --- /dev/null +++ b/lib/types/check_in.dart @@ -0,0 +1,31 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'check_in.freezed.dart'; +part 'check_in.g.dart'; + +@freezed +class SnCheckInRecord with _$SnCheckInRecord { + const SnCheckInRecord._(); + + const factory SnCheckInRecord({ + required int id, + required DateTime createdAt, + required DateTime updatedAt, + required DateTime? deletedAt, + required int resultTier, + required int resultExperience, + required List resultModifiers, + required int accountId, + }) = _SnCheckInRecord; + + factory SnCheckInRecord.fromJson(Map json) => + _$SnCheckInRecordFromJson(json); + + String get symbol => switch (resultTier) { + 0 => '大凶', + 1 => '凶', + 2 => '中平', + 3 => '吉', + _ => '大吉', + }; +} diff --git a/lib/types/check_in.freezed.dart b/lib/types/check_in.freezed.dart new file mode 100644 index 0000000..1ad8207 --- /dev/null +++ b/lib/types/check_in.freezed.dart @@ -0,0 +1,334 @@ +// 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 'check_in.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +SnCheckInRecord _$SnCheckInRecordFromJson(Map json) { + return _SnCheckInRecord.fromJson(json); +} + +/// @nodoc +mixin _$SnCheckInRecord { + int get id => throw _privateConstructorUsedError; + DateTime get createdAt => throw _privateConstructorUsedError; + DateTime get updatedAt => throw _privateConstructorUsedError; + DateTime? get deletedAt => throw _privateConstructorUsedError; + int get resultTier => throw _privateConstructorUsedError; + int get resultExperience => throw _privateConstructorUsedError; + List get resultModifiers => throw _privateConstructorUsedError; + int get accountId => throw _privateConstructorUsedError; + + /// Serializes this SnCheckInRecord to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of SnCheckInRecord + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $SnCheckInRecordCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SnCheckInRecordCopyWith<$Res> { + factory $SnCheckInRecordCopyWith( + SnCheckInRecord value, $Res Function(SnCheckInRecord) then) = + _$SnCheckInRecordCopyWithImpl<$Res, SnCheckInRecord>; + @useResult + $Res call( + {int id, + DateTime createdAt, + DateTime updatedAt, + DateTime? deletedAt, + int resultTier, + int resultExperience, + List resultModifiers, + int accountId}); +} + +/// @nodoc +class _$SnCheckInRecordCopyWithImpl<$Res, $Val extends SnCheckInRecord> + implements $SnCheckInRecordCopyWith<$Res> { + _$SnCheckInRecordCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of SnCheckInRecord + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? createdAt = null, + Object? updatedAt = null, + Object? deletedAt = freezed, + Object? resultTier = null, + Object? resultExperience = null, + Object? resultModifiers = null, + Object? accountId = null, + }) { + return _then(_value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as int, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + updatedAt: null == updatedAt + ? _value.updatedAt + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime, + deletedAt: freezed == deletedAt + ? _value.deletedAt + : deletedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + resultTier: null == resultTier + ? _value.resultTier + : resultTier // ignore: cast_nullable_to_non_nullable + as int, + resultExperience: null == resultExperience + ? _value.resultExperience + : resultExperience // ignore: cast_nullable_to_non_nullable + as int, + resultModifiers: null == resultModifiers + ? _value.resultModifiers + : resultModifiers // ignore: cast_nullable_to_non_nullable + as List, + accountId: null == accountId + ? _value.accountId + : accountId // ignore: cast_nullable_to_non_nullable + as int, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$SnCheckInRecordImplCopyWith<$Res> + implements $SnCheckInRecordCopyWith<$Res> { + factory _$$SnCheckInRecordImplCopyWith(_$SnCheckInRecordImpl value, + $Res Function(_$SnCheckInRecordImpl) then) = + __$$SnCheckInRecordImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {int id, + DateTime createdAt, + DateTime updatedAt, + DateTime? deletedAt, + int resultTier, + int resultExperience, + List resultModifiers, + int accountId}); +} + +/// @nodoc +class __$$SnCheckInRecordImplCopyWithImpl<$Res> + extends _$SnCheckInRecordCopyWithImpl<$Res, _$SnCheckInRecordImpl> + implements _$$SnCheckInRecordImplCopyWith<$Res> { + __$$SnCheckInRecordImplCopyWithImpl( + _$SnCheckInRecordImpl _value, $Res Function(_$SnCheckInRecordImpl) _then) + : super(_value, _then); + + /// Create a copy of SnCheckInRecord + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? createdAt = null, + Object? updatedAt = null, + Object? deletedAt = freezed, + Object? resultTier = null, + Object? resultExperience = null, + Object? resultModifiers = null, + Object? accountId = null, + }) { + return _then(_$SnCheckInRecordImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as int, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + updatedAt: null == updatedAt + ? _value.updatedAt + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime, + deletedAt: freezed == deletedAt + ? _value.deletedAt + : deletedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + resultTier: null == resultTier + ? _value.resultTier + : resultTier // ignore: cast_nullable_to_non_nullable + as int, + resultExperience: null == resultExperience + ? _value.resultExperience + : resultExperience // ignore: cast_nullable_to_non_nullable + as int, + resultModifiers: null == resultModifiers + ? _value._resultModifiers + : resultModifiers // ignore: cast_nullable_to_non_nullable + as List, + accountId: null == accountId + ? _value.accountId + : accountId // ignore: cast_nullable_to_non_nullable + as int, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$SnCheckInRecordImpl extends _SnCheckInRecord { + const _$SnCheckInRecordImpl( + {required this.id, + required this.createdAt, + required this.updatedAt, + required this.deletedAt, + required this.resultTier, + required this.resultExperience, + required final List resultModifiers, + required this.accountId}) + : _resultModifiers = resultModifiers, + super._(); + + factory _$SnCheckInRecordImpl.fromJson(Map json) => + _$$SnCheckInRecordImplFromJson(json); + + @override + final int id; + @override + final DateTime createdAt; + @override + final DateTime updatedAt; + @override + final DateTime? deletedAt; + @override + final int resultTier; + @override + final int resultExperience; + final List _resultModifiers; + @override + List get resultModifiers { + if (_resultModifiers is EqualUnmodifiableListView) return _resultModifiers; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_resultModifiers); + } + + @override + final int accountId; + + @override + String toString() { + return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultModifiers: $resultModifiers, accountId: $accountId)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SnCheckInRecordImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt) && + (identical(other.updatedAt, updatedAt) || + other.updatedAt == updatedAt) && + (identical(other.deletedAt, deletedAt) || + other.deletedAt == deletedAt) && + (identical(other.resultTier, resultTier) || + other.resultTier == resultTier) && + (identical(other.resultExperience, resultExperience) || + other.resultExperience == resultExperience) && + const DeepCollectionEquality() + .equals(other._resultModifiers, _resultModifiers) && + (identical(other.accountId, accountId) || + other.accountId == accountId)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + id, + createdAt, + updatedAt, + deletedAt, + resultTier, + resultExperience, + const DeepCollectionEquality().hash(_resultModifiers), + accountId); + + /// Create a copy of SnCheckInRecord + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$SnCheckInRecordImplCopyWith<_$SnCheckInRecordImpl> get copyWith => + __$$SnCheckInRecordImplCopyWithImpl<_$SnCheckInRecordImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$SnCheckInRecordImplToJson( + this, + ); + } +} + +abstract class _SnCheckInRecord extends SnCheckInRecord { + const factory _SnCheckInRecord( + {required final int id, + required final DateTime createdAt, + required final DateTime updatedAt, + required final DateTime? deletedAt, + required final int resultTier, + required final int resultExperience, + required final List resultModifiers, + required final int accountId}) = _$SnCheckInRecordImpl; + const _SnCheckInRecord._() : super._(); + + factory _SnCheckInRecord.fromJson(Map json) = + _$SnCheckInRecordImpl.fromJson; + + @override + int get id; + @override + DateTime get createdAt; + @override + DateTime get updatedAt; + @override + DateTime? get deletedAt; + @override + int get resultTier; + @override + int get resultExperience; + @override + List get resultModifiers; + @override + int get accountId; + + /// Create a copy of SnCheckInRecord + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SnCheckInRecordImplCopyWith<_$SnCheckInRecordImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/types/check_in.g.dart b/lib/types/check_in.g.dart new file mode 100644 index 0000000..1a23194 --- /dev/null +++ b/lib/types/check_in.g.dart @@ -0,0 +1,37 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'check_in.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$SnCheckInRecordImpl _$$SnCheckInRecordImplFromJson( + Map json) => + _$SnCheckInRecordImpl( + id: (json['id'] as num).toInt(), + 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), + resultTier: (json['result_tier'] as num).toInt(), + resultExperience: (json['result_experience'] as num).toInt(), + resultModifiers: (json['result_modifiers'] as List) + .map((e) => (e as num).toInt()) + .toList(), + accountId: (json['account_id'] as num).toInt(), + ); + +Map _$$SnCheckInRecordImplToJson( + _$SnCheckInRecordImpl instance) => + { + 'id': instance.id, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'deleted_at': instance.deletedAt?.toIso8601String(), + 'result_tier': instance.resultTier, + 'result_experience': instance.resultExperience, + 'result_modifiers': instance.resultModifiers, + 'account_id': instance.accountId, + }; diff --git a/pubspec.lock b/pubspec.lock index 3d40927..b78499f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -612,10 +612,10 @@ packages: dependency: "direct main" description: name: flutter_markdown - sha256: "999a4e3cb3e1532a971c86d6c73a480264f6a687959d4887cb4e2990821827e4" + sha256: "255b00afa1a7bad19727da6a7780cf3db6c3c12e68d302d85e0ff1fdf173db9e" url: "https://pub.dev" source: hosted - version: "0.7.4+2" + version: "0.7.4+3" flutter_native_splash: dependency: "direct dev" description: @@ -640,6 +640,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.3" + flutter_staggered_grid_view: + dependency: "direct main" + description: + name: flutter_staggered_grid_view + sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395" + url: "https://pub.dev" + source: hosted + version: "0.7.0" flutter_test: dependency: "direct dev" description: flutter @@ -1014,10 +1022,10 @@ packages: dependency: "direct main" description: name: material_symbols_icons - sha256: "1dea2aef1c83434f832f14341a5ffa1254e76b68e4d90333f95f8a2643bf1024" + sha256: "8a57be605b8bc3fd57005eb776ede61f569214e48834258fb02ab80c7034b82c" url: "https://pub.dev" source: hosted - version: "4.2799.0" + version: "4.2800.0" media_kit: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 4eefc08..1a664dc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -91,6 +91,7 @@ dependencies: livekit_client: ^2.3.0 wakelock_plus: ^1.2.8 permission_handler: ^11.3.1 + flutter_staggered_grid_view: ^0.7.0 dev_dependencies: flutter_test: