This commit is contained in:
2026-06-03 14:07:10 +08:00
parent 3bdece45c3
commit 9eb8d1cc37
118 changed files with 5689 additions and 2 deletions

68
lib/core/cache/app_storage.dart vendored Normal file
View File

@@ -0,0 +1,68 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class AppStorage {
AppStorage._();
static SharedPreferences? _prefs;
static Future<void> init() async {
_prefs ??= await SharedPreferences.getInstance();
}
static SharedPreferences get instance {
final prefs = _prefs;
if (prefs == null) {
throw StateError('AppStorage.init() must be called before use.');
}
return prefs;
}
static String? getString(String key) => instance.getString(key);
static Future<bool> setString(String key, String value) {
return instance.setString(key, value);
}
static int getInt(String key, {int defaultValue = 0}) {
return instance.getInt(key) ?? defaultValue;
}
static Future<bool> setInt(String key, int value) {
return instance.setInt(key, value);
}
static bool getBool(String key, {bool defaultValue = false}) {
return instance.getBool(key) ?? defaultValue;
}
static Future<bool> setBool(String key, bool value) {
return instance.setBool(key, value);
}
static List<String> getStringList(String key) {
return instance.getStringList(key) ?? const [];
}
static Future<bool> setStringList(String key, List<String> value) {
return instance.setStringList(key, value);
}
static Map<String, dynamic>? getJson(String key) {
final raw = instance.getString(key);
if (raw == null || raw.isEmpty) return null;
final decoded = jsonDecode(raw);
return decoded is Map<String, dynamic> ? decoded : null;
}
static Future<bool> setJson(String key, Map<String, dynamic> value) {
return instance.setString(key, jsonEncode(value));
}
static bool containsKey(String key) => instance.containsKey(key);
static Future<bool> remove(String key) => instance.remove(key);
static Future<bool> clear() => instance.clear();
}

9
lib/core/cache/storage_keys.dart vendored Normal file
View File

