app scaffold

This commit is contained in:
Guangfei.Zhao
2026-08-17 15:29:55 +08:00
commit 681688dfae
301 changed files with 18414 additions and 0 deletions
@@ -0,0 +1 @@
include: ../../analysis_options.yaml
@@ -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],不注入 Dio05)。
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)),
);
},
),
),
],
),
);
}
}
+28
View File
@@ -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 不必依赖
/// 任何 feature01 的分层),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(),
),
];
+30
View File
@@ -0,0 +1,30 @@
name: feature_auth
description: 登录、登出、门店选择。
publish_to: none
version: 0.1.0
resolution: workspace
# feature_* 的 pubspec 是包边界的**执行现场**:
# - 这里不出现任何另一个 feature_*(feature 间禁止互相依赖,见 01)
# - 这里不直接出现 go_router(路由类型由 core_router re-export,见 04
environment:
sdk: ^3.12.0
dependencies:
core_auth: ^0.1.0
core_foundation: ^0.1.0
core_network: ^0.1.0
core_router: ^0.1.0
core_ui: ^0.1.0
flutter:
sdk: flutter
flutter_riverpod: ^3.3.2
riverpod_annotation: ^4.0.3
dev_dependencies:
build_runner: ^2.4.13
flutter_lints: ^6.0.0
flutter_test:
sdk: flutter
mocktail: ^1.0.5
riverpod_generator: ^4.0.4
@@ -0,0 +1,89 @@
// feature_auth 的高价值断言:
// 1. 登录失败不能变成未捕获异常("密码错了"不是崩溃),要留在 state 里展示。
// 2. 提交中按钮必须置灰——门店网络慢,用户会反复点。
import 'dart:async';
import 'package:core_auth/core_auth.dart';
import 'package:core_foundation/core_foundation.dart';
import 'package:feature_auth/feature_auth.dart';
// LoginPage 不对外导出(别的包没有直接构造它的正当理由),测试走 src。
import 'package:feature_auth/src/presentation/login_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
class _FailingRepo implements AuthRepository {
/// 非 null 时 login 挂在这个 completer 上,用来观察"请求在途"这一帧。
Completer<LoginResult>? gate;
int calls = 0;
String? lastUsername;
@override
Future<LoginResult> login({required String username, required String password}) {
calls++;
lastUsername = username;
return gate?.future ?? Future<LoginResult>.error(const BusinessException(40101, '账号或密码错误'));
}
@override
Future<UserContext> fetchCurrentUser() => throw UnimplementedError();
@override
Future<List<StoreContext>> fetchAccessibleStores() => throw UnimplementedError();
@override
Future<StoreContext> switchStore(int storeId) => throw UnimplementedError();
@override
Future<void> revokeSession() => throw UnimplementedError();
}
void main() {
testWidgets('登录失败展示业务文案,不抛出未捕获异常', (WidgetTester tester) async {
final _FailingRepo repo = _FailingRepo();
await tester.pumpWidget(
ProviderScope(
overrides: [authRepositoryProvider.overrideWithValue(repo)],
child: const MaterialApp(home: LoginPage()),
),
);
// 前后空格是扫码枪/手动输入的常见污染,必须在提交前 trim。
await tester.enterText(find.byKey(const Key('login_username')), ' clerk01 ');
await tester.enterText(find.byKey(const Key('login_password')), 'pwd');
await tester.tap(find.byKey(const Key('login_submit')));
await tester.pump();
expect(repo.lastUsername, 'clerk01');
await tester.pumpAndSettle();
expect(find.text('账号或密码错误'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('提交中按钮置灰,重复点击不会重复发请求', (WidgetTester tester) async {
final _FailingRepo repo = _FailingRepo()..gate = Completer<LoginResult>();
await tester.pumpWidget(
ProviderScope(
overrides: [authRepositoryProvider.overrideWithValue(repo)],
child: const MaterialApp(home: LoginPage()),
),
);
await tester.tap(find.byKey(const Key('login_submit')));
await tester.pump(); // 请求还挂在 gate 上,这一帧就是"提交中"
final FilledButton button = tester.widget(find.byKey(const Key('login_submit')));
expect(button.onPressed, isNull, reason: '门店网络慢,用户会反复点');
await tester.tap(find.byKey(const Key('login_submit')), warnIfMissed: false);
await tester.pump();
expect(repo.calls, 1);
repo.gate!.completeError(const BusinessException(40101, '账号或密码错误'));
await tester.pumpAndSettle();
});
}