5 Commits

Author SHA1 Message Date
sccsbc
1a9d56b07c chore: 添加 appinfo 配置文件
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 15:31:36 +08:00
sccsbc
e52e21d295 chore: Android 正式签名配置 + 截图尺寸适配
- 生成 release.jks 正式签名密钥库
- build.gradle.kts 配置 release 签名自动读取 key.properties
- .gitignore 排除 keystore 和 key.properties
- 截图调整为 6.5 寸规格 (1242x2688)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 14:20:57 +08:00
ec5af19d30 chore: 补全 Apple Sign-In 迁移 + 调低开发环境日志级别
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-10 05:37:24 +00:00
sccsbc
bd4350c17f fix: 修复 Apple 登录 sub claim 映射 + 按钮文案中文化
- AppleTokenValidator: 从 JwtSecurityToken 原始 Claims 取 sub,避免 .NET ClaimType 映射导致丢失
- 登录页 Apple 按钮改为中文"通过 Apple 登录"

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 13:35:28 +08:00
sccsbc
fe4c81c54f feat: 集成 Apple Sign-In + iOS 上线准备
- 后端新增 Apple 登录端点 /api/auth/apple-login
- 新增 AppleTokenValidator 验证 IdentityToken
- User 实体添加 AppleUserId 字段,Phone 改为可空
- 前端添加 sign_in_with_apple 依赖和 Apple 登录按钮
- 统一 Bundle ID 为 com.datalumina.YYA,显示名称为小脉健康
- 配置 DEVELOPMENT_TEAM 和 Sign in with Apple Entitlements
- 补充 NSCamera/NSPhotoLibrary 权限描述
- 生产 API 地址改为 https://erpapi.datalumina.cn/xiaomai
- Flutter 升级至 3.44.6 / Dart 3.12.2

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 13:24:03 +08:00
34 changed files with 2967 additions and 58 deletions

4
.gitignore vendored
View File

@@ -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/

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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; }

View File

@@ -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);

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}
}
}

View File

@@ -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")

View File

@@ -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);
});

View File

@@ -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(); }
}
}

View File

@@ -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);

View File

@@ -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>();

View File

@@ -1,7 +1,7 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Default": "Warning",
"Microsoft.AspNetCore": "Warning"
}
}

View 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 返回的 identityTokenJWT查找或创建 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 调用(预期返回 40005token 无效)**
```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;
```

View File

@@ -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
View 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

View File

@@ -20,7 +20,5 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>

View File

@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"

View File

@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"

43
health_app/ios/Podfile Normal file
View 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

View 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

View File

@@ -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 */;
}

View File

@@ -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
}

View File

@@ -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 &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<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"

View File

@@ -4,4 +4,7 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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>

View 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>

View File

@@ -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 {

View File

@@ -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(() {

View File

@@ -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);

View File

@@ -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:

View File

@@ -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