@@ -0,0 +1,9 @@
class StorageKeys {
StorageKeys._();
static const authToken = 'auth_token';
static const locale = 'locale';
static const themeMode = 'theme_mode';
static const offlineQueue = 'offline_queue';
static const offlineDeadQueue = 'offline_dead_queue';
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
extension BuildContextX on BuildContext {
ThemeData get theme => Theme.of(this);
ColorScheme get colors => theme.colorScheme;
TextTheme get textTheme => theme.textTheme;
Size get screenSize => MediaQuery.sizeOf(this);
EdgeInsets get safePadding => MediaQuery.paddingOf(this);
bool get isDarkMode => theme.brightness == Brightness.dark;
bool get isKeyboardVisible => MediaQuery.viewInsetsOf(this).bottom > 0;
void hideKeyboard() {
FocusManager.instance.primaryFocus?.unfocus();
}
}

View File

@@ -0,0 +1,20 @@
import 'package:flutter/foundation.dart';
class AppLogger {
AppLogger._();
static void debug(Object? message, {Object? error, StackTrace? stackTrace}) {
if (!kDebugMode) return;
debugPrint('[DEBUG] $message');
if (error != null) debugPrint('[ERROR] $error');
if (stackTrace != null) debugPrint(stackTrace.toString());
}
static void info(Object? message) {
if (kDebugMode) debugPrint('[INFO] $message');
}
static void warning(Object? message) {
if (kDebugMode) debugPrint('[WARN] $message');
}
}

View File

@@ -0,0 +1,20 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
mixin StreamSubscriptionMixin<T extends StatefulWidget> on State<T> {
final List<StreamSubscription<dynamic>> _subscriptions = [];
void addSubscription(StreamSubscription<dynamic> subscription) {
_subscriptions.add(subscription);
}
@override
void dispose() {
for (final subscription in _subscriptions) {
subscription.cancel();
}
_subscriptions.clear();
super.dispose();
}
}

View File

@@ -0,0 +1,126 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_template/core/network/api_exception.dart';
import 'package:flutter_template/core/network/api_response.dart';
import 'package:flutter_template/core/network/http_method.dart';
typedef JsonParser<T> = T Function(dynamic json);
class ApiClient {
const ApiClient(this._dio);
final Dio _dio;
Future<T> request<T>(
String path, {
HttpMethod method = HttpMethod.get,
Map<String, dynamic>? queryParameters,
Object? data,
Map<String, dynamic>? headers,
JsonParser<T>? parser,
bool wrapResponse = true,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
}) async {
try {
final response = await _dio.request<dynamic>(
path,
queryParameters: queryParameters,
data: data,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
options: Options(method: method.value, headers: headers),
);
final raw = _decodeIfNeeded(response.data);
if (!wrapResponse) {
return _parseData<T>(raw, parser);
}
if (raw is! Map<String, dynamic>) {
return _parseData<T>(raw, parser);
}
final wrapped = ApiResponse<T>.fromJson(raw, fromJsonT: parser);
if (!wrapped.isSuccess) {
throw ApiException(
code: wrapped.code,
statusCode: response.statusCode,
message: wrapped.message.isEmpty ? '请求失败' : wrapped.message,
details: raw,
);
}
return wrapped.data as T;
} on DioException catch (error) {
throw _mapDioException(error);
}
}
Future<T> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
JsonParser<T>? parser,
bool wrapResponse = true,
}) {
return request<T>(
path,
method: HttpMethod.get,
queryParameters: queryParameters,
parser: parser,
wrapResponse: wrapResponse,
);
}
Future<T> post<T>(
String path, {
Object? data,
JsonParser<T>? parser,
bool wrapResponse = true,
}) {
return request<T>(
path,
method: HttpMethod.post,
data: data,
parser: parser,
wrapResponse: wrapResponse,
);
}
dynamic _decodeIfNeeded(dynamic data) {
if (data is String) {
try {
return jsonDecode(data);
} catch (_) {
return data;
}
}
return data;
}
T _parseData<T>(dynamic data, JsonParser<T>? parser) {
if (parser != null) return parser(data);
return data as T;
}
ApiException _mapDioException(DioException error) {
final statusCode = error.response?.statusCode;
final message = switch (error.type) {
DioExceptionType.connectionTimeout => '网络连接超时',
DioExceptionType.sendTimeout => '请求发送超时',
DioExceptionType.receiveTimeout => '响应接收超时',
DioExceptionType.badCertificate => '证书校验失败',
DioExceptionType.badResponse => '服务器响应异常',
DioExceptionType.cancel => '请求已取消',
DioExceptionType.connectionError => '网络连接不可用',
DioExceptionType.unknown => '网络请求失败',
};
return ApiException(
message: message,
statusCode: statusCode,
details: error,
);
}
}

View File

@@ -0,0 +1,23 @@
class ApiException implements Exception {
const ApiException({
required this.message,
this.code,
this.statusCode,
this.details,
});
final String message;
final int? code;
final int? statusCode;
final Object? details;
@override
String toString() {
final parts = <String>[
if (statusCode != null) 'status=$statusCode',
if (code != null) 'code=$code',
message,
];
return 'ApiException(${parts.join(', ')})';
}
}

View File

