init
This commit is contained in:
53
lib/app/app.dart
Normal file
53
lib/app/app.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_template/app/config/app_config.dart';
|
||||
import 'package:flutter_template/app/router/app_navigator.dart';
|
||||
import 'package:flutter_template/app/theme/app_theme.dart';
|
||||
import 'package:flutter_template/features/demo/demo_page.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
class FlutterTemplateApp extends StatelessWidget {
|
||||
const FlutterTemplateApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ScreenUtilInit(
|
||||
designSize: const Size(375, 812),
|
||||
minTextAdapt: true,
|
||||
splitScreenMode: true,
|
||||
builder: (context, child) {
|
||||
return MaterialApp(
|
||||
title: AppConfig.appName,
|
||||
navigatorKey: AppNavigator.navigatorKey,
|
||||
navigatorObservers: [RouteTracker()],
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light,
|
||||
darkTheme: AppTheme.dark,
|
||||
supportedLocales: const [Locale('zh', 'CN'), Locale('en', 'US')],
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
builder: EasyLoading.init(
|
||||
builder: (context, child) {
|
||||
return MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
context,
|
||||
).copyWith(textScaler: const TextScaler.linear(1)),
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
),
|
||||
home: RefreshConfiguration(
|
||||
enableLoadingWhenNoData: false,
|
||||
headerTriggerDistance: 80,
|
||||
child: const DemoPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
29
lib/app/bootstrap.dart
Normal file
29
lib/app/bootstrap.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_template/app/app.dart';
|
||||
import 'package:flutter_template/app/config/app_config.dart';
|
||||
import 'package:flutter_template/core/cache/app_storage.dart';
|
||||
import 'package:flutter_template/core/logging/app_logger.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
class AppBootstrapper {
|
||||
AppBootstrapper._();
|
||||
|
||||
static Future<void> bootstrap({
|
||||
AppEnvironment environment = AppEnvironment.dev,
|
||||
}) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||
|
||||
await AppStorage.init();
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
|
||||
AppConfig.configure(environment: environment, packageInfo: packageInfo);
|
||||
|
||||
AppLogger.debug('App started in ${AppConfig.current.environment.name}');
|
||||
|
||||
runApp(const ProviderScope(child: FlutterTemplateApp()));
|
||||
}
|
||||
}
|
||||
48
lib/app/config/app_config.dart
Normal file
48
lib/app/config/app_config.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
enum AppEnvironment { dev, staging, prod }
|
||||
|
||||
class EnvironmentValues {
|
||||
const EnvironmentValues({
|
||||
required this.environment,
|
||||
required this.baseUrl,
|
||||
required this.enableNetworkLog,
|
||||
});
|
||||
|
||||
final AppEnvironment environment;
|
||||
final String baseUrl;
|
||||
final bool enableNetworkLog;
|
||||
}
|
||||
|
||||
class AppConfig {
|
||||
AppConfig._();
|
||||
|
||||
static late EnvironmentValues current;
|
||||
static PackageInfo? packageInfo;
|
||||
|
||||
static const appName = 'Flutter Template';
|
||||
|
||||
static void configure({
|
||||
required AppEnvironment environment,
|
||||
PackageInfo? packageInfo,
|
||||
}) {
|
||||
AppConfig.packageInfo = packageInfo;
|
||||
current = switch (environment) {
|
||||
AppEnvironment.dev => const EnvironmentValues(
|
||||
environment: AppEnvironment.dev,
|
||||
baseUrl: 'https://example.com/api',
|
||||
enableNetworkLog: true,
|
||||
),
|
||||
AppEnvironment.staging => const EnvironmentValues(
|
||||
environment: AppEnvironment.staging,
|
||||
baseUrl: 'https://staging.example.com/api',
|
||||
enableNetworkLog: true,
|
||||
),
|
||||
AppEnvironment.prod => const EnvironmentValues(
|
||||
environment: AppEnvironment.prod,
|
||||
baseUrl: 'https://api.example.com',
|
||||
enableNetworkLog: false,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
189
lib/app/router/app_navigator.dart
Normal file
189
lib/app/router/app_navigator.dart
Normal file
@@ -0,0 +1,189 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppNavigator {
|
||||
AppNavigator._();
|
||||
|
||||
static final navigatorKey = GlobalKey<NavigatorState>();
|
||||
static final Set<String> _pushingRoutes = <String>{};
|
||||
|
||||
static BuildContext? get context => navigatorKey.currentContext;
|
||||
|
||||
static Future<T?> push<T>(
|
||||
Widget page, {
|
||||
BuildContext? context,
|
||||
Object? arguments,
|
||||
String? name,
|
||||
bool preventDuplicate = true,
|
||||
}) async {
|
||||
final routeName = name ?? page.runtimeType.toString();
|
||||
if (preventDuplicate && RouteTracker.contains(routeName)) {
|
||||
return null;
|
||||
}
|
||||
if (_pushingRoutes.contains(routeName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
_pushingRoutes.add(routeName);
|
||||
final nav = Navigator.of(
|
||||
context ?? AppNavigator.context!,
|
||||
rootNavigator: true,
|
||||
);
|
||||
try {
|
||||
return await nav.push<T>(
|
||||
SlidePageRoute(
|
||||
builder: (_) => page,
|
||||
settings: RouteSettings(name: routeName, arguments: arguments),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_pushingRoutes.remove(routeName);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static Future<T?> pushReplacement<T, TO>(
|
||||
Widget page, {
|
||||
BuildContext? context,
|
||||
Object? arguments,
|
||||
String? name,
|
||||
}) {
|
||||
return Navigator.of(
|
||||
context ?? AppNavigator.context!,
|
||||
rootNavigator: true,
|
||||
).pushReplacement<T, TO>(
|
||||
SlidePageRoute(
|
||||
builder: (_) => page,
|
||||
settings: RouteSettings(name: name, arguments: arguments),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<T?> pushAndRemoveUntil<T>(
|
||||
Widget page, {
|
||||
BuildContext? context,
|
||||
RoutePredicate? predicate,
|
||||
Object? arguments,
|
||||
String? name,
|
||||
}) {
|
||||
return Navigator.of(
|
||||
context ?? AppNavigator.context!,
|
||||
rootNavigator: true,
|
||||
).pushAndRemoveUntil<T>(
|
||||
SlidePageRoute(
|
||||
builder: (_) => page,
|
||||
settings: RouteSettings(name: name, arguments: arguments),
|
||||
),
|
||||
predicate ?? (_) => false,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<T?> pushTransparent<T>(
|
||||
Widget page, {
|
||||
BuildContext? context,
|
||||
Color barrierColor = Colors.black54,
|
||||
Duration duration = const Duration(milliseconds: 200),
|
||||
bool dismissible = true,
|
||||
}) {
|
||||
return Navigator.of(context ?? AppNavigator.context!).push<T>(
|
||||
PageRouteBuilder<T>(
|
||||
opaque: false,
|
||||
barrierColor: barrierColor,
|
||||
barrierDismissible: dismissible,
|
||||
transitionDuration: duration,
|
||||
reverseTransitionDuration: duration,
|
||||
pageBuilder: (_, __, ___) => page,
|
||||
transitionsBuilder: (_, animation, __, child) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static void pop<T extends Object?>({BuildContext? context, T? result}) {
|
||||
Navigator.of(
|
||||
context ?? AppNavigator.context!,
|
||||
rootNavigator: true,
|
||||
).pop<T>(result);
|
||||
}
|
||||
|
||||
static void popTimes({BuildContext? context, int count = 1}) {
|
||||
var popped = 0;
|
||||
Navigator.of(context ?? AppNavigator.context!).popUntil((route) {
|
||||
if (popped < count) {
|
||||
popped++;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class SlidePageRoute<T> extends MaterialPageRoute<T> {
|
||||
SlidePageRoute({required super.builder, super.settings});
|
||||
|
||||
@override
|
||||
Widget buildTransitions(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) {
|
||||
if (Platform.isAndroid) {
|
||||
final tween = Tween(
|
||||
begin: const Offset(1, 0),
|
||||
end: Offset.zero,
|
||||
).chain(CurveTween(curve: Curves.easeOutCubic));
|
||||
return SlideTransition(position: animation.drive(tween), child: child);
|
||||
}
|
||||
|
||||
return Theme.of(context).pageTransitionsTheme.buildTransitions<T>(
|
||||
this,
|
||||
context,
|
||||
animation,
|
||||
secondaryAnimation,
|
||||
child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RouteTracker extends NavigatorObserver {
|
||||
static final List<String?> pageStack = [];
|
||||
|
||||
bool _isPage(Route<dynamic> route) => route is PageRoute;
|
||||
|
||||
@override
|
||||
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
if (_isPage(route)) {
|
||||
pageStack.add(route.settings.name);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
if (_isPage(route) && pageStack.isNotEmpty) {
|
||||
pageStack.removeLast();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
if (_isPage(route)) {
|
||||
pageStack.remove(route.settings.name);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
|
||||
if (oldRoute != null && _isPage(oldRoute) && pageStack.isNotEmpty) {
|
||||
pageStack.removeLast();
|
||||
}
|
||||
if (newRoute != null && _isPage(newRoute)) {
|
||||
pageStack.add(newRoute.settings.name);
|
||||
}
|
||||
}
|
||||
|
||||
static bool contains(String name) => pageStack.contains(name);
|
||||
}
|
||||
80
lib/app/theme/app_theme.dart
Normal file
80
lib/app/theme/app_theme.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
|
||||
static const seedColor = Color(0xFF2563EB);
|
||||
static const background = Color(0xFFF6F7FB);
|
||||
static const surface = Colors.white;
|
||||
static const textPrimary = Color(0xFF111827);
|
||||
static const textSecondary = Color(0xFF6B7280);
|
||||
static const border = Color(0xFFE5E7EB);
|
||||
static const success = Color(0xFF16A34A);
|
||||
static const warning = Color(0xFFF59E0B);
|
||||
static const danger = Color(0xFFDC2626);
|
||||
|
||||
static ThemeData get light {
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: seedColor,
|
||||
brightness: Brightness.light,
|
||||
surface: surface,
|
||||
);
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: background,
|
||||
appBarTheme: const AppBarTheme(
|
||||
centerTitle: true,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
backgroundColor: surface,
|
||||
foregroundColor: textPrimary,
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(88, 44),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(88, 44),
|
||||
side: const BorderSide(color: border),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static ThemeData get dark {
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: seedColor,
|
||||
brightness: Brightness.dark,
|
||||
);
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
appBarTheme: const AppBarTheme(
|
||||
centerTitle: true,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppSpacing {
|
||||
AppSpacing._();
|
||||
|
||||
static const double xs = 4;
|
||||
static const double sm = 8;
|
||||
static const double md = 12;
|
||||
static const double lg = 16;
|
||||
static const double xl = 24;
|
||||
static const double xxl = 32;
|
||||
}
|
||||
68
lib/core/cache/app_storage.dart
vendored
Normal file
68
lib/core/cache/app_storage.dart
vendored
Normal 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
9
lib/core/cache/storage_keys.dart
vendored
Normal 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';
|
||||
}
|
||||
16
lib/core/extensions/context_extensions.dart
Normal file
16
lib/core/extensions/context_extensions.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
20
lib/core/logging/app_logger.dart
Normal file
20
lib/core/logging/app_logger.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
20
lib/core/mixins/stream_subscription_mixin.dart
Normal file
20
lib/core/mixins/stream_subscription_mixin.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
126
lib/core/network/api_client.dart
Normal file
126
lib/core/network/api_client.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
23
lib/core/network/api_exception.dart
Normal file
23
lib/core/network/api_exception.dart
Normal 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(', ')})';
|
||||
}
|
||||
}
|
||||
49
lib/core/network/api_response.dart
Normal file
49
lib/core/network/api_response.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
31
lib/core/network/header_interceptor.dart
Normal file
31
lib/core/network/header_interceptor.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
11
lib/core/network/http_method.dart
Normal file
11
lib/core/network/http_method.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
enum HttpMethod {
|
||||
get('GET'),
|
||||
post('POST'),
|
||||
put('PUT'),
|
||||
patch('PATCH'),
|
||||
delete('DELETE');
|
||||
|
||||
const HttpMethod(this.value);
|
||||
|
||||
final String value;
|
||||
}
|
||||
58
lib/core/network/network_monitor.dart
Normal file
58
lib/core/network/network_monitor.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
11
lib/core/network/network_state.dart
Normal file
11
lib/core/network/network_state.dart
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
141
lib/core/network/offline_queue/offline_queue_manager.dart
Normal file
141
lib/core/network/offline_queue/offline_queue_manager.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
13
lib/core/network/offline_queue/offline_queue_state.dart
Normal file
13
lib/core/network/offline_queue/offline_queue_state.dart
Normal 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);
|
||||
}
|
||||
29
lib/core/network/offline_queue/offline_queue_storage.dart
Normal file
29
lib/core/network/offline_queue/offline_queue_storage.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
89
lib/core/network/offline_queue/offline_request.dart
Normal file
89
lib/core/network/offline_queue/offline_request.dart
Normal 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>);
|
||||
}
|
||||
}
|
||||
42
lib/core/network/providers/dio_providers.dart
Normal file
42
lib/core/network/providers/dio_providers.dart
Normal 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));
|
||||
});
|
||||
14
lib/core/network/providers/network_providers.dart
Normal file
14
lib/core/network/providers/network_providers.dart
Normal 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;
|
||||
});
|
||||
29
lib/core/network/providers/offline_queue_providers.dart
Normal file
29
lib/core/network/providers/offline_queue_providers.dart
Normal 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;
|
||||
});
|
||||
33
lib/core/permission/permission_service.dart
Normal file
33
lib/core/permission/permission_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
34
lib/core/utils/date_time_formatter.dart
Normal file
34
lib/core/utils/date_time_formatter.dart
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
54
lib/core/utils/device_utils.dart
Normal file
54
lib/core/utils/device_utils.dart
Normal 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};
|
||||
}
|
||||
}
|
||||
25
lib/core/utils/form_validators.dart
Normal file
25
lib/core/utils/form_validators.dart
Normal 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 个字符';
|
||||
}
|
||||
}
|
||||
166
lib/core/utils/rate_limiter.dart
Normal file
166
lib/core/utils/rate_limiter.dart
Normal 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();
|
||||
}
|
||||
19
lib/core/utils/url_utils.dart
Normal file
19
lib/core/utils/url_utils.dart
Normal 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}';
|
||||
}
|
||||
}
|
||||
29
lib/features/demo/demo_controller.dart
Normal file
29
lib/features/demo/demo_controller.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class DemoState {
|
||||
const DemoState({this.count = 0, this.query = ''});
|
||||
|
||||
final int count;
|
||||
final String query;
|
||||
|
||||
DemoState copyWith({int? count, String? query}) {
|
||||
return DemoState(count: count ?? this.count, query: query ?? this.query);
|
||||
}
|
||||
}
|
||||
|
||||
class DemoController extends Notifier<DemoState> {
|
||||
@override
|
||||
DemoState build() => const DemoState();
|
||||
|
||||
void increment() {
|
||||
state = state.copyWith(count: state.count + 1);
|
||||
}
|
||||
|
||||
void updateQuery(String query) {
|
||||
state = state.copyWith(query: query);
|
||||
}
|
||||
}
|
||||
|
||||
final demoControllerProvider = NotifierProvider<DemoController, DemoState>(
|
||||
DemoController.new,
|
||||
);
|
||||
116
lib/features/demo/demo_page.dart
Normal file
116
lib/features/demo/demo_page.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_template/app/theme/app_theme.dart';
|
||||
import 'package:flutter_template/features/demo/demo_controller.dart';
|
||||
import 'package:flutter_template/shared/widgets/widgets.dart';
|
||||
|
||||
class DemoPage extends ConsumerWidget {
|
||||
const DemoPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(demoControllerProvider);
|
||||
final controller = ref.read(demoControllerProvider.notifier);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Flutter Template')),
|
||||
body: SafeAreaWrapper(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
children: [
|
||||
AppSearchBar(hint: '搜索模板组件', onChanged: controller.updateQuery),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
AppCard(
|
||||
child: Row(
|
||||
children: [
|
||||
const AppAvatar(initials: 'T', size: 48),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'通用 Flutter 快速开发模板',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'已内置网络、缓存、路由、主题、权限、日志和常用 UI 组件。',
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: const [
|
||||
AppTag(label: 'Riverpod', tone: AppTagTone.info),
|
||||
AppTag(label: 'Dio', tone: AppTagTone.success),
|
||||
AppTag(label: '缓存', tone: AppTagTone.warning),
|
||||
AppTag(label: '无业务代码'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'状态管理示例',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text('当前计数:${state.count}'),
|
||||
if (state.query.isNotEmpty) ...[
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text('搜索关键字:${state.query}'),
|
||||
],
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
AppButton(
|
||||
label: '增加计数',
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
onPressed: controller.increment,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
AppStatusView(
|
||||
status: AppViewStatus.empty,
|
||||
empty: AppEmptyView(
|
||||
title: '空状态组件',
|
||||
message: '业务项目可替换图标、文案和操作按钮。',
|
||||
action: AppButton(
|
||||
label: '显示确认弹窗',
|
||||
variant: AppButtonVariant.outline,
|
||||
icon: const Icon(Icons.open_in_new, size: 18),
|
||||
onPressed: () async {
|
||||
final confirmed = await AppDialog.confirm(
|
||||
context,
|
||||
title: '模板弹窗',
|
||||
message: '这是可复用的确认弹窗示例。',
|
||||
);
|
||||
if (confirmed == true) {
|
||||
AppToast.show('已确认');
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
child: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
3
lib/main.dart
Normal file
3
lib/main.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
import 'package:flutter_template/app/bootstrap.dart';
|
||||
|
||||
Future<void> main() => AppBootstrapper.bootstrap();
|
||||
37
lib/shared/widgets/app_avatar.dart
Normal file
37
lib/shared/widgets/app_avatar.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_template/shared/widgets/app_network_image.dart';
|
||||
|
||||
class AppAvatar extends StatelessWidget {
|
||||
const AppAvatar({super.key, this.imageUrl, this.initials, this.size = 40});
|
||||
|
||||
final String? imageUrl;
|
||||
final String? initials;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final radius = BorderRadius.circular(size / 2);
|
||||
return ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: SizedBox.square(
|
||||
dimension: size,
|
||||
child: imageUrl == null || imageUrl!.isEmpty
|
||||
? ColoredBox(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Center(
|
||||
child: Text(
|
||||
(initials == null || initials!.isEmpty)
|
||||
? 'A'
|
||||
: initials!.characters.first.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: AppNetworkImage(url: imageUrl!, fit: BoxFit.cover),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
100
lib/shared/widgets/app_button.dart
Normal file
100
lib/shared/widgets/app_button.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum AppButtonVariant { primary, secondary, outline, text, danger }
|
||||
|
||||
class AppButton extends StatelessWidget {
|
||||
const AppButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.onPressed,
|
||||
this.icon,
|
||||
this.variant = AppButtonVariant.primary,
|
||||
this.isLoading = false,
|
||||
this.expand = false,
|
||||
this.height = 44,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final Widget? icon;
|
||||
final AppButtonVariant variant;
|
||||
final bool isLoading;
|
||||
final bool expand;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = _ButtonContent(
|
||||
label: label,
|
||||
icon: icon,
|
||||
isLoading: isLoading,
|
||||
);
|
||||
final enabled = isLoading ? null : onPressed;
|
||||
final size = Size(expand ? double.infinity : 0, height);
|
||||
|
||||
return switch (variant) {
|
||||
AppButtonVariant.primary => ElevatedButton(
|
||||
onPressed: enabled,
|
||||
style: ElevatedButton.styleFrom(minimumSize: size),
|
||||
child: child,
|
||||
),
|
||||
AppButtonVariant.secondary => FilledButton.tonal(
|
||||
onPressed: enabled,
|
||||
style: FilledButton.styleFrom(minimumSize: size),
|
||||
child: child,
|
||||
),
|
||||
AppButtonVariant.outline => OutlinedButton(
|
||||
onPressed: enabled,
|
||||
style: OutlinedButton.styleFrom(minimumSize: size),
|
||||
child: child,
|
||||
),
|
||||
AppButtonVariant.text => TextButton(
|
||||
onPressed: enabled,
|
||||
style: TextButton.styleFrom(minimumSize: size),
|
||||
child: child,
|
||||
),
|
||||
AppButtonVariant.danger => FilledButton(
|
||||
onPressed: enabled,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: size,
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _ButtonContent extends StatelessWidget {
|
||||
const _ButtonContent({
|
||||
required this.label,
|
||||
required this.isLoading,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Widget? icon;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isLoading) {
|
||||
return const SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
);
|
||||
}
|
||||
|
||||
if (icon == null) return Text(label);
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
icon!,
|
||||
const SizedBox(width: 8),
|
||||
Flexible(child: Text(label, overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
39
lib/shared/widgets/app_card.dart
Normal file
39
lib/shared/widgets/app_card.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_template/app/theme/app_theme.dart';
|
||||
|
||||
class AppCard extends StatelessWidget {
|
||||
const AppCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.all(AppSpacing.lg),
|
||||
this.margin,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final card = Container(
|
||||
margin: margin,
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppTheme.border),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
if (onTap == null) return card;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
}
|
||||
33
lib/shared/widgets/app_dialog.dart
Normal file
33
lib/shared/widgets/app_dialog.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppDialog {
|
||||
AppDialog._();
|
||||
|
||||
static Future<bool?> confirm(
|
||||
BuildContext context, {
|
||||
String title = '确认操作',
|
||||
String message = '是否继续?',
|
||||
String cancelText = '取消',
|
||||
String confirmText = '确认',
|
||||
}) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(cancelText),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(confirmText),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
45
lib/shared/widgets/app_empty_view.dart
Normal file
45
lib/shared/widgets/app_empty_view.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppEmptyView extends StatelessWidget {
|
||||
const AppEmptyView({
|
||||
super.key,
|
||||
this.title = '暂无数据',
|
||||
this.message,
|
||||
this.icon = Icons.inbox_outlined,
|
||||
this.action,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? message;
|
||||
final IconData icon;
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 56, color: colors.outline),
|
||||
const SizedBox(height: 12),
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
message!,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (action != null) ...[const SizedBox(height: 16), action!],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
53
lib/shared/widgets/app_error_view.dart
Normal file
53
lib/shared/widgets/app_error_view.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_template/shared/widgets/app_button.dart';
|
||||
|
||||
class AppErrorView extends StatelessWidget {
|
||||
const AppErrorView({
|
||||
super.key,
|
||||
this.title = '加载失败',
|
||||
this.message = '请稍后重试',
|
||||
this.onRetry,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String message;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 56,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
AppButton(
|
||||
label: '重试',
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
variant: AppButtonVariant.outline,
|
||||
onPressed: onRetry,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
30
lib/shared/widgets/app_loading_view.dart
Normal file
30
lib/shared/widgets/app_loading_view.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppLoadingView extends StatelessWidget {
|
||||
const AppLoadingView({super.key, this.message = '加载中', this.size = 24});
|
||||
|
||||
final String message;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: size,
|
||||
child: const CircularProgressIndicator(strokeWidth: 2.4),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
message,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
48
lib/shared/widgets/app_network_image.dart
Normal file
48
lib/shared/widgets/app_network_image.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppNetworkImage extends StatelessWidget {
|
||||
const AppNetworkImage({
|
||||
super.key,
|
||||
required this.url,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fit = BoxFit.cover,
|
||||
this.borderRadius,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxFit fit;
|
||||
final BorderRadius? borderRadius;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final image = CachedNetworkImage(
|
||||
imageUrl: url,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
placeholder: (_, __) => ColoredBox(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: const Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
errorWidget: (_, __, ___) => ColoredBox(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (borderRadius == null) return image;
|
||||
return ClipRRect(borderRadius: borderRadius!, child: image);
|
||||
}
|
||||
}
|
||||
80
lib/shared/widgets/app_refresh_list.dart
Normal file
80
lib/shared/widgets/app_refresh_list.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
class AppRefreshList<T> extends StatefulWidget {
|
||||
const AppRefreshList({
|
||||
super.key,
|
||||
required this.items,
|
||||
required this.itemBuilder,
|
||||
this.onRefresh,
|
||||
this.onLoadMore,
|
||||
this.enablePullUp = false,
|
||||
this.separator,
|
||||
this.empty,
|
||||
this.padding,
|
||||
});
|
||||
|
||||
final List<T> items;
|
||||
final Widget Function(BuildContext context, T item, int index) itemBuilder;
|
||||
final Future<void> Function()? onRefresh;
|
||||
final Future<void> Function()? onLoadMore;
|
||||
final bool enablePullUp;
|
||||
final Widget? separator;
|
||||
final Widget? empty;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
@override
|
||||
State<AppRefreshList<T>> createState() => _AppRefreshListState<T>();
|
||||
}
|
||||
|
||||
class _AppRefreshListState<T> extends State<AppRefreshList<T>> {
|
||||
final _controller = RefreshController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
try {
|
||||
await widget.onRefresh?.call();
|
||||
_controller.refreshCompleted();
|
||||
} catch (_) {
|
||||
_controller.refreshFailed();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
try {
|
||||
await widget.onLoadMore?.call();
|
||||
_controller.loadComplete();
|
||||
} catch (_) {
|
||||
_controller.loadFailed();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = widget.items.isEmpty && widget.empty != null
|
||||
? widget.empty!
|
||||
: ListView.separated(
|
||||
padding: widget.padding,
|
||||
itemCount: widget.items.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
widget.separator ?? const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
return widget.itemBuilder(context, widget.items[index], index);
|
||||
},
|
||||
);
|
||||
|
||||
return SmartRefresher(
|
||||
controller: _controller,
|
||||
enablePullDown: widget.onRefresh != null,
|
||||
enablePullUp: widget.enablePullUp && widget.onLoadMore != null,
|
||||
onRefresh: _refresh,
|
||||
onLoading: _loadMore,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
62
lib/shared/widgets/app_search_bar.dart
Normal file
62
lib/shared/widgets/app_search_bar.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_template/core/utils/rate_limiter.dart';
|
||||
|
||||
class AppSearchBar extends StatefulWidget {
|
||||
const AppSearchBar({
|
||||
super.key,
|
||||
this.hint = '搜索',
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
this.debounceDuration = const Duration(milliseconds: 300),
|
||||
});
|
||||
|
||||
final String hint;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final Duration debounceDuration;
|
||||
|
||||
@override
|
||||
State<AppSearchBar> createState() => _AppSearchBarState();
|
||||
}
|
||||
|
||||
class _AppSearchBarState extends State<AppSearchBar> {
|
||||
final _controller = TextEditingController();
|
||||
final _rateLimit = RateLimitHub();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_rateLimit.clear();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SearchBar(
|
||||
controller: _controller,
|
||||
hintText: widget.hint,
|
||||
leading: const Icon(Icons.search),
|
||||
trailing: [
|
||||
if (_controller.text.isNotEmpty)
|
||||
IconButton(
|
||||
tooltip: '清除',
|
||||
onPressed: () {
|
||||
setState(_controller.clear);
|
||||
widget.onChanged?.call('');
|
||||
},
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
setState(() {});
|
||||
_rateLimit.debounce<String>(
|
||||
key: 'search',
|
||||
value: value,
|
||||
duration: widget.debounceDuration,
|
||||
onCallback: (text) => widget.onChanged?.call(text),
|
||||
);
|
||||
},
|
||||
onSubmitted: widget.onSubmitted,
|
||||
);
|
||||
}
|
||||
}
|
||||
35
lib/shared/widgets/app_status_view.dart
Normal file
35
lib/shared/widgets/app_status_view.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_template/shared/widgets/app_empty_view.dart';
|
||||
import 'package:flutter_template/shared/widgets/app_error_view.dart';
|
||||
import 'package:flutter_template/shared/widgets/app_loading_view.dart';
|
||||
|
||||
enum AppViewStatus { loading, empty, error, content }
|
||||
|
||||
class AppStatusView extends StatelessWidget {
|
||||
const AppStatusView({
|
||||
super.key,
|
||||
required this.status,
|
||||
required this.child,
|
||||
this.empty,
|
||||
this.error,
|
||||
this.loading,
|
||||
this.onRetry,
|
||||
});
|
||||
|
||||
final AppViewStatus status;
|
||||
final Widget child;
|
||||
final Widget? empty;
|
||||
final Widget? error;
|
||||
final Widget? loading;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return switch (status) {
|
||||
AppViewStatus.loading => loading ?? const AppLoadingView(),
|
||||
AppViewStatus.empty => empty ?? const AppEmptyView(),
|
||||
AppViewStatus.error => error ?? AppErrorView(onRetry: onRetry),
|
||||
AppViewStatus.content => child,
|
||||
};
|
||||
}
|
||||
}
|
||||
58
lib/shared/widgets/app_tag.dart
Normal file
58
lib/shared/widgets/app_tag.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum AppTagTone { neutral, success, warning, danger, info }
|
||||
|
||||
class AppTag extends StatelessWidget {
|
||||
const AppTag({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.tone = AppTagTone.neutral,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final AppTagTone tone;
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (foreground, background) = _colors(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 14, color: foreground),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: foreground,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
(Color, Color) _colors(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return switch (tone) {
|
||||
AppTagTone.neutral => (
|
||||
colors.onSurfaceVariant,
|
||||
colors.surfaceContainerHighest,
|
||||
),
|
||||
AppTagTone.success => (const Color(0xFF166534), const Color(0xFFDCFCE7)),
|
||||
AppTagTone.warning => (const Color(0xFF92400E), const Color(0xFFFEF3C7)),
|
||||
AppTagTone.danger => (colors.error, colors.errorContainer),
|
||||
AppTagTone.info => (colors.primary, colors.primaryContainer),
|
||||
};
|
||||
}
|
||||
}
|
||||
113
lib/shared/widgets/app_text_field.dart
Normal file
113
lib/shared/widgets/app_text_field.dart
Normal file
@@ -0,0 +1,113 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class AppTextField extends StatefulWidget {
|
||||
const AppTextField({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.initialValue,
|
||||
this.label,
|
||||
this.hint,
|
||||
this.prefixIcon,
|
||||
this.suffixIcon,
|
||||
this.keyboardType,
|
||||
this.textInputAction,
|
||||
this.obscureText = false,
|
||||
this.readOnly = false,
|
||||
this.maxLines = 1,
|
||||
this.maxLength,
|
||||
this.validator,
|
||||
this.inputFormatters,
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
this.onFinished,
|
||||
this.finishLength,
|
||||
this.debounceDuration = const Duration(milliseconds: 500),
|
||||
});
|
||||
|
||||
final TextEditingController? controller;
|
||||
final String? initialValue;
|
||||
final String? label;
|
||||
final String? hint;
|
||||
final Widget? prefixIcon;
|
||||
final Widget? suffixIcon;
|
||||
final TextInputType? keyboardType;
|
||||
final TextInputAction? textInputAction;
|
||||
final bool obscureText;
|
||||
final bool readOnly;
|
||||
final int maxLines;
|
||||
final int? maxLength;
|
||||
final FormFieldValidator<String>? validator;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final ValueChanged<String>? onFinished;
|
||||
final int? finishLength;
|
||||
final Duration debounceDuration;
|
||||
|
||||
@override
|
||||
State<AppTextField> createState() => _AppTextFieldState();
|
||||
}
|
||||
|
||||
class _AppTextFieldState extends State<AppTextField> {
|
||||
late final TextEditingController _controller;
|
||||
Timer? _debounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller =
|
||||
widget.controller ??
|
||||
TextEditingController(text: widget.initialValue ?? '');
|
||||
_controller.addListener(_handleInput);
|
||||
}
|
||||
|
||||
void _handleInput() {
|
||||
final text = _controller.text;
|
||||
if (widget.finishLength != null && text.length == widget.finishLength) {
|
||||
widget.onFinished?.call(text);
|
||||
return;
|
||||
}
|
||||
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(widget.debounceDuration, () {
|
||||
widget.onFinished?.call(text);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_controller.removeListener(_handleInput);
|
||||
if (widget.controller == null) _controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: _controller,
|
||||
keyboardType: widget.keyboardType,
|
||||
textInputAction: widget.textInputAction ?? TextInputAction.done,
|
||||
obscureText: widget.obscureText,
|
||||
readOnly: widget.readOnly,
|
||||
maxLines: widget.maxLines,
|
||||
maxLength: widget.maxLength,
|
||||
validator: widget.validator,
|
||||
inputFormatters: widget.inputFormatters,
|
||||
onChanged: widget.onChanged,
|
||||
onFieldSubmitted: widget.onSubmitted,
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.label,
|
||||
hintText: widget.hint,
|
||||
prefixIcon: widget.prefixIcon,
|
||||
suffixIcon: widget.suffixIcon,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
counterText: '',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
87
lib/shared/widgets/app_toast.dart
Normal file
87
lib/shared/widgets/app_toast.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_template/app/router/app_navigator.dart';
|
||||
|
||||
class AppToast {
|
||||
AppToast._();
|
||||
|
||||
static OverlayEntry? _entry;
|
||||
static Timer? _timer;
|
||||
|
||||
static String messageOf(Object? error) {
|
||||
if (error == null) return '未知错误';
|
||||
if (error is FormatException) return error.message;
|
||||
if (error is TimeoutException) return error.message ?? '请求超时';
|
||||
|
||||
var text = error.toString().trim();
|
||||
const prefix = 'Exception: ';
|
||||
while (text.startsWith(prefix)) {
|
||||
text = text.substring(prefix.length).trimLeft();
|
||||
}
|
||||
return text.isEmpty ? '未知错误' : text;
|
||||
}
|
||||
|
||||
static void show(
|
||||
String message, {
|
||||
Duration duration = const Duration(seconds: 2),
|
||||
}) {
|
||||
final context = AppNavigator.context;
|
||||
if (context == null) return;
|
||||
|
||||
_dismiss();
|
||||
final overlay = Overlay.maybeOf(context, rootOverlay: true);
|
||||
if (overlay == null) return;
|
||||
|
||||
_entry = OverlayEntry(builder: (_) => _ToastWidget(message: message));
|
||||
overlay.insert(_entry!);
|
||||
_timer = Timer(duration, _dismiss);
|
||||
}
|
||||
|
||||
static void showError(Object error) => show(messageOf(error));
|
||||
|
||||
static void _dismiss() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
try {
|
||||
_entry?.remove();
|
||||
} catch (_) {
|
||||
// OverlayEntry may already be removed.
|
||||
}
|
||||
_entry = null;
|
||||
}
|
||||
}
|
||||
|
||||
class _ToastWidget extends StatelessWidget {
|
||||
const _ToastWidget({required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: Center(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.sizeOf(context).width * 0.8,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 11),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.82),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
26
lib/shared/widgets/safe_area_wrapper.dart
Normal file
26
lib/shared/widgets/safe_area_wrapper.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SafeAreaWrapper extends StatelessWidget {
|
||||
const SafeAreaWrapper({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.backgroundColor,
|
||||
this.top = true,
|
||||
this.bottom = true,
|
||||
this.minimum = EdgeInsets.zero,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Color? backgroundColor;
|
||||
final bool top;
|
||||
final bool bottom;
|
||||
final EdgeInsets minimum;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: backgroundColor ?? Theme.of(context).scaffoldBackgroundColor,
|
||||
child: SafeArea(top: top, bottom: bottom, minimum: minimum, child: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
15
lib/shared/widgets/widgets.dart
Normal file
15
lib/shared/widgets/widgets.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
export 'app_avatar.dart';
|
||||
export 'app_button.dart';
|
||||
export 'app_card.dart';
|
||||
export 'app_dialog.dart';
|
||||
export 'app_empty_view.dart';
|
||||
export 'app_error_view.dart';
|
||||
export 'app_loading_view.dart';
|
||||
export 'app_network_image.dart';
|
||||
export 'app_refresh_list.dart';
|
||||
export 'app_search_bar.dart';
|
||||
export 'app_status_view.dart';
|
||||
export 'app_tag.dart';
|
||||
export 'app_text_field.dart';
|
||||
export 'app_toast.dart';
|
||||
export 'safe_area_wrapper.dart';
|
||||
Reference in New Issue
Block a user