app scaffold
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
/// 登录、登出、门店选择。
|
||||
///
|
||||
/// 对外只暴露三样东西:[buildAuthRoutes](给 app/ 拼路由表)、
|
||||
/// [authRepositoryProvider](给 app/ override `sessionRemoteProvider`)、
|
||||
/// 以及登录动作的 [loginControllerProvider]。页面本身不导出——别的包没有
|
||||
/// 直接构造它们的正当理由。
|
||||
library;
|
||||
|
||||
export 'src/data/auth_repository.dart' show AuthRepository, LoginResult, authRepositoryProvider;
|
||||
export 'src/presentation/login_controller.dart';
|
||||
export 'src/routes.dart';
|
||||
@@ -0,0 +1,125 @@
|
||||
/// 登录与会话相关的服务端调用。
|
||||
///
|
||||
/// 02 §Repository 接口的位置规则:本 feature 没有 `domain` 层(登录是直白的
|
||||
/// 请求-响应,没有跨 repository 协调),所以接口直接声明在 `data/repository/`,
|
||||
/// presentation 只依赖接口,不依赖 [AuthRepositoryImpl]。
|
||||
library;
|
||||
|
||||
import 'package:core_auth/core_auth.dart';
|
||||
import 'package:core_foundation/core_foundation.dart';
|
||||
import 'package:core_network/core_network.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 登录成功后拿到的东西。
|
||||
typedef LoginResult = ({TokenPair tokens, UserContext user});
|
||||
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// 它同时实现 core_auth 的 [SessionRemote]:core_auth 不能依赖 core_network
|
||||
/// (01 的硬约束),所以那边只声明接口,实现落在这里,由 `app/bootstrap.dart`
|
||||
/// 把 `sessionRemoteProvider` override 成本实现。
|
||||
/// ---------------------------------------------------------------------------
|
||||
abstract interface class AuthRepository implements SessionRemote {
|
||||
/// 账号密码登录。
|
||||
Future<LoginResult> login({required String username, required String password});
|
||||
}
|
||||
|
||||
/// [AuthRepository] 的实现。
|
||||
///
|
||||
/// TODO(backend): 下面所有路径和字段名都是按 05 / 11 的示例推的,
|
||||
/// 待后端接口契约确认后校准。
|
||||
class AuthRepositoryImpl implements AuthRepository {
|
||||
/// repository 一律注入 [ApiClient],不注入 Dio(05)。
|
||||
const AuthRepositoryImpl(this._api);
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
@override
|
||||
Future<LoginResult> login({required String username, required String password}) async {
|
||||
final Map<String, dynamic> data = await _api.post<Map<String, dynamic>>(
|
||||
'/api/v1/auth/login',
|
||||
data: <String, String>{'username': username, 'password': password},
|
||||
);
|
||||
return (
|
||||
tokens: TokenPair(
|
||||
accessToken: _requireString(data, 'accessToken'),
|
||||
refreshToken: _requireString(data, 'refreshToken'),
|
||||
),
|
||||
user: _parseUser(_requireMap(data, 'user')),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UserContext> fetchCurrentUser() async =>
|
||||
_parseUser(await _api.get<Map<String, dynamic>>('/api/v1/auth/me'));
|
||||
|
||||
@override
|
||||
Future<List<StoreContext>> fetchAccessibleStores() async {
|
||||
final List<dynamic> raw = await _api.get<List<dynamic>>('/api/v1/stores/accessible');
|
||||
return raw.map((dynamic e) => _parseStore(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StoreContext> switchStore(int storeId) async => _parseStore(
|
||||
await _api.post<Map<String, dynamic>>(
|
||||
'/api/v1/stores/switch',
|
||||
data: <String, int>{'storeId': storeId},
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> revokeSession() => _api.post<void>('/api/v1/auth/logout');
|
||||
|
||||
static UserContext _parseUser(Map<String, dynamic> json) => UserContext(
|
||||
userId: _requireString(json, 'userId'),
|
||||
employeeId: _requireString(json, 'employeeId'),
|
||||
phone: _requireString(json, 'phone'),
|
||||
roleCode: _requireString(json, 'roleCode'),
|
||||
channel: _requireString(json, 'channel'),
|
||||
permissions: <String>{
|
||||
...?(json['permissions'] as List<dynamic>?)?.map((dynamic e) => e as String),
|
||||
},
|
||||
);
|
||||
|
||||
static StoreContext _parseStore(Map<String, dynamic> json) => StoreContext(
|
||||
storeId: (json['storeId'] as num).toInt(),
|
||||
storeCode: _requireString(json, 'storeCode'),
|
||||
storeName: _requireString(json, 'storeName'),
|
||||
orgId: (json['orgId'] as num).toInt(),
|
||||
parentStoreId: json['parentStoreId'] as String?,
|
||||
menus: _parseMenus(json['menus'] as List<dynamic>?),
|
||||
);
|
||||
|
||||
static List<MenuItem> _parseMenus(List<dynamic>? raw) => raw == null
|
||||
? const <MenuItem>[]
|
||||
: raw.map((dynamic e) {
|
||||
final Map<String, dynamic> m = e as Map<String, dynamic>;
|
||||
return MenuItem(
|
||||
code: _requireString(m, 'code'),
|
||||
name: _requireString(m, 'name'),
|
||||
children: _parseMenus(m['children'] as List<dynamic>?),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
// 缺字段直接当服务异常,不给默认值——一个 userId 为 '' 的会话会在后面
|
||||
// 十个地方以更难懂的方式炸掉。
|
||||
static String _requireString(Map<String, dynamic> json, String key) {
|
||||
final Object? v = json[key];
|
||||
if (v is! String) {
|
||||
throw ServerException('响应缺少字段 $key');
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _requireMap(Map<String, dynamic> json, String key) {
|
||||
final Object? v = json[key];
|
||||
if (v is! Map<String, dynamic>) {
|
||||
throw ServerException('响应缺少字段 $key');
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
/// presentation 通过它拿接口类型。
|
||||
final Provider<AuthRepository> authRepositoryProvider = Provider<AuthRepository>(
|
||||
(Ref ref) => AuthRepositoryImpl(ref.watch(apiClientProvider)),
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
/// 登录页的状态。
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:core_auth/core_auth.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../data/auth_repository.dart';
|
||||
|
||||
part 'login_controller.g.dart';
|
||||
|
||||
/// 登录动作的三态。
|
||||
///
|
||||
/// 用 `AsyncNotifier<void>` 而不是自定义 state 类:登录只有"进行中/失败/成功"
|
||||
/// 三种形态,成功后页面由路由 redirect 接管(会话变成 SessionActive,
|
||||
/// goRouterProvider 会把用户送走),不需要在这里保留成功数据。
|
||||
@riverpod
|
||||
class LoginController extends _$LoginController {
|
||||
@override
|
||||
FutureOr<void> build() {}
|
||||
|
||||
/// 提交登录。
|
||||
Future<void> submit({required String username, required String password}) async {
|
||||
state = const AsyncLoading<void>();
|
||||
// AsyncValue.guard:异常留在 state 里由页面展示,不往外抛——
|
||||
// 抛出去会变成未捕获异常上报,而"密码错了"不是崩溃。
|
||||
state = await AsyncValue.guard(() async {
|
||||
final LoginResult result = await ref
|
||||
.read(authRepositoryProvider)
|
||||
.login(username: username, password: password);
|
||||
await ref.read(sessionProvider.notifier).onLoggedIn(tokens: result.tokens, user: result.user);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/// 登录页。
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:core_ui/core_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'login_controller.dart';
|
||||
|
||||
/// 账号密码登录。
|
||||
///
|
||||
/// 这是一个**骨架**:字段、找回密码、验证码、记住账号等都待 PRD 细化。
|
||||
/// Key 是 integration test 的约定(09),改名会让端到端用例失效。
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
/// 构造。
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
final TextEditingController _username = TextEditingController();
|
||||
final TextEditingController _password = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_username.dispose();
|
||||
_password.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
// 结果通过 state 回到 UI,这里不需要 await——但也不能裸调,
|
||||
// discarded_futures 会拦。
|
||||
unawaited(
|
||||
ref
|
||||
.read(loginControllerProvider.notifier)
|
||||
.submit(username: _username.text.trim(), password: _password.text),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final AsyncValue<void> state = ref.watch(loginControllerProvider);
|
||||
final bool busy = state.isLoading;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('登录')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
TextField(
|
||||
key: const Key('login_username'),
|
||||
controller: _username,
|
||||
enabled: !busy,
|
||||
decoration: const InputDecoration(labelText: '账号'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
key: const Key('login_password'),
|
||||
controller: _password,
|
||||
enabled: !busy,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(labelText: '密码'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (state.hasError && !ErrorPresenter.isSilent(state.error!)) ...<Widget>[
|
||||
Text(
|
||||
ErrorPresenter.presentUnknown(state.error!).title,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
FilledButton(
|
||||
key: const Key('login_submit'),
|
||||
// busy 时置灰而不是靠节流:门店网络慢,用户会反复点。
|
||||
onPressed: busy ? null : _submit,
|
||||
child: busy
|
||||
? const SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('登录'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/// 选店页。来源:conti-docs/11-store-context-and-session.md。
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:core_auth/core_auth.dart';
|
||||
import 'package:core_ui/core_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 登录后选择门店;也用于登录后切换门店。
|
||||
///
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// 用户停在这里时会话是 [SessionAwaitingStore]——**没有门店上下文**,
|
||||
/// `currentStoreIdProvider` 会直接 throw。所以这个页面不能读任何门店维度的
|
||||
/// provider,路由 redirect 也不放行去别的页面。
|
||||
/// ---------------------------------------------------------------------------
|
||||
class StorePickerPage extends ConsumerStatefulWidget {
|
||||
/// 构造。
|
||||
const StorePickerPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<StorePickerPage> createState() => _StorePickerPageState();
|
||||
}
|
||||
|
||||
class _StorePickerPageState extends ConsumerState<StorePickerPage> {
|
||||
Object? _error;
|
||||
bool _switching = false;
|
||||
|
||||
Future<void> _pick(int storeId) async {
|
||||
setState(() {
|
||||
_switching = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
// 切店的 8 步级联全在 SessionNotifier 里,页面只负责触发和展示失败。
|
||||
await ref.read(sessionProvider.notifier).switchStore(storeId);
|
||||
} on Object catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _error = e);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _switching = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final AppSession? session = ref.watch(sessionProvider).value;
|
||||
final List<StoreContext> stores = switch (session) {
|
||||
SessionAwaitingStore(:final List<StoreContext> candidates) => candidates,
|
||||
SessionActive(:final StoreContext store) => <StoreContext>[store],
|
||||
_ => const <StoreContext>[],
|
||||
};
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('选择门店')),
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
if (_error != null) TileErrorView(error: _error!),
|
||||
if (_switching) const LinearProgressIndicator(),
|
||||
Expanded(
|
||||
child: stores.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
const Text('没有可访问的门店'),
|
||||
const SizedBox(height: 12),
|
||||
// 拉门店失败和"确实一家都没有"在 UI 上无法区分,
|
||||
// 所以两种情况都给重试入口(11 §冷启动恢复)。
|
||||
FilledButton(
|
||||
onPressed: () =>
|
||||
unawaited(ref.read(sessionProvider.notifier).retryStoreLoad()),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: stores.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final StoreContext store = stores[index];
|
||||
return ListTile(
|
||||
// 09 的端到端用例按 index 取,改 key 名会让用例失效。
|
||||
key: Key('store_item_$index'),
|
||||
title: Text(store.storeName),
|
||||
subtitle: Text(store.storeCode),
|
||||
onTap: _switching ? null : () => unawaited(_pick(store.storeId)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/// 本 feature 对外暴露的路由。来源:conti-docs/04-routing.md。
|
||||
library;
|
||||
|
||||
import 'package:core_router/core_router.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'presentation/login_page.dart';
|
||||
import 'presentation/store_picker_page.dart';
|
||||
|
||||
/// 登录相关路由。
|
||||
///
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// feature 只**导出**自己的路由,不知道别人的存在,也不持有 GoRouter。
|
||||
/// 拼装在 `app/` 的 `appRoutesProvider` 里完成——这样 core_router 不必依赖
|
||||
/// 任何 feature(01 的分层),feature 之间也不会互相 import。
|
||||
///
|
||||
/// `GoRoute` 类型来自 core_router 的 re-export,本包 pubspec 里没有 go_router。
|
||||
/// ---------------------------------------------------------------------------
|
||||
List<RouteBase> buildAuthRoutes() => <RouteBase>[
|
||||
GoRoute(
|
||||
path: AppRoutes.login,
|
||||
builder: (BuildContext context, GoRouterState state) => const LoginPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.storePicker,
|
||||
builder: (BuildContext context, GoRouterState state) => const StorePickerPage(),
|
||||
),
|
||||
];
|
||||
Reference in New Issue
Block a user