@@ -0,0 +1,49 @@
class ApiResponse<T> {
const ApiResponse({required this.code, required this.message, this.data});
final int code;
final String message;
final T? data;
bool get isSuccess => code >= 200 && code < 300;
factory ApiResponse.fromJson(
Map<String, dynamic> json, {
T Function(dynamic json)? fromJsonT,
}) {
return ApiResponse<T>(
code: (json['code'] as num?)?.toInt() ?? 200,
message: (json['message'] ?? json['msg'] ?? '').toString(),
data: fromJsonT == null ? json['data'] as T? : fromJsonT(json['data']),
);
}
}
class PageResponse<T> {
const PageResponse({
required this.items,
required this.total,
this.page,
this.pageSize,
});
final List<T> items;
final int total;
final int? page;
final int? pageSize;
bool get isEmpty => items.isEmpty;
factory PageResponse.fromJson(
Map<String, dynamic> json, {
required T Function(dynamic json) fromJsonT,
}) {
final rawItems = json['items'] ?? json['rows'] ?? json['list'] ?? const [];
return PageResponse<T>(
items: rawItems is List ? rawItems.map(fromJsonT).toList() : <T>[],
total: (json['total'] as num?)?.toInt() ?? 0,
page: (json['page'] as num?)?.toInt(),
pageSize: (json['pageSize'] as num?)?.toInt(),
);
}
}

View File

@@ -0,0 +1,31 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter_template/app/config/app_config.dart';
import 'package:flutter_template/core/cache/app_storage.dart';
import 'package:flutter_template/core/cache/storage_keys.dart';
import 'package:flutter_template/core/utils/device_utils.dart';
class HeaderInterceptor extends Interceptor {
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final token = AppStorage.getString(StorageKeys.authToken);
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
final packageInfo = AppConfig.packageInfo;
final device = await DeviceUtils.deviceInfo();
options.headers.addAll({
'platform': Platform.operatingSystem,
if (packageInfo != null) 'appVersion': packageInfo.version,
'environment': AppConfig.current.environment.name,
...device,
});
handler.next(options);
}
}

View File

@@ -0,0 +1,11 @@
enum HttpMethod {
get('GET'),
post('POST'),
put('PUT'),
patch('PATCH'),
delete('DELETE');
const HttpMethod(this.value);
final String value;
}

View File

@@ -0,0 +1,58 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_template/core/network/network_state.dart';
class NetworkMonitor {
final _controller = StreamController<NetworkState>.broadcast();
final _connectivity = Connectivity();
StreamSubscription<List<ConnectivityResult>>? _subscription;
NetworkState _state = const NetworkState(Reachability.unknown);
Stream<NetworkState> get stream => _controller.stream;
NetworkState get current => _state;
void start() {
try {
_subscription = _connectivity.onConnectivityChanged.listen(
(results) => _setOnline(_isOnline(results)),
onError: (Object error, StackTrace stackTrace) {
debugPrint('[NetworkMonitor] connectivity error: $error');
},
);
_connectivity.checkConnectivity().then(
(results) => _setOnline(_isOnline(results)),
onError: (Object error, StackTrace stackTrace) {
debugPrint('[NetworkMonitor] check error: $error');
},
);
} on MissingPluginException catch (error) {
debugPrint('[NetworkMonitor] plugin unavailable: $error');
}
}
bool _isOnline(List<ConnectivityResult> results) {
if (results.isEmpty) return false;
if (results.contains(ConnectivityResult.none)) return false;
return true;
}
void _setOnline(bool online) {
final next = NetworkState(
online ? Reachability.online : Reachability.offline,
);
if (next.reachability == _state.reachability) return;
_state = next;
_controller.add(_state);
}
void dispose() {
_subscription?.cancel();
_controller.close();
}
}

View File

@@ -0,0 +1,11 @@
enum Reachability { unknown, online, offline }
class NetworkState {
const NetworkState(this.reachability);
final Reachability reachability;
bool get isOnline => reachability == Reachability.online;
bool get isOffline => reachability == Reachability.offline;
bool get isUnknown => reachability == Reachability.unknown;
}

View File

