迁移 Flutter 端与训练脚本,模型/训练产物移出 git(遵循纯代码约定)
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../auth/session_store.dart';
|
||||
import 'models.dart';
|
||||
import 'order_api.dart';
|
||||
|
||||
/// 授权管理:本地缓存用于主界面展示到期时间;识别入口强制服务端校验。
|
||||
/// 缓存按手机号隔离(键含账号),切换账号不会读到上一账号的到期时间。
|
||||
class LicenseService {
|
||||
static const _storage = FlutterSecureStorage();
|
||||
|
||||
final OrderApi orderApi;
|
||||
final SessionStore sessionStore;
|
||||
|
||||
LicenseService({required this.orderApi, required this.sessionStore});
|
||||
|
||||
/// 主界面展示/启动加载:先读本账号本地缓存,有效则直接用;
|
||||
/// 无效或缺失时询问服务端(网络失败按缓存兜底,未登录按过期处理)。
|
||||
Future<LicenseStatus> check() async {
|
||||
final phone = await sessionStore.readPhone();
|
||||
if (phone == null) return const LicenseStatus(active: false);
|
||||
|
||||
final cached = await _readCache(phone);
|
||||
final now = DateTime.now();
|
||||
if (cached != null && cached.expiresAt!.isAfter(now)) return cached;
|
||||
|
||||
if (await sessionStore.readToken() == null) {
|
||||
return const LicenseStatus(active: false);
|
||||
}
|
||||
try {
|
||||
final remote = await orderApi.fetchLicense();
|
||||
try {
|
||||
if (remote.active && remote.expiresAt != null) {
|
||||
await _writeCache(phone, remote.expiresAt!);
|
||||
} else if (!remote.active) {
|
||||
await _clearCache(phone);
|
||||
}
|
||||
} catch (_) {
|
||||
// 本地缓存不可用不影响授权状态展示
|
||||
}
|
||||
return remote;
|
||||
} on OrderApiException {
|
||||
return cached ?? const LicenseStatus(active: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 识别入口强制校验:必须走服务端且 active 才放行,失败即抛错(不进相机)
|
||||
Future<LicenseStatus> verifyServer() async {
|
||||
if (await sessionStore.readToken() == null) {
|
||||
throw const SessionExpiredException();
|
||||
}
|
||||
return orderApi.fetchLicense();
|
||||
}
|
||||
|
||||
/// 支付成功后立即刷新(清缓存强制走服务端,避免旧授权干扰)
|
||||
Future<LicenseStatus> refresh() async {
|
||||
final phone = await sessionStore.readPhone();
|
||||
if (phone != null) await _clearCache(phone);
|
||||
return orderApi.fetchLicense();
|
||||
}
|
||||
|
||||
Future<LicenseStatus?> _readCache(String phone) async {
|
||||
try {
|
||||
final raw = await _storage.read(key: _cacheKey(phone));
|
||||
final t = raw == null ? null : DateTime.tryParse(raw);
|
||||
if (t == null || !t.isAfter(DateTime.now())) return null;
|
||||
return LicenseStatus(active: true, expiresAt: t);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _writeCache(String phone, DateTime expiresAt) =>
|
||||
_storage.write(key: _cacheKey(phone), value: expiresAt.toIso8601String());
|
||||
|
||||
Future<void> _clearCache(String phone) => _storage.delete(key: _cacheKey(phone));
|
||||
|
||||
static String _cacheKey(String phone) => 'license_expires_at:$phone';
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/// 充值套餐(来自后端 GET /api/v1/plans,价格以服务端 config.yml 为准)
|
||||
class Plan {
|
||||
final String id;
|
||||
final int days;
|
||||
final int priceYuan;
|
||||
|
||||
const Plan({
|
||||
required this.id,
|
||||
required this.days,
|
||||
required this.priceYuan,
|
||||
});
|
||||
|
||||
/// 展示名由天数派生(接口无 label 字段)
|
||||
String get label => '$days天';
|
||||
|
||||
factory Plan.fromJson(Map<String, dynamic> json) => Plan(
|
||||
id: json['planId'] as String,
|
||||
days: json['days'] as int,
|
||||
priceYuan: (json['priceCents'] as num) ~/ 100,
|
||||
);
|
||||
}
|
||||
|
||||
/// 支付渠道
|
||||
enum PayChannel { wechat, alipay }
|
||||
|
||||
/// 订单(由后端创建)
|
||||
class Order {
|
||||
final String orderId;
|
||||
|
||||
/// 微信支付参数(prepay_id / partner_id / nonce_str / time_stamp / sign 等)
|
||||
final Map<String, dynamic> wechatParams;
|
||||
|
||||
/// 支付宝订单串(orderStr)
|
||||
final String? alipayOrderStr;
|
||||
|
||||
const Order({
|
||||
required this.orderId,
|
||||
this.wechatParams = const {},
|
||||
this.alipayOrderStr,
|
||||
});
|
||||
}
|
||||
|
||||
/// 授权状态(服务端为准)
|
||||
class LicenseStatus {
|
||||
final bool active;
|
||||
final DateTime? expiresAt;
|
||||
|
||||
const LicenseStatus({required this.active, this.expiresAt});
|
||||
|
||||
bool get isActive => active && (expiresAt?.isAfter(DateTime.now()) ?? false);
|
||||
|
||||
factory LicenseStatus.fromJson(Map<String, dynamic> json) => LicenseStatus(
|
||||
active: json['active'] == true,
|
||||
expiresAt: json['expiresAt'] != null
|
||||
? DateTime.tryParse(json['expiresAt'] as String)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// 支付结果
|
||||
class PayResult {
|
||||
final bool success;
|
||||
final String? message;
|
||||
final String? orderId;
|
||||
|
||||
const PayResult({required this.success, this.message, this.orderId});
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:cupertino_http/cupertino_http.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../auth/session_store.dart';
|
||||
import '../config/app_config.dart';
|
||||
import 'models.dart';
|
||||
|
||||
/// 后端账号/支付/授权 API 客户端(契约见 docs/PaymentApi.md)。
|
||||
/// 后端未部署或请求失败时抛出 [OrderApiException];登录失效抛出 [SessionExpiredException],
|
||||
/// 由 UI 层回登录页。
|
||||
class OrderApiException implements Exception {
|
||||
final String message;
|
||||
const OrderApiException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// 登录已失效(后端返回 code 61):token 过期/被清,UI 应清除会话回登录页
|
||||
class SessionExpiredException extends OrderApiException {
|
||||
const SessionExpiredException() : super('登录已失效,请重新登录');
|
||||
}
|
||||
|
||||
class OrderApi {
|
||||
final String baseUrl;
|
||||
final SessionStore sessionStore;
|
||||
final http.Client _client;
|
||||
|
||||
// iOS 26 对 dart:io 原生 socket 访问本地网络存在拦截 bug(权限已允许仍
|
||||
// 拒绝连接),改用 NSURLSession 网络栈(CupertinoClient)绕过;非 Apple
|
||||
// 平台回退 IOClient。
|
||||
OrderApi({String? baseUrl, required this.sessionStore, http.Client? client})
|
||||
: baseUrl = baseUrl ?? AppConfig.apiBaseUrl,
|
||||
_client = client ?? _defaultHttpClient();
|
||||
|
||||
static http.Client _defaultHttpClient() {
|
||||
if (!kIsWeb && Platform.isIOS) {
|
||||
return CupertinoClient.defaultSessionConfiguration();
|
||||
}
|
||||
return http.Client();
|
||||
}
|
||||
|
||||
/// 注册账号;重复注册等业务错误由 [_decode] 抛出
|
||||
Future<void> register({
|
||||
required String phone,
|
||||
required String password,
|
||||
}) async {
|
||||
final res = await _post('/api/v1/auth/register',
|
||||
jsonEncode({'phone': phone, 'password': password}),
|
||||
auth: false);
|
||||
_decode(res);
|
||||
}
|
||||
|
||||
/// 登录,成功返回 token(由调用方存入 SessionStore)
|
||||
Future<String> login({
|
||||
required String phone,
|
||||
required String password,
|
||||
}) async {
|
||||
final res = await _post('/api/v1/auth/login',
|
||||
jsonEncode({'phone': phone, 'password': password}),
|
||||
auth: false);
|
||||
final json = _decode(res);
|
||||
return json['token'] as String;
|
||||
}
|
||||
|
||||
/// 创建订单,返回支付参数(微信 prepay 参数或支付宝 orderStr)
|
||||
Future<Order> createOrder({
|
||||
required String planId,
|
||||
required PayChannel channel,
|
||||
}) async {
|
||||
final body = jsonEncode({'planId': planId, 'channel': channel.name});
|
||||
final res = await _post('/api/v1/orders', body);
|
||||
final json = _decode(res);
|
||||
final params = (json['payParams'] as Map?)?.cast<String, dynamic>() ?? {};
|
||||
return Order(
|
||||
orderId: json['orderId'] as String,
|
||||
wechatParams: params,
|
||||
alipayOrderStr: params['orderStr'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// 客户端支付完成后通知服务端(幂等),服务端据异步回调落授权
|
||||
Future<void> confirmOrder(String orderId) async {
|
||||
final res =
|
||||
await _post('/api/v1/orders/$orderId/confirm', jsonEncode({}));
|
||||
_decode(res);
|
||||
}
|
||||
|
||||
/// 拉取套餐价格方案(config.yml 静态定价,客户端不硬编码)
|
||||
Future<List<Plan>> fetchPlans() async {
|
||||
final http.Response res;
|
||||
try {
|
||||
res = await _client.get(
|
||||
Uri.parse('$baseUrl/api/v1/plans'),
|
||||
headers: {'Accept': 'application/json', ...await _authHeaders()},
|
||||
).timeout(const Duration(seconds: 15));
|
||||
} catch (e) {
|
||||
if (e is OrderApiException) rethrow;
|
||||
throw OrderApiException('网络请求失败: $e');
|
||||
}
|
||||
final json = _decode(res);
|
||||
return (json['list'] as List)
|
||||
.map((e) => Plan.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 查询授权状态(服务端为准)
|
||||
Future<LicenseStatus> fetchLicense() async {
|
||||
final http.Response res;
|
||||
try {
|
||||
res = await _client.get(
|
||||
Uri.parse('$baseUrl/api/v1/license'),
|
||||
headers: {'Accept': 'application/json', ...await _authHeaders()},
|
||||
).timeout(const Duration(seconds: 15));
|
||||
} catch (e) {
|
||||
if (e is OrderApiException) rethrow;
|
||||
throw OrderApiException('网络请求失败: $e');
|
||||
}
|
||||
final json = _decode(res);
|
||||
return LicenseStatus.fromJson(json);
|
||||
}
|
||||
|
||||
Future<http.Response> _post(String path, String body,
|
||||
{bool auth = true}) async {
|
||||
try {
|
||||
return await _client
|
||||
.post(
|
||||
Uri.parse('$baseUrl$path'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...auth ? await _authHeaders() : const <String, String>{},
|
||||
},
|
||||
body: body,
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
} catch (e) {
|
||||
if (e is OrderApiException) rethrow;
|
||||
throw OrderApiException('网络请求失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, String>> _authHeaders() async {
|
||||
final token = await sessionStore.readToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
throw const SessionExpiredException();
|
||||
}
|
||||
return {'Authorization': 'Bearer $token'};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _decode(http.Response res) {
|
||||
final Map<String, dynamic> json;
|
||||
try {
|
||||
json = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw OrderApiException('服务端响应异常 (${res.statusCode})');
|
||||
}
|
||||
if (res.statusCode != 200 || json['code'] != 0) {
|
||||
final message = json['message'] as String? ?? '服务端错误 (${res.statusCode})';
|
||||
if (json['code'] == 61) {
|
||||
throw const SessionExpiredException();
|
||||
}
|
||||
throw OrderApiException(message);
|
||||
}
|
||||
return json['data'] as Map<String, dynamic>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fluwx/fluwx.dart' as fluwx;
|
||||
import 'package:tobias/tobias.dart' as tobias;
|
||||
|
||||
import '../config/app_config.dart';
|
||||
import 'models.dart';
|
||||
import 'order_api.dart';
|
||||
|
||||
/// 支付服务抽象:创建订单 + 拉起渠道支付 + 返回支付结果
|
||||
abstract class PaymentService {
|
||||
/// 支付完成后会通知服务端落授权;成功与否以 [PayResult.success] 为准
|
||||
Future<PayResult> pay(Plan plan, PayChannel channel);
|
||||
}
|
||||
|
||||
/// 微信支付(fluwx 实现)
|
||||
class WechatPayService implements PaymentService {
|
||||
WechatPayService({required this.orderApi});
|
||||
|
||||
final OrderApi orderApi;
|
||||
|
||||
@override
|
||||
Future<PayResult> pay(Plan plan, PayChannel channel) async {
|
||||
assert(channel == PayChannel.wechat);
|
||||
final order =
|
||||
await orderApi.createOrder(planId: plan.id, channel: channel);
|
||||
final p = order.wechatParams;
|
||||
|
||||
final launched = await fluwx.Fluwx().pay(
|
||||
which: fluwx.Payment(
|
||||
appId: AppConfig.wechatAppId,
|
||||
partnerId: p['partnerId'] as String? ?? '',
|
||||
prepayId: p['prepayId'] as String? ?? '',
|
||||
packageValue: p['packageValue'] as String? ?? 'Sign=WXPay',
|
||||
nonceStr: p['nonceStr'] as String? ?? '',
|
||||
timestamp: int.tryParse('${p['timeStamp'] ?? p['timestamp']}') ?? 0,
|
||||
sign: p['sign'] as String? ?? '',
|
||||
),
|
||||
);
|
||||
if (!launched) {
|
||||
return const PayResult(
|
||||
success: false, message: '未安装微信或拉起支付失败,请稍后再试');
|
||||
}
|
||||
|
||||
// 等待微信回调(errCode == 0 为成功),10s 超时
|
||||
final completer = Completer<PayResult>();
|
||||
final cancel = fluwx.Fluwx().addSubscriber((response) {
|
||||
if (response is! fluwx.WeChatPaymentResponse) return;
|
||||
completer.complete(PayResult(
|
||||
success: response.isSuccessful,
|
||||
message: response.isSuccessful ? null : (response.errStr ?? '微信支付未完成'),
|
||||
orderId: order.orderId,
|
||||
));
|
||||
});
|
||||
try {
|
||||
final result =
|
||||
await completer.future.timeout(const Duration(seconds: 10));
|
||||
if (result.success) await orderApi.confirmOrder(order.orderId);
|
||||
return result;
|
||||
} on TimeoutException {
|
||||
return PayResult(
|
||||
success: false,
|
||||
message: '等待微信支付结果超时,请确认支付状态',
|
||||
orderId: order.orderId);
|
||||
} finally {
|
||||
cancel.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 支付宝支付(tobias 实现,resultStatus 9000 为成功)
|
||||
class AlipayService implements PaymentService {
|
||||
AlipayService({required this.orderApi});
|
||||
|
||||
final OrderApi orderApi;
|
||||
|
||||
@override
|
||||
Future<PayResult> pay(Plan plan, PayChannel channel) async {
|
||||
assert(channel == PayChannel.alipay);
|
||||
final order =
|
||||
await orderApi.createOrder(planId: plan.id, channel: channel);
|
||||
final orderStr = order.alipayOrderStr;
|
||||
if (orderStr == null) {
|
||||
return const PayResult(success: false, message: '订单缺少支付参数');
|
||||
}
|
||||
|
||||
final map = await tobias.Tobias().pay(
|
||||
orderStr,
|
||||
universalLink: AppConfig.alipayUniversalLink,
|
||||
);
|
||||
final status = map['resultStatus']?.toString() ?? '';
|
||||
final ok = status == '9000';
|
||||
if (ok) await orderApi.confirmOrder(order.orderId);
|
||||
return PayResult(
|
||||
success: ok,
|
||||
message: ok ? null : _alipayMessage(status),
|
||||
orderId: order.orderId,
|
||||
);
|
||||
}
|
||||
|
||||
static String _alipayMessage(String status) => switch (status) {
|
||||
'8000' => '支付结果确认中,请稍后查看',
|
||||
'6001' => '用户取消支付',
|
||||
'6002' => '网络异常,支付未完成',
|
||||
'6004' => '支付结果未知,请查询订单状态',
|
||||
_ => '支付宝支付未完成',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'models.dart';
|
||||
import 'paywall_view_model.dart';
|
||||
|
||||
/// 付费墙:拉取套餐价格 → 选套餐 → 微信/支付宝支付 → 解锁进入相机
|
||||
class PaywallScreen extends StatefulWidget {
|
||||
const PaywallScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PaywallScreen> createState() => _PaywallScreenState();
|
||||
}
|
||||
|
||||
class _PaywallScreenState extends State<PaywallScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<PaywallViewModel>().loadPlans();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final vm = context.watch<PaywallViewModel>();
|
||||
final loading = vm.state.state == PayState.loading;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('视野 · 会员'), centerTitle: true),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(24, 16, 24, 16),
|
||||
child: Text(
|
||||
'开通会员解锁完整功能,按自然日计费,到期自动失效',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 13),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
children: [
|
||||
_PlansSection(vm: vm, loading: loading),
|
||||
const SizedBox(height: 24),
|
||||
_PayButton(
|
||||
label: '微信支付',
|
||||
icon: Icons.wechat,
|
||||
color: const Color(0xFF07C160),
|
||||
enabled: !loading && vm.state.selectedPlan != null,
|
||||
onTap: loading
|
||||
? null
|
||||
: () async {
|
||||
final license = await vm.pay(PayChannel.wechat);
|
||||
if (license != null && context.mounted) {
|
||||
_onPaid(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_PayButton(
|
||||
label: '支付宝支付',
|
||||
icon: Icons.account_balance_wallet,
|
||||
color: const Color(0xFF1677FF),
|
||||
enabled: !loading && vm.state.selectedPlan != null,
|
||||
onTap: loading
|
||||
? null
|
||||
: () async {
|
||||
final license = await vm.pay(PayChannel.alipay);
|
||||
if (license != null && context.mounted) {
|
||||
_onPaid(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (vm.state.state == PayState.failed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Text(
|
||||
vm.state.message ?? '支付失败',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 支付成功:返回主界面(主界面刷新展示新到期时间)
|
||||
void _onPaid(BuildContext context) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// 套餐区三态:加载中 / 失败可重试 / 套餐卡片列表
|
||||
class _PlansSection extends StatelessWidget {
|
||||
final PaywallViewModel vm;
|
||||
final bool loading;
|
||||
|
||||
const _PlansSection({required this.vm, required this.loading});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (vm.plansLoading && vm.plans.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 48),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (vm.plansError != null && vm.plans.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
vm.plansError!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: () => context.read<PaywallViewModel>().loadPlans(),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
...vm.plans.map((p) => _PlanCard(
|
||||
plan: p,
|
||||
selected: vm.state.selectedPlan?.id == p.id,
|
||||
enabled: !loading,
|
||||
onTap: () => vm.selectPlan(p),
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlanCard extends StatelessWidget {
|
||||
final Plan plan;
|
||||
final bool selected;
|
||||
final bool enabled;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _PlanCard({
|
||||
required this.plan,
|
||||
required this.selected,
|
||||
required this.enabled,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: selected ? 3 : 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: selected ? Colors.orange : Colors.grey.shade300,
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
onTap: enabled ? onTap : null,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
title: Text(
|
||||
plan.label,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 17),
|
||||
),
|
||||
subtitle: Text('${plan.days} 天 · 自然日'),
|
||||
trailing: Text(
|
||||
'¥${plan.priceYuan}',
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PayButton extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final bool enabled;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _PayButton({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.enabled,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: color,
|
||||
disabledBackgroundColor: Colors.grey.shade300,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
),
|
||||
onPressed: enabled ? onTap : null,
|
||||
icon: Icon(icon, size: 20),
|
||||
label: Text(label, style: const TextStyle(fontSize: 16)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'license_service.dart';
|
||||
import 'models.dart';
|
||||
import 'order_api.dart';
|
||||
import 'payment_service.dart';
|
||||
|
||||
enum PayState { idle, loading, success, failed }
|
||||
|
||||
@immutable
|
||||
class PaywallUiState {
|
||||
final PayState state;
|
||||
final String? message;
|
||||
final Plan? selectedPlan;
|
||||
|
||||
const PaywallUiState({
|
||||
this.state = PayState.idle,
|
||||
this.message,
|
||||
this.selectedPlan,
|
||||
});
|
||||
}
|
||||
|
||||
class PaywallViewModel extends ChangeNotifier {
|
||||
final OrderApi orderApi;
|
||||
final LicenseService licenseService;
|
||||
final Map<PayChannel, PaymentService> services;
|
||||
|
||||
PaywallUiState _state = const PaywallUiState();
|
||||
PaywallUiState get state => _state;
|
||||
|
||||
List<Plan> _plans = const [];
|
||||
bool _plansLoading = false;
|
||||
String? _plansError;
|
||||
|
||||
/// 套餐价格方案(来自后端,客户端不硬编码)
|
||||
List<Plan> get plans => _plans;
|
||||
bool get plansLoading => _plansLoading;
|
||||
String? get plansError => _plansError;
|
||||
|
||||
PaywallViewModel({
|
||||
required this.orderApi,
|
||||
required this.licenseService,
|
||||
required this.services,
|
||||
});
|
||||
|
||||
/// 进充值页时拉取套餐;已有数据或加载中跳过,失败可重试
|
||||
Future<void> loadPlans() async {
|
||||
if (_plans.isNotEmpty || _plansLoading) return;
|
||||
_plansLoading = true;
|
||||
_plansError = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
_plans = await orderApi.fetchPlans();
|
||||
} on SessionExpiredException {
|
||||
_plansError = '登录已失效,请重新登录后再充值';
|
||||
} on OrderApiException catch (e) {
|
||||
_plansError = e.message;
|
||||
} finally {
|
||||
_plansLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void selectPlan(Plan? plan) {
|
||||
_state = PaywallUiState(selectedPlan: plan);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 发起支付;成功后刷新授权并通知页面进入相机
|
||||
Future<LicenseStatus?> pay(PayChannel channel) async {
|
||||
final plan = _state.selectedPlan;
|
||||
if (plan == null) return null;
|
||||
final service = services[channel];
|
||||
if (service == null) return null;
|
||||
|
||||
_state = PaywallUiState(state: PayState.loading, selectedPlan: plan);
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final result = await service.pay(plan, channel);
|
||||
if (!result.success) {
|
||||
_state = PaywallUiState(
|
||||
state: PayState.failed,
|
||||
message: result.message ?? '支付失败,请重试',
|
||||
selectedPlan: plan,
|
||||
);
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
final license = await licenseService.refresh();
|
||||
_state = PaywallUiState(
|
||||
state: PayState.success,
|
||||
selectedPlan: plan,
|
||||
);
|
||||
notifyListeners();
|
||||
return license;
|
||||
} on SessionExpiredException {
|
||||
_state = PaywallUiState(
|
||||
state: PayState.failed,
|
||||
message: '登录已失效,请重新登录后再充值',
|
||||
selectedPlan: plan,
|
||||
);
|
||||
notifyListeners();
|
||||
return null;
|
||||
} on OrderApiException catch (e) {
|
||||
_state = PaywallUiState(
|
||||
state: PayState.failed,
|
||||
message: '支付不可用: ${e.message}',
|
||||
selectedPlan: plan,
|
||||
);
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_state = const PaywallUiState();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user