Template
chore: 初始化脚手架 — sunny_mochi 企业级 Flutter 模板
Core 基础设施:Flavor 三包体系、Drift+SQLCipher 加密数据库 7 拦截器 Dio 网络栈、CrashReporter 崩溃日志、Sealed Failure 错误体系 5 色板主题系统、Auth 认证骨架(Clean Architecture)、Dev Panel、Mock Adapter 通过验证:flutter analyze(0 errors)、flutter test(全绿)、staging APK 74.8MB
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
// Auth feature provider 出口 — presentation 层通过此文件访问 authRepositoryProvider
|
||||
export 'package:sunny_mochi/features/auth/data/repositories/auth_repository_impl.dart'
|
||||
show authRepositoryProvider;
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:sunny_mochi/core/config/api_paths.dart';
|
||||
import 'package:sunny_mochi/core/error/exception_mapper.dart';
|
||||
import 'package:sunny_mochi/core/error/failures.dart';
|
||||
import 'package:sunny_mochi/core/network/api_response.dart';
|
||||
import 'package:sunny_mochi/core/network/dio_client.dart';
|
||||
import 'package:sunny_mochi/features/auth/data/models/user_model.dart';
|
||||
|
||||
part 'auth_remote_datasource.g.dart';
|
||||
|
||||
@riverpod
|
||||
AuthRemoteDatasource authRemoteDatasource(Ref ref) =>
|
||||
AuthRemoteDatasource(ref.watch(dioClientProvider));
|
||||
|
||||
class AuthRemoteDatasource {
|
||||
const AuthRemoteDatasource(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
static const _mapper = ExceptionMapper();
|
||||
|
||||
/// 登录/发码端点跳过 AuthInterceptor,避免携带残留 token。
|
||||
static final _skipAuthOptions = Options(extra: const {'skip_auth': true});
|
||||
|
||||
Future<UserModel> loginWithPhone(String phone, String code) async {
|
||||
final resp = await _dio.post<Map<String, dynamic>>(
|
||||
ApiPaths.authLoginSms,
|
||||
data: {'account': phone, 'smsCode': code, 'loginType': 2},
|
||||
options: _skipAuthOptions,
|
||||
);
|
||||
return _unwrapLogin(resp.data);
|
||||
}
|
||||
|
||||
Future<UserModel> loginWithPassword(String phone, String password) async {
|
||||
final resp = await _dio.post<Map<String, dynamic>>(
|
||||
ApiPaths.authLoginPwd,
|
||||
data: {'account': phone, 'password': password, 'loginType': 1},
|
||||
options: _skipAuthOptions,
|
||||
);
|
||||
return _unwrapLogin(resp.data);
|
||||
}
|
||||
|
||||
/// 返回 dev/test 环境后端回显的验证码明文(release 返回 null)。
|
||||
Future<String?> sendSmsCode(String phone) async {
|
||||
final resp = await _dio.post<Map<String, dynamic>>(
|
||||
ApiPaths.authSmsSend,
|
||||
data: {'phone': phone, 'codeType': 1},
|
||||
options: _skipAuthOptions,
|
||||
);
|
||||
final apiResp = parseEnvelope<String?>(resp.data, (j) => j as String?);
|
||||
if (!apiResp.isSuccess) {
|
||||
throw _mapper.fromBusinessCode(apiResp.retCode, apiResp.retMsg);
|
||||
}
|
||||
return apiResp.retData;
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _dio.post<void>(ApiPaths.authLogout);
|
||||
}
|
||||
|
||||
Future<UserModel> getUserProfile() async {
|
||||
final resp = await _dio.get<Map<String, dynamic>>(ApiPaths.userProfile);
|
||||
final body = resp.data;
|
||||
if (body == null) throw const ServerFailure(message: '服务器响应为空');
|
||||
final apiResp = ApiResponse.fromJson(
|
||||
body,
|
||||
(json) => json as Map<String, dynamic>,
|
||||
);
|
||||
if (!apiResp.isSuccess) {
|
||||
throw _mapper.fromBusinessCode(apiResp.retCode, apiResp.retMsg);
|
||||
}
|
||||
final data = apiResp.retData ?? const <String, dynamic>{};
|
||||
return UserModel(
|
||||
userId: _flexStr(data['id'] ?? data['userId']),
|
||||
realName: _clean(data['realName']),
|
||||
username: _clean(data['realName']),
|
||||
avatar: _clean(data['avatar']),
|
||||
phone: _flexStr(data['phone']),
|
||||
email: _clean(data['email']),
|
||||
gender: (data['sex'] as num?)?.toInt(),
|
||||
employeeNo: _clean(data['workNo']),
|
||||
company: _clean(data['orgName']),
|
||||
department: _clean(data['deptName']),
|
||||
);
|
||||
}
|
||||
|
||||
static String? _clean(dynamic v) {
|
||||
if (v == null) return null;
|
||||
final s = v.toString();
|
||||
if (s.isEmpty || s == '<null>' || s == 'null') return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
/// 解析登录 envelope:data = {saTokenInfo: {...}, userInfo: {...}}
|
||||
UserModel _unwrapLogin(Map<String, dynamic>? body) {
|
||||
if (body == null) throw const ServerFailure(message: '服务器响应为空');
|
||||
final apiResp = ApiResponse.fromJson(
|
||||
body,
|
||||
(json) => json as Map<String, dynamic>,
|
||||
);
|
||||
if (!apiResp.isSuccess) {
|
||||
throw _mapper.fromBusinessCode(apiResp.retCode, apiResp.retMsg);
|
||||
}
|
||||
final data = apiResp.retData;
|
||||
if (data == null) throw const ServerFailure(message: '登录响应缺少用户数据');
|
||||
final tokenInfo = data['saTokenInfo'] as Map<String, dynamic>?;
|
||||
final userInfo = data['userInfo'] as Map<String, dynamic>?;
|
||||
if (tokenInfo == null || userInfo == null) {
|
||||
throw const ServerFailure(message: '登录响应结构异常');
|
||||
}
|
||||
return UserModel(
|
||||
token: tokenInfo['tokenValue'] as String?,
|
||||
tokenName: tokenInfo['tokenName'] as String?,
|
||||
userId: _flexStr(tokenInfo['loginId']),
|
||||
realName: userInfo['realName'] as String?,
|
||||
username: userInfo['realName'] as String?,
|
||||
avatar: userInfo['avatar'] as String?,
|
||||
phone: _flexStr(userInfo['phone']),
|
||||
email: userInfo['email'] as String?,
|
||||
gender: (userInfo['sex'] as num?)?.toInt(),
|
||||
employeeNo: userInfo['workNo'] as String?,
|
||||
company: userInfo['orgName'] as String?,
|
||||
department: userInfo['deptName'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
static String? _flexStr(dynamic v) {
|
||||
if (v == null) return null;
|
||||
if (v is String) return v.isEmpty ? null : v;
|
||||
if (v is int) return v.toString();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'auth_remote_datasource.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(authRemoteDatasource)
|
||||
final authRemoteDatasourceProvider = AuthRemoteDatasourceProvider._();
|
||||
|
||||
final class AuthRemoteDatasourceProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AuthRemoteDatasource,
|
||||
AuthRemoteDatasource,
|
||||
AuthRemoteDatasource
|
||||
>
|
||||
with $Provider<AuthRemoteDatasource> {
|
||||
AuthRemoteDatasourceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'authRemoteDatasourceProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$authRemoteDatasourceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<AuthRemoteDatasource> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
AuthRemoteDatasource create(Ref ref) {
|
||||
return authRemoteDatasource(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(AuthRemoteDatasource value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<AuthRemoteDatasource>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$authRemoteDatasourceHash() =>
|
||||
r'81f99f43779be5f073a2953ab9a3c1b80883aa9e';
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:sunny_mochi/features/auth/domain/entities/user_entity.dart';
|
||||
|
||||
part 'user_model.freezed.dart';
|
||||
part 'user_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class UserModel with _$UserModel {
|
||||
const factory UserModel({
|
||||
String? userId,
|
||||
String? username,
|
||||
String? realName,
|
||||
String? phone,
|
||||
String? avatar,
|
||||
String? token,
|
||||
String? tokenName,
|
||||
String? refreshToken,
|
||||
String? email,
|
||||
DateTime? birthday,
|
||||
int? gender,
|
||||
String? employeeNo,
|
||||
String? company,
|
||||
String? department,
|
||||
}) = _UserModel;
|
||||
|
||||
factory UserModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$UserModelFromJson(json);
|
||||
}
|
||||
|
||||
extension UserModelX on UserModel {
|
||||
UserEntity toEntity() => UserEntity(
|
||||
userId: userId,
|
||||
username: username,
|
||||
realName: realName,
|
||||
phone: phone,
|
||||
avatar: avatar,
|
||||
token: token,
|
||||
employeeNo: employeeNo,
|
||||
company: company,
|
||||
department: department,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// 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 'user_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$UserModel {
|
||||
|
||||
String? get userId; String? get username; String? get realName; String? get phone; String? get avatar; String? get token; String? get tokenName; String? get refreshToken; String? get email; DateTime? get birthday; int? get gender; String? get employeeNo; String? get company; String? get department;
|
||||
/// Create a copy of UserModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$UserModelCopyWith<UserModel> get copyWith => _$UserModelCopyWithImpl<UserModel>(this as UserModel, _$identity);
|
||||
|
||||
/// Serializes this UserModel to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is UserModel&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.username, username) || other.username == username)&&(identical(other.realName, realName) || other.realName == realName)&&(identical(other.phone, phone) || other.phone == phone)&&(identical(other.avatar, avatar) || other.avatar == avatar)&&(identical(other.token, token) || other.token == token)&&(identical(other.tokenName, tokenName) || other.tokenName == tokenName)&&(identical(other.refreshToken, refreshToken) || other.refreshToken == refreshToken)&&(identical(other.email, email) || other.email == email)&&(identical(other.birthday, birthday) || other.birthday == birthday)&&(identical(other.gender, gender) || other.gender == gender)&&(identical(other.employeeNo, employeeNo) || other.employeeNo == employeeNo)&&(identical(other.company, company) || other.company == company)&&(identical(other.department, department) || other.department == department));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,userId,username,realName,phone,avatar,token,tokenName,refreshToken,email,birthday,gender,employeeNo,company,department);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'UserModel(userId: $userId, username: $username, realName: $realName, phone: $phone, avatar: $avatar, token: $token, tokenName: $tokenName, refreshToken: $refreshToken, email: $email, birthday: $birthday, gender: $gender, employeeNo: $employeeNo, company: $company, department: $department)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $UserModelCopyWith<$Res> {
|
||||
factory $UserModelCopyWith(UserModel value, $Res Function(UserModel) _then) = _$UserModelCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String? userId, String? username, String? realName, String? phone, String? avatar, String? token, String? tokenName, String? refreshToken, String? email, DateTime? birthday, int? gender, String? employeeNo, String? company, String? department
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$UserModelCopyWithImpl<$Res>
|
||||
implements $UserModelCopyWith<$Res> {
|
||||
_$UserModelCopyWithImpl(this._self, this._then);
|
||||
|
||||
final UserModel _self;
|
||||
final $Res Function(UserModel) _then;
|
||||
|
||||
/// Create a copy of UserModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? userId = freezed,Object? username = freezed,Object? realName = freezed,Object? phone = freezed,Object? avatar = freezed,Object? token = freezed,Object? tokenName = freezed,Object? refreshToken = freezed,Object? email = freezed,Object? birthday = freezed,Object? gender = freezed,Object? employeeNo = freezed,Object? company = freezed,Object? department = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
userId: freezed == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,username: freezed == username ? _self.username : username // ignore: cast_nullable_to_non_nullable
|
||||
as String?,realName: freezed == realName ? _self.realName : realName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,phone: freezed == phone ? _self.phone : phone // ignore: cast_nullable_to_non_nullable
|
||||
as String?,avatar: freezed == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable
|
||||
as String?,token: freezed == token ? _self.token : token // ignore: cast_nullable_to_non_nullable
|
||||
as String?,tokenName: freezed == tokenName ? _self.tokenName : tokenName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,refreshToken: freezed == refreshToken ? _self.refreshToken : refreshToken // ignore: cast_nullable_to_non_nullable
|
||||
as String?,email: freezed == email ? _self.email : email // ignore: cast_nullable_to_non_nullable
|
||||
as String?,birthday: freezed == birthday ? _self.birthday : birthday // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,gender: freezed == gender ? _self.gender : gender // ignore: cast_nullable_to_non_nullable
|
||||
as int?,employeeNo: freezed == employeeNo ? _self.employeeNo : employeeNo // ignore: cast_nullable_to_non_nullable
|
||||
as String?,company: freezed == company ? _self.company : company // ignore: cast_nullable_to_non_nullable
|
||||
as String?,department: freezed == department ? _self.department : department // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [UserModel].
|
||||
extension UserModelPatterns on UserModel {
|
||||
/// 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 extends Object?>(TResult Function( _UserModel value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _UserModel() 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 extends Object?>(TResult Function( _UserModel value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _UserModel():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// 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 extends Object?>(TResult? Function( _UserModel value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _UserModel() 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 extends Object?>(TResult Function( String? userId, String? username, String? realName, String? phone, String? avatar, String? token, String? tokenName, String? refreshToken, String? email, DateTime? birthday, int? gender, String? employeeNo, String? company, String? department)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _UserModel() when $default != null:
|
||||
return $default(_that.userId,_that.username,_that.realName,_that.phone,_that.avatar,_that.token,_that.tokenName,_that.refreshToken,_that.email,_that.birthday,_that.gender,_that.employeeNo,_that.company,_that.department);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 extends Object?>(TResult Function( String? userId, String? username, String? realName, String? phone, String? avatar, String? token, String? tokenName, String? refreshToken, String? email, DateTime? birthday, int? gender, String? employeeNo, String? company, String? department) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _UserModel():
|
||||
return $default(_that.userId,_that.username,_that.realName,_that.phone,_that.avatar,_that.token,_that.tokenName,_that.refreshToken,_that.email,_that.birthday,_that.gender,_that.employeeNo,_that.company,_that.department);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// 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 extends Object?>(TResult? Function( String? userId, String? username, String? realName, String? phone, String? avatar, String? token, String? tokenName, String? refreshToken, String? email, DateTime? birthday, int? gender, String? employeeNo, String? company, String? department)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _UserModel() when $default != null:
|
||||
return $default(_that.userId,_that.username,_that.realName,_that.phone,_that.avatar,_that.token,_that.tokenName,_that.refreshToken,_that.email,_that.birthday,_that.gender,_that.employeeNo,_that.company,_that.department);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _UserModel implements UserModel {
|
||||
const _UserModel({this.userId, this.username, this.realName, this.phone, this.avatar, this.token, this.tokenName, this.refreshToken, this.email, this.birthday, this.gender, this.employeeNo, this.company, this.department});
|
||||
factory _UserModel.fromJson(Map<String, dynamic> json) => _$UserModelFromJson(json);
|
||||
|
||||
@override final String? userId;
|
||||
@override final String? username;
|
||||
@override final String? realName;
|
||||
@override final String? phone;
|
||||
@override final String? avatar;
|
||||
@override final String? token;
|
||||
@override final String? tokenName;
|
||||
@override final String? refreshToken;
|
||||
@override final String? email;
|
||||
@override final DateTime? birthday;
|
||||
@override final int? gender;
|
||||
@override final String? employeeNo;
|
||||
@override final String? company;
|
||||
@override final String? department;
|
||||
|
||||
/// Create a copy of UserModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$UserModelCopyWith<_UserModel> get copyWith => __$UserModelCopyWithImpl<_UserModel>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$UserModelToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _UserModel&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.username, username) || other.username == username)&&(identical(other.realName, realName) || other.realName == realName)&&(identical(other.phone, phone) || other.phone == phone)&&(identical(other.avatar, avatar) || other.avatar == avatar)&&(identical(other.token, token) || other.token == token)&&(identical(other.tokenName, tokenName) || other.tokenName == tokenName)&&(identical(other.refreshToken, refreshToken) || other.refreshToken == refreshToken)&&(identical(other.email, email) || other.email == email)&&(identical(other.birthday, birthday) || other.birthday == birthday)&&(identical(other.gender, gender) || other.gender == gender)&&(identical(other.employeeNo, employeeNo) || other.employeeNo == employeeNo)&&(identical(other.company, company) || other.company == company)&&(identical(other.department, department) || other.department == department));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,userId,username,realName,phone,avatar,token,tokenName,refreshToken,email,birthday,gender,employeeNo,company,department);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'UserModel(userId: $userId, username: $username, realName: $realName, phone: $phone, avatar: $avatar, token: $token, tokenName: $tokenName, refreshToken: $refreshToken, email: $email, birthday: $birthday, gender: $gender, employeeNo: $employeeNo, company: $company, department: $department)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$UserModelCopyWith<$Res> implements $UserModelCopyWith<$Res> {
|
||||
factory _$UserModelCopyWith(_UserModel value, $Res Function(_UserModel) _then) = __$UserModelCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String? userId, String? username, String? realName, String? phone, String? avatar, String? token, String? tokenName, String? refreshToken, String? email, DateTime? birthday, int? gender, String? employeeNo, String? company, String? department
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$UserModelCopyWithImpl<$Res>
|
||||
implements _$UserModelCopyWith<$Res> {
|
||||
__$UserModelCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _UserModel _self;
|
||||
final $Res Function(_UserModel) _then;
|
||||
|
||||
/// Create a copy of UserModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? userId = freezed,Object? username = freezed,Object? realName = freezed,Object? phone = freezed,Object? avatar = freezed,Object? token = freezed,Object? tokenName = freezed,Object? refreshToken = freezed,Object? email = freezed,Object? birthday = freezed,Object? gender = freezed,Object? employeeNo = freezed,Object? company = freezed,Object? department = freezed,}) {
|
||||
return _then(_UserModel(
|
||||
userId: freezed == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,username: freezed == username ? _self.username : username // ignore: cast_nullable_to_non_nullable
|
||||
as String?,realName: freezed == realName ? _self.realName : realName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,phone: freezed == phone ? _self.phone : phone // ignore: cast_nullable_to_non_nullable
|
||||
as String?,avatar: freezed == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable
|
||||
as String?,token: freezed == token ? _self.token : token // ignore: cast_nullable_to_non_nullable
|
||||
as String?,tokenName: freezed == tokenName ? _self.tokenName : tokenName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,refreshToken: freezed == refreshToken ? _self.refreshToken : refreshToken // ignore: cast_nullable_to_non_nullable
|
||||
as String?,email: freezed == email ? _self.email : email // ignore: cast_nullable_to_non_nullable
|
||||
as String?,birthday: freezed == birthday ? _self.birthday : birthday // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,gender: freezed == gender ? _self.gender : gender // ignore: cast_nullable_to_non_nullable
|
||||
as int?,employeeNo: freezed == employeeNo ? _self.employeeNo : employeeNo // ignore: cast_nullable_to_non_nullable
|
||||
as String?,company: freezed == company ? _self.company : company // ignore: cast_nullable_to_non_nullable
|
||||
as String?,department: freezed == department ? _self.department : department // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
@@ -0,0 +1,44 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'user_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_UserModel _$UserModelFromJson(Map<String, dynamic> json) => _UserModel(
|
||||
userId: json['userId'] as String?,
|
||||
username: json['username'] as String?,
|
||||
realName: json['realName'] as String?,
|
||||
phone: json['phone'] as String?,
|
||||
avatar: json['avatar'] as String?,
|
||||
token: json['token'] as String?,
|
||||
tokenName: json['tokenName'] as String?,
|
||||
refreshToken: json['refreshToken'] as String?,
|
||||
email: json['email'] as String?,
|
||||
birthday: json['birthday'] == null
|
||||
? null
|
||||
: DateTime.parse(json['birthday'] as String),
|
||||
gender: (json['gender'] as num?)?.toInt(),
|
||||
employeeNo: json['employeeNo'] as String?,
|
||||
company: json['company'] as String?,
|
||||
department: json['department'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$UserModelToJson(_UserModel instance) =>
|
||||
<String, dynamic>{
|
||||
'userId': instance.userId,
|
||||
'username': instance.username,
|
||||
'realName': instance.realName,
|
||||
'phone': instance.phone,
|
||||
'avatar': instance.avatar,
|
||||
'token': instance.token,
|
||||
'tokenName': instance.tokenName,
|
||||
'refreshToken': instance.refreshToken,
|
||||
'email': instance.email,
|
||||
'birthday': instance.birthday?.toIso8601String(),
|
||||
'gender': instance.gender,
|
||||
'employeeNo': instance.employeeNo,
|
||||
'company': instance.company,
|
||||
'department': instance.department,
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:sunny_mochi/core/config/env.dart';
|
||||
import 'package:sunny_mochi/core/crypto/rsa_helper.dart';
|
||||
import 'package:sunny_mochi/core/error/failures.dart';
|
||||
import 'package:sunny_mochi/core/storage/app_database.dart';
|
||||
import 'package:sunny_mochi/core/storage/daos/users_dao.dart';
|
||||
import 'package:sunny_mochi/core/storage/secure_storage.dart';
|
||||
import 'package:sunny_mochi/features/auth/data/datasources/auth_remote_datasource.dart';
|
||||
import 'package:sunny_mochi/features/auth/data/models/user_model.dart';
|
||||
import 'package:sunny_mochi/features/auth/domain/entities/user_entity.dart';
|
||||
import 'package:sunny_mochi/features/auth/domain/repositories/auth_repository.dart';
|
||||
|
||||
part 'auth_repository_impl.g.dart';
|
||||
|
||||
@riverpod
|
||||
AuthRepository authRepository(Ref ref) => AuthRepositoryImpl(
|
||||
ref.watch(authRemoteDatasourceProvider),
|
||||
ref.watch(secureStorageProvider),
|
||||
usersDaoFactory: () => ref.read(usersDaoProvider),
|
||||
);
|
||||
|
||||
class AuthRepositoryImpl implements AuthRepository {
|
||||
AuthRepositoryImpl(
|
||||
this._remote,
|
||||
this._storage, {
|
||||
String? rsaPublicKey,
|
||||
UsersDao Function()? usersDaoFactory,
|
||||
}) : _rsaPublicKey = rsaPublicKey ?? Env.rsaPublicKey,
|
||||
_usersDaoFactory = usersDaoFactory;
|
||||
|
||||
final AuthRemoteDatasource _remote;
|
||||
final SecureStorage _storage;
|
||||
final String _rsaPublicKey;
|
||||
final UsersDao Function()? _usersDaoFactory;
|
||||
|
||||
@override
|
||||
Future<UserEntity> loginWithPhone({
|
||||
required String phone,
|
||||
required String code,
|
||||
}) async {
|
||||
final model = await _remote.loginWithPhone(phone, code);
|
||||
await _persistUser(model);
|
||||
return model.toEntity();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UserEntity> loginWithPassword({
|
||||
required String phone,
|
||||
required String password,
|
||||
}) async {
|
||||
final String encrypted;
|
||||
try {
|
||||
encrypted = RsaHelper.encrypt(password, publicKeyDerBase64: _rsaPublicKey);
|
||||
} on RsaEncryptionException catch (e, st) {
|
||||
throw AuthFailure(
|
||||
message: '密码加密失败,请重试',
|
||||
kind: AuthFailureKind.cryptoFailed,
|
||||
cause: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
}
|
||||
final model = await _remote.loginWithPassword(phone, encrypted);
|
||||
await _persistUser(model);
|
||||
return model.toEntity();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> logout() async {
|
||||
final userId = await _storage.getUserId();
|
||||
await _storage.clearAll();
|
||||
unawaited(_backgroundLogoutCleanup(userId));
|
||||
}
|
||||
|
||||
Future<void> _backgroundLogoutCleanup(String? userId) async {
|
||||
try {
|
||||
await _remote.logout();
|
||||
} on Object {
|
||||
// best-effort
|
||||
}
|
||||
if (userId != null && _usersDaoFactory != null) {
|
||||
try {
|
||||
await _usersDaoFactory().deleteById(userId);
|
||||
} on Object {
|
||||
// mock/测试场景静默
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UserEntity?> getCurrentUser() async {
|
||||
final token = await _storage.getToken();
|
||||
if (token == null) return null;
|
||||
final userId = await _storage.getUserId();
|
||||
if (userId != null && _usersDaoFactory != null) {
|
||||
try {
|
||||
final row = await _usersDaoFactory().getById(userId);
|
||||
if (row != null) {
|
||||
return UserEntity(
|
||||
userId: userId,
|
||||
token: token,
|
||||
username: row.username,
|
||||
realName: row.realName,
|
||||
avatar: row.avatar,
|
||||
phone: row.phone,
|
||||
employeeNo: row.employeeNo,
|
||||
company: row.company,
|
||||
department: row.department,
|
||||
);
|
||||
}
|
||||
} on Object {
|
||||
// Drift 不可用时降级
|
||||
}
|
||||
}
|
||||
return UserEntity(userId: userId, token: token);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> sendSmsCode(String phone) => _remote.sendSmsCode(phone);
|
||||
|
||||
@override
|
||||
Future<UserEntity> fetchUserProfile() async {
|
||||
final model = await _remote.getUserProfile();
|
||||
final userId = await _storage.getUserId();
|
||||
if (userId != null && _usersDaoFactory != null) {
|
||||
try {
|
||||
await _usersDaoFactory().upsert(
|
||||
UsersCompanion(
|
||||
userId: Value(userId),
|
||||
username: Value(model.username),
|
||||
realName: Value(model.realName),
|
||||
phone: Value(model.phone),
|
||||
avatar: Value(model.avatar),
|
||||
email: Value(model.email),
|
||||
gender: Value(model.gender),
|
||||
employeeNo: Value(model.employeeNo),
|
||||
company: Value(model.company),
|
||||
department: Value(model.department),
|
||||
),
|
||||
);
|
||||
} on Object {
|
||||
// dao 不可用静默
|
||||
}
|
||||
}
|
||||
return model.toEntity();
|
||||
}
|
||||
|
||||
Future<void> _persistUser(UserModel model) async {
|
||||
if (model.token != null) await _storage.setToken(model.token!);
|
||||
if (model.tokenName != null && model.tokenName!.isNotEmpty) {
|
||||
await _storage.setTokenName(model.tokenName!);
|
||||
}
|
||||
if (model.refreshToken != null) {
|
||||
await _storage.setRefreshToken(model.refreshToken!);
|
||||
}
|
||||
if (model.userId != null) await _storage.setUserId(model.userId!);
|
||||
final userId = model.userId;
|
||||
if (userId != null && _usersDaoFactory != null) {
|
||||
try {
|
||||
await _usersDaoFactory().upsert(
|
||||
UsersCompanion(
|
||||
userId: Value(userId),
|
||||
username: Value(model.username),
|
||||
realName: Value(model.realName),
|
||||
phone: Value(model.phone),
|
||||
avatar: Value(model.avatar),
|
||||
email: Value(model.email),
|
||||
birthday: Value(model.birthday),
|
||||
gender: Value(model.gender),
|
||||
refreshToken: Value(model.refreshToken),
|
||||
),
|
||||
);
|
||||
} on Object {
|
||||
// dao 不可用静默
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'auth_repository_impl.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(authRepository)
|
||||
final authRepositoryProvider = AuthRepositoryProvider._();
|
||||
|
||||
final class AuthRepositoryProvider
|
||||
extends $FunctionalProvider<AuthRepository, AuthRepository, AuthRepository>
|
||||
with $Provider<AuthRepository> {
|
||||
AuthRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'authRepositoryProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$authRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<AuthRepository> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
AuthRepository create(Ref ref) {
|
||||
return authRepository(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(AuthRepository value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<AuthRepository>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$authRepositoryHash() => r'3a8f259bef644802bc3ef4169a8456a3a05aaea8';
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'user_entity.freezed.dart';
|
||||
|
||||
@freezed
|
||||
abstract class UserEntity with _$UserEntity {
|
||||
const factory UserEntity({
|
||||
String? userId,
|
||||
String? username,
|
||||
String? realName,
|
||||
String? phone,
|
||||
String? avatar,
|
||||
String? token,
|
||||
String? employeeNo,
|
||||
String? company,
|
||||
String? department,
|
||||
}) = _UserEntity;
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// 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 'user_entity.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$UserEntity {
|
||||
|
||||
String? get userId; String? get username; String? get realName; String? get phone; String? get avatar; String? get token; String? get employeeNo; String? get company; String? get department;
|
||||
/// Create a copy of UserEntity
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$UserEntityCopyWith<UserEntity> get copyWith => _$UserEntityCopyWithImpl<UserEntity>(this as UserEntity, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is UserEntity&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.username, username) || other.username == username)&&(identical(other.realName, realName) || other.realName == realName)&&(identical(other.phone, phone) || other.phone == phone)&&(identical(other.avatar, avatar) || other.avatar == avatar)&&(identical(other.token, token) || other.token == token)&&(identical(other.employeeNo, employeeNo) || other.employeeNo == employeeNo)&&(identical(other.company, company) || other.company == company)&&(identical(other.department, department) || other.department == department));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,userId,username,realName,phone,avatar,token,employeeNo,company,department);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'UserEntity(userId: $userId, username: $username, realName: $realName, phone: $phone, avatar: $avatar, token: $token, employeeNo: $employeeNo, company: $company, department: $department)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $UserEntityCopyWith<$Res> {
|
||||
factory $UserEntityCopyWith(UserEntity value, $Res Function(UserEntity) _then) = _$UserEntityCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String? userId, String? username, String? realName, String? phone, String? avatar, String? token, String? employeeNo, String? company, String? department
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$UserEntityCopyWithImpl<$Res>
|
||||
implements $UserEntityCopyWith<$Res> {
|
||||
_$UserEntityCopyWithImpl(this._self, this._then);
|
||||
|
||||
final UserEntity _self;
|
||||
final $Res Function(UserEntity) _then;
|
||||
|
||||
/// Create a copy of UserEntity
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? userId = freezed,Object? username = freezed,Object? realName = freezed,Object? phone = freezed,Object? avatar = freezed,Object? token = freezed,Object? employeeNo = freezed,Object? company = freezed,Object? department = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
userId: freezed == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,username: freezed == username ? _self.username : username // ignore: cast_nullable_to_non_nullable
|
||||
as String?,realName: freezed == realName ? _self.realName : realName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,phone: freezed == phone ? _self.phone : phone // ignore: cast_nullable_to_non_nullable
|
||||
as String?,avatar: freezed == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable
|
||||
as String?,token: freezed == token ? _self.token : token // ignore: cast_nullable_to_non_nullable
|
||||
as String?,employeeNo: freezed == employeeNo ? _self.employeeNo : employeeNo // ignore: cast_nullable_to_non_nullable
|
||||
as String?,company: freezed == company ? _self.company : company // ignore: cast_nullable_to_non_nullable
|
||||
as String?,department: freezed == department ? _self.department : department // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [UserEntity].
|
||||
extension UserEntityPatterns on UserEntity {
|
||||
/// 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 extends Object?>(TResult Function( _UserEntity value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _UserEntity() 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 extends Object?>(TResult Function( _UserEntity value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _UserEntity():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// 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 extends Object?>(TResult? Function( _UserEntity value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _UserEntity() 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 extends Object?>(TResult Function( String? userId, String? username, String? realName, String? phone, String? avatar, String? token, String? employeeNo, String? company, String? department)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _UserEntity() when $default != null:
|
||||
return $default(_that.userId,_that.username,_that.realName,_that.phone,_that.avatar,_that.token,_that.employeeNo,_that.company,_that.department);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 extends Object?>(TResult Function( String? userId, String? username, String? realName, String? phone, String? avatar, String? token, String? employeeNo, String? company, String? department) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _UserEntity():
|
||||
return $default(_that.userId,_that.username,_that.realName,_that.phone,_that.avatar,_that.token,_that.employeeNo,_that.company,_that.department);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// 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 extends Object?>(TResult? Function( String? userId, String? username, String? realName, String? phone, String? avatar, String? token, String? employeeNo, String? company, String? department)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _UserEntity() when $default != null:
|
||||
return $default(_that.userId,_that.username,_that.realName,_that.phone,_that.avatar,_that.token,_that.employeeNo,_that.company,_that.department);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _UserEntity implements UserEntity {
|
||||
const _UserEntity({this.userId, this.username, this.realName, this.phone, this.avatar, this.token, this.employeeNo, this.company, this.department});
|
||||
|
||||
|
||||
@override final String? userId;
|
||||
@override final String? username;
|
||||
@override final String? realName;
|
||||
@override final String? phone;
|
||||
@override final String? avatar;
|
||||
@override final String? token;
|
||||
@override final String? employeeNo;
|
||||
@override final String? company;
|
||||
@override final String? department;
|
||||
|
||||
/// Create a copy of UserEntity
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$UserEntityCopyWith<_UserEntity> get copyWith => __$UserEntityCopyWithImpl<_UserEntity>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _UserEntity&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.username, username) || other.username == username)&&(identical(other.realName, realName) || other.realName == realName)&&(identical(other.phone, phone) || other.phone == phone)&&(identical(other.avatar, avatar) || other.avatar == avatar)&&(identical(other.token, token) || other.token == token)&&(identical(other.employeeNo, employeeNo) || other.employeeNo == employeeNo)&&(identical(other.company, company) || other.company == company)&&(identical(other.department, department) || other.department == department));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,userId,username,realName,phone,avatar,token,employeeNo,company,department);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'UserEntity(userId: $userId, username: $username, realName: $realName, phone: $phone, avatar: $avatar, token: $token, employeeNo: $employeeNo, company: $company, department: $department)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$UserEntityCopyWith<$Res> implements $UserEntityCopyWith<$Res> {
|
||||
factory _$UserEntityCopyWith(_UserEntity value, $Res Function(_UserEntity) _then) = __$UserEntityCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String? userId, String? username, String? realName, String? phone, String? avatar, String? token, String? employeeNo, String? company, String? department
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$UserEntityCopyWithImpl<$Res>
|
||||
implements _$UserEntityCopyWith<$Res> {
|
||||
__$UserEntityCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _UserEntity _self;
|
||||
final $Res Function(_UserEntity) _then;
|
||||
|
||||
/// Create a copy of UserEntity
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? userId = freezed,Object? username = freezed,Object? realName = freezed,Object? phone = freezed,Object? avatar = freezed,Object? token = freezed,Object? employeeNo = freezed,Object? company = freezed,Object? department = freezed,}) {
|
||||
return _then(_UserEntity(
|
||||
userId: freezed == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,username: freezed == username ? _self.username : username // ignore: cast_nullable_to_non_nullable
|
||||
as String?,realName: freezed == realName ? _self.realName : realName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,phone: freezed == phone ? _self.phone : phone // ignore: cast_nullable_to_non_nullable
|
||||
as String?,avatar: freezed == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable
|
||||
as String?,token: freezed == token ? _self.token : token // ignore: cast_nullable_to_non_nullable
|
||||
as String?,employeeNo: freezed == employeeNo ? _self.employeeNo : employeeNo // ignore: cast_nullable_to_non_nullable
|
||||
as String?,company: freezed == company ? _self.company : company // ignore: cast_nullable_to_non_nullable
|
||||
as String?,department: freezed == department ? _self.department : department // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:sunny_mochi/features/auth/domain/entities/user_entity.dart';
|
||||
|
||||
abstract interface class AuthRepository {
|
||||
Future<UserEntity> loginWithPhone({
|
||||
required String phone,
|
||||
required String code,
|
||||
});
|
||||
|
||||
Future<UserEntity> loginWithPassword({
|
||||
required String phone,
|
||||
required String password,
|
||||
});
|
||||
|
||||
Future<void> logout();
|
||||
|
||||
Future<UserEntity?> getCurrentUser();
|
||||
|
||||
Future<String?> sendSmsCode(String phone);
|
||||
|
||||
Future<UserEntity> fetchUserProfile();
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:sunny_mochi/core/error/exception_mapper.dart';
|
||||
import 'package:sunny_mochi/features/auth/auth_providers.dart';
|
||||
import 'package:sunny_mochi/features/auth/domain/entities/user_entity.dart';
|
||||
import 'package:sunny_mochi/features/auth/presentation/notifiers/auth_status_provider.dart';
|
||||
|
||||
part 'auth_notifier.freezed.dart';
|
||||
part 'auth_notifier.g.dart';
|
||||
|
||||
@freezed
|
||||
sealed class AuthState with _$AuthState {
|
||||
const factory AuthState.initial() = AuthInitial;
|
||||
const factory AuthState.loading() = AuthLoading;
|
||||
const factory AuthState.authenticated(UserEntity user) = AuthAuthenticated;
|
||||
const factory AuthState.unauthenticated() = AuthUnauthenticated;
|
||||
const factory AuthState.error(String message) = AuthError;
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class AuthNotifier extends _$AuthNotifier {
|
||||
static const _mapper = ExceptionMapper();
|
||||
|
||||
// generation 计数:防止旧 async 回调覆盖最新登录态
|
||||
int _generation = 0;
|
||||
|
||||
@override
|
||||
AuthState build() {
|
||||
final gen = ++_generation;
|
||||
unawaited(_checkAuth(gen));
|
||||
return const AuthState.initial();
|
||||
}
|
||||
|
||||
Future<void> _checkAuth(int gen) async {
|
||||
final user = await ref.read(authRepositoryProvider).getCurrentUser();
|
||||
if (_generation != gen) return;
|
||||
if (user != null) {
|
||||
state = AuthState.authenticated(user);
|
||||
ref.read(authStatusProvider).markLoggedIn();
|
||||
unawaited(refreshUserInfo());
|
||||
} else {
|
||||
state = const AuthState.unauthenticated();
|
||||
ref.read(authStatusProvider).markLoggedOut();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loginWithPhone(String phone, String code) async {
|
||||
_generation++;
|
||||
state = const AuthState.loading();
|
||||
try {
|
||||
final user = await ref
|
||||
.read(authRepositoryProvider)
|
||||
.loginWithPhone(phone: phone, code: code);
|
||||
state = AuthState.authenticated(user);
|
||||
ref.read(authStatusProvider).markLoggedIn();
|
||||
unawaited(refreshUserInfo());
|
||||
} on Object catch (e, st) {
|
||||
final failure = _mapper.fromUnknown(e, st);
|
||||
state = AuthState.error(failure.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loginWithPassword(String phone, String password) async {
|
||||
_generation++;
|
||||
state = const AuthState.loading();
|
||||
try {
|
||||
final user = await ref
|
||||
.read(authRepositoryProvider)
|
||||
.loginWithPassword(phone: phone, password: password);
|
||||
state = AuthState.authenticated(user);
|
||||
ref.read(authStatusProvider).markLoggedIn();
|
||||
unawaited(refreshUserInfo());
|
||||
} on Object catch (e, st) {
|
||||
final failure = _mapper.fromUnknown(e, st);
|
||||
state = AuthState.error(failure.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
_generation++;
|
||||
state = const AuthState.unauthenticated();
|
||||
ref.read(authStatusProvider).markLoggedOut();
|
||||
unawaited(ref.read(authRepositoryProvider).logout());
|
||||
}
|
||||
|
||||
/// 发送短信验证码。返回 dev/test 环境后端回显的验证码(release 为 null),供 UI 自动填入。
|
||||
Future<String?> sendSmsCode(String phone) async {
|
||||
try {
|
||||
return await ref.read(authRepositoryProvider).sendSmsCode(phone);
|
||||
} on Object catch (e, st) {
|
||||
throw _mapper.fromUnknown(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refreshUserInfo() async {
|
||||
final current = state;
|
||||
if (current is! AuthAuthenticated) return;
|
||||
final gen = _generation;
|
||||
try {
|
||||
final fresh = await ref.read(authRepositoryProvider).fetchUserProfile();
|
||||
if (_generation != gen) return;
|
||||
state = AuthState.authenticated(
|
||||
fresh.copyWith(
|
||||
token: current.user.token,
|
||||
userId: fresh.userId ?? current.user.userId,
|
||||
),
|
||||
);
|
||||
} on Object {
|
||||
// profile 拉取失败不应改变登录状态
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// 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 'auth_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$AuthState {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthState);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => runtimeType.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AuthState()';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class $AuthStateCopyWith<$Res> {
|
||||
$AuthStateCopyWith(AuthState _, $Res Function(AuthState) __);
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [AuthState].
|
||||
extension AuthStatePatterns on AuthState {
|
||||
/// 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 extends Object?>({TResult Function( AuthInitial value)? initial,TResult Function( AuthLoading value)? loading,TResult Function( AuthAuthenticated value)? authenticated,TResult Function( AuthUnauthenticated value)? unauthenticated,TResult Function( AuthError value)? error,required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case AuthInitial() when initial != null:
|
||||
return initial(_that);case AuthLoading() when loading != null:
|
||||
return loading(_that);case AuthAuthenticated() when authenticated != null:
|
||||
return authenticated(_that);case AuthUnauthenticated() when unauthenticated != null:
|
||||
return unauthenticated(_that);case AuthError() when error != null:
|
||||
return error(_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 extends Object?>({required TResult Function( AuthInitial value) initial,required TResult Function( AuthLoading value) loading,required TResult Function( AuthAuthenticated value) authenticated,required TResult Function( AuthUnauthenticated value) unauthenticated,required TResult Function( AuthError value) error,}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case AuthInitial():
|
||||
return initial(_that);case AuthLoading():
|
||||
return loading(_that);case AuthAuthenticated():
|
||||
return authenticated(_that);case AuthUnauthenticated():
|
||||
return unauthenticated(_that);case AuthError():
|
||||
return error(_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 extends Object?>({TResult? Function( AuthInitial value)? initial,TResult? Function( AuthLoading value)? loading,TResult? Function( AuthAuthenticated value)? authenticated,TResult? Function( AuthUnauthenticated value)? unauthenticated,TResult? Function( AuthError value)? error,}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case AuthInitial() when initial != null:
|
||||
return initial(_that);case AuthLoading() when loading != null:
|
||||
return loading(_that);case AuthAuthenticated() when authenticated != null:
|
||||
return authenticated(_that);case AuthUnauthenticated() when unauthenticated != null:
|
||||
return unauthenticated(_that);case AuthError() when error != null:
|
||||
return error(_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 extends Object?>({TResult Function()? initial,TResult Function()? loading,TResult Function( UserEntity user)? authenticated,TResult Function()? unauthenticated,TResult Function( String message)? error,required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case AuthInitial() when initial != null:
|
||||
return initial();case AuthLoading() when loading != null:
|
||||
return loading();case AuthAuthenticated() when authenticated != null:
|
||||
return authenticated(_that.user);case AuthUnauthenticated() when unauthenticated != null:
|
||||
return unauthenticated();case AuthError() when error != null:
|
||||
return error(_that.message);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 extends Object?>({required TResult Function() initial,required TResult Function() loading,required TResult Function( UserEntity user) authenticated,required TResult Function() unauthenticated,required TResult Function( String message) error,}) {final _that = this;
|
||||
switch (_that) {
|
||||
case AuthInitial():
|
||||
return initial();case AuthLoading():
|
||||
return loading();case AuthAuthenticated():
|
||||
return authenticated(_that.user);case AuthUnauthenticated():
|
||||
return unauthenticated();case AuthError():
|
||||
return error(_that.message);}
|
||||
}
|
||||
/// 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 extends Object?>({TResult? Function()? initial,TResult? Function()? loading,TResult? Function( UserEntity user)? authenticated,TResult? Function()? unauthenticated,TResult? Function( String message)? error,}) {final _that = this;
|
||||
switch (_that) {
|
||||
case AuthInitial() when initial != null:
|
||||
return initial();case AuthLoading() when loading != null:
|
||||
return loading();case AuthAuthenticated() when authenticated != null:
|
||||
return authenticated(_that.user);case AuthUnauthenticated() when unauthenticated != null:
|
||||
return unauthenticated();case AuthError() when error != null:
|
||||
return error(_that.message);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class AuthInitial implements AuthState {
|
||||
const AuthInitial();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthInitial);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => runtimeType.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AuthState.initial()';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class AuthLoading implements AuthState {
|
||||
const AuthLoading();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthLoading);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => runtimeType.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AuthState.loading()';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class AuthAuthenticated implements AuthState {
|
||||
const AuthAuthenticated(this.user);
|
||||
|
||||
|
||||
final UserEntity user;
|
||||
|
||||
/// Create a copy of AuthState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$AuthAuthenticatedCopyWith<AuthAuthenticated> get copyWith => _$AuthAuthenticatedCopyWithImpl<AuthAuthenticated>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthAuthenticated&&(identical(other.user, user) || other.user == user));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,user);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AuthState.authenticated(user: $user)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $AuthAuthenticatedCopyWith<$Res> implements $AuthStateCopyWith<$Res> {
|
||||
factory $AuthAuthenticatedCopyWith(AuthAuthenticated value, $Res Function(AuthAuthenticated) _then) = _$AuthAuthenticatedCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
UserEntity user
|
||||
});
|
||||
|
||||
|
||||
$UserEntityCopyWith<$Res> get user;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$AuthAuthenticatedCopyWithImpl<$Res>
|
||||
implements $AuthAuthenticatedCopyWith<$Res> {
|
||||
_$AuthAuthenticatedCopyWithImpl(this._self, this._then);
|
||||
|
||||
final AuthAuthenticated _self;
|
||||
final $Res Function(AuthAuthenticated) _then;
|
||||
|
||||
/// Create a copy of AuthState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') $Res call({Object? user = null,}) {
|
||||
return _then(AuthAuthenticated(
|
||||
null == user ? _self.user : user // ignore: cast_nullable_to_non_nullable
|
||||
as UserEntity,
|
||||
));
|
||||
}
|
||||
|
||||
/// Create a copy of AuthState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$UserEntityCopyWith<$Res> get user {
|
||||
|
||||
return $UserEntityCopyWith<$Res>(_self.user, (value) {
|
||||
return _then(_self.copyWith(user: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class AuthUnauthenticated implements AuthState {
|
||||
const AuthUnauthenticated();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthUnauthenticated);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => runtimeType.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AuthState.unauthenticated()';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class AuthError implements AuthState {
|
||||
const AuthError(this.message);
|
||||
|
||||
|
||||
final String message;
|
||||
|
||||
/// Create a copy of AuthState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$AuthErrorCopyWith<AuthError> get copyWith => _$AuthErrorCopyWithImpl<AuthError>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthError&&(identical(other.message, message) || other.message == message));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,message);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AuthState.error(message: $message)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $AuthErrorCopyWith<$Res> implements $AuthStateCopyWith<$Res> {
|
||||
factory $AuthErrorCopyWith(AuthError value, $Res Function(AuthError) _then) = _$AuthErrorCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String message
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$AuthErrorCopyWithImpl<$Res>
|
||||
implements $AuthErrorCopyWith<$Res> {
|
||||
_$AuthErrorCopyWithImpl(this._self, this._then);
|
||||
|
||||
final AuthError _self;
|
||||
final $Res Function(AuthError) _then;
|
||||
|
||||
/// Create a copy of AuthState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') $Res call({Object? message = null,}) {
|
||||
return _then(AuthError(
|
||||
null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
@@ -0,0 +1,62 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'auth_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(AuthNotifier)
|
||||
final authProvider = AuthNotifierProvider._();
|
||||
|
||||
final class AuthNotifierProvider
|
||||
extends $NotifierProvider<AuthNotifier, AuthState> {
|
||||
AuthNotifierProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'authProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$authNotifierHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
AuthNotifier create() => AuthNotifier();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(AuthState value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<AuthState>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$authNotifierHash() => r'40b3d6e25632667d1c904bec4fd347ac6b32848c';
|
||||
|
||||
abstract class _$AuthNotifier extends $Notifier<AuthState> {
|
||||
AuthState build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<AuthState, AuthState>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AuthState, AuthState>,
|
||||
AuthState,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart' show ValueNotifier;
|
||||
import 'package:sunny_mochi/core/observability/talker_setup.dart';
|
||||
import 'package:sunny_mochi/core/storage/secure_storage.dart'
|
||||
show secureStorageProvider;
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'auth_status_provider.g.dart';
|
||||
|
||||
/// 同步可读的登录态门面 — 给 GoRouter `redirect` 用。
|
||||
///
|
||||
/// 三态语义:
|
||||
/// - `null` → 启动期未知(异步 storage 还没读完,redirect 应不动)
|
||||
/// - `true` → 已登录
|
||||
/// - `false` → 未登录(redirect 把非 /login 的访问跳 /login)
|
||||
class AuthStatusController {
|
||||
AuthStatusController() : _notifier = ValueNotifier<bool?>(null);
|
||||
|
||||
final ValueNotifier<bool?> _notifier;
|
||||
|
||||
ValueNotifier<bool?> get listenable => _notifier;
|
||||
|
||||
bool? get value => _notifier.value;
|
||||
|
||||
void markLoggedIn() {
|
||||
appTalker.info('[AuthStatus] markLoggedIn');
|
||||
_notifier.value = true;
|
||||
}
|
||||
|
||||
void markLoggedOut() {
|
||||
appTalker.info('[AuthStatus] markLoggedOut');
|
||||
_notifier.value = false;
|
||||
}
|
||||
|
||||
void dispose() => _notifier.dispose();
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
AuthStatusController authStatus(Ref ref) {
|
||||
final controller = AuthStatusController();
|
||||
unawaited(_bootstrap(ref, controller));
|
||||
ref.onDispose(controller.dispose);
|
||||
return controller;
|
||||
}
|
||||
|
||||
Future<void> _bootstrap(Ref ref, AuthStatusController controller) async {
|
||||
try {
|
||||
final token = await ref.read(secureStorageProvider).getToken();
|
||||
final loggedIn = token != null && token.isNotEmpty;
|
||||
if (loggedIn) {
|
||||
controller.markLoggedIn();
|
||||
} else {
|
||||
controller.markLoggedOut();
|
||||
}
|
||||
} on Object catch (e, st) {
|
||||
appTalker.warning('[AuthStatus] bootstrap 异常 → 视为未登录', e, st);
|
||||
controller.markLoggedOut();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'auth_status_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(authStatus)
|
||||
final authStatusProvider = AuthStatusProvider._();
|
||||
|
||||
final class AuthStatusProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AuthStatusController,
|
||||
AuthStatusController,
|
||||
AuthStatusController
|
||||
>
|
||||
with $Provider<AuthStatusController> {
|
||||
AuthStatusProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'authStatusProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$authStatusHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<AuthStatusController> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
AuthStatusController create(Ref ref) {
|
||||
return authStatus(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(AuthStatusController value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<AuthStatusController>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$authStatusHash() => r'ef142e4b7c9544c953e6da320fd43f0ee020da53';
|
||||
@@ -0,0 +1,188 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sunny_mochi/core/utils/validator.dart';
|
||||
import 'package:sunny_mochi/core/widgets/app_button.dart';
|
||||
import 'package:sunny_mochi/core/widgets/app_text_field.dart';
|
||||
import 'package:sunny_mochi/core/widgets/app_toast.dart';
|
||||
import 'package:sunny_mochi/core/widgets/count_down_button.dart';
|
||||
import 'package:sunny_mochi/features/auth/presentation/notifiers/auth_notifier.dart';
|
||||
|
||||
/// 通用登录页骨架 — 手机号 + 双模式(短信验证码 / 密码)。
|
||||
///
|
||||
/// TODO: 替换为项目品牌 UI(logo / 背景 / 色彩)。
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabCtrl;
|
||||
final _phoneCtrl = TextEditingController();
|
||||
final _codeCtrl = TextEditingController();
|
||||
final _passwordCtrl = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabCtrl = TabController(length: 2, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabCtrl.dispose();
|
||||
_phoneCtrl.dispose();
|
||||
_codeCtrl.dispose();
|
||||
_passwordCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _isSms => _tabCtrl.index == 0;
|
||||
|
||||
Future<void> _onSendCode() async {
|
||||
final phone = _phoneCtrl.text.trim();
|
||||
if (!Validator.isValidPhone(phone)) {
|
||||
if (mounted) {
|
||||
AppToast.show(context, '请输入有效的手机号码', type: ToastType.warning);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final echoCode = await ref
|
||||
.read(authProvider.notifier)
|
||||
.sendSmsCode(phone);
|
||||
if (echoCode != null && mounted) {
|
||||
_codeCtrl.text = echoCode; // dev/test 环境自动填入
|
||||
}
|
||||
} on Object catch (e) {
|
||||
if (mounted) {
|
||||
AppToast.show(context, e.toString(), type: ToastType.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLogin() async {
|
||||
final phone = _phoneCtrl.text.trim();
|
||||
if (!Validator.isValidPhone(phone)) {
|
||||
AppToast.show(context, '请输入有效的手机号码', type: ToastType.warning);
|
||||
return;
|
||||
}
|
||||
if (_isSms) {
|
||||
final code = _codeCtrl.text.trim();
|
||||
if (code.isEmpty) {
|
||||
AppToast.show(context, '请输入验证码', type: ToastType.warning);
|
||||
return;
|
||||
}
|
||||
await ref.read(authProvider.notifier).loginWithPhone(phone, code);
|
||||
} else {
|
||||
final pwd = _passwordCtrl.text;
|
||||
if (pwd.isEmpty) {
|
||||
AppToast.show(context, '请输入密码', type: ToastType.warning);
|
||||
return;
|
||||
}
|
||||
await ref
|
||||
.read(authProvider.notifier)
|
||||
.loginWithPassword(phone, pwd);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authState = ref.watch(authProvider);
|
||||
final isLoading = authState is AuthLoading;
|
||||
|
||||
ref.listen(authProvider, (_, next) {
|
||||
if (next is AuthError && mounted) {
|
||||
AppToast.show(context, next.message, type: ToastType.error);
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 48),
|
||||
// TODO: 替换为项目 Logo
|
||||
const FlutterLogo(size: 64),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'欢迎登录',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// 手机号
|
||||
AppTextField(
|
||||
controller: _phoneCtrl,
|
||||
label: '手机号',
|
||||
hint: '请输入手机号',
|
||||
keyboardType: TextInputType.phone,
|
||||
maxLength: 11,
|
||||
prefixIcon: const Icon(Icons.phone_outlined),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 模式切换 Tab
|
||||
TabBar(
|
||||
controller: _tabCtrl,
|
||||
onTap: (_) => setState(() {}),
|
||||
tabs: const [
|
||||
Tab(text: '短信验证码'),
|
||||
Tab(text: '密码登录'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 验证码 / 密码输入
|
||||
if (_isSms)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AppTextField(
|
||||
controller: _codeCtrl,
|
||||
label: '验证码',
|
||||
hint: '请输入验证码',
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
CountDownButton(onSend: _onSendCode),
|
||||
],
|
||||
)
|
||||
else
|
||||
AppTextField(
|
||||
controller: _passwordCtrl,
|
||||
label: '密码',
|
||||
hint: '请输入密码',
|
||||
obscureText: _obscurePassword,
|
||||
suffix: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
onPressed: () =>
|
||||
setState(() => _obscurePassword = !_obscurePassword),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
AppButton(
|
||||
label: '登录',
|
||||
onPressed: _onLogin,
|
||||
loading: isLoading,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sunny_mochi/core/observability/talker_setup.dart';
|
||||
import 'package:talker_flutter/talker_flutter.dart';
|
||||
|
||||
/// 开发者面板(TalkerScreen)— 仅在 Env.enableDevPanel 为真时可路由进入。
|
||||
///
|
||||
/// 提供日志查看 / 网络请求审阅 / 错误回放等开发期可观测性能力。
|
||||
/// 不要在 Release 主线包暴露入口(合规:用户日志可能含 PII)。
|
||||
class DevPanelPage extends StatelessWidget {
|
||||
const DevPanelPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TalkerScreen(talker: appTalker);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'device_info_service.g.dart';
|
||||
|
||||
class DeviceSnapshot {
|
||||
const DeviceSnapshot({
|
||||
required this.deviceModel,
|
||||
required this.osVersion,
|
||||
required this.appVersion,
|
||||
required this.appBuild,
|
||||
});
|
||||
|
||||
final String deviceModel;
|
||||
final String osVersion;
|
||||
final String appVersion;
|
||||
final String appBuild;
|
||||
|
||||
Map<String, String> toJson() => {
|
||||
'deviceModel': deviceModel,
|
||||
'osVersion': osVersion,
|
||||
'appVersion': appVersion,
|
||||
'appBuild': appBuild,
|
||||
};
|
||||
}
|
||||
|
||||
@riverpod
|
||||
DeviceInfoService deviceInfoService(Ref ref) => const DeviceInfoService();
|
||||
|
||||
class DeviceInfoService {
|
||||
const DeviceInfoService();
|
||||
|
||||
DeviceInfoPlugin get _plugin => DeviceInfoPlugin();
|
||||
|
||||
Future<DeviceSnapshot> snapshot() async {
|
||||
final pkg = await PackageInfo.fromPlatform();
|
||||
|
||||
if (kIsWeb) {
|
||||
return DeviceSnapshot(
|
||||
deviceModel: 'Web',
|
||||
osVersion: 'Web',
|
||||
appVersion: pkg.version,
|
||||
appBuild: pkg.buildNumber,
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final info = await _plugin.androidInfo;
|
||||
return DeviceSnapshot(
|
||||
deviceModel: '${info.manufacturer} ${info.model}',
|
||||
osVersion: 'Android ${info.version.release}',
|
||||
appVersion: pkg.version,
|
||||
appBuild: pkg.buildNumber,
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.isIOS) {
|
||||
final info = await _plugin.iosInfo;
|
||||
return DeviceSnapshot(
|
||||
deviceModel: info.model,
|
||||
osVersion: '${info.systemName} ${info.systemVersion}',
|
||||
appVersion: pkg.version,
|
||||
appBuild: pkg.buildNumber,
|
||||
);
|
||||
}
|
||||
|
||||
return DeviceSnapshot(
|
||||
deviceModel: Platform.operatingSystem,
|
||||
osVersion: Platform.operatingSystemVersion,
|
||||
appVersion: pkg.version,
|
||||
appBuild: pkg.buildNumber,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'device_info_service.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(deviceInfoService)
|
||||
final deviceInfoServiceProvider = DeviceInfoServiceProvider._();
|
||||
|
||||
final class DeviceInfoServiceProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
DeviceInfoService,
|
||||
DeviceInfoService,
|
||||
DeviceInfoService
|
||||
>
|
||||
with $Provider<DeviceInfoService> {
|
||||
DeviceInfoServiceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'deviceInfoServiceProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$deviceInfoServiceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<DeviceInfoService> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
DeviceInfoService create(Ref ref) {
|
||||
return deviceInfoService(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(DeviceInfoService value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<DeviceInfoService>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$deviceInfoServiceHash() => r'a529d8f8673a6207e1c73bc69db4555857056c57';
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:sunny_mochi/core/error/failures.dart';
|
||||
import 'package:sunny_mochi/core/storage/app_database.dart';
|
||||
import 'package:sunny_mochi/features/error_report/data/device_info_service.dart';
|
||||
|
||||
part 'error_logger.g.dart';
|
||||
|
||||
enum ErrorKind {
|
||||
network,
|
||||
server,
|
||||
auth,
|
||||
cache,
|
||||
unknown;
|
||||
|
||||
String get displayLabel => switch (this) {
|
||||
ErrorKind.network => '网络错误',
|
||||
ErrorKind.server => '接口错误',
|
||||
ErrorKind.auth => '认证错误',
|
||||
ErrorKind.cache => '本地错误',
|
||||
ErrorKind.unknown => '未知错误',
|
||||
};
|
||||
|
||||
static ErrorKind from(String name) =>
|
||||
ErrorKind.values.firstWhere((k) => k.name == name,
|
||||
orElse: () => ErrorKind.unknown);
|
||||
}
|
||||
|
||||
class ErrorEntry {
|
||||
const ErrorEntry({
|
||||
required this.id,
|
||||
required this.kind,
|
||||
required this.message,
|
||||
required this.displayMessage,
|
||||
required this.occurredAt,
|
||||
required this.reported,
|
||||
this.code,
|
||||
this.statusCode,
|
||||
});
|
||||
|
||||
factory ErrorEntry.fromRow(ErrorLogRow row) => ErrorEntry(
|
||||
id: row.id,
|
||||
kind: ErrorKind.from(row.kind),
|
||||
message: row.message,
|
||||
displayMessage: row.displayMessage,
|
||||
code: row.code,
|
||||
statusCode: row.statusCode,
|
||||
occurredAt: row.occurredAt,
|
||||
reported: row.reported,
|
||||
);
|
||||
|
||||
final int id;
|
||||
final ErrorKind kind;
|
||||
final String message;
|
||||
final String displayMessage;
|
||||
final String? code;
|
||||
final int? statusCode;
|
||||
final DateTime occurredAt;
|
||||
final bool reported;
|
||||
}
|
||||
|
||||
extension _FailureKind on Failure {
|
||||
ErrorKind get kind => switch (this) {
|
||||
NetworkFailure() => ErrorKind.network,
|
||||
ServerFailure() => ErrorKind.server,
|
||||
AuthFailure() => ErrorKind.auth,
|
||||
CacheFailure() => ErrorKind.cache,
|
||||
UnknownFailure() => ErrorKind.unknown,
|
||||
};
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
ErrorLogger errorLogger(Ref ref) => ErrorLogger(
|
||||
ref.watch(appDatabaseProvider),
|
||||
ref.watch(deviceInfoServiceProvider),
|
||||
);
|
||||
|
||||
class ErrorLogger {
|
||||
ErrorLogger(this._db, this._deviceService);
|
||||
|
||||
final AppDatabase _db;
|
||||
final DeviceInfoService _deviceService;
|
||||
DeviceSnapshot? _cachedDevice;
|
||||
static const maxEntries = 200;
|
||||
static const maxRetentionDays = 30;
|
||||
static const maxBatchUpload = 50;
|
||||
|
||||
Future<DeviceSnapshot> _device() async =>
|
||||
_cachedDevice ??= await _deviceService.snapshot();
|
||||
|
||||
Future<void> log(Failure failure) async {
|
||||
final device = await _device();
|
||||
await _db.into(_db.errorLogs).insert(
|
||||
ErrorLogsCompanion.insert(
|
||||
kind: failure.kind.name,
|
||||
message: failure.message,
|
||||
displayMessage: failure.userMessage,
|
||||
code: Value(failure is ServerFailure ? failure.code : null),
|
||||
statusCode: Value(
|
||||
failure is ServerFailure ? failure.statusCode : null,
|
||||
),
|
||||
occurredAt: DateTime.now(),
|
||||
deviceModel: Value(device.deviceModel),
|
||||
osVersion: Value(device.osVersion),
|
||||
appVersion: Value(device.appVersion),
|
||||
appBuild: Value(device.appBuild),
|
||||
),
|
||||
);
|
||||
await _prune();
|
||||
}
|
||||
|
||||
Future<void> _prune() async {
|
||||
final ids = await (_db.selectOnly(_db.errorLogs)
|
||||
..addColumns([_db.errorLogs.id])
|
||||
..orderBy([OrderingTerm.desc(_db.errorLogs.occurredAt)]))
|
||||
.map((r) => r.read(_db.errorLogs.id)!)
|
||||
.get();
|
||||
|
||||
if (ids.length > maxEntries) {
|
||||
final toDelete = ids.skip(maxEntries).toList();
|
||||
await (_db.delete(_db.errorLogs)
|
||||
..where((t) => t.id.isIn(toDelete)))
|
||||
.go();
|
||||
}
|
||||
}
|
||||
|
||||
Stream<List<ErrorEntry>> watchAll() =>
|
||||
(_db.select(_db.errorLogs)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.occurredAt)])
|
||||
..limit(100))
|
||||
.watch()
|
||||
.map((rows) => rows.map(ErrorEntry.fromRow).toList());
|
||||
|
||||
Future<void> pruneOld() async {
|
||||
final cutoff = DateTime.now().subtract(
|
||||
const Duration(days: maxRetentionDays),
|
||||
);
|
||||
await (_db.delete(_db.errorLogs)
|
||||
..where((t) => t.occurredAt.isSmallerOrEqualValue(cutoff)))
|
||||
.go();
|
||||
}
|
||||
|
||||
Future<List<ErrorLogRow>> getUnreported() =>
|
||||
(_db.select(_db.errorLogs)
|
||||
..where((t) => t.reported.equals(false))
|
||||
..orderBy([(t) => OrderingTerm.desc(t.occurredAt)])
|
||||
..limit(maxBatchUpload))
|
||||
.get();
|
||||
|
||||
Future<void> markReported(List<int> ids) async {
|
||||
if (ids.isEmpty) return;
|
||||
await (_db.update(_db.errorLogs)..where((t) => t.id.isIn(ids)))
|
||||
.write(const ErrorLogsCompanion(reported: Value(true)));
|
||||
}
|
||||
|
||||
Future<void> clearAll() => _db.delete(_db.errorLogs).go();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'error_logger.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(errorLogger)
|
||||
final errorLoggerProvider = ErrorLoggerProvider._();
|
||||
|
||||
final class ErrorLoggerProvider
|
||||
extends $FunctionalProvider<ErrorLogger, ErrorLogger, ErrorLogger>
|
||||
with $Provider<ErrorLogger> {
|
||||
ErrorLoggerProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'errorLoggerProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$errorLoggerHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<ErrorLogger> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
ErrorLogger create(Ref ref) {
|
||||
return errorLogger(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(ErrorLogger value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<ErrorLogger>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$errorLoggerHash() => r'0486bd08edcaa19da8c2242f759ab8434dfad93e';
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:sunny_mochi/core/config/api_paths.dart';
|
||||
import 'package:sunny_mochi/core/network/api_response.dart';
|
||||
import 'package:sunny_mochi/core/network/dio_client.dart';
|
||||
import 'package:sunny_mochi/core/storage/app_database.dart';
|
||||
|
||||
part 'error_report_datasource.g.dart';
|
||||
|
||||
@riverpod
|
||||
ErrorReportDatasource errorReportDatasource(Ref ref) =>
|
||||
ErrorReportDatasource(ref.watch(dioClientProvider));
|
||||
|
||||
class ErrorReportDatasource {
|
||||
const ErrorReportDatasource(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
Future<void> sendReport(List<ErrorLogRow> logs) async {
|
||||
final resp = await _dio.post<Map<String, dynamic>>(
|
||||
ApiPaths.errorReport,
|
||||
data: {
|
||||
'entries': logs
|
||||
.map(
|
||||
(l) => {
|
||||
'kind': l.kind,
|
||||
'message': l.message,
|
||||
'displayMessage': l.displayMessage,
|
||||
if (l.code != null) 'code': l.code,
|
||||
if (l.statusCode != null) 'statusCode': l.statusCode,
|
||||
'occurredAt': l.occurredAt.toUtc().toIso8601String(),
|
||||
if (l.deviceModel != null) 'deviceModel': l.deviceModel,
|
||||
if (l.osVersion != null) 'osVersion': l.osVersion,
|
||||
if (l.appVersion != null) 'appVersion': l.appVersion,
|
||||
if (l.appBuild != null) 'appBuild': l.appBuild,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
},
|
||||
);
|
||||
parseEnvelope(resp.data, (j) => j).unwrapVoid();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'error_report_datasource.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(errorReportDatasource)
|
||||
final errorReportDatasourceProvider = ErrorReportDatasourceProvider._();
|
||||
|
||||
final class ErrorReportDatasourceProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
ErrorReportDatasource,
|
||||
ErrorReportDatasource,
|
||||
ErrorReportDatasource
|
||||
>
|
||||
with $Provider<ErrorReportDatasource> {
|
||||
ErrorReportDatasourceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'errorReportDatasourceProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$errorReportDatasourceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<ErrorReportDatasource> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
ErrorReportDatasource create(Ref ref) {
|
||||
return errorReportDatasource(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(ErrorReportDatasource value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<ErrorReportDatasource>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$errorReportDatasourceHash() =>
|
||||
r'2a3077bdcb8d59bad619607dcea7dea278b382a0';
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:sunny_mochi/features/error_report/data/error_logger.dart';
|
||||
import 'package:sunny_mochi/features/error_report/data/error_report_datasource.dart';
|
||||
|
||||
part 'error_report_notifier.g.dart';
|
||||
|
||||
@riverpod
|
||||
Stream<List<ErrorEntry>> errorLogs(Ref ref) =>
|
||||
ref.watch(errorLoggerProvider).watchAll();
|
||||
|
||||
@riverpod
|
||||
class ErrorReportSender extends _$ErrorReportSender {
|
||||
@override
|
||||
AsyncValue<bool> build() => const AsyncData(true);
|
||||
|
||||
Future<void> send() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final logger = ref.read(errorLoggerProvider);
|
||||
final logs = await logger.getUnreported();
|
||||
if (logs.isEmpty) return true;
|
||||
await ref.read(errorReportDatasourceProvider).sendReport(logs);
|
||||
await logger.markReported(logs.map((l) => l.id).toList());
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> clearAll() async {
|
||||
await ref.read(errorLoggerProvider).clearAll();
|
||||
state = const AsyncData(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'error_report_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(errorLogs)
|
||||
final errorLogsProvider = ErrorLogsProvider._();
|
||||
|
||||
final class ErrorLogsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<ErrorEntry>>,
|
||||
List<ErrorEntry>,
|
||||
Stream<List<ErrorEntry>>
|
||||
>
|
||||
with $FutureModifier<List<ErrorEntry>>, $StreamProvider<List<ErrorEntry>> {
|
||||
ErrorLogsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'errorLogsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$errorLogsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$StreamProviderElement<List<ErrorEntry>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $StreamProviderElement(pointer);
|
||||
|
||||
@override
|
||||
Stream<List<ErrorEntry>> create(Ref ref) {
|
||||
return errorLogs(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$errorLogsHash() => r'16329046d2b056a007f56835c40e43664ba87533';
|
||||
|
||||
@ProviderFor(ErrorReportSender)
|
||||
final errorReportSenderProvider = ErrorReportSenderProvider._();
|
||||
|
||||
final class ErrorReportSenderProvider
|
||||
extends $NotifierProvider<ErrorReportSender, AsyncValue<bool>> {
|
||||
ErrorReportSenderProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'errorReportSenderProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$errorReportSenderHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
ErrorReportSender create() => ErrorReportSender();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(AsyncValue<bool> value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<AsyncValue<bool>>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$errorReportSenderHash() => r'12c474e98289540bc9307911a2e40049ef49b449';
|
||||
|
||||
abstract class _$ErrorReportSender extends $Notifier<AsyncValue<bool>> {
|
||||
AsyncValue<bool> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<AsyncValue<bool>, AsyncValue<bool>>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<bool>, AsyncValue<bool>>,
|
||||
AsyncValue<bool>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:sunny_mochi/core/extensions/context_x.dart';
|
||||
import 'package:sunny_mochi/core/widgets/app_toast.dart';
|
||||
import 'package:sunny_mochi/features/error_report/data/error_logger.dart';
|
||||
import 'package:sunny_mochi/features/error_report/presentation/error_report_notifier.dart';
|
||||
|
||||
class ErrorReportPage extends ConsumerWidget {
|
||||
const ErrorReportPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final logsAsync = ref.watch(errorLogsProvider);
|
||||
final sender = ref.watch(errorReportSenderProvider);
|
||||
|
||||
ref.listen(errorReportSenderProvider, (prev, next) {
|
||||
if (next is AsyncError) {
|
||||
context.showError(ref, next.error ?? Exception('未知错误'),
|
||||
next.stackTrace ?? StackTrace.empty);
|
||||
} else if (next is AsyncData && prev is AsyncLoading) {
|
||||
context.showToast('上报成功,感谢您的反馈', type: ToastType.success);
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('错误报告'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: sender.isLoading
|
||||
? null
|
||||
: () =>
|
||||
ref.read(errorReportSenderProvider.notifier).clearAll(),
|
||||
child: Text(
|
||||
'清除',
|
||||
style: TextStyle(
|
||||
color: sender.isLoading
|
||||
? const Color(0xFFAEAEB2)
|
||||
: const Color(0xFFFF3B30),
|
||||
fontSize: 15.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: logsAsync.when(
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator.adaptive()),
|
||||
error: (e, _) => Center(child: Text('$e')),
|
||||
data: (logs) =>
|
||||
logs.isEmpty ? const _EmptyState() : _LogList(logs: logs),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, 16.h),
|
||||
child: FilledButton(
|
||||
onPressed: sender.isLoading
|
||||
? null
|
||||
: () => ref.read(errorReportSenderProvider.notifier).send(),
|
||||
style: FilledButton.styleFrom(minimumSize: Size.fromHeight(50.h)),
|
||||
child: sender.isLoading
|
||||
? SizedBox(
|
||||
width: 20.w,
|
||||
height: 20.w,
|
||||
child: const CircularProgressIndicator.adaptive(
|
||||
strokeWidth: 2),
|
||||
)
|
||||
: const Text('一键发送错误报告'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LogList extends StatelessWidget {
|
||||
const _LogList({required this.logs});
|
||||
final List<ErrorEntry> logs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.separated(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.h),
|
||||
itemCount: logs.length,
|
||||
separatorBuilder: (_, _) => Divider(
|
||||
height: 1,
|
||||
indent: 16.w,
|
||||
endIndent: 16.w,
|
||||
color: const Color(0xFFE5E5EA),
|
||||
),
|
||||
itemBuilder: (_, i) => _LogTile(log: logs[i]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LogTile extends StatelessWidget {
|
||||
const _LogTile({required this.log});
|
||||
final ErrorEntry log;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (color, bg) = switch (log.kind) {
|
||||
ErrorKind.network =>
|
||||
(const Color(0xFFFF9F0A), const Color(0xFFFFF3E0)),
|
||||
ErrorKind.server =>
|
||||
(const Color(0xFFFF3B30), const Color(0xFFFFEBEB)),
|
||||
ErrorKind.auth =>
|
||||
(const Color(0xFF6B63D9), const Color(0xFFF0EFFF)),
|
||||
ErrorKind.cache =>
|
||||
(const Color(0xFF34C759), const Color(0xFFE8F8ED)),
|
||||
ErrorKind.unknown =>
|
||||
(const Color(0xFF8E8E93), const Color(0xFFF2F2F7)),
|
||||
};
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
),
|
||||
child: Text(
|
||||
log.kind.displayLabel,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(log.displayMessage,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF252535),
|
||||
fontWeight: FontWeight.w500)),
|
||||
SizedBox(height: 4.h),
|
||||
Text(log.message,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp, color: const Color(0xFF8E8E93))),
|
||||
SizedBox(height: 4.h),
|
||||
Text(_fmt(log.occurredAt),
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp, color: const Color(0xFFAEAEB2))),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (log.reported)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 8.w),
|
||||
child: Icon(Icons.check_circle_outline_rounded,
|
||||
size: 16.sp, color: const Color(0xFF34C759)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _fmt(DateTime dt) {
|
||||
final t = dt.toLocal();
|
||||
return '${t.year}-${t.month.toString().padLeft(2, '0')}-'
|
||||
'${t.day.toString().padLeft(2, '0')} '
|
||||
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle_outline_rounded,
|
||||
size: 64.sp, color: const Color(0xFF34C759)),
|
||||
SizedBox(height: 16.h),
|
||||
Text('暂无错误记录',
|
||||
style: TextStyle(fontSize: 16.sp, color: const Color(0xFF8E8E93))),
|
||||
SizedBox(height: 8.h),
|
||||
Text('应用运行正常',
|
||||
style: TextStyle(fontSize: 14.sp, color: const Color(0xFFAEAEB2))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user