@@ -0,0 +1,61 @@
import 'package:dio/dio.dart';
import 'package:flutter_template/core/network/network_monitor.dart';
import 'package:flutter_template/core/network/offline_queue/offline_queue_manager.dart';
import 'package:flutter_template/core/network/offline_queue/offline_request.dart';
import 'package:uuid/uuid.dart';
class OfflineQueueInterceptor extends Interceptor {
OfflineQueueInterceptor({
required this.monitor,
required this.manager,
this.enabled = false,
});
final NetworkMonitor monitor;
final OfflineQueueManager manager;
final bool enabled;
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if (!enabled || options.extra['offline'] != true) {
handler.next(options);
return;
}
if (options.extra['replay'] == true || monitor.current.isUnknown) {
handler.next(options);
return;
}
if (monitor.current.isOffline) {
final extra = options.extra;
final request = OfflineRequest(
id: const Uuid().v4(),
idempotencyKey: (extra['idempotencyKey'] ?? const Uuid().v4())
.toString(),
method: options.method,
path: options.path,
query: options.queryParameters,
body: options.data,
headers: Map<String, dynamic>.from(options.headers),
priority: QueuePriority
.values[(extra['priority'] as int?) ?? QueuePriority.normal.index],
category:
QueueCategory.values[(extra['category'] as int?) ??
QueueCategory.userAction.index],
);
manager.enqueue(request);
handler.reject(
DioException(
requestOptions: options,
type: DioExceptionType.cancel,
message: 'OFFLINE_QUEUED',
),
);
return;
}
handler.next(options);
}
}

View File

@@ -0,0 +1,141 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter_template/core/logging/app_logger.dart';
import 'package:flutter_template/core/network/network_monitor.dart';
import 'package:flutter_template/core/network/offline_queue/offline_queue_state.dart';
import 'package:flutter_template/core/network/offline_queue/offline_queue_storage.dart';
import 'package:flutter_template/core/network/offline_queue/offline_request.dart';
class OfflineQueueManager {
OfflineQueueManager({
required this.dio,
required this.monitor,
required this.storage,
});
final Dio dio;
final NetworkMonitor monitor;
final OfflineQueueStorage storage;
final _queue = <OfflineRequest>[];
final _dead = <OfflineRequest>[];
final _stateController = StreamController<OfflineQueueState>.broadcast();
StreamSubscription<dynamic>? _networkSubscription;
Timer? _replayDebounceTimer;
bool _replaying = false;
Stream<OfflineQueueState> get stateStream => _stateController.stream;
Future<void> init() async {
_queue.addAll(await storage.loadQueue());
_dead.addAll(await storage.loadDead());
_sort();
_emit();
_networkSubscription = monitor.stream.listen((state) {
if (state.isOffline) {
_replayDebounceTimer?.cancel();
return;
}
if (state.isOnline) {
_replayDebounceTimer?.cancel();
_replayDebounceTimer = Timer(const Duration(seconds: 2), replay);
}
});
}
Future<void> enqueue(OfflineRequest request) async {
if (_isDuplicate(request)) return;
_queue.add(request);
_sort();
await storage.saveQueue(_queue);
_emit();
}
Future<void> replay() async {
if (!monitor.current.isOnline || _replaying || _queue.isEmpty) return;
_replaying = true;
_emit();
try {
while (_queue.isNotEmpty && monitor.current.isOnline) {
final request = _queue.first;
try {
await dio.request<dynamic>(
request.path,
data: request.body,
queryParameters: request.query,
options: Options(
method: request.method,
headers: request.headers,
extra: const {'replay': true, 'offline': false},
),
);
_queue.removeAt(0);
await storage.saveQueue(_queue);
_emit();
} catch (error, stackTrace) {
AppLogger.debug(
'Offline replay failed: ${request.method} ${request.path}',
error: error,
stackTrace: stackTrace,
);
final updated = request.copyWith(retryCount: request.retryCount + 1);
_queue.removeAt(0);
if (updated.isDead) {
_dead.add(updated);
await storage.saveDead(_dead);
} else {
_queue.add(updated);
_sort();
}
await storage.saveQueue(_queue);
_emit();
break;
}
}
} finally {
_replaying = false;
_emit();
}
}
bool _isDuplicate(OfflineRequest request) {
return _queue.any(
(item) =>
(request.idempotencyKey.isNotEmpty &&
item.idempotencyKey == request.idempotencyKey) ||
(item.method == request.method && item.path == request.path),
);
}
void _sort() {
_queue.sort((a, b) {
final priority = a.priority.index.compareTo(b.priority.index);
return priority != 0
? priority
: a.category.index.compareTo(b.category.index);
});
}
void _emit() {
_stateController.add(
OfflineQueueState(
pending: _queue.length,
dead: _dead.length,
replaying: _replaying,
),
);
}
void dispose() {
_replayDebounceTimer?.cancel();
_networkSubscription?.cancel();
_stateController.close();
}
}

