Compare commits
5 Commits
fade61ac21
...
sccsbc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a9d56b07c | ||
|
|
e52e21d295 | ||
| ec5af19d30 | |||
|
|
bd4350c17f | ||
|
|
fe4c81c54f |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,6 +4,10 @@
|
||||
*.key
|
||||
*.pfx
|
||||
|
||||
# Android release keystore
|
||||
health_app/android/**/*.jks
|
||||
health_app/android/key.properties
|
||||
|
||||
# .NET build outputs
|
||||
backend/**/bin/
|
||||
backend/**/obj/
|
||||
|
||||
@@ -2,12 +2,14 @@ namespace Health.Application.Auth;
|
||||
|
||||
public sealed record AuthResult(int Code, object? Data, string? Message = null);
|
||||
public sealed record RegisterCommand(string Phone, string SmsCode, string Name, Guid DoctorId);
|
||||
public sealed record AppleLoginCommand(string IdentityToken, string? AuthorizationCode, string? Name);
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct);
|
||||
Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct);
|
||||
Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct);
|
||||
Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct);
|
||||
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct);
|
||||
Task LogoutAsync(string refreshToken, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace Health.Application.Users;
|
||||
|
||||
public sealed record UserProfileDto(
|
||||
Guid Id,
|
||||
string Phone,
|
||||
string? Phone, // Apple 用户无手机号
|
||||
string Role,
|
||||
string? Name,
|
||||
string? Gender,
|
||||
|
||||
@@ -6,7 +6,8 @@ namespace Health.Domain.Entities;
|
||||
public sealed class User
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
public string? Phone { get; set; } // Apple 用户无手机号
|
||||
public string? AppleUserId { get; set; } // Apple Sign-In 唯一标识
|
||||
public string Role { get; set; } = "User"; // "User" | "Doctor" | "Admin"
|
||||
public Guid? DoctorId { get; set; } // 患者选择的医生
|
||||
public string? Name { get; set; }
|
||||
|
||||
@@ -3,7 +3,12 @@ using Health.Infrastructure.Services;
|
||||
|
||||
namespace Health.Infrastructure.Auth;
|
||||
|
||||
public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms) : IAuthService
|
||||
public sealed class AuthService(
|
||||
AppDbContext db,
|
||||
JwtProvider jwt,
|
||||
SmsService sms,
|
||||
AppleTokenValidator appleValidator // Apple Sign-In JWT 验证
|
||||
) : IAuthService
|
||||
{
|
||||
private const string AdminPhone = "12345678910";
|
||||
private const string AdminSmsCode = "000000";
|
||||
@@ -11,6 +16,7 @@ public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms
|
||||
private readonly AppDbContext _db = db;
|
||||
private readonly JwtProvider _jwt = jwt;
|
||||
private readonly SmsService _sms = sms;
|
||||
private readonly AppleTokenValidator _appleValidator = appleValidator;
|
||||
|
||||
public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct)
|
||||
{
|
||||
@@ -99,13 +105,64 @@ public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct)
|
||||
{
|
||||
// 1. 验证 Apple identityToken
|
||||
string appleUserId;
|
||||
try
|
||||
{
|
||||
appleUserId = await _appleValidator.ValidateAsync(command.IdentityToken, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Error(40005, $"Apple 登录验证失败: {ex.Message}");
|
||||
}
|
||||
|
||||
// 2. 查找已有用户
|
||||
var existingUser = await _db.Users.FirstOrDefaultAsync(x => x.AppleUserId == appleUserId, ct);
|
||||
if (existingUser != null)
|
||||
{
|
||||
existingUser.UpdatedAt = DateTime.UtcNow;
|
||||
if (!string.IsNullOrWhiteSpace(command.Name)) existingUser.Name ??= command.Name;
|
||||
var tokens = AddTokens(existingUser.Id, existingUser.Phone, existingUser.Role);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new AuthResult(0, new
|
||||
{
|
||||
tokens.accessToken, tokens.refreshToken,
|
||||
user = new { existingUser.Id, existingUser.Phone, existingUser.Role, existingUser.Name, existingUser.Gender, existingUser.AvatarUrl, isNew = false }
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 新用户 → 创建账户(无需手机号、无需选医生)
|
||||
var newUser = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Phone = null,
|
||||
AppleUserId = appleUserId,
|
||||
Role = "User",
|
||||
Name = command.Name,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
_db.Users.Add(newUser);
|
||||
_db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = newUser.Id });
|
||||
_db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = newUser.Id });
|
||||
var newTokens = AddTokens(newUser.Id, null, newUser.Role);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new AuthResult(0, new
|
||||
{
|
||||
newTokens.accessToken, newTokens.refreshToken,
|
||||
user = new { newUser.Id, Phone = (string?)null, newUser.Role, newUser.Name, isNew = true }
|
||||
});
|
||||
}
|
||||
|
||||
private Task<VerificationCode?> TakeCodeAsync(string phone, string code, CancellationToken ct) =>
|
||||
_db.VerificationCodes.Where(x => x.Phone == phone && x.Code == code && x.ExpiresAt > DateTime.UtcNow && !x.IsUsed)
|
||||
.OrderByDescending(x => x.CreatedAt).FirstOrDefaultAsync(ct);
|
||||
|
||||
private (string accessToken, string refreshToken) AddTokens(Guid userId, string phone, string role)
|
||||
private (string accessToken, string refreshToken) AddTokens(Guid userId, string? phone, string role)
|
||||
{
|
||||
var access = _jwt.GenerateAccessToken(userId, phone, role);
|
||||
var access = _jwt.GenerateAccessToken(userId, phone ?? "", role);
|
||||
var refresh = _jwt.GenerateRefreshToken();
|
||||
_db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) });
|
||||
return (access, refresh);
|
||||
|
||||
1466
backend/src/Health.Infrastructure/Data/Migrations/20260710052811_AddAppleSignIn.Designer.cs
generated
Normal file
1466
backend/src/Health.Infrastructure/Data/Migrations/20260710052811_AddAppleSignIn.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Health.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAppleSignIn : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Phone",
|
||||
table: "Users",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "AppleUserId",
|
||||
table: "Users",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_AppleUserId",
|
||||
table: "Users",
|
||||
column: "AppleUserId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Users_AppleUserId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AppleUserId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Phone",
|
||||
table: "Users",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldNullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -803,6 +803,9 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AppleUserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AvatarUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -822,7 +825,6 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Role")
|
||||
@@ -837,6 +839,9 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AppleUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("DoctorId");
|
||||
|
||||
b.HasIndex("Phone")
|
||||
|
||||
@@ -50,6 +50,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
builder.Entity<User>(e =>
|
||||
{
|
||||
e.HasIndex(u => u.Phone).IsUnique();
|
||||
e.HasIndex(u => u.AppleUserId).IsUnique(); // Apple Sign-In 唯一标识
|
||||
e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User");
|
||||
e.HasOne(u => u.Doctor).WithMany().HasForeignKey(u => u.DoctorId).IsRequired(false).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Health.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Apple IdentityToken 验证:下载 Apple 公钥、验签、检查声明
|
||||
/// </summary>
|
||||
public sealed class AppleTokenValidator
|
||||
{
|
||||
private const string AppleKeysUrl = "https://appleid.apple.com/auth/keys";
|
||||
private const string AppleIssuer = "https://appleid.apple.com";
|
||||
private static readonly HttpClient _http = new();
|
||||
private static List<JsonWebKey>? _cachedKeys;
|
||||
private static DateTime _keysExpiry = DateTime.MinValue;
|
||||
private static readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
private readonly string _clientId;
|
||||
|
||||
public AppleTokenValidator(IConfiguration config)
|
||||
{
|
||||
_clientId = config["APPLE_CLIENT_ID"] ?? "com.datalumina.YYA";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 Apple identityToken,返回 sub(用户唯一标识)
|
||||
/// </summary>
|
||||
public async Task<string> ValidateAsync(string identityToken, CancellationToken ct)
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
// 先解码获取 kid
|
||||
var rawToken = handler.ReadJwtToken(identityToken);
|
||||
var kid = rawToken.Header.Kid;
|
||||
|
||||
var keys = await GetKeysAsync(ct);
|
||||
var key = keys.FirstOrDefault(k => k.KeyId == kid)
|
||||
?? throw new SecurityTokenException($"No matching key for kid: {kid}");
|
||||
|
||||
var validationParams = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = AppleIssuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudiences = [_clientId, "com.datalumina.YYA.signin"],
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = key,
|
||||
ClockSkew = TimeSpan.FromMinutes(1),
|
||||
};
|
||||
|
||||
handler.ValidateToken(identityToken, validationParams, out _);
|
||||
// 从原始 JWT 取 sub(避免 .NET 的 ClaimType 映射把 "sub" 转成 NameIdentifier)
|
||||
var sub = rawToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
|
||||
if (string.IsNullOrWhiteSpace(sub))
|
||||
throw new SecurityTokenException("Missing 'sub' claim in Apple identityToken");
|
||||
|
||||
return sub;
|
||||
}
|
||||
|
||||
private static async Task<List<JsonWebKey>> GetKeysAsync(CancellationToken ct)
|
||||
{
|
||||
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
||||
|
||||
await _lock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
||||
|
||||
var response = await _http.GetStringAsync(AppleKeysUrl, ct);
|
||||
var jwks = new JsonWebKeySet(response);
|
||||
_cachedKeys = jwks.Keys.ToList();
|
||||
_keysExpiry = DateTime.UtcNow.AddHours(6); // 缓存 6 小时
|
||||
return _cachedKeys;
|
||||
}
|
||||
finally { _lock.Release(); }
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ public static class AuthEndpoints
|
||||
ToResult(await auth.RegisterAsync(new RegisterCommand(request.Phone, request.SmsCode, request.Name, request.DoctorId), ct)));
|
||||
app.MapPost("/api/auth/login", async (LoginRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.LoginAsync(request.Phone, request.SmsCode, ct)));
|
||||
app.MapPost("/api/auth/apple-login", async (AppleLoginRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.AppleLoginAsync(new AppleLoginCommand(request.IdentityToken, request.AuthorizationCode, request.Name), ct)));
|
||||
app.MapPost("/api/auth/refresh", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.RefreshAsync(request.RefreshToken, ct)));
|
||||
app.MapPost("/api/auth/logout", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
@@ -28,4 +30,5 @@ public static class AuthEndpoints
|
||||
public sealed record SendSmsRequest(string Phone);
|
||||
public sealed record RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId);
|
||||
public sealed record LoginRequest(string Phone, string SmsCode);
|
||||
public sealed record AppleLoginRequest(string IdentityToken, string? AuthorizationCode, string? Name);
|
||||
public sealed record RefreshRequest(string RefreshToken);
|
||||
|
||||
@@ -108,6 +108,7 @@ builder.Services.AddAuthorization();
|
||||
// ---- 业务服务 ----
|
||||
builder.Services.AddSingleton<JwtProvider>();
|
||||
builder.Services.AddSingleton<SmsService>();
|
||||
builder.Services.AddSingleton<AppleTokenValidator>(); // Apple Sign-In JWT 验证
|
||||
builder.Services.AddSingleton<PromptManager>();
|
||||
builder.Services.AddScoped<IAiWriteConfirmationStore, EfAiWriteConfirmationStore>();
|
||||
builder.Services.AddScoped<IAiToolExecutionService, AiToolExecutionService>();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Default": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
|
||||
688
docs/superpowers/plans/2026-07-10-apple-sign-in.md
Normal file
688
docs/superpowers/plans/2026-07-10-apple-sign-in.md
Normal file
@@ -0,0 +1,688 @@
|
||||
# Apple Sign-In 集成实施方案
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为健康管家 App 集成 Sign in with Apple,让 iOS 用户可以通过 Apple ID 一键登录/注册。
|
||||
|
||||
**Architecture:** 后端新增 `POST /api/auth/apple-login` 端点,验证 Apple 返回的 identityToken(JWT),查找或创建 User 记录(无需手机号),返回 JWT token。前端添加 `sign_in_with_apple` Flutter 包,在登录页放置 Apple 登录按钮。
|
||||
|
||||
**Tech Stack:** Flutter 3.44 + Dart 3.12 | C# .NET 10 + EF Core + PostgreSQL | sign_in_with_apple 包 | Apple RSA 公钥验证
|
||||
|
||||
**设计决策:**
|
||||
- User.Phone 改为可空(Apple 用户无手机号),PostgreSQL 对多个 NULL 值不违反唯一索引
|
||||
- 新增 `AppleUserId` 字段(可空字符串 + 唯一索引)标识 Apple 用户
|
||||
- Apple 登录首次无需绑定医生(doctorId 可为空),用户可在个人中心后续设置
|
||||
- Apple identityToken 验证:缓存 Apple 公钥,本地验签,验证 audience/issuer/有效期
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 后端 — User 实体 + DB 迁移
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/Health.Domain/Entities/user.cs`
|
||||
- Modify: `backend/src/Health.Infrastructure/Data/app_db_context.cs`
|
||||
|
||||
- [ ] **Step 1: User 实体添加 AppleUserId 字段,Phone 改为可空**
|
||||
|
||||
`backend/src/Health.Domain/Entities/user.cs`:
|
||||
|
||||
```csharp
|
||||
namespace Health.Domain.Entities;
|
||||
|
||||
public sealed class User
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string? Phone { get; set; } // 改为可空,Apple 用户无手机号
|
||||
public string? AppleUserId { get; set; } // Apple 唯一标识
|
||||
public string Role { get; set; } = "User";
|
||||
public Guid? DoctorId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
// ... 其余字段不变
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: AppDbContext 添加 AppleUserId 配置**
|
||||
|
||||
`backend/src/Health.Infrastructure/Data/app_db_context.cs` 的 User 配置段:
|
||||
|
||||
```csharp
|
||||
builder.Entity<User>(e =>
|
||||
{
|
||||
e.HasIndex(u => u.Phone).IsUnique();
|
||||
e.HasIndex(u => u.AppleUserId).IsUnique(); // 新增
|
||||
e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证编译**
|
||||
|
||||
```bash
|
||||
cd backend/src/Health.WebApi && dotnet build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 后端 — Apple JWT 验证服务
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/src/Health.Infrastructure/Services/apple_token_validator.cs`
|
||||
|
||||
- [ ] **Step 1: 创建 AppleTokenValidator**
|
||||
|
||||
`backend/src/Health.Infrastructure/Services/apple_token_validator.cs`:
|
||||
|
||||
```csharp
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Health.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Apple IdentityToken 验证:下载公钥、验签、检查声明
|
||||
/// </summary>
|
||||
public sealed class AppleTokenValidator
|
||||
{
|
||||
private const string AppleKeysUrl = "https://appleid.apple.com/auth/keys";
|
||||
private const string AppleIssuer = "https://appleid.apple.com";
|
||||
private static readonly HttpClient _http = new();
|
||||
private static List<JsonWebKey>? _cachedKeys;
|
||||
private static DateTime _keysExpiry = DateTime.MinValue;
|
||||
private static readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
private readonly string _clientId;
|
||||
|
||||
public AppleTokenValidator(IConfiguration config)
|
||||
{
|
||||
_clientId = config["APPLE_CLIENT_ID"] ?? "com.datalumina.YYA";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 Apple identityToken,返回 sub(用户唯一标识)
|
||||
/// </summary>
|
||||
public async Task<string> ValidateAsync(string identityToken, CancellationToken ct)
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
// 先解码 header 获取 kid
|
||||
var rawToken = handler.ReadJwtToken(identityToken);
|
||||
var kid = rawToken.Header.Kid;
|
||||
|
||||
var keys = await GetKeysAsync(ct);
|
||||
var key = keys.FirstOrDefault(k => k.KeyId == kid)
|
||||
?? throw new SecurityTokenException($"No matching key for kid: {kid}");
|
||||
|
||||
var validationParams = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = AppleIssuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudiences = [_clientId, "com.datalumina.YYA.signin"],
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = key,
|
||||
ClockSkew = TimeSpan.FromMinutes(1),
|
||||
};
|
||||
|
||||
var principal = handler.ValidateToken(identityToken, validationParams, out _);
|
||||
var sub = principal.FindFirst("sub")?.Value;
|
||||
if (string.IsNullOrWhiteSpace(sub))
|
||||
throw new SecurityTokenException("Missing 'sub' claim in Apple identityToken");
|
||||
|
||||
return sub;
|
||||
}
|
||||
|
||||
private static async Task<List<JsonWebKey>> GetKeysAsync(CancellationToken ct)
|
||||
{
|
||||
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
||||
|
||||
await _lock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
||||
|
||||
var response = await _http.GetStringAsync(AppleKeysUrl, ct);
|
||||
var jwks = new JsonWebKeySet(response);
|
||||
_cachedKeys = jwks.Keys.ToList();
|
||||
_keysExpiry = DateTime.UtcNow.AddHours(6); // 缓存 6 小时
|
||||
return _cachedKeys;
|
||||
}
|
||||
finally { _lock.Release(); }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 注册服务到 DI**
|
||||
|
||||
在 `backend/src/Health.WebApi/Program.cs` 的 services 注册区添加:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddSingleton<AppleTokenValidator>();
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证编译**
|
||||
|
||||
```bash
|
||||
cd backend/src/Health.WebApi && dotnet build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 后端 — AuthService 添加 Apple 登录
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/Health.Application/Auth/AuthContracts.cs`
|
||||
- Modify: `backend/src/Health.Infrastructure/Auth/AuthService.cs`
|
||||
|
||||
- [ ] **Step 1: 添加 AppleLoginCommand DTO 和接口方法**
|
||||
|
||||
`backend/src/Health.Application/Auth/AuthContracts.cs`:
|
||||
|
||||
```csharp
|
||||
namespace Health.Application.Auth;
|
||||
|
||||
public sealed record AuthResult(int Code, object? Data, string? Message = null);
|
||||
public sealed record RegisterCommand(string Phone, string SmsCode, string Name, Guid DoctorId);
|
||||
public sealed record AppleLoginCommand(string IdentityToken, string? AuthorizationCode, string? Name); // 新增
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct);
|
||||
Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct);
|
||||
Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct);
|
||||
Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct); // 新增
|
||||
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct);
|
||||
Task LogoutAsync(string refreshToken, CancellationToken ct);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: AuthService 实现 AppleLoginAsync**
|
||||
|
||||
`backend/src/Health.Infrastructure/Auth/AuthService.cs`:
|
||||
|
||||
在类的顶部依赖注入区添加 `AppleTokenValidator`:
|
||||
|
||||
```csharp
|
||||
public sealed class AuthService(
|
||||
AppDbContext db,
|
||||
JwtProvider jwt,
|
||||
SmsService sms,
|
||||
AppleTokenValidator appleValidator // 新增
|
||||
) : IAuthService
|
||||
{
|
||||
private readonly AppDbContext _db = db;
|
||||
private readonly JwtProvider _jwt = jwt;
|
||||
private readonly SmsService _sms = sms;
|
||||
private readonly AppleTokenValidator _appleValidator = appleValidator;
|
||||
// ... 现有代码不变
|
||||
```
|
||||
|
||||
在 `LogoutAsync` 方法之后添加新方法,并修改 `AddTokens` 的 phone 参数类型为 `string?`:
|
||||
|
||||
```csharp
|
||||
public async Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct)
|
||||
{
|
||||
// 1. 验证 Apple identityToken
|
||||
string appleUserId;
|
||||
try
|
||||
{
|
||||
appleUserId = await _appleValidator.ValidateAsync(command.IdentityToken, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Error(40005, $"Apple 登录验证失败: {ex.Message}");
|
||||
}
|
||||
|
||||
// 2. 查找已有用户
|
||||
var existingUser = await _db.Users.FirstOrDefaultAsync(x => x.AppleUserId == appleUserId, ct);
|
||||
if (existingUser != null)
|
||||
{
|
||||
// 已有用户 → 直接登录
|
||||
existingUser.UpdatedAt = DateTime.UtcNow;
|
||||
if (!string.IsNullOrWhiteSpace(command.Name)) existingUser.Name ??= command.Name;
|
||||
var tokens = AddTokens(existingUser.Id, existingUser.Phone, existingUser.Role);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new AuthResult(0, new
|
||||
{
|
||||
tokens.accessToken, tokens.refreshToken,
|
||||
user = new { existingUser.Id, existingUser.Phone, existingUser.Role, existingUser.Name, existingUser.Gender, existingUser.AvatarUrl, isNew = false }
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 新用户 → 创建账户(无需手机号、无需选医生)
|
||||
var newUser = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Phone = null, // Apple 用户未绑定手机
|
||||
AppleUserId = appleUserId,
|
||||
Role = "User",
|
||||
Name = command.Name,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
_db.Users.Add(newUser);
|
||||
_db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = newUser.Id });
|
||||
_db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = newUser.Id });
|
||||
var newTokens = AddTokens(newUser.Id, null, newUser.Role);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new AuthResult(0, new
|
||||
{
|
||||
newTokens.accessToken, newTokens.refreshToken,
|
||||
user = new { newUser.Id, Phone = (string?)null, newUser.Role, newUser.Name, isNew = true }
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
同时修改 `AddTokens` 方法签名,phone 改为可空:
|
||||
|
||||
```csharp
|
||||
private (string accessToken, string refreshToken) AddTokens(Guid userId, string? phone, string role)
|
||||
{
|
||||
var access = _jwt.GenerateAccessToken(userId, phone ?? "", role);
|
||||
var refresh = _jwt.GenerateRefreshToken();
|
||||
_db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) });
|
||||
return (access, refresh);
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: `AddTokens` 的 phone 参数改为 `string?`,`GenerateAccessToken` 接收到空字符串(Apple 用户无需手机号)。`RefreshAsync` 和 `RegisterAsync` 中现有的 `AddTokens` 调用无需修改(传入 `string?` 的 `user.Phone` 自动匹配)。Admin 的 `AddTokens(AdminId, AdminPhone, "Admin")`(`AdminPhone` 是 `string` 常量)也无需修改。
|
||||
|
||||
- [ ] **Step 3: 验证编译**
|
||||
|
||||
```bash
|
||||
cd backend/src/Health.WebApi && dotnet build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 后端 — 添加 Apple 登录端点
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/Health.WebApi/Endpoints/auth_endpoints.cs`
|
||||
|
||||
- [ ] **Step 1: 添加 AppleLoginRequest 和端点**
|
||||
|
||||
`backend/src/Health.WebApi/Endpoints/auth_endpoints.cs`:
|
||||
|
||||
在现有端点中添加:
|
||||
|
||||
```csharp
|
||||
// 在 MapAuthEndpoints 方法内、logout 端点之后添加:
|
||||
app.MapPost("/api/auth/apple-login", async (AppleLoginRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.AppleLoginAsync(new AppleLoginCommand(request.IdentityToken, request.AuthorizationCode, request.Name), ct)));
|
||||
```
|
||||
|
||||
在文件末尾添加 DTO:
|
||||
|
||||
```csharp
|
||||
public sealed record AppleLoginRequest(string IdentityToken, string? AuthorizationCode, string? Name);
|
||||
```
|
||||
|
||||
文件最终结果为:
|
||||
|
||||
```csharp
|
||||
using Health.Application.Auth;
|
||||
|
||||
namespace Health.WebApi.Endpoints;
|
||||
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
public static void MapAuthEndpoints(this WebApplication app)
|
||||
{
|
||||
app.MapPost("/api/auth/send-sms", async (SendSmsRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.SendCodeAsync(request.Phone, app.Environment.IsDevelopment(), ct)));
|
||||
app.MapPost("/api/auth/register", async (RegisterRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.RegisterAsync(new RegisterCommand(request.Phone, request.SmsCode, request.Name, request.DoctorId), ct)));
|
||||
app.MapPost("/api/auth/login", async (LoginRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.LoginAsync(request.Phone, request.SmsCode, ct)));
|
||||
app.MapPost("/api/auth/apple-login", async (AppleLoginRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.AppleLoginAsync(new AppleLoginCommand(request.IdentityToken, request.AuthorizationCode, request.Name), ct)));
|
||||
app.MapPost("/api/auth/refresh", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.RefreshAsync(request.RefreshToken, ct)));
|
||||
app.MapPost("/api/auth/logout", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
{
|
||||
await auth.LogoutAsync(request.RefreshToken, ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult ToResult(AuthResult result) =>
|
||||
Results.Ok(new { code = result.Code, data = result.Data, message = result.Message });
|
||||
}
|
||||
|
||||
public sealed record SendSmsRequest(string Phone);
|
||||
public sealed record RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId);
|
||||
public sealed record LoginRequest(string Phone, string SmsCode);
|
||||
public sealed record AppleLoginRequest(string IdentityToken, string? AuthorizationCode, string? Name);
|
||||
public sealed record RefreshRequest(string RefreshToken);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证编译**
|
||||
|
||||
```bash
|
||||
cd backend/src/Health.WebApi && dotnet build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 后端 — 重启并测试端点连通性
|
||||
|
||||
- [ ] **Step 1: 重启后端服务**
|
||||
|
||||
```bash
|
||||
cd backend/src/Health.WebApi && dotnet run &
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 测试无 token 调用(预期返回 40005,token 无效)**
|
||||
|
||||
```bash
|
||||
curl -s -w "\nHTTP %{http_code}" -X POST "http://localhost:5000/api/auth/apple-login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"identityToken":"invalid","name":"test"}'
|
||||
```
|
||||
|
||||
Expected: HTTP 200, code=40005, message 包含"验证失败"
|
||||
|
||||
- [ ] **Step 3: 测试真实场景**
|
||||
|
||||
此时无法用 curl 直接测(需要真实的 Apple identityToken)。稍后在 Task 8 中从 App 端联合验证。
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 前端 — 添加 sign_in_with_apple 依赖
|
||||
|
||||
**Files:**
|
||||
- Modify: `health_app/pubspec.yaml`
|
||||
|
||||
- [ ] **Step 1: 添加依赖**
|
||||
|
||||
`health_app/pubspec.yaml`,在 dependencies 区添加:
|
||||
|
||||
```yaml
|
||||
# Apple 登录
|
||||
sign_in_with_apple: ^6.1.0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 安装依赖**
|
||||
|
||||
```bash
|
||||
cd health_app && flutter pub get
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 前端 — AuthProvider 添加 Apple 登录方法
|
||||
|
||||
**Files:**
|
||||
- Modify: `health_app/lib/providers/auth_provider.dart`
|
||||
|
||||
- [ ] **Step 1: 添加 appleLogin 方法**
|
||||
|
||||
`health_app/lib/providers/auth_provider.dart`,在 `login()` 方法之后、`logout()` 方法之前添加:
|
||||
|
||||
```dart
|
||||
/// Apple 登录
|
||||
Future<String?> appleLogin(String identityToken, String? authorizationCode, String? name) async {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final response = await api.post(
|
||||
'/api/auth/apple-login',
|
||||
data: {
|
||||
'identityToken': identityToken,
|
||||
if (authorizationCode != null) 'authorizationCode': authorizationCode,
|
||||
if (name != null && name.isNotEmpty) 'name': name,
|
||||
},
|
||||
);
|
||||
final data = response.data['data'];
|
||||
if (data == null) return response.data['message'] ?? 'Apple 登录失败';
|
||||
|
||||
await api.saveTokens(data['accessToken'], data['refreshToken']);
|
||||
final user = data['user'];
|
||||
state = AuthState(
|
||||
isLoggedIn: true,
|
||||
isLoading: false,
|
||||
user: UserInfo(
|
||||
id: user['id'] ?? '',
|
||||
phone: user['phone'] ?? '',
|
||||
role: user['role'] ?? 'User',
|
||||
name: user['name'],
|
||||
avatarUrl: user['avatarUrl'],
|
||||
),
|
||||
);
|
||||
return null;
|
||||
} catch (e) {
|
||||
return 'Apple 登录失败: $e';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**: `UserInfo` 的 `phone` 字段当前是 `required String`。Apple 用户可能无手机号。`user['phone']` 可能是 null。在 login() 方法中 Phone 断言 `phone: user['phone'] ?? ''` 已有兜底,appleLogin 保持一致。
|
||||
|
||||
- [ ] **Step 2: 编译检查**
|
||||
|
||||
```bash
|
||||
cd health_app && flutter analyze lib/providers/auth_provider.dart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 前端 — 登录页添加 Apple 登录按钮
|
||||
|
||||
**Files:**
|
||||
- Modify: `health_app/lib/pages/auth/login_page.dart`
|
||||
|
||||
- [ ] **Step 1: 添加 Apple 登录按钮组件和点击逻辑**
|
||||
|
||||
`health_app/lib/pages/auth/login_page.dart`:
|
||||
|
||||
顶部添加 import:
|
||||
|
||||
```dart
|
||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
||||
```
|
||||
|
||||
在 `_LoginPageState` 类中添加 Apple 登录方法(在 `_startCountdown` 方法之后):
|
||||
|
||||
```dart
|
||||
Future<void> _appleSignIn() async {
|
||||
if (_loading) return;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
_successMsg = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final credential = await SignInWithApple.getAppleIDCredential(
|
||||
scopes: [
|
||||
AppleIDAuthorizationScopes.fullName,
|
||||
],
|
||||
webAuthenticationOptions: WebAuthenticationOptions(
|
||||
clientId: 'com.datalumina.YYA.signin',
|
||||
redirectUri: Uri.parse('https://erpapi.datalumina.cn/xiaomai/api/auth/apple-login'),
|
||||
),
|
||||
);
|
||||
|
||||
final identityToken = credential.identityToken;
|
||||
if (identityToken == null) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = 'Apple 登录未返回身份信息,请重试';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final name = credential.givenName != null && credential.familyName != null
|
||||
? '${credential.familyName}${credential.givenName}'
|
||||
: null;
|
||||
|
||||
final err = await ref.read(authProvider.notifier).appleLogin(
|
||||
identityToken,
|
||||
credential.authorizationCode,
|
||||
name,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
if (err != null) {
|
||||
setState(() => _error = err);
|
||||
} else {
|
||||
_goHome();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
if (e is SignInWithAppleAuthorizationException) {
|
||||
_error = 'Apple 登录已取消';
|
||||
} else {
|
||||
_error = 'Apple 登录失败: $e';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在 `_LoginFormCard` 的 children 中,在 `_PrimaryButton` 和"去注册"链接之间(`_PrimaryButton` 之后,`const SizedBox(height: 16)` 之前)插入 Apple 按钮:
|
||||
|
||||
```dart
|
||||
const SizedBox(height: 20),
|
||||
// --- Apple 登录按钮 ---
|
||||
SignInWithAppleButton(
|
||||
onPressed: _appleSignIn,
|
||||
style: SignInWithAppleButtonStyle.whiteOutline,
|
||||
height: 48,
|
||||
cornerRadius: 14,
|
||||
),
|
||||
// --- 结束 ---
|
||||
const SizedBox(height: 16),
|
||||
```
|
||||
|
||||
即在 build 方法中 `_LoginFormCard` child 的末尾(`_PrimaryButton` 之后、`GestureDetector` "去注册" 之前):
|
||||
|
||||
```dart
|
||||
// ... 上面是 _PrimaryButton 的 const SizedBox(height: 22) 和 _PrimaryButton 本身
|
||||
const SizedBox(height: 20),
|
||||
SignInWithAppleButton(
|
||||
onPressed: _appleSignIn,
|
||||
style: SignInWithAppleButtonStyle.whiteOutline,
|
||||
height: 48,
|
||||
cornerRadius: 14,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() {
|
||||
_isLogin = !_isLogin;
|
||||
_error = null;
|
||||
_successMsg = null;
|
||||
}),
|
||||
// ... 去注册/去登录 链接
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 1 补充**: `_submit()` 方法中也需要加 `_loading` 检查,确保 Apple 按钮点击期间短信登录不响应。现有 `_submit` 已有 `setState(() => _loading = true)`,问题不大。
|
||||
|
||||
- [ ] **Step 2: 编译检查**
|
||||
|
||||
```bash
|
||||
cd health_app && flutter analyze lib/pages/auth/login_page.dart
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编译 iOS 验证无语法错误**
|
||||
|
||||
```bash
|
||||
cd health_app && flutter build ios --debug --no-codesign
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: iOS — Xcode 配置 Sign in with Apple
|
||||
|
||||
- [ ] **Step 1: 在 Xcode 中启用 Sign in with Apple Capability**
|
||||
|
||||
```bash
|
||||
open health_app/ios/Runner.xcworkspace
|
||||
```
|
||||
|
||||
在 Xcode 中操作:
|
||||
1. 选择 Runner target → Signing & Capabilities
|
||||
2. 点击 "+ Capability" → 选择 "Sign in with Apple"
|
||||
3. 确认 .entitlements 文件自动生成了 `com.apple.developer.applesignin` entitlement
|
||||
|
||||
这也会自动在 `project.pbxproj` 中写入相关配置,后续 git diff 可以看到变化。
|
||||
|
||||
- [ ] **Step 2: 验证 .entitlements 文件**
|
||||
|
||||
确认 `health_app/ios/Runner/Runner.entitlements` 包含:
|
||||
|
||||
```xml
|
||||
<key>com.apple.developer.applesignin</key>
|
||||
<array>
|
||||
<string>Default</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: App Store Connect 配置
|
||||
|
||||
- [ ] **Step 1: 在 App Store Connect 中启用 Sign in with Apple**
|
||||
|
||||
1. 打开 [App Store Connect](https://appstoreconnect.apple.com)
|
||||
2. 进入 App 记录 → App 隐私 → Sign in with Apple
|
||||
3. 确认已为 `com.datalumina.YYA` 这个 App ID 启用了 Sign in with Apple capability
|
||||
|
||||
如果还未创建 App Store Connect 上的 App 记录,需要在 Certificates, Identifiers & Profiles 中为 App ID 手动勾选 "Sign in with Apple" capability。
|
||||
|
||||
---
|
||||
|
||||
### 验证计划
|
||||
|
||||
**后端单元验证:**
|
||||
|
||||
```bash
|
||||
# 1. 后端编译通过
|
||||
cd backend/src/Health.WebApi && dotnet build
|
||||
|
||||
# 2. 启动后端
|
||||
cd backend/src/Health.WebApi && dotnet run
|
||||
|
||||
# 3. 测试 Apple 登录端点(无 token 场景)
|
||||
curl -s -X POST "http://localhost:5000/api/auth/apple-login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"identityToken":"test","name":"测试"}'
|
||||
# 预期: HTTP 200, body 包含 code=40005
|
||||
|
||||
# 4. 测试现有发送短信端点未被影响
|
||||
curl -s -X POST "http://localhost:5000/api/auth/send-sms" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"phone":"13800138000"}'
|
||||
# 预期: HTTP 200, code=0
|
||||
```
|
||||
|
||||
**前端验证:**
|
||||
|
||||
```bash
|
||||
# 1. 编译检查
|
||||
cd health_app && flutter analyze
|
||||
|
||||
# 2. iOS 构建通过
|
||||
flutter build ios --debug --no-codesign
|
||||
|
||||
# 3. 在模拟器上运行
|
||||
flutter run -d "iPhone 17 Pro"
|
||||
# 验证:登录页显示 Apple 登录按钮;点击后弹出 Apple ID 授权界面
|
||||
```
|
||||
|
||||
**端到端验证(模拟器):**
|
||||
1. 模拟器上启动 App → 进入登录页
|
||||
2. 点击 Apple 登录按钮 → Apple 授权弹窗出现 → 选择"继续"
|
||||
3. 后端验证 identityToken → 创建新用户或登录已有 → 返回 JWT → App 进入首页
|
||||
4. 二次打开 App → 如果之前 Apple 登录成功 + refresh_token 有效 → 自动恢复登录态
|
||||
|
||||
**DB 验证:**
|
||||
```sql
|
||||
-- 确认 Apple 用户创建成功
|
||||
SELECT id, phone, apple_user_id, name, role FROM "Users" WHERE apple_user_id IS NOT NULL;
|
||||
```
|
||||
@@ -5,6 +5,13 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
// 加载正式签名配置
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
val keystoreProperties = java.util.Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(keystorePropertiesFile.inputStream())
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.datalumina.YYA"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
@@ -20,21 +27,28 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.datalumina.YYA"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
// 正式签名配置
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keyAlias = keystoreProperties["keyAlias"] as String?
|
||||
keyPassword = keystoreProperties["keyPassword"] as String?
|
||||
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
|
||||
storePassword = keystoreProperties["storePassword"] as String?
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
health_app/appinfo
Normal file
12
health_app/appinfo
Normal file
@@ -0,0 +1,12 @@
|
||||
IOS:
|
||||
SHA1:
|
||||
B1:17:1D:FD:7E:CA:CE:61:65:C2:2D:8B:A0:7C:57:5A:EB:EF:20:5E
|
||||
SHA256:
|
||||
D0:1E:AF:24:40:06:42:1B:FA:86:5E:3D:90:ED:FA:D7:3F:2D:E2:99:52:60:58:F4:85:4F:18:95:1E:B9:CC:96
|
||||
MD5:
|
||||
15:3B:89:ED:6B:C2:1B:6A:F9:6D:E0:71:44:54:CB:0F
|
||||
|
||||
Android:
|
||||
SHA1: 48:48:22:D2:DA:B5:0D:4B:3F:D6:CA:8C:77:B1:36:B5:B5:8F:47:D1
|
||||
SHA256: 0F:10:E7:B5:D0:8A:1E:4B:A6:67:EB:D4:D4:7F:00:C1:08:7E:EB:46:72:51:73:B2:AC:30:DB:69:18:DD:15:A2
|
||||
MD5:D9:75:8F:17:66:17:3F:83:49:1A:82:34:59:9A:08:25
|
||||
@@ -20,7 +20,5 @@
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>13.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
43
health_app/ios/Podfile
Normal file
43
health_app/ios/Podfile
Normal file
@@ -0,0 +1,43 @@
|
||||
# Apple Sign-In requires CocoaPods (plugin not yet on SPM)
|
||||
platform :ios, '13.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
project 'Runner', {
|
||||
'Debug' => :debug,
|
||||
'Profile' => :release,
|
||||
'Release' => :release,
|
||||
}
|
||||
|
||||
def flutter_root
|
||||
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
|
||||
unless File.exist?(generated_xcode_build_settings_path)
|
||||
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
|
||||
end
|
||||
|
||||
File.foreach(generated_xcode_build_settings_path) do |line|
|
||||
matches = line.match(/FLUTTER_ROOT\=(.*)/)
|
||||
return matches[1].strip if matches
|
||||
end
|
||||
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
|
||||
end
|
||||
|
||||
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
|
||||
|
||||
flutter_ios_podfile_setup
|
||||
|
||||
target 'Runner' do
|
||||
use_frameworks!
|
||||
|
||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||
target 'RunnerTests' do
|
||||
inherit! :search_paths
|
||||
end
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
end
|
||||
end
|
||||
22
health_app/ios/Podfile.lock
Normal file
22
health_app/ios/Podfile.lock
Normal file
@@ -0,0 +1,22 @@
|
||||
PODS:
|
||||
- Flutter (1.0.0)
|
||||
- sign_in_with_apple (0.0.1):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- Flutter (from `Flutter`)
|
||||
- sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
sign_in_with_apple:
|
||||
:path: ".symlinks/plugins/sign_in_with_apple/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418
|
||||
|
||||
PODFILE CHECKSUM: 261798c42a06ff769f68b1209723cd96e5586217
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
@@ -11,9 +11,12 @@
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
B79095E86F02076D8BE3225F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C5A7F45AA4D485688D46789 /* Pods_Runner.framework */; };
|
||||
D33A34F4E410B032A1D843E3 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C6D436F0A8387C8ED95A60B0 /* Pods_RunnerTests.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -42,12 +45,18 @@
|
||||
/* Begin PBXFileReference section */
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
2C5A7F45AA4D485688D46789 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
5D74CE3B9D4E625B6EC3403A /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
6331745AE8BC5EE4BCD290B9 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
65BAE750C7D318CC27513494 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
93E70FBCBD3EC11A08FAD800 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -55,19 +64,46 @@
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
C52233F0D1C275E97BE00A91 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
C6D436F0A8387C8ED95A60B0 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
ECE998D63CAE2AC9FF401968 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
5B6028AD445E1E5F91C5C300 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D33A34F4E410B032A1D843E3 /* Pods_RunnerTests.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||
B79095E86F02076D8BE3225F /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
04069A07808CEF12713E8A9C /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ECE998D63CAE2AC9FF401968 /* Pods-Runner.debug.xcconfig */,
|
||||
6331745AE8BC5EE4BCD290B9 /* Pods-Runner.release.xcconfig */,
|
||||
65BAE750C7D318CC27513494 /* Pods-Runner.profile.xcconfig */,
|
||||
5D74CE3B9D4E625B6EC3403A /* Pods-RunnerTests.debug.xcconfig */,
|
||||
93E70FBCBD3EC11A08FAD800 /* Pods-RunnerTests.release.xcconfig */,
|
||||
C52233F0D1C275E97BE00A91 /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -76,9 +112,19 @@
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
789F6ED1EF63DAEA6E803F1D /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2C5A7F45AA4D485688D46789 /* Pods_Runner.framework */,
|
||||
C6D436F0A8387C8ED95A60B0 /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
@@ -94,6 +140,8 @@
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
04069A07808CEF12713E8A9C /* Pods */,
|
||||
789F6ED1EF63DAEA6E803F1D /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -128,8 +176,10 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
C41A5F4922F3BF2ACAF8E553 /* [CP] Check Pods Manifest.lock */,
|
||||
331C807D294A63A400263BE5 /* Sources */,
|
||||
331C807F294A63A400263BE5 /* Resources */,
|
||||
5B6028AD445E1E5F91C5C300 /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -145,18 +195,23 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
0A8FFE29BE998F8F679CF732 /* [CP] Check Pods Manifest.lock */,
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
B60F509C791DA959BCD8E827 /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Runner;
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
productName = Runner;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
@@ -190,6 +245,9 @@
|
||||
Base,
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
||||
);
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
@@ -222,6 +280,28 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
0A8FFE29BE998F8F679CF732 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
@@ -253,6 +333,45 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
B60F509C791DA959BCD8E827 /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
C41A5F4922F3BF2ACAF8E553 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -348,8 +467,7 @@
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
@@ -361,14 +479,16 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = KBM2RZ74VR;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -378,13 +498,14 @@
|
||||
};
|
||||
331C8088294A63A400263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 5D74CE3B9D4E625B6EC3403A /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -395,13 +516,14 @@
|
||||
};
|
||||
331C8089294A63A400263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 93E70FBCBD3EC11A08FAD800 /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -410,13 +532,14 @@
|
||||
};
|
||||
331C808A294A63A400263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = C52233F0D1C275E97BE00A91 /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -475,7 +598,6 @@
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
@@ -525,8 +647,7 @@
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -540,14 +661,16 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = KBM2RZ74VR;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -562,14 +685,16 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = KBM2RZ74VR;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -611,6 +736,20 @@
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "dkcamera",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKCamera",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkimagepickercontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKImagePickerController",
|
||||
"state" : {
|
||||
"branch" : "4.3.9",
|
||||
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkphotogallery",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sdwebimage",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/SDWebImage",
|
||||
"state" : {
|
||||
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
|
||||
"version" : "5.21.7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftygif",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/kirualex/SwiftyGif.git",
|
||||
"state" : {
|
||||
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
|
||||
"version" : "5.4.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "tocropviewcontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/TimOliver/TOCropViewController",
|
||||
"state" : {
|
||||
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
|
||||
"version" : "2.8.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
@@ -1,10 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.3">
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Run Prepare Flutter Framework Script"
|
||||
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
@@ -52,9 +70,9 @@
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
buildConfiguration = "Release"
|
||||
selectedDebuggerIdentifier = ""
|
||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
|
||||
@@ -4,4 +4,7 @@
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "dkcamera",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKCamera",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkimagepickercontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKImagePickerController",
|
||||
"state" : {
|
||||
"branch" : "4.3.9",
|
||||
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkphotogallery",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sdwebimage",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/SDWebImage",
|
||||
"state" : {
|
||||
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
|
||||
"version" : "5.21.7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftygif",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/kirualex/SwiftyGif.git",
|
||||
"state" : {
|
||||
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
|
||||
"version" : "5.4.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "tocropviewcontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/TimOliver/TOCropViewController",
|
||||
"state" : {
|
||||
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
|
||||
"version" : "2.8.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
@@ -2,12 +2,15 @@ import Flutter
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Health App</string>
|
||||
<string>小脉健康</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -13,7 +15,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>health_app</string>
|
||||
<string>小脉健康</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
@@ -24,6 +26,39 @@
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>需要使用蓝牙连接欧姆龙血压计以同步测量数据</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>需要使用蓝牙连接血压计</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>需要使用相机拍摄饮食照片和体检报告,以便 AI 分析和记录</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>需要保存图片到相册</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>需要访问相册以选取饮食照片、体检报告或个人头像</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>FlutterSceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
@@ -41,13 +76,5 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>需要使用蓝牙连接欧姆龙血压计以同步测量数据</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>需要使用蓝牙连接血压计</string>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
10
health_app/ios/Runner/Runner.entitlements
Normal file
10
health_app/ios/Runner/Runner.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.applesignin</key>
|
||||
<array>
|
||||
<string>Default</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -3,10 +3,10 @@ import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'local_database.dart';
|
||||
|
||||
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
|
||||
/// API 基础地址(生产模式)。本地开发可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
|
||||
const String baseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'http://10.4.221.78:5000',
|
||||
defaultValue: 'https://erpapi.datalumina.cn/xiaomai',
|
||||
);
|
||||
|
||||
class ApiException implements Exception {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
@@ -63,6 +66,70 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
}
|
||||
}
|
||||
|
||||
// Apple 登录
|
||||
Future<void> _appleSignIn() async {
|
||||
if (_loading) return;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
_successMsg = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final credential = await SignInWithApple.getAppleIDCredential(
|
||||
scopes: [
|
||||
AppleIDAuthorizationScopes.fullName,
|
||||
],
|
||||
webAuthenticationOptions: WebAuthenticationOptions(
|
||||
clientId: 'com.datalumina.YYA.signin',
|
||||
redirectUri: Uri.parse(
|
||||
'https://erpapi.datalumina.cn/xiaomai/api/auth/apple-login',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final identityToken = credential.identityToken;
|
||||
if (identityToken == null) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = 'Apple 登录未返回身份信息,请重试';
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final name = credential.givenName != null && credential.familyName != null
|
||||
? '${credential.familyName}${credential.givenName}'
|
||||
: null;
|
||||
|
||||
final err = await ref.read(authProvider.notifier).appleLogin(
|
||||
identityToken,
|
||||
credential.authorizationCode,
|
||||
name,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
if (err != null) {
|
||||
setState(() => _error = err);
|
||||
} else {
|
||||
_goHome();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
if (e is SignInWithAppleAuthorizationException) {
|
||||
_error = 'Apple 登录已取消';
|
||||
} else {
|
||||
_error = 'Apple 登录失败: $e';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_agreed) {
|
||||
final accepted = await _showAgreementDialog();
|
||||
@@ -489,6 +556,16 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
label: _isLogin ? '登录' : '注册',
|
||||
onTap: _loading ? null : _submit,
|
||||
),
|
||||
// Apple 登录按钮(仅 iOS/macOS)
|
||||
if (_isLogin && (Platform.isIOS || Platform.isMacOS)) ...[
|
||||
const SizedBox(height: 20),
|
||||
SignInWithAppleButton(
|
||||
onPressed: _loading ? () {} : () { _appleSignIn(); },
|
||||
style: SignInWithAppleButtonStyle.whiteOutlined,
|
||||
height: 48,
|
||||
text: '通过 Apple 登录',
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() {
|
||||
|
||||
@@ -207,6 +207,40 @@ class AuthNotifier extends Notifier<AuthState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apple 登录
|
||||
Future<String?> appleLogin(String identityToken, String? authorizationCode, String? name) async {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final response = await api.post(
|
||||
'/api/auth/apple-login',
|
||||
data: {
|
||||
'identityToken': identityToken,
|
||||
if (authorizationCode != null) 'authorizationCode': authorizationCode,
|
||||
if (name != null && name.isNotEmpty) 'name': name,
|
||||
},
|
||||
);
|
||||
final data = response.data['data'];
|
||||
if (data == null) return response.data['message'] ?? 'Apple 登录失败';
|
||||
|
||||
await api.saveTokens(data['accessToken'], data['refreshToken']);
|
||||
final user = data['user'];
|
||||
state = AuthState(
|
||||
isLoggedIn: true,
|
||||
isLoading: false,
|
||||
user: UserInfo(
|
||||
id: user['id'] ?? '',
|
||||
phone: user['phone'] ?? '',
|
||||
role: user['role'] ?? 'User',
|
||||
name: user['name'],
|
||||
avatarUrl: user['avatarUrl'],
|
||||
),
|
||||
);
|
||||
return null;
|
||||
} catch (e) {
|
||||
return 'Apple 登录失败: $e';
|
||||
}
|
||||
}
|
||||
|
||||
/// 登出
|
||||
Future<void> logout() async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
|
||||
@@ -61,10 +61,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
version: "1.4.1"
|
||||
cli_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -617,18 +617,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
version: "0.13.0"
|
||||
message_pack_dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -641,10 +641,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.18.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -901,6 +901,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
sign_in_with_apple:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sign_in_with_apple
|
||||
sha256: e84a62e17b7e463abf0a64ce826c2cd1f0b72dff07b7b275e32d5302d76fb4c5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.4"
|
||||
sign_in_with_apple_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sign_in_with_apple_platform_interface
|
||||
sha256: c2ef2ce6273fce0c61acd7e9ff5be7181e33d7aa2b66508b39418b786cca2119
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
sign_in_with_apple_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sign_in_with_apple_web
|
||||
sha256: "2f7c38368f49e3f2043bca4b46a4a61aaae568c140a79aa0675dc59ad0ca49bc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
signalr_netcore:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1062,26 +1086,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test
|
||||
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
||||
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.26.3"
|
||||
version: "1.31.0"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.11"
|
||||
test_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_core
|
||||
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
|
||||
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.12"
|
||||
version: "0.6.17"
|
||||
theme_extensions_builder_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -39,6 +39,9 @@ dependencies:
|
||||
flutter_blue_plus: ^1.34.0
|
||||
permission_handler: ^11.3.0
|
||||
|
||||
# Apple 登录
|
||||
sign_in_with_apple: ^6.1.0
|
||||
|
||||
# 基础图标
|
||||
cupertino_icons: ^1.0.8
|
||||
signalr_netcore: ^1.4.4
|
||||
|
||||
Reference in New Issue
Block a user