feat: app 骨架 + 网络层 + 认证(登录/注册)
- flutter create 初始化 + 依赖(riverpod 3/dio/go_router/three_dart 0.0.16) - ApiClient:JWT 拦截器 + 统一响应解包 + 401 登出回调 + multipart 上传 - TokenStorage(shared_preferences)+ AuthNotifier 登录/登出 - 登录页(含注册对话框)+ 4 Tab 主框架 - 6 个单元测试通过(网络层 4 + 认证 2) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../network/api_client.dart';
|
||||
import '../storage/token_storage.dart';
|
||||
|
||||
class AuthState {
|
||||
final bool authenticated;
|
||||
final int? userId;
|
||||
final String? name;
|
||||
|
||||
const AuthState({
|
||||
this.authenticated = false,
|
||||
this.userId,
|
||||
this.name,
|
||||
});
|
||||
}
|
||||
|
||||
final tokenStorageProvider = Provider<TokenStorage>((ref) => TokenStorage());
|
||||
|
||||
final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
final client = ApiClient(tokenStorage: ref.watch(tokenStorageProvider));
|
||||
client.onUnauthorized = () {
|
||||
ref.read(authProvider.notifier).logout();
|
||||
};
|
||||
return client;
|
||||
});
|
||||
|
||||
class AuthNotifier extends AsyncNotifier<AuthState> {
|
||||
@override
|
||||
Future<AuthState> build() async {
|
||||
final storage = ref.watch(tokenStorageProvider);
|
||||
await storage.load();
|
||||
return AuthState(authenticated: storage.hasToken);
|
||||
}
|
||||
|
||||
Future<void> login(String account, String password) async {
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final data = await client.post<Map<String, dynamic>>('/user/login', {
|
||||
'account': account,
|
||||
'password': password,
|
||||
});
|
||||
final token = data['token'] as String? ?? '';
|
||||
final user = data['user'] as Map<String, dynamic>? ?? {};
|
||||
await ref.read(tokenStorageProvider).save(token);
|
||||
state = AsyncData(AuthState(
|
||||
authenticated: true,
|
||||
userId: (user['id'] as num?)?.toInt(),
|
||||
name: user['name'] as String?,
|
||||
));
|
||||
} catch (e) {
|
||||
state = AsyncError(e, StackTrace.current);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await ref.read(tokenStorageProvider).clear();
|
||||
state = const AsyncData(AuthState());
|
||||
}
|
||||
}
|
||||
|
||||
final authProvider =
|
||||
AsyncNotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new);
|
||||
@@ -0,0 +1,5 @@
|
||||
/// 全局配置
|
||||
class AppConfig {
|
||||
/// 后端地址:iOS 模拟器用 127.0.0.1;Android 模拟器用 10.0.2.2;真机填局域网 IP
|
||||
static const String baseUrl = 'http://127.0.0.1:3007';
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
import '../storage/token_storage.dart';
|
||||
import 'api_exception.dart';
|
||||
|
||||
/// 统一网络客户端:JWT 拦截器 + 统一响应解包(code != 0 抛 ApiException)
|
||||
class ApiClient {
|
||||
final TokenStorage _tokenStorage;
|
||||
late final Dio _dio;
|
||||
|
||||
/// 401 时回调(登出)
|
||||
void Function()? onUnauthorized;
|
||||
|
||||
// ignore: prefer_initializing_formals
|
||||
ApiClient({required TokenStorage tokenStorage, Dio? dio})
|
||||
: _tokenStorage = tokenStorage {
|
||||
_dio = dio ??
|
||||
Dio(BaseOptions(
|
||||
baseUrl: AppConfig.baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 60),
|
||||
));
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
final token = _tokenStorage.token;
|
||||
if (token != null && token.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
},
|
||||
onError: (e, handler) {
|
||||
if (e.response?.statusCode == 401) onUnauthorized?.call();
|
||||
handler.next(e);
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
Future<T> post<T>(String path, Map<String, dynamic> body,
|
||||
{T Function(dynamic data)? parse}) async {
|
||||
final res = await _dio.post(path, data: body);
|
||||
return _unwrap(res, parse);
|
||||
}
|
||||
|
||||
Future<T> get<T>(String path,
|
||||
{Map<String, dynamic>? query, T Function(dynamic data)? parse}) async {
|
||||
final res = await _dio.get(path, queryParameters: query);
|
||||
return _unwrap(res, parse);
|
||||
}
|
||||
|
||||
/// multipart 上传:fields 为文本字段,fileField 为文件字段名
|
||||
Future<T> upload<T>(String path, Map<String, dynamic> fields,
|
||||
String fileField, String filePath,
|
||||
{T Function(dynamic data)? parse}) async {
|
||||
final form = FormData.fromMap({
|
||||
...fields,
|
||||
fileField: await MultipartFile.fromFile(filePath),
|
||||
});
|
||||
final res = await _dio.post(path, data: form);
|
||||
return _unwrap(res, parse);
|
||||
}
|
||||
|
||||
T _unwrap<T>(Response res, T Function(dynamic data)? parse) {
|
||||
final data = res.data;
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw ApiException(-1, '响应格式错误');
|
||||
}
|
||||
final code = data['code'] as int? ?? -1;
|
||||
final message = data['message'] as String? ?? '';
|
||||
if (code != 0) {
|
||||
throw ApiException(code, message);
|
||||
}
|
||||
final payload = data['data'];
|
||||
if (parse != null) return parse(payload);
|
||||
return payload as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/// 业务异常(后端统一响应 code != 0)
|
||||
class ApiException implements Exception {
|
||||
final int code;
|
||||
final String message;
|
||||
|
||||
ApiException(this.code, this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// JWT token 本地存储
|
||||
class TokenStorage {
|
||||
static const _key = 'auth_token';
|
||||
String? _token;
|
||||
|
||||
String? get token => _token;
|
||||
|
||||
bool get hasToken => _token != null && _token!.isNotEmpty;
|
||||
|
||||
Future<void> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_token = prefs.getString(_key);
|
||||
}
|
||||
|
||||
Future<void> save(String token) async {
|
||||
_token = token;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_key, token);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
_token = null;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/auth/auth_provider.dart';
|
||||
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
final _accountCtrl = TextEditingController();
|
||||
final _passwordCtrl = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_accountCtrl.dispose();
|
||||
_passwordCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final account = _accountCtrl.text.trim();
|
||||
final password = _passwordCtrl.text;
|
||||
if (account.isEmpty || password.isEmpty) {
|
||||
Fluttertoast.showToast(msg: '请输入账号和密码');
|
||||
return;
|
||||
}
|
||||
await ref.read(authProvider.notifier).login(account, password);
|
||||
final state = ref.read(authProvider);
|
||||
if (state.hasError) {
|
||||
Fluttertoast.showToast(
|
||||
msg: state.error.toString().replaceFirst('Exception: ', ''));
|
||||
return;
|
||||
}
|
||||
if (state.value?.authenticated == true && mounted) {
|
||||
context.go('/home');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showRegisterDialog() async {
|
||||
final account = TextEditingController();
|
||||
final password = TextEditingController();
|
||||
final name = TextEditingController();
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('注册新账号'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: account,
|
||||
decoration: const InputDecoration(labelText: '账号')),
|
||||
TextField(
|
||||
controller: password,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(labelText: '密码(至少6位)')),
|
||||
TextField(
|
||||
controller: name,
|
||||
decoration: const InputDecoration(labelText: '昵称')),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('取消')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('注册')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (result != true) return;
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.post<Map<String, dynamic>>('/user/register', {
|
||||
'account': account.text.trim(),
|
||||
'password': password.text,
|
||||
'name': name.text.trim(),
|
||||
});
|
||||
Fluttertoast.showToast(msg: '注册成功,正在登录...');
|
||||
_accountCtrl.text = account.text.trim();
|
||||
_passwordCtrl.text = password.text;
|
||||
await _submit();
|
||||
} catch (e) {
|
||||
Fluttertoast.showToast(
|
||||
msg: '注册失败:${e.toString().replaceFirst('Exception: ', '')}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = ref.watch(authProvider);
|
||||
final loading = auth.isLoading;
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Icon(Icons.face_retouching_natural,
|
||||
size: 72, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(height: 12),
|
||||
const Text('我的形象穿搭',
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
|
||||
const Text('拍出你的个人形象,生成专属穿搭方案',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey, fontSize: 13)),
|
||||
const SizedBox(height: 40),
|
||||
TextField(
|
||||
controller: _accountCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '账号',
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
border: OutlineInputBorder()),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _passwordCtrl,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '密码',
|
||||
prefixIcon: Icon(Icons.lock_outline),
|
||||
border: OutlineInputBorder()),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
FilledButton(
|
||||
onPressed: loading ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14)),
|
||||
child: loading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('登录'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: loading ? null : _showRegisterDialog,
|
||||
child: const Text('还没有账号?注册新账号'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CommercialPage extends StatelessWidget {
|
||||
const CommercialPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(child: Text('门店电商(开发中)'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../commercial/commercial_page.dart';
|
||||
import '../outfit/outfit_page.dart';
|
||||
import '../profile/profile_page.dart';
|
||||
import '../wardrobe/wardrobe_page.dart';
|
||||
|
||||
/// 主框架:4 Tab(我的形象 / 我的衣橱 / 穿搭方案 / 门店电商)
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
int _index = 0;
|
||||
|
||||
static const _titles = ['我的形象', '我的衣橱', '穿搭方案', '门店电商'];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(_titles[_index])),
|
||||
body: IndexedStack(
|
||||
index: _index,
|
||||
children: const [
|
||||
ProfilePage(),
|
||||
WardrobePage(),
|
||||
OutfitPage(),
|
||||
CommercialPage(),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _index,
|
||||
onDestinationSelected: (i) => setState(() => _index = i),
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.face_outlined),
|
||||
selectedIcon: Icon(Icons.face),
|
||||
label: '形象'),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.checkroom_outlined),
|
||||
selectedIcon: Icon(Icons.checkroom),
|
||||
label: '衣橱'),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.auto_awesome_outlined),
|
||||
selectedIcon: Icon(Icons.auto_awesome),
|
||||
label: '穿搭'),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.store_outlined),
|
||||
selectedIcon: Icon(Icons.store),
|
||||
label: '门店'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class OutfitPage extends StatelessWidget {
|
||||
const OutfitPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(child: Text('穿搭方案(开发中)'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ProfilePage extends StatelessWidget {
|
||||
const ProfilePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(child: Text('我的形象(开发中)'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class WardrobePage extends StatelessWidget {
|
||||
const WardrobePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(child: Text('我的衣橱(开发中)'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'features/auth/login_page.dart';
|
||||
import 'features/commercial/commercial_page.dart';
|
||||
import 'features/home/home_page.dart';
|
||||
import 'features/outfit/outfit_page.dart';
|
||||
import 'features/profile/profile_page.dart';
|
||||
import 'features/wardrobe/wardrobe_page.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const ProviderScope(child: SloganApp()));
|
||||
}
|
||||
|
||||
class SloganApp extends ConsumerWidget {
|
||||
const SloganApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(routerProvider);
|
||||
return MaterialApp.router(
|
||||
title: '我的形象穿搭',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF5C6BC0)),
|
||||
useMaterial3: true,
|
||||
appBarTheme: const AppBarTheme(centerTitle: true),
|
||||
),
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 路由表:启动无 token 进登录页;有 token 进主框架
|
||||
final routerProvider = Provider<GoRouter>((ref) {
|
||||
return GoRouter(
|
||||
initialLocation: '/login',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
builder: (ctx, state) => const LoginPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/home',
|
||||
builder: (ctx, state) => const HomePage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/profile',
|
||||
builder: (ctx, state) => const ProfilePage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/wardrobe',
|
||||
builder: (ctx, state) => const WardrobePage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/outfit',
|
||||
builder: (ctx, state) => const OutfitPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/commercial',
|
||||
builder: (ctx, state) => const CommercialPage(),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user