View File

@@ -0,0 +1,13 @@
class OfflineQueueState {
const OfflineQueueState({
required this.pending,
required this.dead,
required this.replaying,
});
final int pending;
final int dead;
final bool replaying;
static const empty = OfflineQueueState(pending: 0, dead: 0, replaying: false);
}

View File

@@ -0,0 +1,29 @@
import 'package:flutter_template/core/cache/app_storage.dart';
import 'package:flutter_template/core/cache/storage_keys.dart';
import 'package:flutter_template/core/network/offline_queue/offline_request.dart';
class OfflineQueueStorage {
Future<List<OfflineRequest>> loadQueue() async {
final list = AppStorage.getStringList(StorageKeys.offlineQueue);
return list.map(OfflineRequest.decode).toList();
}
Future<List<OfflineRequest>> loadDead() async {
final list = AppStorage.getStringList(StorageKeys.offlineDeadQueue);
return list.map(OfflineRequest.decode).toList();
}
Future<void> saveQueue(List<OfflineRequest> list) async {
await AppStorage.setStringList(
StorageKeys.offlineQueue,
list.map((item) => item.encode()).toList(),
);
}
Future<void> saveDead(List<OfflineRequest> list) async {
await AppStorage.setStringList(
StorageKeys.offlineDeadQueue,
list.map((item) => item.encode()).toList(),
);
}
}

View File

@@ -0,0 +1,89 @@
import 'dart:convert';
enum QueuePriority { high, normal, low }
enum QueueCategory { userAction, sync, analytics, log }
class OfflineRequest {
const OfflineRequest({
required this.id,
required this.idempotencyKey,
required this.method,
required this.path,
this.query,
this.body,
this.headers,
this.priority = QueuePriority.normal,
this.category = QueueCategory.sync,
this.retryCount = 0,
this.maxRetry = 3,
});
final String id;
final String idempotencyKey;
final String method;
final String path;
final Map<String, dynamic>? query;
final dynamic body;
final Map<String, dynamic>? headers;
final QueuePriority priority;
final QueueCategory category;
final int retryCount;
final int maxRetry;
bool get isDead => retryCount >= maxRetry;
OfflineRequest copyWith({int? retryCount}) {
return OfflineRequest(
id: id,
idempotencyKey: idempotencyKey,
method: method,
path: path,
query: query,
body: body,
headers: headers,
priority: priority,
category: category,
retryCount: retryCount ?? this.retryCount,
maxRetry: maxRetry,
);
}
factory OfflineRequest.fromJson(Map<String, dynamic> json) {
return OfflineRequest(
id: json['id'] as String,
idempotencyKey: json['idempotencyKey'] as String,
method: json['method'] as String,
path: json['path'] as String,
query: (json['query'] as Map?)?.cast<String, dynamic>(),
body: json['body'],
headers: (json['headers'] as Map?)?.cast<String, dynamic>(),
priority: QueuePriority.values[(json['priority'] as num).toInt()],
category: QueueCategory.values[(json['category'] as num).toInt()],
retryCount: (json['retryCount'] as num).toInt(),
maxRetry: (json['maxRetry'] as num).toInt(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'idempotencyKey': idempotencyKey,
'method': method,
'path': path,
'query': query,
'body': body,
'headers': headers,
'priority': priority.index,
'category': category.index,
'retryCount': retryCount,
'maxRetry': maxRetry,
};
}
String encode() => jsonEncode(toJson());
static OfflineRequest decode(String raw) {
return OfflineRequest.fromJson(jsonDecode(raw) as Map<String, dynamic>);
}
}

View File

@@ -0,0 +1,42 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_template/app/config/app_config.dart';
import 'package:flutter_template/core/network/api_client.dart';
import 'package:flutter_template/core/network/header_interceptor.dart';
import 'package:flutter_template/core/network/offline_queue/offline_queue_interceptor.dart';
import 'package:flutter_template/core/network/providers/network_providers.dart';
import 'package:flutter_template/core/network/providers/offline_queue_providers.dart';
final dioProvider = Provider<Dio>((ref) {
final dio = Dio(
BaseOptions(
baseUrl: AppConfig.current.baseUrl,
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(seconds: 30),
responseType: ResponseType.json,
),
);
dio.interceptors.add(HeaderInterceptor());
final monitor = ref.watch(networkMonitorProvider);
final queueManager = ref.watch(offlineQueueManagerProvider);
dio.interceptors.add(
OfflineQueueInterceptor(
monitor: monitor,
manager: queueManager,
enabled: false,
),
);
if (AppConfig.current.enableNetworkLog) {
dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true));
}
return dio;
});
final apiClientProvider = Provider<ApiClient>((ref) {
return ApiClient(ref.watch(dioProvider));
});

