From fe4c81c54ff1e42686f56083ba5e357fceefcf1f Mon Sep 17 00:00:00 2001 From: sccsbc Date: Fri, 10 Jul 2026 13:24:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9B=86=E6=88=90=20Apple=20Sign-In=20?= =?UTF-8?q?+=20iOS=20=E4=B8=8A=E7=BA=BF=E5=87=86=E5=A4=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 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 --- .../Health.Application/Auth/AuthContracts.cs | 2 + .../Health.Application/Users/UserContracts.cs | 2 +- backend/src/Health.Domain/Entities/user.cs | 3 +- .../Health.Infrastructure/Auth/AuthService.cs | 63 +- .../Data/app_db_context.cs | 1 + .../Services/apple_token_validator.cs | 77 ++ .../Health.WebApi/Endpoints/auth_endpoints.cs | 3 + backend/src/Health.WebApi/Program.cs | 1 + .../plans/2026-07-10-apple-sign-in.md | 688 ++++++++++++++++++ health_app/ios/Flutter/AppFrameworkInfo.plist | 2 - health_app/ios/Flutter/Debug.xcconfig | 1 + health_app/ios/Flutter/Release.xcconfig | 1 + health_app/ios/Podfile | 43 ++ health_app/ios/Podfile.lock | 22 + .../ios/Runner.xcodeproj/project.pbxproj | 161 +++- .../xcshareddata/swiftpm/Package.resolved | 59 ++ .../xcshareddata/xcschemes/Runner.xcscheme | 26 +- .../contents.xcworkspacedata | 3 + .../xcshareddata/swiftpm/Package.resolved | 59 ++ health_app/ios/Runner/AppDelegate.swift | 7 +- health_app/ios/Runner/Info.plist | 47 +- health_app/ios/Runner/Runner.entitlements | 10 + health_app/lib/core/api_client.dart | 4 +- health_app/lib/pages/auth/login_page.dart | 76 ++ health_app/lib/providers/auth_provider.dart | 34 + health_app/pubspec.lock | 52 +- health_app/pubspec.yaml | 3 + 27 files changed, 1400 insertions(+), 50 deletions(-) create mode 100644 backend/src/Health.Infrastructure/Services/apple_token_validator.cs create mode 100644 docs/superpowers/plans/2026-07-10-apple-sign-in.md create mode 100644 health_app/ios/Podfile create mode 100644 health_app/ios/Podfile.lock create mode 100644 health_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 health_app/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 health_app/ios/Runner/Runner.entitlements diff --git a/backend/src/Health.Application/Auth/AuthContracts.cs b/backend/src/Health.Application/Auth/AuthContracts.cs index 83e76ce..ec7d355 100644 --- a/backend/src/Health.Application/Auth/AuthContracts.cs +++ b/backend/src/Health.Application/Auth/AuthContracts.cs @@ -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 SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct); Task RegisterAsync(RegisterCommand command, CancellationToken ct); Task LoginAsync(string phone, string smsCode, CancellationToken ct); + Task AppleLoginAsync(AppleLoginCommand command, CancellationToken ct); Task RefreshAsync(string refreshToken, CancellationToken ct); Task LogoutAsync(string refreshToken, CancellationToken ct); } diff --git a/backend/src/Health.Application/Users/UserContracts.cs b/backend/src/Health.Application/Users/UserContracts.cs index d5b357d..a377294 100644 --- a/backend/src/Health.Application/Users/UserContracts.cs +++ b/backend/src/Health.Application/Users/UserContracts.cs @@ -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, diff --git a/backend/src/Health.Domain/Entities/user.cs b/backend/src/Health.Domain/Entities/user.cs index 541495c..8a5dec2 100644 --- a/backend/src/Health.Domain/Entities/user.cs +++ b/backend/src/Health.Domain/Entities/user.cs @@ -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; } diff --git a/backend/src/Health.Infrastructure/Auth/AuthService.cs b/backend/src/Health.Infrastructure/Auth/AuthService.cs index f8822fe..7761256 100644 --- a/backend/src/Health.Infrastructure/Auth/AuthService.cs +++ b/backend/src/Health.Infrastructure/Auth/AuthService.cs @@ -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 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 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 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); diff --git a/backend/src/Health.Infrastructure/Data/app_db_context.cs b/backend/src/Health.Infrastructure/Data/app_db_context.cs index 37bc5fa..e78ed2a 100644 --- a/backend/src/Health.Infrastructure/Data/app_db_context.cs +++ b/backend/src/Health.Infrastructure/Data/app_db_context.cs @@ -50,6 +50,7 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon builder.Entity(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); }); diff --git a/backend/src/Health.Infrastructure/Services/apple_token_validator.cs b/backend/src/Health.Infrastructure/Services/apple_token_validator.cs new file mode 100644 index 0000000..38a420a --- /dev/null +++ b/backend/src/Health.Infrastructure/Services/apple_token_validator.cs @@ -0,0 +1,77 @@ +using System.IdentityModel.Tokens.Jwt; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; + +namespace Health.Infrastructure.Services; + +/// +/// Apple IdentityToken 验证:下载 Apple 公钥、验签、检查声明 +/// +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? _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"; + } + + /// + /// 验证 Apple identityToken,返回 sub(用户唯一标识) + /// + public async Task 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), + }; + + 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> 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(); } + } +} diff --git a/backend/src/Health.WebApi/Endpoints/auth_endpoints.cs b/backend/src/Health.WebApi/Endpoints/auth_endpoints.cs index 24f2501..c535a0f 100644 --- a/backend/src/Health.WebApi/Endpoints/auth_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/auth_endpoints.cs @@ -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); diff --git a/backend/src/Health.WebApi/Program.cs b/backend/src/Health.WebApi/Program.cs index e3e63b5..81ccd1d 100644 --- a/backend/src/Health.WebApi/Program.cs +++ b/backend/src/Health.WebApi/Program.cs @@ -108,6 +108,7 @@ builder.Services.AddAuthorization(); // ---- 业务服务 ---- builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Apple Sign-In JWT 验证 builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/docs/superpowers/plans/2026-07-10-apple-sign-in.md b/docs/superpowers/plans/2026-07-10-apple-sign-in.md new file mode 100644 index 0000000..186eccb --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-apple-sign-in.md @@ -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(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; + +/// +/// Apple IdentityToken 验证:下载公钥、验签、检查声明 +/// +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? _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"; + } + + /// + /// 验证 Apple identityToken,返回 sub(用户唯一标识) + /// + public async Task 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> 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(); +``` + +- [ ] **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 SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct); + Task RegisterAsync(RegisterCommand command, CancellationToken ct); + Task LoginAsync(string phone, string smsCode, CancellationToken ct); + Task AppleLoginAsync(AppleLoginCommand command, CancellationToken ct); // 新增 + Task 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 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 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 _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 +com.apple.developer.applesignin + + Default + +``` + +--- + +### 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; +``` diff --git a/health_app/ios/Flutter/AppFrameworkInfo.plist b/health_app/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf7..391a902 100644 --- a/health_app/ios/Flutter/AppFrameworkInfo.plist +++ b/health_app/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/health_app/ios/Flutter/Debug.xcconfig b/health_app/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/health_app/ios/Flutter/Debug.xcconfig +++ b/health_app/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/health_app/ios/Flutter/Release.xcconfig b/health_app/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/health_app/ios/Flutter/Release.xcconfig +++ b/health_app/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/health_app/ios/Podfile b/health_app/ios/Podfile new file mode 100644 index 0000000..131d5f5 --- /dev/null +++ b/health_app/ios/Podfile @@ -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 diff --git a/health_app/ios/Podfile.lock b/health_app/ios/Podfile.lock new file mode 100644 index 0000000..7f1f694 --- /dev/null +++ b/health_app/ios/Podfile.lock @@ -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 diff --git a/health_app/ios/Runner.xcodeproj/project.pbxproj b/health_app/ios/Runner.xcodeproj/project.pbxproj index 7f2721f..5acccbc 100644 --- a/health_app/ios/Runner.xcodeproj/project.pbxproj +++ b/health_app/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 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 = ""; }; 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 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 = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 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 = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; /* 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 = ""; + }; 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -76,9 +112,19 @@ path = RunnerTests; sourceTree = ""; }; + 789F6ED1EF63DAEA6E803F1D /* Frameworks */ = { + isa = PBXGroup; + children = ( + 2C5A7F45AA4D485688D46789 /* Pods_Runner.framework */, + C6D436F0A8387C8ED95A60B0 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 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 = ""; }; @@ -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 */; } diff --git a/health_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/health_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..4d7193e --- /dev/null +++ b/health_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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 +} diff --git a/health_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/health_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e3773d4..6e007ba 100644 --- a/health_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/health_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,10 +1,28 @@ + version = "1.7"> + + + + + + + + + + + + diff --git a/health_app/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/health_app/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..4d7193e --- /dev/null +++ b/health_app/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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 +} diff --git a/health_app/ios/Runner/AppDelegate.swift b/health_app/ios/Runner/AppDelegate.swift index 6266644..c30b367 100644 --- a/health_app/ios/Runner/AppDelegate.swift +++ b/health_app/ios/Runner/AppDelegate.swift @@ -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) + } } diff --git a/health_app/ios/Runner/Info.plist b/health_app/ios/Runner/Info.plist index 25d462c..a6f4a5c 100644 --- a/health_app/ios/Runner/Info.plist +++ b/health_app/ios/Runner/Info.plist @@ -2,10 +2,12 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Health App + 小脉健康 CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -13,7 +15,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - health_app + 小脉健康 CFBundlePackageType APPL CFBundleShortVersionString @@ -24,6 +26,39 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSBluetoothAlwaysUsageDescription + 需要使用蓝牙连接欧姆龙血压计以同步测量数据 + NSBluetoothPeripheralUsageDescription + 需要使用蓝牙连接血压计 + NSCameraUsageDescription + 需要使用相机拍摄饮食照片和体检报告,以便 AI 分析和记录 + NSPhotoLibraryAddUsageDescription + 需要保存图片到相册 + NSPhotoLibraryUsageDescription + 需要访问相册以选取饮食照片、体检报告或个人头像 + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,13 +76,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - NSBluetoothAlwaysUsageDescription - 需要使用蓝牙连接欧姆龙血压计以同步测量数据 - NSBluetoothPeripheralUsageDescription - 需要使用蓝牙连接血压计 - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/health_app/ios/Runner/Runner.entitlements b/health_app/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..a812db5 --- /dev/null +++ b/health_app/ios/Runner/Runner.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.applesignin + + Default + + + diff --git a/health_app/lib/core/api_client.dart b/health_app/lib/core/api_client.dart index 22fb71b..5ccc1ea 100644 --- a/health_app/lib/core/api_client.dart +++ b/health_app/lib/core/api_client.dart @@ -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 { diff --git a/health_app/lib/pages/auth/login_page.dart b/health_app/lib/pages/auth/login_page.dart index 29f821e..e408710 100644 --- a/health_app/lib/pages/auth/login_page.dart +++ b/health_app/lib/pages/auth/login_page.dart @@ -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 { } } + // Apple 登录 + Future _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 _submit() async { if (!_agreed) { final accepted = await _showAgreementDialog(); @@ -489,6 +556,15 @@ class _LoginPageState extends ConsumerState { 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, + ), + ], const SizedBox(height: 16), GestureDetector( onTap: () => setState(() { diff --git a/health_app/lib/providers/auth_provider.dart b/health_app/lib/providers/auth_provider.dart index 4cd1f81..18060ad 100644 --- a/health_app/lib/providers/auth_provider.dart +++ b/health_app/lib/providers/auth_provider.dart @@ -207,6 +207,40 @@ class AuthNotifier extends Notifier { } } + /// Apple 登录 + Future 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 logout() async { final api = ref.read(apiClientProvider); diff --git a/health_app/pubspec.lock b/health_app/pubspec.lock index 2a7670a..6508bb0 100644 --- a/health_app/pubspec.lock +++ b/health_app/pubspec.lock @@ -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: diff --git a/health_app/pubspec.yaml b/health_app/pubspec.yaml index c2992c2..cb41e2c 100644 --- a/health_app/pubspec.yaml +++ b/health_app/pubspec.yaml @@ -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