View File

@@ -0,0 +1,14 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_template/core/network/network_monitor.dart';
import 'package:flutter_template/core/network/network_state.dart';
final networkMonitorProvider = Provider<NetworkMonitor>((ref) {
final monitor = NetworkMonitor()..start();
ref.onDispose(monitor.dispose);
return monitor;
});
final networkStateProvider = StreamProvider<NetworkState>((ref) {
final monitor = ref.watch(networkMonitorProvider);
return monitor.stream;
});

View File

@@ -0,0 +1,29 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_template/core/network/offline_queue/offline_queue_manager.dart';
import 'package:flutter_template/core/network/offline_queue/offline_queue_state.dart';
import 'package:flutter_template/core/network/offline_queue/offline_queue_storage.dart';
import 'package:flutter_template/core/network/providers/network_providers.dart';
import 'package:dio/dio.dart';
final offlineQueueStorageProvider = Provider<OfflineQueueStorage>((ref) {
return OfflineQueueStorage();
});
final offlineQueueDioProvider = Provider<Dio>((ref) {
return Dio();
});
final offlineQueueManagerProvider = Provider<OfflineQueueManager>((ref) {
final manager = OfflineQueueManager(
dio: ref.watch(offlineQueueDioProvider),
monitor: ref.watch(networkMonitorProvider),
storage: ref.watch(offlineQueueStorageProvider),
);
manager.init();
ref.onDispose(manager.dispose);
return manager;
});
final offlineQueueStateProvider = StreamProvider<OfflineQueueState>((ref) {
return ref.watch(offlineQueueManagerProvider).stateStream;
});

View File

@@ -0,0 +1,33 @@
import 'package:permission_handler/permission_handler.dart';
class PermissionService {
PermissionService._();
static Future<bool> request(Permission permission) async {
final status = await permission.status;
if (status.isGranted || status.isLimited) return true;
final next = await permission.request();
return next.isGranted || next.isLimited;
}
static Future<Map<Permission, PermissionStatus>> requestAll(
Iterable<Permission> permissions,
) {
return permissions.toList().request();
}
static Future<bool> ensure(
Permission permission, {
bool openSettingsWhenPermanentlyDenied = true,
}) async {
final granted = await request(permission);
if (granted) return true;
final status = await permission.status;
if (openSettingsWhenPermanentlyDenied && status.isPermanentlyDenied) {
await openAppSettings();
}
return false;
}
}

View File

@@ -0,0 +1,34 @@
import 'package:intl/intl.dart';
class DateTimeFormatter {
DateTimeFormatter._();
static String format(
DateTime? value, {
String pattern = 'yyyy-MM-dd HH:mm',
String locale = 'zh_CN',
}) {
if (value == null) return '';
return DateFormat(pattern, locale).format(value);
}
static String dayOrDate(String? isoString, {DateTime? now}) {
if (isoString == null || isoString.trim().isEmpty) return '';
final parsed = DateTime.tryParse(isoString)?.toLocal();
if (parsed == null) return '';
final current = (now ?? DateTime.now()).toLocal();
final currentDate = DateTime(current.year, current.month, current.day);
final parsedDate = DateTime(parsed.year, parsed.month, parsed.day);
final diffDays = currentDate.difference(parsedDate).inDays;
final time = DateFormat('HH:mm').format(parsed);
return switch (diffDays) {
0 => '今天 $time',
1 => '昨天 $time',
2 => '前天 $time',
_ => DateFormat('yyyy-MM-dd').format(parsed),
};
}
}

View File

@@ -0,0 +1,54 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
class DeviceUtils {
DeviceUtils._();
static double screenWidth(BuildContext context) =>
MediaQuery.sizeOf(context).width;
static double screenHeight(BuildContext context) =>
MediaQuery.sizeOf(context).height;
static double topSafePadding(BuildContext context) =>
MediaQuery.paddingOf(context).top;
static double bottomSafePadding(BuildContext context) =>
MediaQuery.paddingOf(context).bottom;
static Future<bool> isPhysicalDevice() async {
final plugin = DeviceInfoPlugin();
if (Platform.isAndroid) {
return (await plugin.androidInfo).isPhysicalDevice;
}
if (Platform.isIOS) {
return (await plugin.iosInfo).isPhysicalDevice;
}
return true;
}
static Future<Map<String, String>> deviceInfo() async {
final plugin = DeviceInfoPlugin();
if (Platform.isAndroid) {
final info = await plugin.androidInfo;
return {
'platform': 'android',
'brand': info.brand,
'model': info.model,
'systemVersion': info.version.release,
};
}
if (Platform.isIOS) {
final info = await plugin.iosInfo;
return {
'platform': 'ios',
'brand': info.systemName,
'model': info.utsname.machine,
'systemVersion': info.systemVersion,
};
}
return {'platform': Platform.operatingSystem};
}
}

View File

@@ -0,0 +1,25 @@
class FormValidators {
FormValidators._();
static String? required(String? value, {String message = '请输入内容'}) {
if (value == null || value.trim().isEmpty) return message;
return null;
}
static String? email(String? value, {String message = '请输入有效邮箱'}) {
if (value == null || value.trim().isEmpty) return null;
final regex = RegExp(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$');
return regex.hasMatch(value.trim()) ? null : message;
}
static String? phoneCN(String? value, {String message = '请输入有效手机号'}) {
if (value == null || value.trim().isEmpty) return null;
final regex = RegExp(r'^1[3-9]\d{9}$');
return regex.hasMatch(value.trim()) ? null : message;
}
static String? minLength(String? value, int min, {String? message}) {
if (value == null || value.isEmpty) return null;
return value.length >= min ? null : message ?? '至少输入 $min 个字符';
}
}

View File

@@ -0,0 +1,166 @@
import 'dart:async';
enum RateLimitMode { debounce, throttle }
class ThrottleOptions {
const ThrottleOptions({this.leading = true, this.trailing = true});
final bool leading;
final bool trailing;
}
class DebounceThrottle<T> {
DebounceThrottle({
required this.duration,
required this.mode,
this.onCallback,
this.throttleOptions = const ThrottleOptions(),
});
final Duration duration;
final RateLimitMode mode;
final void Function(T value)? onCallback;
final ThrottleOptions throttleOptions;
Timer? _timer;
T? _lastValue;
DateTime? _lastExecuteTime;
bool _waitingTrailing = false;
void call(T value) {
_lastValue = value;
switch (mode) {
case RateLimitMode.debounce:
_debounce(value);
case RateLimitMode.throttle:
_throttle(value);
}
}
void _debounce(T value) {
_timer?.cancel();
_timer = Timer(duration, () {
onCallback?.call(value);
_lastExecuteTime = DateTime.now();
});
}
void _throttle(T value) {
final now = DateTime.now();
final last = _lastExecuteTime;
final inWindow = last != null && now.difference(last) < duration;
if (!inWindow && throttleOptions.leading) {
onCallback?.call(value);
_lastExecuteTime = now;
_waitingTrailing = false;
_timer?.cancel();
return;
}
if (!throttleOptions.trailing || _waitingTrailing) return;
_waitingTrailing = true;
final remaining = last == null ? duration : duration - now.difference(last);
_timer?.cancel();
_timer = Timer(remaining, () {
final value = _lastValue;
if (value != null) onCallback?.call(value);
_lastExecuteTime = DateTime.now();
_waitingTrailing = false;
});
}
void flush() {
_timer?.cancel();
final value = _lastValue;
if (value != null) {
onCallback?.call(value);
_lastExecuteTime = DateTime.now();
}
_lastValue = null;
_waitingTrailing = false;
}
void cancel() {
_timer?.cancel();
_lastValue = null;
_waitingTrailing = false;
}
void dispose() => cancel();
}
class RateLimitHub {
RateLimitHub({this.removeDebouncerOnDone = true});
final bool removeDebouncerOnDone;
final Map<Object, DebounceThrottle<dynamic>> _debouncers = {};
final Map<Object, DebounceThrottle<dynamic>> _throttlers = {};
void debounce<T>({
required Object key,
required T value,
Duration duration = const Duration(milliseconds: 300),
required void Function(T value) onCallback,
}) {
_debouncers[key]?.cancel();
late DebounceThrottle<T> limiter;
limiter = DebounceThrottle<T>(
duration: duration,
mode: RateLimitMode.debounce,
onCallback: (value) {
onCallback(value);
if (removeDebouncerOnDone) _debouncers.remove(key);
},
);
_debouncers[key] = limiter;
limiter(value);
}
void throttle<T>({
required Object key,
required T value,
Duration duration = const Duration(milliseconds: 300),
ThrottleOptions options = const ThrottleOptions(),
required void Function(T value) onCallback,
}) {
_throttlers.putIfAbsent(
key,
() =>
DebounceThrottle<T>(
duration: duration,
mode: RateLimitMode.throttle,
throttleOptions: options,
onCallback: onCallback,
)
as DebounceThrottle<dynamic>,
);
(_throttlers[key] as DebounceThrottle<T>)(value);
}
void cancel(Object key) {
_debouncers.remove(key)?.cancel();
_throttlers.remove(key)?.cancel();
}
void clear() {
for (final limiter in _debouncers.values) {
limiter.dispose();
}
for (final limiter in _throttlers.values) {
limiter.dispose();
}
_debouncers.clear();
_throttlers.clear();
}
}
class RateLimit {
RateLimit._();
static final instance = RateLimitHub();
}

View File

@@ -0,0 +1,19 @@
class UrlUtils {
UrlUtils._();
static String buildQueryString(Map<String, dynamic>? params) {
if (params == null || params.isEmpty) return '';
final queryParams = <String, String>{};
for (final entry in params.entries) {
final value = entry.value;
if (value == null) continue;
if (value is String || value is num || value is bool) {
queryParams[entry.key] = value.toString();
}
}
if (queryParams.isEmpty) return '';
return '?${Uri(queryParameters: queryParams).query}';
}
}