Merge branch 'canary' into fix/callout-delete-merge
@@ -669,12 +669,12 @@
|
||||
},
|
||||
"scenarios": {
|
||||
"type": "object",
|
||||
"description": "Use custom models in scenarios and override default settings.\n@default {\"override_enabled\":false,\"scenarios\":{\"audio_transcribing\":\"gemini-2.5-flash\",\"chat\":\"claude-sonnet-4@20250514\",\"embedding\":\"gemini-embedding-001\",\"image\":\"gpt-image-1\",\"rerank\":\"gpt-4.1\",\"coding\":\"claude-sonnet-4@20250514\",\"complex_text_generation\":\"gpt-4o-2024-08-06\",\"quick_decision_making\":\"gpt-5-mini\",\"quick_text_generation\":\"gemini-2.5-flash\",\"polish_and_summarize\":\"gemini-2.5-flash\"}}",
|
||||
"description": "Use custom models in scenarios and override default settings.\n@default {\"override_enabled\":false,\"scenarios\":{\"audio_transcribing\":\"gemini-2.5-flash\",\"chat\":\"gemini-2.5-flash\",\"embedding\":\"gemini-embedding-001\",\"image\":\"gpt-image-1\",\"rerank\":\"gpt-4.1\",\"coding\":\"claude-sonnet-4@20250514\",\"complex_text_generation\":\"gpt-4o-2024-08-06\",\"quick_decision_making\":\"gpt-5-mini\",\"quick_text_generation\":\"gemini-2.5-flash\",\"polish_and_summarize\":\"gemini-2.5-flash\"}}",
|
||||
"default": {
|
||||
"override_enabled": false,
|
||||
"scenarios": {
|
||||
"audio_transcribing": "gemini-2.5-flash",
|
||||
"chat": "claude-sonnet-4@20250514",
|
||||
"chat": "gemini-2.5-flash",
|
||||
"embedding": "gemini-embedding-001",
|
||||
"image": "gpt-image-1",
|
||||
"rerank": "gpt-4.1",
|
||||
|
||||
@@ -444,3 +444,37 @@ Generated by [AVA](https://avajs.dev).
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
## should resolve model correctly based on subscription status and prompt config
|
||||
|
||||
> should honor requested pro model
|
||||
|
||||
'gemini-2.5-pro'
|
||||
|
||||
> should fallback to default model
|
||||
|
||||
'gemini-2.5-flash'
|
||||
|
||||
> should fallback to default model when requesting pro model during trialing
|
||||
|
||||
'gemini-2.5-flash'
|
||||
|
||||
> should honor requested non-pro model during trialing
|
||||
|
||||
'gemini-2.5-flash'
|
||||
|
||||
> should pick default model when no requested model during trialing
|
||||
|
||||
'gemini-2.5-flash'
|
||||
|
||||
> should pick default model when no requested model during active
|
||||
|
||||
'gemini-2.5-flash'
|
||||
|
||||
> should honor requested pro model during active
|
||||
|
||||
'claude-sonnet-4@20250514'
|
||||
|
||||
> should fallback to default model when requesting non-optional model during active
|
||||
|
||||
'gemini-2.5-flash'
|
||||
|
||||
@@ -60,6 +60,9 @@ import {
|
||||
import { AutoRegisteredWorkflowExecutor } from '../plugins/copilot/workflow/executor/utils';
|
||||
import { WorkflowGraphList } from '../plugins/copilot/workflow/graph';
|
||||
import { CopilotWorkspaceService } from '../plugins/copilot/workspace';
|
||||
import { PaymentModule } from '../plugins/payment';
|
||||
import { SubscriptionService } from '../plugins/payment/service';
|
||||
import { SubscriptionStatus } from '../plugins/payment/types';
|
||||
import { MockCopilotProvider } from './mocks';
|
||||
import { createTestingModule, TestingModule } from './utils';
|
||||
import { WorkflowTestCases } from './utils/copilot';
|
||||
@@ -82,6 +85,7 @@ type Context = {
|
||||
storage: CopilotStorage;
|
||||
workflow: CopilotWorkflowService;
|
||||
cronJobs: CopilotCronJobs;
|
||||
subscription: SubscriptionService;
|
||||
executors: {
|
||||
image: CopilotChatImageExecutor;
|
||||
text: CopilotChatTextExecutor;
|
||||
@@ -116,6 +120,7 @@ test.before(async t => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
PaymentModule,
|
||||
QuotaModule,
|
||||
StorageModule,
|
||||
CopilotModule,
|
||||
@@ -124,6 +129,13 @@ test.before(async t => {
|
||||
// use real JobQueue for testing
|
||||
builder.overrideProvider(JobQueue).useClass(JobQueue);
|
||||
builder.overrideProvider(OpenAIProvider).useClass(MockCopilotProvider);
|
||||
builder.overrideProvider(SubscriptionService).useClass(
|
||||
class {
|
||||
select() {
|
||||
return { getSubscription: async () => undefined };
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -145,6 +157,7 @@ test.before(async t => {
|
||||
const transcript = module.get(CopilotTranscriptionService);
|
||||
const workspaceEmbedding = module.get(CopilotWorkspaceService);
|
||||
const cronJobs = module.get(CopilotCronJobs);
|
||||
const subscription = module.get(SubscriptionService);
|
||||
|
||||
t.context.module = module;
|
||||
t.context.auth = auth;
|
||||
@@ -163,6 +176,7 @@ test.before(async t => {
|
||||
t.context.transcript = transcript;
|
||||
t.context.workspaceEmbedding = workspaceEmbedding;
|
||||
t.context.cronJobs = cronJobs;
|
||||
t.context.subscription = subscription;
|
||||
|
||||
t.context.executors = {
|
||||
image: module.get(CopilotChatImageExecutor),
|
||||
@@ -2047,3 +2061,90 @@ test('should handle copilot cron jobs correctly', async t => {
|
||||
toBeGenerateStub.restore();
|
||||
jobAddStub.restore();
|
||||
});
|
||||
|
||||
test('should resolve model correctly based on subscription status and prompt config', async t => {
|
||||
const { db, session, subscription } = t.context;
|
||||
|
||||
// 1) Seed a prompt that has optionalModels and proModels in config
|
||||
const promptName = 'resolve-model-test';
|
||||
await db.aiPrompt.create({
|
||||
data: {
|
||||
name: promptName,
|
||||
model: 'gemini-2.5-flash',
|
||||
messages: {
|
||||
create: [{ idx: 0, role: 'system', content: 'test' }],
|
||||
},
|
||||
config: { proModels: ['gemini-2.5-pro', 'claude-sonnet-4@20250514'] },
|
||||
optionalModels: [
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-pro',
|
||||
'claude-sonnet-4@20250514',
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// 2) Create a chat session with this prompt
|
||||
const sessionId = await session.create({
|
||||
promptName,
|
||||
docId: 'test',
|
||||
workspaceId: 'test',
|
||||
userId,
|
||||
pinned: false,
|
||||
});
|
||||
const s = (await session.get(sessionId))!;
|
||||
|
||||
const mockStatus = (status?: SubscriptionStatus) => {
|
||||
Sinon.restore();
|
||||
Sinon.stub(subscription, 'select').callsFake(() => ({
|
||||
// @ts-expect-error mock
|
||||
getSubscription: async () => (status ? { status } : null),
|
||||
}));
|
||||
};
|
||||
|
||||
// payment disabled -> allow requested if in optional; pro not blocked
|
||||
{
|
||||
const model1 = await s.resolveModel(false, 'gemini-2.5-pro');
|
||||
t.snapshot(model1, 'should honor requested pro model');
|
||||
|
||||
const model2 = await s.resolveModel(false, 'not-in-optional');
|
||||
t.snapshot(model2, 'should fallback to default model');
|
||||
}
|
||||
|
||||
// payment enabled + trialing: requesting pro should fallback to default
|
||||
{
|
||||
mockStatus(SubscriptionStatus.Trialing);
|
||||
const model3 = await s.resolveModel(true, 'gemini-2.5-pro');
|
||||
t.snapshot(
|
||||
model3,
|
||||
'should fallback to default model when requesting pro model during trialing'
|
||||
);
|
||||
|
||||
const model4 = await s.resolveModel(true, 'gemini-2.5-flash');
|
||||
t.snapshot(model4, 'should honor requested non-pro model during trialing');
|
||||
|
||||
const model5 = await s.resolveModel(true);
|
||||
t.snapshot(
|
||||
model5,
|
||||
'should pick default model when no requested model during trialing'
|
||||
);
|
||||
}
|
||||
|
||||
// payment enabled + active: without requested -> default model; requested pro should be honored
|
||||
{
|
||||
mockStatus(SubscriptionStatus.Active);
|
||||
const model6 = await s.resolveModel(true);
|
||||
t.snapshot(
|
||||
model6,
|
||||
'should pick default model when no requested model during active'
|
||||
);
|
||||
|
||||
const model7 = await s.resolveModel(true, 'claude-sonnet-4@20250514');
|
||||
t.snapshot(model7, 'should honor requested pro model during active');
|
||||
|
||||
const model8 = await s.resolveModel(true, 'not-in-optional');
|
||||
t.snapshot(
|
||||
model8,
|
||||
'should fallback to default model when requesting non-optional model during active'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ defineModuleConfig('copilot', {
|
||||
override_enabled: false,
|
||||
scenarios: {
|
||||
audio_transcribing: 'gemini-2.5-flash',
|
||||
chat: 'claude-sonnet-4@20250514',
|
||||
chat: 'gemini-2.5-flash',
|
||||
embedding: 'gemini-embedding-001',
|
||||
image: 'gpt-image-1',
|
||||
rerank: 'gpt-4.1',
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
NoCopilotProviderAvailable,
|
||||
UnsplashIsNotConfigured,
|
||||
} from '../../base';
|
||||
import { ServerFeature, ServerService } from '../../core';
|
||||
import { CurrentUser, Public } from '../../core/auth';
|
||||
import { CopilotContextService } from './context';
|
||||
import {
|
||||
@@ -75,6 +76,7 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly server: ServerService,
|
||||
private readonly chatSession: ChatSessionService,
|
||||
private readonly context: CopilotContextService,
|
||||
private readonly provider: CopilotProviderFactory,
|
||||
@@ -112,10 +114,10 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
throw new CopilotSessionNotFound();
|
||||
}
|
||||
|
||||
const model =
|
||||
modelId && session.optionalModels.includes(modelId)
|
||||
? modelId
|
||||
: session.model;
|
||||
const model = await session.resolveModel(
|
||||
this.server.features.includes(ServerFeature.Payment),
|
||||
modelId
|
||||
);
|
||||
|
||||
const hasAttachment = messageId
|
||||
? !!(await session.getMessageById(messageId)).attachments?.length
|
||||
|
||||
@@ -1928,7 +1928,7 @@ Now apply the \`updates\` to the \`content\`, following the intent in \`op\`, an
|
||||
];
|
||||
|
||||
const CHAT_PROMPT: Omit<Prompt, 'name'> = {
|
||||
model: 'claude-sonnet-4@20250514',
|
||||
model: 'gemini-2.5-flash',
|
||||
optionalModels: [
|
||||
'gpt-4.1',
|
||||
'gpt-5',
|
||||
@@ -2099,6 +2099,13 @@ Below is the user's query. Please respond in the user's preferred language witho
|
||||
'codeArtifact',
|
||||
'blobRead',
|
||||
],
|
||||
proModels: [
|
||||
'gemini-2.5-pro',
|
||||
'claude-opus-4@20250514',
|
||||
'claude-sonnet-4@20250514',
|
||||
'claude-3-7-sonnet@20250219',
|
||||
'claude-3-5-sonnet-v2@20241022',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export class AnthropicOfficialProvider extends AnthropicProvider<AnthropicOffici
|
||||
|
||||
override readonly models = [
|
||||
{
|
||||
name: 'Claude Opus 4',
|
||||
id: 'claude-opus-4-20250514',
|
||||
capabilities: [
|
||||
{
|
||||
@@ -30,6 +31,7 @@ export class AnthropicOfficialProvider extends AnthropicProvider<AnthropicOffici
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude Sonnet 4',
|
||||
id: 'claude-sonnet-4-20250514',
|
||||
capabilities: [
|
||||
{
|
||||
@@ -39,6 +41,7 @@ export class AnthropicOfficialProvider extends AnthropicProvider<AnthropicOffici
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude 3.7 Sonnet',
|
||||
id: 'claude-3-7-sonnet-20250219',
|
||||
capabilities: [
|
||||
{
|
||||
@@ -48,6 +51,7 @@ export class AnthropicOfficialProvider extends AnthropicProvider<AnthropicOffici
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude 3.5 Sonnet',
|
||||
id: 'claude-3-5-sonnet-20241022',
|
||||
capabilities: [
|
||||
{
|
||||
|
||||
@@ -15,6 +15,7 @@ export class AnthropicVertexProvider extends AnthropicProvider<AnthropicVertexCo
|
||||
|
||||
override readonly models = [
|
||||
{
|
||||
name: 'Claude Opus 4',
|
||||
id: 'claude-opus-4@20250514',
|
||||
capabilities: [
|
||||
{
|
||||
@@ -24,6 +25,7 @@ export class AnthropicVertexProvider extends AnthropicProvider<AnthropicVertexCo
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude Sonnet 4',
|
||||
id: 'claude-sonnet-4@20250514',
|
||||
capabilities: [
|
||||
{
|
||||
@@ -33,6 +35,7 @@ export class AnthropicVertexProvider extends AnthropicProvider<AnthropicVertexCo
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude 3.7 Sonnet',
|
||||
id: 'claude-3-7-sonnet@20250219',
|
||||
capabilities: [
|
||||
{
|
||||
@@ -42,6 +45,7 @@ export class AnthropicVertexProvider extends AnthropicProvider<AnthropicVertexCo
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude 3.5 Sonnet',
|
||||
id: 'claude-3-5-sonnet-v2@20241022',
|
||||
capabilities: [
|
||||
{
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
type OpenAIProvider as VercelOpenAIProvider,
|
||||
OpenAIResponsesProviderOptions,
|
||||
} from '@ai-sdk/openai';
|
||||
import {
|
||||
createOpenAICompatible,
|
||||
type OpenAICompatibleProvider as VercelOpenAICompatibleProvider,
|
||||
} from '@ai-sdk/openai-compatible';
|
||||
import {
|
||||
AISDKError,
|
||||
embedMany,
|
||||
@@ -18,6 +22,7 @@ import { z } from 'zod';
|
||||
|
||||
import {
|
||||
CopilotPromptInvalid,
|
||||
CopilotProviderNotSupported,
|
||||
CopilotProviderSideError,
|
||||
metrics,
|
||||
UserFriendlyError,
|
||||
@@ -47,6 +52,7 @@ export const DEFAULT_DIMENSIONS = 256;
|
||||
export type OpenAIConfig = {
|
||||
apiKey: string;
|
||||
baseURL?: string;
|
||||
oldApiStyle?: boolean;
|
||||
};
|
||||
|
||||
const ModelListSchema = z.object({
|
||||
@@ -85,6 +91,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
readonly models = [
|
||||
// Text to Text models
|
||||
{
|
||||
name: 'GPT 4o',
|
||||
id: 'gpt-4o',
|
||||
capabilities: [
|
||||
{
|
||||
@@ -95,6 +102,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
},
|
||||
// FIXME(@darkskygit): deprecated
|
||||
{
|
||||
name: 'GPT 4o 2024-08-06',
|
||||
id: 'gpt-4o-2024-08-06',
|
||||
capabilities: [
|
||||
{
|
||||
@@ -104,6 +112,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'GPT 4o Mini',
|
||||
id: 'gpt-4o-mini',
|
||||
capabilities: [
|
||||
{
|
||||
@@ -114,6 +123,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
},
|
||||
// FIXME(@darkskygit): deprecated
|
||||
{
|
||||
name: 'GPT 4o Mini 2024-07-18',
|
||||
id: 'gpt-4o-mini-2024-07-18',
|
||||
capabilities: [
|
||||
{
|
||||
@@ -123,6 +133,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'GPT 4.1',
|
||||
id: 'gpt-4.1',
|
||||
capabilities: [
|
||||
{
|
||||
@@ -137,6 +148,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'GPT 4.1 2025-04-14',
|
||||
id: 'gpt-4.1-2025-04-14',
|
||||
capabilities: [
|
||||
{
|
||||
@@ -150,6 +162,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'GPT 4.1 Mini',
|
||||
id: 'gpt-4.1-mini',
|
||||
capabilities: [
|
||||
{
|
||||
@@ -163,6 +176,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'GPT 4.1 Nano',
|
||||
id: 'gpt-4.1-nano',
|
||||
capabilities: [
|
||||
{
|
||||
@@ -176,6 +190,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'GPT 5',
|
||||
id: 'gpt-5',
|
||||
capabilities: [
|
||||
{
|
||||
@@ -189,6 +204,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'GPT 5 2025-08-07',
|
||||
id: 'gpt-5-2025-08-07',
|
||||
capabilities: [
|
||||
{
|
||||
@@ -202,6 +218,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'GPT 5 Mini',
|
||||
id: 'gpt-5-mini',
|
||||
capabilities: [
|
||||
{
|
||||
@@ -215,6 +232,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'GPT 5 Nano',
|
||||
id: 'gpt-5-nano',
|
||||
capabilities: [
|
||||
{
|
||||
@@ -228,6 +246,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'GPT O1',
|
||||
id: 'o1',
|
||||
capabilities: [
|
||||
{
|
||||
@@ -237,6 +256,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'GPT O3',
|
||||
id: 'o3',
|
||||
capabilities: [
|
||||
{
|
||||
@@ -246,6 +266,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'GPT O4 Mini',
|
||||
id: 'o4-mini',
|
||||
capabilities: [
|
||||
{
|
||||
@@ -296,7 +317,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
},
|
||||
];
|
||||
|
||||
#instance!: VercelOpenAIProvider;
|
||||
#instance!: VercelOpenAIProvider | VercelOpenAICompatibleProvider;
|
||||
|
||||
override configured(): boolean {
|
||||
return !!this.config.apiKey;
|
||||
@@ -304,10 +325,17 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
|
||||
protected override setup() {
|
||||
super.setup();
|
||||
this.#instance = createOpenAI({
|
||||
apiKey: this.config.apiKey,
|
||||
baseURL: this.config.baseURL,
|
||||
});
|
||||
this.#instance =
|
||||
this.config.oldApiStyle && this.config.baseURL
|
||||
? createOpenAICompatible({
|
||||
name: 'openai-compatible-old-style',
|
||||
apiKey: this.config.apiKey,
|
||||
baseURL: this.config.baseURL,
|
||||
})
|
||||
: createOpenAI({
|
||||
apiKey: this.config.apiKey,
|
||||
baseURL: this.config.baseURL,
|
||||
});
|
||||
}
|
||||
|
||||
private handleError(
|
||||
@@ -341,7 +369,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
override async refreshOnlineModels() {
|
||||
try {
|
||||
const baseUrl = this.config.baseURL || 'https://api.openai.com/v1';
|
||||
if (baseUrl && !this.onlineModelList.length) {
|
||||
if (this.config.apiKey && baseUrl && !this.onlineModelList.length) {
|
||||
const { data } = await fetch(`${baseUrl}/models`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
@@ -361,7 +389,11 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
toolName: CopilotChatTools,
|
||||
model: string
|
||||
): [string, Tool?] | undefined {
|
||||
if (toolName === 'webSearch' && !this.isReasoningModel(model)) {
|
||||
if (
|
||||
toolName === 'webSearch' &&
|
||||
'responses' in this.#instance &&
|
||||
!this.isReasoningModel(model)
|
||||
) {
|
||||
return ['web_search_preview', openai.tools.webSearchPreview({})];
|
||||
} else if (toolName === 'docEdit') {
|
||||
return ['doc_edit', undefined];
|
||||
@@ -374,10 +406,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
messages: PromptMessage[],
|
||||
options: CopilotChatOptions = {}
|
||||
): Promise<string> {
|
||||
const fullCond = {
|
||||
...cond,
|
||||
outputType: ModelOutputType.Text,
|
||||
};
|
||||
const fullCond = { ...cond, outputType: ModelOutputType.Text };
|
||||
await this.checkParams({ messages, cond: fullCond, options });
|
||||
const model = this.selectModel(fullCond);
|
||||
|
||||
@@ -386,7 +415,10 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
|
||||
const [system, msgs] = await chatToGPTMessage(messages);
|
||||
|
||||
const modelInstance = this.#instance.responses(model.id);
|
||||
const modelInstance =
|
||||
'responses' in this.#instance
|
||||
? this.#instance.responses(model.id)
|
||||
: this.#instance(model.id);
|
||||
|
||||
const { text } = await generateText({
|
||||
model: modelInstance,
|
||||
@@ -507,7 +539,10 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
throw new CopilotPromptInvalid('Schema is required');
|
||||
}
|
||||
|
||||
const modelInstance = this.#instance.responses(model.id);
|
||||
const modelInstance =
|
||||
'responses' in this.#instance
|
||||
? this.#instance.responses(model.id)
|
||||
: this.#instance(model.id);
|
||||
|
||||
const { object } = await generateObject({
|
||||
model: modelInstance,
|
||||
@@ -539,7 +574,10 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
await this.checkParams({ messages: [], cond: fullCond, options });
|
||||
const model = this.selectModel(fullCond);
|
||||
// get the log probability of "yes"/"no"
|
||||
const instance = this.#instance.chat(model.id);
|
||||
const instance =
|
||||
'chat' in this.#instance
|
||||
? this.#instance.chat(model.id)
|
||||
: this.#instance(model.id);
|
||||
|
||||
const scores = await Promise.all(
|
||||
chunkMessages.map(async messages => {
|
||||
@@ -600,7 +638,10 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
options: CopilotChatOptions = {}
|
||||
) {
|
||||
const [system, msgs] = await chatToGPTMessage(messages);
|
||||
const modelInstance = this.#instance.responses(model.id);
|
||||
const modelInstance =
|
||||
'responses' in this.#instance
|
||||
? this.#instance.responses(model.id)
|
||||
: this.#instance(model.id);
|
||||
const { fullStream } = streamText({
|
||||
model: modelInstance,
|
||||
system,
|
||||
@@ -685,6 +726,13 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
await this.checkParams({ messages, cond: fullCond, options });
|
||||
const model = this.selectModel(fullCond);
|
||||
|
||||
if (!('image' in this.#instance)) {
|
||||
throw new CopilotProviderNotSupported({
|
||||
provider: this.type,
|
||||
kind: 'image',
|
||||
});
|
||||
}
|
||||
|
||||
metrics.ai
|
||||
.counter('generate_images_stream_calls')
|
||||
.add(1, { model: model.id });
|
||||
@@ -735,6 +783,13 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
await this.checkParams({ embeddings: messages, cond: fullCond, options });
|
||||
const model = this.selectModel(fullCond);
|
||||
|
||||
if (!('embedding' in this.#instance)) {
|
||||
throw new CopilotProviderNotSupported({
|
||||
provider: this.type,
|
||||
kind: 'embedding',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
metrics.ai
|
||||
.counter('generate_embedding_calls')
|
||||
@@ -775,6 +830,6 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
|
||||
private isReasoningModel(model: string) {
|
||||
// o series reasoning models
|
||||
return model.startsWith('o');
|
||||
return model.startsWith('o') || model.startsWith('gpt-5');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ export const PromptToolsSchema = z
|
||||
|
||||
export const PromptConfigStrictSchema = z.object({
|
||||
tools: PromptToolsSchema.nullable().optional(),
|
||||
proModels: z.array(z.string()).nullable().optional(),
|
||||
// params requirements
|
||||
requireContent: z.boolean().nullable().optional(),
|
||||
requireAttachment: z.boolean().nullable().optional(),
|
||||
|
||||
@@ -362,6 +362,27 @@ class CopilotPromptType {
|
||||
messages!: CopilotPromptMessageType[];
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class CopilotModelType {
|
||||
@Field(() => String)
|
||||
id!: string;
|
||||
|
||||
@Field(() => String)
|
||||
name!: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class CopilotModelsType {
|
||||
@Field(() => String)
|
||||
defaultModel!: string;
|
||||
|
||||
@Field(() => [CopilotModelType])
|
||||
optionalModels!: CopilotModelType[];
|
||||
|
||||
@Field(() => [CopilotModelType])
|
||||
proModels!: CopilotModelType[];
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class CopilotSessionType {
|
||||
@Field(() => ID)
|
||||
@@ -400,9 +421,12 @@ export class CopilotType {
|
||||
@Throttle()
|
||||
@Resolver(() => CopilotType)
|
||||
export class CopilotResolver {
|
||||
private readonly modelNames = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
private readonly ac: AccessController,
|
||||
private readonly mutex: RequestMutex,
|
||||
private readonly prompt: PromptService,
|
||||
private readonly chatSession: ChatSessionService,
|
||||
private readonly storage: CopilotStorage,
|
||||
private readonly docReader: DocReader,
|
||||
@@ -443,6 +467,48 @@ export class CopilotResolver {
|
||||
return { userId: user.id, workspaceId, docId: docId || undefined };
|
||||
}
|
||||
|
||||
@ResolveField(() => CopilotModelsType, {
|
||||
description:
|
||||
'List available models for a prompt, with human-readable names',
|
||||
complexity: 2,
|
||||
})
|
||||
async models(
|
||||
@Args('promptName') promptName: string
|
||||
): Promise<CopilotModelsType> {
|
||||
const prompt = await this.prompt.get(promptName);
|
||||
if (!prompt) {
|
||||
throw new NotFoundException('Prompt not found');
|
||||
}
|
||||
const convertModels = (ids: string[]) => {
|
||||
return ids
|
||||
.map(id => ({ id, name: this.modelNames.get(id) }))
|
||||
.filter(m => !!m.name) as CopilotModelType[];
|
||||
};
|
||||
const proModels = prompt.config?.proModels || [];
|
||||
const missing = new Set(
|
||||
[...prompt.optionalModels, ...proModels].filter(
|
||||
id => !this.modelNames.has(id)
|
||||
)
|
||||
);
|
||||
if (missing.size) {
|
||||
for (const model of missing) {
|
||||
if (this.modelNames.has(model)) continue;
|
||||
const provider = await this.providerFactory.getProviderByModel(model);
|
||||
if (provider?.configured()) {
|
||||
for (const m of provider.models) {
|
||||
if (m.name) this.modelNames.set(m.id, m.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
defaultModel: prompt.model,
|
||||
optionalModels: convertModels(prompt.optionalModels),
|
||||
proModels: convertModels(proModels),
|
||||
};
|
||||
}
|
||||
|
||||
@ResolveField(() => CopilotSessionType, {
|
||||
description: 'Get the session by id',
|
||||
complexity: 2,
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
type UpdateChatSession,
|
||||
UpdateChatSessionOptions,
|
||||
} from '../../models';
|
||||
import { SubscriptionService } from '../payment/service';
|
||||
import { SubscriptionPlan, SubscriptionStatus } from '../payment/types';
|
||||
import { ChatMessageCache } from './message';
|
||||
import { ChatPrompt, PromptService } from './prompt';
|
||||
import {
|
||||
@@ -58,6 +60,7 @@ declare global {
|
||||
export class ChatSession implements AsyncDisposable {
|
||||
private stashMessageCount = 0;
|
||||
constructor(
|
||||
private readonly moduleRef: ModuleRef,
|
||||
private readonly messageCache: ChatMessageCache,
|
||||
private readonly state: ChatSessionState,
|
||||
private readonly dispose?: (state: ChatSessionState) => Promise<void>,
|
||||
@@ -72,6 +75,10 @@ export class ChatSession implements AsyncDisposable {
|
||||
return this.state.prompt.optionalModels;
|
||||
}
|
||||
|
||||
get proModels() {
|
||||
return this.state.prompt.config?.proModels || [];
|
||||
}
|
||||
|
||||
get config() {
|
||||
const {
|
||||
sessionId,
|
||||
@@ -93,6 +100,43 @@ export class ChatSession implements AsyncDisposable {
|
||||
return this.state.messages.findLast(m => m.role === 'user');
|
||||
}
|
||||
|
||||
async resolveModel(
|
||||
hasPayment: boolean,
|
||||
requestedModelId?: string
|
||||
): Promise<string> {
|
||||
const defaultModel = this.model;
|
||||
const normalize = (m?: string) =>
|
||||
!!m && this.optionalModels.includes(m) ? m : defaultModel;
|
||||
const isPro = (m?: string) => !!m && this.proModels.includes(m);
|
||||
|
||||
// try resolve payment subscription service lazily
|
||||
let paymentEnabled = hasPayment;
|
||||
let isUserAIPro = false;
|
||||
try {
|
||||
if (paymentEnabled) {
|
||||
const sub = this.moduleRef.get(SubscriptionService, {
|
||||
strict: false,
|
||||
});
|
||||
const subscription = await sub
|
||||
.select(SubscriptionPlan.AI)
|
||||
.getSubscription({
|
||||
userId: this.config.userId,
|
||||
plan: SubscriptionPlan.AI,
|
||||
} as any);
|
||||
isUserAIPro = subscription?.status === SubscriptionStatus.Active;
|
||||
}
|
||||
} catch {
|
||||
// payment not available -> skip checks
|
||||
paymentEnabled = false;
|
||||
}
|
||||
|
||||
if (paymentEnabled && !isUserAIPro && isPro(requestedModelId)) {
|
||||
return defaultModel;
|
||||
}
|
||||
|
||||
return normalize(requestedModelId);
|
||||
}
|
||||
|
||||
push(message: ChatMessage) {
|
||||
if (
|
||||
this.state.prompt.action &&
|
||||
@@ -539,12 +583,17 @@ export class ChatSessionService {
|
||||
async get(sessionId: string): Promise<ChatSession | null> {
|
||||
const state = await this.getSessionInfo(sessionId);
|
||||
if (state) {
|
||||
return new ChatSession(this.messageCache, state, async state => {
|
||||
await this.models.copilotSession.updateMessages(state);
|
||||
if (!state.prompt.action) {
|
||||
await this.jobs.add('copilot.session.generateTitle', { sessionId });
|
||||
return new ChatSession(
|
||||
this.moduleRef,
|
||||
this.messageCache,
|
||||
state,
|
||||
async state => {
|
||||
await this.models.copilotSession.updateMessages(state);
|
||||
if (!state.prompt.action) {
|
||||
await this.jobs.add('copilot.session.generateTitle', { sessionId });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ export class SubscriptionService {
|
||||
return this.stripeProvider.stripe;
|
||||
}
|
||||
|
||||
private select(plan: SubscriptionPlan): SubscriptionManager {
|
||||
select(plan: SubscriptionPlan): SubscriptionManager {
|
||||
switch (plan) {
|
||||
case SubscriptionPlan.Team:
|
||||
return this.workspaceManager;
|
||||
|
||||
@@ -226,6 +226,9 @@ type Copilot {
|
||||
contexts(contextId: String, sessionId: String): [CopilotContext!]!
|
||||
histories(docId: String, options: QueryChatHistoriesInput): [CopilotHistories!]! @deprecated(reason: "use `chats` instead")
|
||||
|
||||
"""List available models for a prompt, with human-readable names"""
|
||||
models(promptName: String!): CopilotModelsType!
|
||||
|
||||
"""Get the quota of the user in the workspace"""
|
||||
quota: CopilotQuota!
|
||||
|
||||
@@ -360,6 +363,17 @@ type CopilotMessageNotFoundDataType {
|
||||
messageId: String!
|
||||
}
|
||||
|
||||
type CopilotModelType {
|
||||
id: String!
|
||||
name: String!
|
||||
}
|
||||
|
||||
type CopilotModelsType {
|
||||
defaultModel: String!
|
||||
optionalModels: [CopilotModelType!]!
|
||||
proModels: [CopilotModelType!]!
|
||||
}
|
||||
|
||||
input CopilotPromptConfigInput {
|
||||
frequencyPenalty: Float
|
||||
presencePenalty: Float
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
query getPromptModels($promptName: String!) {
|
||||
currentUser {
|
||||
copilot {
|
||||
models(promptName: $promptName) {
|
||||
defaultModel
|
||||
optionalModels {
|
||||
id
|
||||
name
|
||||
}
|
||||
proModels {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1059,6 +1059,28 @@ export const createCopilotMessageMutation = {
|
||||
file: true,
|
||||
};
|
||||
|
||||
export const getPromptModelsQuery = {
|
||||
id: 'getPromptModelsQuery' as const,
|
||||
op: 'getPromptModels',
|
||||
query: `query getPromptModels($promptName: String!) {
|
||||
currentUser {
|
||||
copilot {
|
||||
models(promptName: $promptName) {
|
||||
defaultModel
|
||||
optionalModels {
|
||||
id
|
||||
name
|
||||
}
|
||||
proModels {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const copilotQuotaQuery = {
|
||||
id: 'copilotQuotaQuery' as const,
|
||||
op: 'copilotQuota',
|
||||
|
||||
@@ -263,6 +263,8 @@ export interface Copilot {
|
||||
contexts: Array<CopilotContext>;
|
||||
/** @deprecated use `chats` instead */
|
||||
histories: Array<CopilotHistories>;
|
||||
/** List available models for a prompt, with human-readable names */
|
||||
models: CopilotModelsType;
|
||||
/** Get the quota of the user in the workspace */
|
||||
quota: CopilotQuota;
|
||||
/** Get the session by id */
|
||||
@@ -296,6 +298,10 @@ export interface CopilotHistoriesArgs {
|
||||
options?: InputMaybe<QueryChatHistoriesInput>;
|
||||
}
|
||||
|
||||
export interface CopilotModelsArgs {
|
||||
promptName: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface CopilotSessionArgs {
|
||||
sessionId: Scalars['String']['input'];
|
||||
}
|
||||
@@ -451,6 +457,19 @@ export interface CopilotMessageNotFoundDataType {
|
||||
messageId: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export interface CopilotModelType {
|
||||
__typename?: 'CopilotModelType';
|
||||
id: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export interface CopilotModelsType {
|
||||
__typename?: 'CopilotModelsType';
|
||||
defaultModel: Scalars['String']['output'];
|
||||
optionalModels: Array<CopilotModelType>;
|
||||
proModels: Array<CopilotModelType>;
|
||||
}
|
||||
|
||||
export interface CopilotPromptConfigInput {
|
||||
frequencyPenalty?: InputMaybe<Scalars['Float']['input']>;
|
||||
presencePenalty?: InputMaybe<Scalars['Float']['input']>;
|
||||
@@ -4343,6 +4362,34 @@ export type CreateCopilotMessageMutation = {
|
||||
createCopilotMessage: string;
|
||||
};
|
||||
|
||||
export type GetPromptModelsQueryVariables = Exact<{
|
||||
promptName: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type GetPromptModelsQuery = {
|
||||
__typename?: 'Query';
|
||||
currentUser: {
|
||||
__typename?: 'UserType';
|
||||
copilot: {
|
||||
__typename?: 'Copilot';
|
||||
models: {
|
||||
__typename?: 'CopilotModelsType';
|
||||
defaultModel: string;
|
||||
optionalModels: Array<{
|
||||
__typename?: 'CopilotModelType';
|
||||
id: string;
|
||||
name: string;
|
||||
}>;
|
||||
proModels: Array<{
|
||||
__typename?: 'CopilotModelType';
|
||||
id: string;
|
||||
name: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type CopilotQuotaQueryVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type CopilotQuotaQuery = {
|
||||
@@ -6380,6 +6427,11 @@ export type Queries =
|
||||
variables: GetAudioTranscriptionQueryVariables;
|
||||
response: GetAudioTranscriptionQuery;
|
||||
}
|
||||
| {
|
||||
name: 'getPromptModelsQuery';
|
||||
variables: GetPromptModelsQueryVariables;
|
||||
response: GetPromptModelsQuery;
|
||||
}
|
||||
| {
|
||||
name: 'copilotQuotaQuery';
|
||||
variables: CopilotQuotaQueryVariables;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Swift Code Style Guidelines
|
||||
|
||||
## Core Style
|
||||
|
||||
- **Indentation**: 2 spaces
|
||||
- **Braces**: Opening brace on same line
|
||||
- **Spacing**: Single space around operators and commas
|
||||
- **Naming**: PascalCase for types, camelCase for properties/methods
|
||||
|
||||
## File Organization
|
||||
|
||||
- Logical directory grouping
|
||||
- PascalCase files for types, `+` for extensions
|
||||
- Modular design with extensions
|
||||
|
||||
## Modern Swift Features
|
||||
|
||||
- **@Observable macro**: Replace `ObservableObject`/`@Published`
|
||||
- **Swift concurrency**: `async/await`, `Task`, `actor`, `@MainActor`
|
||||
- **Result builders**: Declarative APIs
|
||||
- **Property wrappers**: Use line breaks for long declarations
|
||||
- **Opaque types**: `some` for protocol returns
|
||||
|
||||
## Code Structure
|
||||
|
||||
- Early returns to reduce nesting
|
||||
- Guard statements for optional unwrapping
|
||||
- Single responsibility per type/extension
|
||||
- Value types over reference types
|
||||
|
||||
## Error Handling
|
||||
|
||||
- `Result` enum for typed errors
|
||||
- `throws`/`try` for propagation
|
||||
- Optional chaining with `guard let`/`if let`
|
||||
- Typed error definitions
|
||||
|
||||
## Architecture
|
||||
|
||||
- Protocol-oriented design
|
||||
- Dependency injection over singletons
|
||||
- Composition over inheritance
|
||||
- Factory/Repository patterns
|
||||
|
||||
## Debug Assertions
|
||||
|
||||
- Use `assert()` for development-time invariant checking
|
||||
- Use `assertionFailure()` for unreachable code paths
|
||||
- Assertions removed in release builds for performance
|
||||
- Precondition checking with `precondition()` for fatal errors
|
||||
|
||||
## Memory Management
|
||||
|
||||
- `weak` references for cycles
|
||||
- `unowned` when guaranteed non-nil
|
||||
- Capture lists in closures
|
||||
- `deinit` for cleanup
|
||||
@@ -3,11 +3,15 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
2E0DD47B57B994A104B25EED /* Pods_AFFiNE.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF48636D7DB5BEE00770FD9A /* Pods_AFFiNE.framework */; };
|
||||
5027D4782E7C5FBD00ADD25A /* AffinePaywall in Frameworks */ = {isa = PBXBuildFile; productRef = 5027D4772E7C5FBD00ADD25A /* AffinePaywall */; };
|
||||
5027D47A2E7C5FC100ADD25A /* AffineResources in Frameworks */ = {isa = PBXBuildFile; productRef = 5027D4792E7C5FC100ADD25A /* AffineResources */; };
|
||||
5027D47C2E7C5FC400ADD25A /* AffineGraphQL in Frameworks */ = {isa = PBXBuildFile; productRef = 5027D47B2E7C5FC400ADD25A /* AffineGraphQL */; };
|
||||
5027D4802E7C611900ADD25A /* Tools.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5027D47F2E7C611900ADD25A /* Tools.swift */; };
|
||||
5075136A2D1924C600AD60C0 /* RootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507513692D1924C600AD60C0 /* RootViewController.swift */; };
|
||||
50802D612D112F8700694021 /* Intelligents in Frameworks */ = {isa = PBXBuildFile; productRef = 50802D602D112F8700694021 /* Intelligents */; settings = {ATTRIBUTES = (Required, ); }; };
|
||||
50A285D72D112A5E000D5A6D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D62D112A5E000D5A6D /* Localizable.xcstrings */; };
|
||||
@@ -16,10 +20,6 @@
|
||||
50FF428A2D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */; };
|
||||
50FF428C2D2E77CC0050AA83 /* AffineViewController+AIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */; };
|
||||
9D52FC432D26CDBF00105D0A /* JSValueContainerExt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D52FC422D26CDB600105D0A /* JSValueContainerExt.swift */; };
|
||||
9D5622962D64A6A5009F1BE4 /* AuthPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5622952D64A6A4009F1BE4 /* AuthPlugin.swift */; };
|
||||
9D6A85332CCF6DA700DAB35F /* HashcashPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D6A85322CCF6DA700DAB35F /* HashcashPlugin.swift */; };
|
||||
9D90BE252CCB9876006677DB /* CookieManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D90BE172CCB9876006677DB /* CookieManager.swift */; };
|
||||
9D90BE262CCB9876006677DB /* CookiePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D90BE182CCB9876006677DB /* CookiePlugin.swift */; };
|
||||
9D90BE272CCB9876006677DB /* AffineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D90BE1B2CCB9876006677DB /* AffineViewController.swift */; };
|
||||
9D90BE282CCB9876006677DB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D90BE1C2CCB9876006677DB /* AppDelegate.swift */; };
|
||||
9D90BE292CCB9876006677DB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9D90BE1D2CCB9876006677DB /* Assets.xcassets */; };
|
||||
@@ -33,7 +33,6 @@
|
||||
C4C97C7C2D030BE000BC2AD1 /* affine_mobile_native.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C6F2D0307B700BC2AD1 /* affine_mobile_native.swift */; };
|
||||
C4C97C7D2D030BE000BC2AD1 /* affine_mobile_nativeFFI.h in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C702D0307B700BC2AD1 /* affine_mobile_nativeFFI.h */; };
|
||||
C4C97C7E2D030BE000BC2AD1 /* affine_mobile_nativeFFI.modulemap in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C712D0307B700BC2AD1 /* affine_mobile_nativeFFI.modulemap */; };
|
||||
E93B276C2CED92B1001409B8 /* NavigationGesturePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = E93B276B2CED92B1001409B8 /* NavigationGesturePlugin.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
@@ -52,19 +51,18 @@
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
3256F4410D881A03FE77D092 /* Pods-AFFiNE.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AFFiNE.debug.xcconfig"; path = "Pods/Target Support Files/Pods-AFFiNE/Pods-AFFiNE.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
5027D4762E7C5FB700ADD25A /* AffinePaywall */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AffinePaywall; sourceTree = "<group>"; };
|
||||
5027D47F2E7C611900ADD25A /* Tools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tools.swift; sourceTree = "<group>"; };
|
||||
5039CC962D1D42C700874F32 /* AffineGraphQL */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AffineGraphQL; sourceTree = "<group>"; };
|
||||
504EC3041FED79650016851F /* AFFiNE.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AFFiNE.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
507513692D1924C600AD60C0 /* RootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewController.swift; sourceTree = "<group>"; };
|
||||
50802D5E2D112F7D00694021 /* Intelligents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Intelligents; sourceTree = "<group>"; };
|
||||
50A285D52D112A5E000D5A6D /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; };
|
||||
50A285D62D112A5E000D5A6D /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
50CECF1E2E7C1084004487AA /* AffineResources */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AffineResources; sourceTree = "<group>"; };
|
||||
50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationBridgedWindowScript.swift; sourceTree = "<group>"; };
|
||||
50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AffineViewController+AIButton.swift"; sourceTree = "<group>"; };
|
||||
9D52FC422D26CDB600105D0A /* JSValueContainerExt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSValueContainerExt.swift; sourceTree = "<group>"; };
|
||||
9D5622952D64A6A4009F1BE4 /* AuthPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthPlugin.swift; sourceTree = "<group>"; };
|
||||
9D6A85322CCF6DA700DAB35F /* HashcashPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashcashPlugin.swift; sourceTree = "<group>"; };
|
||||
9D90BE172CCB9876006677DB /* CookieManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieManager.swift; sourceTree = "<group>"; };
|
||||
9D90BE182CCB9876006677DB /* CookiePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookiePlugin.swift; sourceTree = "<group>"; };
|
||||
9D90BE1B2CCB9876006677DB /* AffineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AffineViewController.swift; sourceTree = "<group>"; };
|
||||
9D90BE1C2CCB9876006677DB /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
9D90BE1D2CCB9876006677DB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
@@ -83,16 +81,13 @@
|
||||
C4C97C702D0307B700BC2AD1 /* affine_mobile_nativeFFI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = affine_mobile_nativeFFI.h; sourceTree = "<group>"; };
|
||||
C4C97C712D0307B700BC2AD1 /* affine_mobile_nativeFFI.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = affine_mobile_nativeFFI.modulemap; sourceTree = "<group>"; };
|
||||
E5E5070D1CA1200D4964D91F /* Pods-AFFiNE.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AFFiNE.release.xcconfig"; path = "Pods/Target Support Files/Pods-AFFiNE/Pods-AFFiNE.release.xcconfig"; sourceTree = "<group>"; };
|
||||
E93B276B2CED92B1001409B8 /* NavigationGesturePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationGesturePlugin.swift; sourceTree = "<group>"; };
|
||||
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
C45499AB2D140B5000E21978 /* NBStore */ = {
|
||||
9DAE85B72E7BAC3B00DB9F1D /* Plugins */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = NBStore;
|
||||
path = Plugins;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
@@ -102,7 +97,10 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5027D47A2E7C5FC100ADD25A /* AffineResources in Frameworks */,
|
||||
5027D4782E7C5FBD00ADD25A /* AffinePaywall in Frameworks */,
|
||||
9DFCD1462D27D1D70028C92B /* libaffine_mobile_native.a in Frameworks */,
|
||||
5027D47C2E7C5FC400ADD25A /* AffineGraphQL in Frameworks */,
|
||||
50802D612D112F8700694021 /* Intelligents in Frameworks */,
|
||||
2E0DD47B57B994A104B25EED /* Pods_AFFiNE.framework in Frameworks */,
|
||||
);
|
||||
@@ -147,6 +145,8 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5039CC962D1D42C700874F32 /* AffineGraphQL */,
|
||||
5027D4762E7C5FB700ADD25A /* AffinePaywall */,
|
||||
50CECF1E2E7C1084004487AA /* AffineResources */,
|
||||
50802D5E2D112F7D00694021 /* Intelligents */,
|
||||
);
|
||||
path = Packages;
|
||||
@@ -163,47 +163,19 @@
|
||||
name = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9D5622942D64A69C009F1BE4 /* Auth */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9D5622952D64A6A4009F1BE4 /* AuthPlugin.swift */,
|
||||
);
|
||||
path = Auth;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9D90BE192CCB9876006677DB /* Cookie */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9D90BE172CCB9876006677DB /* CookieManager.swift */,
|
||||
9D90BE182CCB9876006677DB /* CookiePlugin.swift */,
|
||||
9D6A85322CCF6DA700DAB35F /* HashcashPlugin.swift */,
|
||||
);
|
||||
path = Cookie;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9D90BE1A2CCB9876006677DB /* Plugins */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9D5622942D64A69C009F1BE4 /* Auth */,
|
||||
C45499AB2D140B5000E21978 /* NBStore */,
|
||||
E93B276A2CED9298001409B8 /* NavigationGesture */,
|
||||
9D90BE192CCB9876006677DB /* Cookie */,
|
||||
);
|
||||
path = Plugins;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9D90BE242CCB9876006677DB /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9DAE9BD82D8D1AA9000C1D5A /* AppConfigManager.swift */,
|
||||
9DEC59422D323EE00027CEBD /* Mutex.swift */,
|
||||
9D52FC422D26CDB600105D0A /* JSValueContainerExt.swift */,
|
||||
9D90BE1A2CCB9876006677DB /* Plugins */,
|
||||
9DAE85B72E7BAC3B00DB9F1D /* Plugins */,
|
||||
9D90BE1C2CCB9876006677DB /* AppDelegate.swift */,
|
||||
507513692D1924C600AD60C0 /* RootViewController.swift */,
|
||||
9D90BE1B2CCB9876006677DB /* AffineViewController.swift */,
|
||||
50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */,
|
||||
50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */,
|
||||
5027D47F2E7C611900ADD25A /* Tools.swift */,
|
||||
9D90BE1D2CCB9876006677DB /* Assets.xcassets */,
|
||||
9D90BE1E2CCB9876006677DB /* capacitor.config.json */,
|
||||
9D90BE1F2CCB9876006677DB /* config.xml */,
|
||||
@@ -227,14 +199,6 @@
|
||||
path = App/uniffi;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E93B276A2CED9298001409B8 /* NavigationGesture */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E93B276B2CED92B1001409B8 /* NavigationGesturePlugin.swift */,
|
||||
);
|
||||
path = NavigationGesture;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -255,7 +219,7 @@
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
C45499AB2D140B5000E21978 /* NBStore */,
|
||||
9DAE85B72E7BAC3B00DB9F1D /* Plugins */,
|
||||
);
|
||||
name = AFFiNE;
|
||||
productName = App;
|
||||
@@ -339,9 +303,13 @@
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AFFiNE/Pods-AFFiNE-frameworks.sh\"\n";
|
||||
@@ -378,16 +346,12 @@
|
||||
9DAE9BD92D8D1AB0000C1D5A /* AppConfigManager.swift in Sources */,
|
||||
50FF428A2D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift in Sources */,
|
||||
C4C97C7D2D030BE000BC2AD1 /* affine_mobile_nativeFFI.h in Sources */,
|
||||
9D5622962D64A6A5009F1BE4 /* AuthPlugin.swift in Sources */,
|
||||
C4C97C7E2D030BE000BC2AD1 /* affine_mobile_nativeFFI.modulemap in Sources */,
|
||||
E93B276C2CED92B1001409B8 /* NavigationGesturePlugin.swift in Sources */,
|
||||
9DEC59432D323EE40027CEBD /* Mutex.swift in Sources */,
|
||||
9D90BE252CCB9876006677DB /* CookieManager.swift in Sources */,
|
||||
50FF428C2D2E77CC0050AA83 /* AffineViewController+AIButton.swift in Sources */,
|
||||
9D90BE262CCB9876006677DB /* CookiePlugin.swift in Sources */,
|
||||
9D6A85332CCF6DA700DAB35F /* HashcashPlugin.swift in Sources */,
|
||||
9D90BE272CCB9876006677DB /* AffineViewController.swift in Sources */,
|
||||
9D90BE282CCB9876006677DB /* AppDelegate.swift in Sources */,
|
||||
5027D4802E7C611900ADD25A /* Tools.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -532,7 +496,7 @@
|
||||
DEVELOPMENT_TEAM = 964G86XT2P;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.5;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -568,7 +532,7 @@
|
||||
DEVELOPMENT_TEAM = 964G86XT2P;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.5;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -617,6 +581,18 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
5027D4772E7C5FBD00ADD25A /* AffinePaywall */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = AffinePaywall;
|
||||
};
|
||||
5027D4792E7C5FC100ADD25A /* AffineResources */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = AffineResources;
|
||||
};
|
||||
5027D47B2E7C5FC400ADD25A /* AffineGraphQL */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = AffineGraphQL;
|
||||
};
|
||||
50802D602D112F8700694021 /* Intelligents */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Intelligents;
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Lakr233/MarkdownView",
|
||||
"state" : {
|
||||
"revision" : "1b0267f115274260d7cc875c4e9043f976f003a2",
|
||||
"version" : "3.4.1"
|
||||
"revision" : "c052f57768436212c91e4369d76181c38eaa3ba3",
|
||||
"version" : "3.4.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -107,15 +107,6 @@
|
||||
"revision" : "cfd646dcac0c5553e21ebf1ee05f9078277518bc",
|
||||
"version" : "1.7.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "then",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/devxoul/Then",
|
||||
"state" : {
|
||||
"revision" : "d41ef523faef0f911369f79c0b96815d9dbb6d7a",
|
||||
"version" : "3.0.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
|
||||
@@ -32,8 +32,8 @@ class AFFiNEViewController: CAPBridgeViewController {
|
||||
CookiePlugin(),
|
||||
HashcashPlugin(),
|
||||
NavigationGesturePlugin(),
|
||||
// IntelligentsPlugin(representController: self), // no longer put in use
|
||||
NbStorePlugin(),
|
||||
PayWallPlugin(associatedController: self),
|
||||
]
|
||||
plugins.forEach { bridge?.registerPluginInstance($0) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import AffinePaywall
|
||||
import Capacitor
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@objc(PayWallPlugin)
|
||||
public class PayWallPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
init(associatedController: UIViewController? = nil) {
|
||||
controller = associatedController
|
||||
super.init()
|
||||
}
|
||||
|
||||
weak var controller: UIViewController?
|
||||
|
||||
public let identifier = "PayWallPlugin"
|
||||
public let jsName = "PayWall"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "showPayWall", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
@objc func showPayWall(_ call: CAPPluginCall) {
|
||||
do {
|
||||
let type = try call.getStringEnsure("type")
|
||||
let controller = try controller.get()
|
||||
|
||||
// TODO: GET TO KNOW THE PAYWALL TYPE
|
||||
print("[*] showing paywall of type: \(type)")
|
||||
DispatchQueue.main.async {
|
||||
Paywall.presentWall(toController: controller, type: type)
|
||||
}
|
||||
|
||||
call.resolve(["success": true, "type": type])
|
||||
} catch {
|
||||
call.reject("failed to show paywall", nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// Tools.swift
|
||||
// AFFiNE
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Optional {
|
||||
func get(_ failure: String? = nil) throws -> Wrapped {
|
||||
guard let self else {
|
||||
if let failure {
|
||||
throw NSError(domain: #function, code: -1, userInfo: [NSLocalizedDescriptionKey: failure])
|
||||
} else {
|
||||
throw NSError(domain: #function, code: -1)
|
||||
}
|
||||
}
|
||||
return self
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@
|
||||
import ApolloAPI
|
||||
|
||||
public enum SchemaConfiguration: ApolloAPI.SchemaConfiguration {
|
||||
public static func cacheKeyInfo(for _: ApolloAPI.Object, object _: ApolloAPI.ObjectData) -> CacheKeyInfo? {
|
||||
public static func cacheKeyInfo(for type: ApolloAPI.Object, object: ApolloAPI.ObjectData) -> CacheKeyInfo? {
|
||||
// Implement this function to configure cache key resolution for your schema types.
|
||||
nil
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/configuration/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
@@ -0,0 +1,27 @@
|
||||
// swift-tools-version: 6.2
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "AffinePaywall",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.macOS(.v14), // just for build so LLM can verify their code
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
name: "AffinePaywall",
|
||||
targets: ["AffinePaywall"]
|
||||
),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../AffineResources"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "AffinePaywall",
|
||||
dependencies: ["AffineResources"],
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// IntelligentFeatureView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import AffineResources
|
||||
import SwiftUI
|
||||
|
||||
struct IntelligentFeatureView: View {
|
||||
let feature: Feature
|
||||
|
||||
struct Feature: Identifiable {
|
||||
let id: UUID = .init()
|
||||
let preview: String
|
||||
let icon: String
|
||||
let title: String
|
||||
let features: [String]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Image(feature.preview, bundle: .module)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
HStack(spacing: 8) {
|
||||
Image(feature.icon, bundle: .module)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 24, height: 24)
|
||||
Text(feature.title)
|
||||
.font(.system(size: 24, weight: .semibold, design: .default))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(feature.features, id: \.self) { item in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||
Rectangle()
|
||||
.frame(width: 4, height: 10)
|
||||
.foregroundStyle(.clear)
|
||||
.overlay {
|
||||
Image(systemName: "circle.fill")
|
||||
.font(.system(size: 4))
|
||||
.foregroundColor(AffineColors.textSecondary.color)
|
||||
}
|
||||
Text(item)
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(AffineColors.textSecondary.color)
|
||||
.lineLimit(nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
IntelligentFeatureView(
|
||||
feature: SKUnitIntelligentDetailView.features.first!
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// SKUnitIntelligentDetailView+Feature.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import AffineResources
|
||||
import SwiftUI
|
||||
|
||||
extension SKUnitIntelligentDetailView {
|
||||
static let features: [IntelligentFeatureView.Feature] = [
|
||||
.init(
|
||||
preview: "AI_PREVIEW_WRITE",
|
||||
icon: "AI_TEXT",
|
||||
title: "Write with you",
|
||||
features: [
|
||||
"Create quality content from sentences to articles on topics you need",
|
||||
"Rewrite like the professionals",
|
||||
"Change the tones / fix spelling & grammar",
|
||||
]
|
||||
),
|
||||
.init(
|
||||
preview: "AI_PREVIEW_DRAW",
|
||||
icon: "AI_PEN",
|
||||
title: "Draw with you",
|
||||
features: [
|
||||
"Visualize your mind, magically",
|
||||
"Turn your outline into beautiful, engaging presentations(Beta)",
|
||||
"Summarize your content into structured mind-maps",
|
||||
]
|
||||
),
|
||||
.init(
|
||||
preview: "AI_PREVIEW_PLAN",
|
||||
icon: "AI_CHECK",
|
||||
title: "Plan with you",
|
||||
features: [
|
||||
"Memorize and tidy up your knowledge",
|
||||
"Auto-sorting and auto-tagging (Coming soon)",
|
||||
"Privacy ensured",
|
||||
]
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//
|
||||
// SKUnitIntelligentDetailView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import AffineResources
|
||||
import SwiftUI
|
||||
|
||||
struct SKUnitIntelligentDetailView: View {
|
||||
@StateObject var viewModel: ViewModel
|
||||
@State var detailIndex: Int = 0 {
|
||||
didSet { lastInteractionDate = Date() }
|
||||
}
|
||||
|
||||
@State var lastInteractionDate: Date = .init()
|
||||
|
||||
let timer = Timer
|
||||
.publish(every: 5, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
HeadlineView(viewModel: viewModel)
|
||||
|
||||
GeometryReader { r in
|
||||
let height = r.size.height
|
||||
let width = r.size.width
|
||||
ScrollViewReader { scrollView in
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
GeometryReader { geometry in
|
||||
Color.clear
|
||||
.preference(
|
||||
key: ViewOffsetKey.self,
|
||||
value: geometry.frame(in: .named("scrollView")).origin
|
||||
)
|
||||
}
|
||||
.frame(width: 0, height: 0)
|
||||
HStack(spacing: 0) {
|
||||
ForEach(0 ..< Self.features.count, id: \.self) { featureIndex in
|
||||
let feature = Self.features[featureIndex]
|
||||
IntelligentFeatureView(feature: feature)
|
||||
.padding()
|
||||
.frame(width: width, height: height)
|
||||
.id(featureIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
.coordinateSpace(name: "scrollView")
|
||||
.onPreferenceChange(ViewOffsetKey.self) { newValue in
|
||||
let page = Int(round(-newValue.x / width))
|
||||
guard page != detailIndex else { return }
|
||||
guard page >= 0, page < Self.features.count else { return }
|
||||
detailIndex = page
|
||||
}
|
||||
.frame(height: height)
|
||||
.onChange(of: detailIndex) { newValue in
|
||||
withAnimation(.spring) {
|
||||
scrollView.scrollTo(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PageDotsView(
|
||||
current: detailIndex,
|
||||
total: Self.features.count
|
||||
) { index in
|
||||
detailIndex = index
|
||||
}
|
||||
}
|
||||
.onReceive(timer) { _ in
|
||||
if Date().timeIntervalSince(lastInteractionDate) > 5 {
|
||||
detailIndex = (detailIndex + 1) % Self.features.count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SKUnitIntelligentDetailView(viewModel: .vmPreviewForAI)
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// SKUnitBelieverDetailView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SKUnitBelieverDetailView: View {
|
||||
@StateObject var viewModel: ViewModel
|
||||
|
||||
let features: [Feature] = [
|
||||
.init("Everything in AFFiNE Pro"),
|
||||
.init("Life-time Personal usage"),
|
||||
.init("1TB Cloud Storage"),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
HeadlineView(viewModel: viewModel)
|
||||
Image("BELIVER_ICON", bundle: .module)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
ForEach(features.indices, id: \.self) { index in
|
||||
let feature = features[index]
|
||||
ProFeatureRowView(feature: feature, index: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SKUnitBelieverDetailView(viewModel: .vmPreviewForBeliever)
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// CategorySelectionView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/17/25.
|
||||
//
|
||||
|
||||
import AffineResources
|
||||
import SwiftUI
|
||||
|
||||
struct CategorySelectionView: View {
|
||||
let selectedTab: SKUnitCategory
|
||||
let onSelect: (SKUnitCategory) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
ForEach(SKUnitCategory.allCases) { tab in
|
||||
TabItem(type: tab, isSelected: tab == selectedTab)
|
||||
.onTapGesture { onSelect(tab) }
|
||||
}
|
||||
}
|
||||
.animation(.spring.speed(2), value: selectedTab)
|
||||
}
|
||||
|
||||
struct TabItem: View {
|
||||
let type: SKUnitCategory
|
||||
let isSelected: Bool
|
||||
|
||||
var font: Font {
|
||||
if isSelected {
|
||||
.system(size: 24, weight: .bold)
|
||||
} else {
|
||||
.system(size: 24, weight: .regular)
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
if isSelected {
|
||||
AffineColors.textPrimary.color
|
||||
} else {
|
||||
AffineColors.textSecondary.color
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(type.title)
|
||||
.lineLimit(1)
|
||||
.font(font)
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
struct PreviewWrapper: View {
|
||||
@State var selectedTab: SKUnitCategory = .pro
|
||||
var body: some View {
|
||||
CategorySelectionView(selectedTab: selectedTab, onSelect: { selectedTab = $0 })
|
||||
}
|
||||
}
|
||||
return VStack(alignment: .leading, spacing: 12) {
|
||||
CategorySelectionView(selectedTab: .pro, onSelect: { _ in })
|
||||
CategorySelectionView(selectedTab: .ai, onSelect: { _ in })
|
||||
CategorySelectionView(selectedTab: .believer, onSelect: { _ in })
|
||||
Divider()
|
||||
PreviewWrapper()
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.25).ignoresSafeArea())
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// HeadlineView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import AffineResources
|
||||
import SwiftUI
|
||||
|
||||
struct HeadlineView: View {
|
||||
@StateObject var viewModel: ViewModel
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
Text(viewModel.selectedUnit.primaryText)
|
||||
.font(.system(size: 24, weight: .heavy))
|
||||
.contentTransition(.numericText())
|
||||
.animation(.spring.speed(2), value: viewModel.category)
|
||||
.padding(.top, 8)
|
||||
|
||||
Text(viewModel.selectedUnit.secondaryText)
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(AffineColors.textSecondary.color)
|
||||
.contentTransition(.numericText())
|
||||
.animation(.spring.speed(2), value: viewModel.category)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//
|
||||
// PageDotsView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import AffineResources
|
||||
import SwiftUI
|
||||
|
||||
struct PageDotsView: View {
|
||||
let current: Int
|
||||
let total: Int
|
||||
|
||||
let onSelection: (Int) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(0 ..< total, id: \.self) { index in
|
||||
Circle()
|
||||
.foregroundStyle(
|
||||
index == current
|
||||
? AffineColors.buttonPrimary.color
|
||||
: AffineColors.textSecondary.color.opacity(0.5)
|
||||
)
|
||||
.frame(width: 6, height: 6)
|
||||
.padding(4)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
onSelection(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 32) {
|
||||
PageDotsView(current: 0, total: 8) { _ in }
|
||||
PageDotsView(current: 1, total: 8) { _ in }
|
||||
PageDotsView(current: 2, total: 8) { _ in }
|
||||
PageDotsView(current: 3, total: 8) { _ in }
|
||||
PageDotsView(current: 4, total: 8) { _ in }
|
||||
PageDotsView(current: 5, total: 8) { _ in }
|
||||
PageDotsView(current: 6, total: 8) { _ in }
|
||||
PageDotsView(current: 7, total: 8) { _ in }
|
||||
PageDotsView(current: 8, total: 8) { _ in }
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//
|
||||
// PricingOptionView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import AffineResources
|
||||
import SwiftUI
|
||||
|
||||
struct PricingOptionView: View {
|
||||
let price: String
|
||||
let description: String
|
||||
var badge: String
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
init(
|
||||
price: String,
|
||||
description: String,
|
||||
badge: String = "",
|
||||
isSelected: Bool,
|
||||
action: @escaping () -> Void = {}
|
||||
) {
|
||||
self.price = price
|
||||
self.description = description
|
||||
self.badge = badge
|
||||
self.isSelected = isSelected
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(price)
|
||||
.contentTransition(.numericText())
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.lineLimit(1)
|
||||
.foregroundColor(isSelected ? AffineColors.buttonPrimary.color : AffineColors.textPrimary.color)
|
||||
}
|
||||
.layoutPriority(.infinity)
|
||||
Spacer(minLength: 0)
|
||||
if !badge.isEmpty {
|
||||
Text(badge)
|
||||
.contentTransition(.numericText())
|
||||
.font(.system(size: 12))
|
||||
.bold()
|
||||
.lineLimit(1)
|
||||
.foregroundColor(AffineColors.layerPureWhite.color)
|
||||
.padding(2)
|
||||
.padding(.horizontal, 2)
|
||||
.background(AffineColors.buttonPrimary.color)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
}
|
||||
|
||||
if !description.isEmpty {
|
||||
Text(description)
|
||||
.contentTransition(.numericText())
|
||||
.foregroundColor(isSelected ? AffineColors.buttonPrimary.color : AffineColors.textSecondary.color)
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
}
|
||||
.animation(.interactiveSpring, value: price)
|
||||
.animation(.interactiveSpring, value: description)
|
||||
.animation(.interactiveSpring, value: badge)
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.foregroundColor(AffineColors.layerBackgroundPrimary.color)
|
||||
if isSelected {
|
||||
Rectangle()
|
||||
.foregroundColor(AffineColors.buttonPrimary.color)
|
||||
.opacity(0.05)
|
||||
}
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.overlay {
|
||||
if isSelected {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(AffineColors.buttonPrimary.color, lineWidth: 1.5)
|
||||
.foregroundColor(.clear)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(AffineColors.layerBorder.color.opacity(0.15), lineWidth: 1.5)
|
||||
.foregroundColor(.clear)
|
||||
}
|
||||
}
|
||||
.shadow(color: AffineColors.layerBorder.color.opacity(0.05), radius: 4, x: 0, y: 0)
|
||||
.animation(.interactiveSpring, value: isSelected)
|
||||
.contentShape(.rect)
|
||||
.onTapGesture {
|
||||
action()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 16) {
|
||||
HStack(spacing: 16) {
|
||||
PricingOptionView(
|
||||
price: "$7.99",
|
||||
description: "Monthly",
|
||||
isSelected: false
|
||||
) {}
|
||||
PricingOptionView(
|
||||
price: "$6.75",
|
||||
description: "Annually",
|
||||
badge: "Save 15%",
|
||||
isSelected: true
|
||||
) {}
|
||||
}
|
||||
HStack(spacing: 16) {
|
||||
PricingOptionView(
|
||||
price: "$114514",
|
||||
description: "Monthly",
|
||||
badge: "Most Popular",
|
||||
isSelected: true
|
||||
) {}
|
||||
PricingOptionView(
|
||||
price: "$6.75",
|
||||
description: "Annually",
|
||||
badge: "Save 15%",
|
||||
isSelected: false
|
||||
) {}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.gray.opacity(0.25).ignoresSafeArea())
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// PurchaseFooterView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import AffineResources
|
||||
import SwiftUI
|
||||
|
||||
struct PurchaseFooterView: View {
|
||||
@StateObject var viewModel: ViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
if viewModel.availablePricingOptions.count > 1 {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(viewModel.availablePricingOptions) { option in
|
||||
PricingOptionView(
|
||||
price: option.price,
|
||||
description: option.description,
|
||||
badge: option.badge ?? "",
|
||||
isSelected: option.id == viewModel.selectedPricingIdentifier
|
||||
) {
|
||||
viewModel.select(pricingOption: option)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TheGiveMeMoneyButtonView(
|
||||
primaryTitle: viewModel.selectedPricingOption.primaryTitle,
|
||||
secondaryTitle: viewModel.selectedPricingOption.secondaryTitle,
|
||||
callback: viewModel.purchase
|
||||
)
|
||||
|
||||
Button(action: viewModel.restore) {
|
||||
Text("Restore Purchase")
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(AffineColors.textSecondary.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
PurchaseFooterView(viewModel: .init())
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.25).ignoresSafeArea())
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//
|
||||
// TheGiveMeMoneyButtonView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import AffineResources
|
||||
import SwiftUI
|
||||
|
||||
struct TheGiveMeMoneyButtonView: View {
|
||||
let primaryTitle: String
|
||||
let secondaryTitle: String
|
||||
let callback: () -> Void
|
||||
|
||||
init(
|
||||
primaryTitle: String = "",
|
||||
secondaryTitle: String = "",
|
||||
callback: @escaping () -> Void = {}
|
||||
) {
|
||||
self.primaryTitle = primaryTitle
|
||||
self.secondaryTitle = secondaryTitle
|
||||
self.callback = callback
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button { callback() } label: {
|
||||
HStack(spacing: 4) {
|
||||
if !primaryTitle.isEmpty {
|
||||
Text(primaryTitle)
|
||||
.bold()
|
||||
.font(.system(size: 16))
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
if !secondaryTitle.isEmpty {
|
||||
Text("(\(secondaryTitle))")
|
||||
.font(.system(size: 12))
|
||||
.opacity(0.8)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
}
|
||||
.foregroundColor(AffineColors.layerPureWhite.color)
|
||||
.padding(12)
|
||||
}
|
||||
.animation(.spring, value: primaryTitle)
|
||||
.animation(.spring, value: secondaryTitle)
|
||||
.buttonStyle(.plain)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(AffineColors.buttonPrimary.color)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 16) {
|
||||
TheGiveMeMoneyButtonView(
|
||||
primaryTitle: "Upgrade for $6.75 per month",
|
||||
secondaryTitle: ""
|
||||
)
|
||||
TheGiveMeMoneyButtonView(
|
||||
primaryTitle: "Upgrade for $10 per month",
|
||||
secondaryTitle: ""
|
||||
)
|
||||
TheGiveMeMoneyButtonView(
|
||||
primaryTitle: "$8.9 per month",
|
||||
secondaryTitle: "billed annually"
|
||||
)
|
||||
TheGiveMeMoneyButtonView(
|
||||
primaryTitle: "Upgrade for $499",
|
||||
secondaryTitle: ""
|
||||
)
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// ProFeatureRowView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import AffineResources
|
||||
import SwiftUI
|
||||
|
||||
struct ProFeatureRowView: View {
|
||||
let feature: Feature
|
||||
let index: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(AffineColors.buttonPrimary.color)
|
||||
|
||||
Text(feature.text)
|
||||
.font(.system(size: 16))
|
||||
.contentTransition(.numericText())
|
||||
.foregroundColor(feature.isHighlighted ? AffineColors.buttonPrimary.color : AffineColors.textPrimary.color)
|
||||
.lineLimit(nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Divider()
|
||||
ProFeatureRowView(
|
||||
feature: .init(
|
||||
"Hello World Feature Row View",
|
||||
isHighlighted: true
|
||||
),
|
||||
index: 0
|
||||
)
|
||||
Divider()
|
||||
ProFeatureRowView(
|
||||
feature: .init("Hello World Feature Row View"),
|
||||
index: 0
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.25).ignoresSafeArea())
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// ProFeaturesCardView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import AffineResources
|
||||
import SwiftUI
|
||||
|
||||
struct ProFeaturesCardView: View {
|
||||
let features: [Feature]
|
||||
let headerText: String
|
||||
|
||||
let timer = Timer
|
||||
.publish(every: 0.08, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
@State var animationIndex: Int64 = 0
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if !headerText.isEmpty {
|
||||
Text(headerText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(AffineColors.textSecondary.color)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
ForEach(Array(features.enumerated()), id: \.element.id) { index, feature in
|
||||
ProFeatureRowView(feature: feature, index: index)
|
||||
.opacity(index < animationIndex ? 1 : 0)
|
||||
}
|
||||
}
|
||||
.animation(.spring.speed(2), value: animationIndex)
|
||||
.onChange(of: features) { _ in animationIndex = 0 }
|
||||
.onReceive(timer) { _ in animationIndex += 1 }
|
||||
.clipped()
|
||||
.padding(16)
|
||||
.background(AffineColors.layerBackgroundPrimary.color)
|
||||
.cornerRadius(16)
|
||||
.shadow(color: AffineColors.layerBorder.color.opacity(0.08), radius: 8, y: 2)
|
||||
.animation(.spring.speed(2), value: features)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Pro") {
|
||||
ProFeaturesCardView(features: SKUnitSubcategoryProPlan.default.features, headerText: SKUnitSubcategoryProPlan.default.headerText)
|
||||
.padding()
|
||||
.background(Color.gray.ignoresSafeArea())
|
||||
}
|
||||
|
||||
#Preview("Pro team") {
|
||||
ProFeaturesCardView(
|
||||
features: SKUnitSubcategoryProPlan.team.features,
|
||||
headerText: SKUnitSubcategoryProPlan.team.headerText
|
||||
)
|
||||
.padding()
|
||||
.background(Color.gray.ignoresSafeArea())
|
||||
}
|
||||
|
||||
#Preview("Self Hosted") {
|
||||
ProFeaturesCardView(features: SKUnitSubcategoryProPlan.selfHost.features, headerText: SKUnitSubcategoryProPlan.selfHost.headerText)
|
||||
.padding()
|
||||
.background(Color.gray.ignoresSafeArea())
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// SKUnitProDetailView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/17/25.
|
||||
//
|
||||
|
||||
import AffineResources
|
||||
import SwiftUI
|
||||
|
||||
struct SKUnitProDetailView: View {
|
||||
@StateObject var viewModel: ViewModel
|
||||
|
||||
@State var selection: SKUnitSubcategoryProPlan = .default
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Picker("Plan", selection: $selection) {
|
||||
ForEach(SKUnitSubcategoryProPlan.allCases) { plan in
|
||||
Text(plan.title).tag(plan)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: selection) { _ in
|
||||
viewModel.select(subcategory: selection)
|
||||
}
|
||||
|
||||
HeadlineView(viewModel: viewModel)
|
||||
|
||||
ScrollView {
|
||||
ProFeaturesCardView(
|
||||
features: selection.features,
|
||||
headerText: selection.headerText
|
||||
)
|
||||
.padding(16)
|
||||
}
|
||||
.padding(-16)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
maxHeight: .infinity,
|
||||
alignment: .top
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SKUnitProDetailView(viewModel: .vmPreviewForPro)
|
||||
.padding()
|
||||
.background(
|
||||
AffineColors.layerBackgroundSecondary
|
||||
.color
|
||||
.ignoresSafeArea()
|
||||
)
|
||||
.background(Color.gray.opacity(0.25).ignoresSafeArea())
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// Feature.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Feature: Identifiable, Equatable {
|
||||
var id = UUID()
|
||||
var text: String
|
||||
var isHighlighted: Bool // For text like "Everything in AFFINE Pro"
|
||||
|
||||
init(_ text: String, isHighlighted: Bool = false) {
|
||||
self.text = text
|
||||
self.isHighlighted = isHighlighted
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// SKUnit+AI.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension SKUnit {
|
||||
static let aiUnits: [SKUnit] = [
|
||||
SKUnit(
|
||||
category: SKUnitCategory.ai,
|
||||
primaryText: "AFFINE AI",
|
||||
secondaryText: "A true multimodal AI copilot.",
|
||||
pricing: [
|
||||
SKUnitPricingOption(
|
||||
price: "$8.9 per month",
|
||||
description: "",
|
||||
isDefaultSelected: true,
|
||||
primaryTitle: "$8.9 per month",
|
||||
secondaryTitle: "billed annually"
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// SKUnit+Believer.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension SKUnit {
|
||||
static let believerUnits: [SKUnit] = [
|
||||
SKUnit(
|
||||
category: SKUnitCategory.believer,
|
||||
primaryText: "Believer Plan",
|
||||
secondaryText: "AFFINE's Everything",
|
||||
pricing: [
|
||||
SKUnitPricingOption(
|
||||
price: "$499",
|
||||
description: "",
|
||||
isDefaultSelected: true,
|
||||
primaryTitle: "Upgrade for $499",
|
||||
secondaryTitle: ""
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//
|
||||
// SKUnit+Pro.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension SKUnit {
|
||||
static let proUnits: [SKUnit] = [
|
||||
SKUnit(
|
||||
category: SKUnitCategory.pro,
|
||||
subcategory: SKUnitSubcategoryProPlan.default,
|
||||
primaryText: "Pro",
|
||||
secondaryText: "For family and small teams.",
|
||||
pricing: [
|
||||
SKUnitPricingOption(
|
||||
price: "$7.99",
|
||||
description: "Monthly",
|
||||
isDefaultSelected: false,
|
||||
primaryTitle: "Upgrade for $7.99/month",
|
||||
secondaryTitle: ""
|
||||
),
|
||||
SKUnitPricingOption(
|
||||
price: "$6.75",
|
||||
description: "Annual",
|
||||
badge: "Save 15%",
|
||||
isDefaultSelected: true,
|
||||
primaryTitle: "Upgrade for $6.75/month",
|
||||
secondaryTitle: ""
|
||||
),
|
||||
]
|
||||
),
|
||||
SKUnit(
|
||||
category: SKUnitCategory.pro,
|
||||
subcategory: SKUnitSubcategoryProPlan.team,
|
||||
primaryText: "Pro team",
|
||||
secondaryText: "Best for scalable teams.",
|
||||
pricing: [
|
||||
SKUnitPricingOption(
|
||||
price: "$12",
|
||||
description: "Per seat monthly",
|
||||
isDefaultSelected: false,
|
||||
primaryTitle: "Upgrade for $12/month",
|
||||
secondaryTitle: ""
|
||||
),
|
||||
SKUnitPricingOption(
|
||||
price: "$10",
|
||||
description: "Annual",
|
||||
badge: "Save 15%",
|
||||
isDefaultSelected: true,
|
||||
primaryTitle: "Upgrade for $10/month",
|
||||
secondaryTitle: ""
|
||||
),
|
||||
]
|
||||
),
|
||||
SKUnit(
|
||||
category: SKUnitCategory.pro,
|
||||
subcategory: SKUnitSubcategoryProPlan.selfHost,
|
||||
primaryText: "Self Hosted team",
|
||||
secondaryText: "Best for scalable teams.",
|
||||
pricing: [
|
||||
SKUnitPricingOption(
|
||||
price: "$12",
|
||||
description: "Per seat monthly",
|
||||
isDefaultSelected: false,
|
||||
primaryTitle: "Upgrade for $12/month",
|
||||
secondaryTitle: ""
|
||||
),
|
||||
SKUnitPricingOption(
|
||||
price: "$10",
|
||||
description: "Annual",
|
||||
badge: "Save 15%",
|
||||
isDefaultSelected: true,
|
||||
primaryTitle: "Upgrade for $10/month",
|
||||
secondaryTitle: ""
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//
|
||||
// SKUnit.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct SKUnit: Identifiable, Sendable {
|
||||
let id = UUID()
|
||||
let category: SKUnitCategory
|
||||
let subcategory: any SKUnitSubcategorizable
|
||||
let primaryText: String
|
||||
let secondaryText: String
|
||||
let pricing: [SKUnitPricingOption]
|
||||
|
||||
init(
|
||||
category: SKUnitCategory,
|
||||
subcategory: (any SKUnitSubcategorizable) = SKUnitSingleSubcategory.single,
|
||||
primaryText: String,
|
||||
secondaryText: String,
|
||||
pricing: [SKUnitPricingOption]
|
||||
) {
|
||||
self.category = category
|
||||
self.subcategory = subcategory
|
||||
self.primaryText = primaryText
|
||||
self.secondaryText = secondaryText
|
||||
self.pricing = pricing
|
||||
}
|
||||
}
|
||||
|
||||
extension SKUnit {
|
||||
static let allUnits: [SKUnit] = [
|
||||
proUnits,
|
||||
aiUnits,
|
||||
believerUnits,
|
||||
].flatMap(\.self)
|
||||
|
||||
static func units(for category: SKUnitCategory) -> [SKUnit] {
|
||||
allUnits.filter { $0.category == category }
|
||||
}
|
||||
|
||||
static func unit(
|
||||
for type: SKUnitCategory,
|
||||
subcategory: (any SKUnitSubcategorizable) = SKUnitSingleSubcategory.single
|
||||
) -> SKUnit? {
|
||||
let subcategory = subcategory.subcategoryIdentifier
|
||||
let item = allUnits
|
||||
.filter { $0.category == type }
|
||||
.filter { $0.subcategory.subcategoryIdentifier == subcategory }
|
||||
assert(item.count == 1)
|
||||
return item.first
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// SKUnitCategory.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum SKUnitCategory: Int, CaseIterable, Equatable, Identifiable {
|
||||
var id: Int { rawValue }
|
||||
|
||||
case pro
|
||||
case ai
|
||||
case believer
|
||||
}
|
||||
|
||||
extension SKUnitCategory {
|
||||
var title: String {
|
||||
switch self {
|
||||
case .pro: "AFFINE.Pro"
|
||||
case .ai: "AI"
|
||||
case .believer: "Believer"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// SKUnitPricingOption.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct SKUnitPricingOption: Identifiable, Equatable {
|
||||
var id: UUID
|
||||
|
||||
// pricing selection button
|
||||
var price: String
|
||||
var description: String
|
||||
var badge: String?
|
||||
var isDefaultSelected: Bool
|
||||
|
||||
// subscribe button titles
|
||||
var primaryTitle: String
|
||||
var secondaryTitle: String
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
price: String,
|
||||
description: String,
|
||||
badge: String? = nil,
|
||||
isDefaultSelected: Bool = false,
|
||||
primaryTitle: String,
|
||||
secondaryTitle: String
|
||||
) {
|
||||
self.id = id
|
||||
self.price = price
|
||||
self.description = description
|
||||
self.badge = badge
|
||||
self.isDefaultSelected = isDefaultSelected
|
||||
self.primaryTitle = primaryTitle
|
||||
self.secondaryTitle = secondaryTitle
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// SKUnitSubcategorizable.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol SKUnitSubcategorizable: Identifiable, Equatable, Hashable, CaseIterable, Sendable {
|
||||
var id: String { get }
|
||||
var subcategoryIdentifier: String { get }
|
||||
}
|
||||
|
||||
extension SKUnitSubcategorizable {
|
||||
var id: String {
|
||||
subcategoryIdentifier
|
||||
}
|
||||
}
|
||||
|
||||
extension SKUnitSubcategorizable where Self: RawRepresentable, Self.RawValue == String {
|
||||
var subcategoryIdentifier: String { rawValue }
|
||||
}
|
||||
|
||||
enum SKUnitSingleSubcategory: String, SKUnitSubcategorizable {
|
||||
case single
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//
|
||||
// SKUnitSubcategoryProPlan.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum SKUnitSubcategoryProPlan: String, SKUnitSubcategorizable {
|
||||
case `default`
|
||||
case team
|
||||
case selfHost
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .default: "Pro"
|
||||
case .team: "Pro team"
|
||||
case .selfHost: "Self Hosted"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .default:
|
||||
"For family and small teams."
|
||||
case .team:
|
||||
"Best for scalable teams."
|
||||
case .selfHost:
|
||||
"Best for scalable teams."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SKUnitSubcategoryProPlan {
|
||||
var headerText: String {
|
||||
switch self {
|
||||
case .default:
|
||||
"Include in Pro"
|
||||
case .team:
|
||||
"Include in Team Workspace"
|
||||
case .selfHost:
|
||||
"Both in Teams & Enterprise"
|
||||
}
|
||||
}
|
||||
|
||||
var features: [Feature] {
|
||||
switch self {
|
||||
case .default:
|
||||
[
|
||||
Feature("Everything in AFFINE FOSS & Basic."),
|
||||
Feature("100 GB of Cloud Storage"),
|
||||
Feature("100 MB of Maximum file size"),
|
||||
Feature("Up to 10 members per Workspace"),
|
||||
Feature("30-days Cloud Time Machine file version history"),
|
||||
Feature("Community Support"),
|
||||
Feature("Real-time Syncing & Collaboration for more people"),
|
||||
]
|
||||
case .team:
|
||||
[
|
||||
Feature("Everything in AFFINE Pro", isHighlighted: true),
|
||||
Feature("100 GB initial storage + 20 GB per seat"),
|
||||
Feature("500 MB of maximum file size"),
|
||||
Feature("Unlimited team members (10+ seats)"),
|
||||
Feature("Multiple admin roles"),
|
||||
Feature("Priority customer support"),
|
||||
]
|
||||
case .selfHost:
|
||||
[
|
||||
Feature("Everything in Self Hosted FOSS"),
|
||||
Feature("100 GB initial storage + 20 GB per seat"),
|
||||
Feature("500 MB of maximum file size"),
|
||||
Feature("Unlimited team members (10+ seats)"),
|
||||
Feature("Multiple admin roles"),
|
||||
Feature("Priority customer support"),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// ViewModel+Action.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension ViewModel {
|
||||
func purchase() {
|
||||
let unit = selectedUnit
|
||||
let option = selectedPricingOption
|
||||
|
||||
print(#function, unit, option)
|
||||
}
|
||||
|
||||
func restore() {
|
||||
let unit = selectedUnit
|
||||
let option = selectedPricingOption
|
||||
|
||||
print(#function, unit, option)
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
let unit = selectedUnit
|
||||
let option = selectedPricingOption
|
||||
|
||||
print(#function, unit, option)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// ViewModel+Preview.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension ViewModel {
|
||||
static let vmPreviewForPro: ViewModel = {
|
||||
let vm = ViewModel()
|
||||
vm.select(category: .pro)
|
||||
vm.select(subcategory: SKUnitSubcategoryProPlan.default)
|
||||
return vm
|
||||
}()
|
||||
|
||||
static let vmPreviewForAI: ViewModel = {
|
||||
let vm = ViewModel()
|
||||
vm.select(category: .ai)
|
||||
return vm
|
||||
}()
|
||||
|
||||
static let vmPreviewForBeliever: ViewModel = {
|
||||
let vm = ViewModel()
|
||||
vm.select(category: .believer)
|
||||
return vm
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// ViewModel.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
class ViewModel: ObservableObject {
|
||||
var availableUnits: [SKUnit] {
|
||||
SKUnit.units(for: category)
|
||||
}
|
||||
|
||||
@Published private(set) var category: SKUnitCategory = .pro
|
||||
@Published private(set) var subcategory: any SKUnitSubcategorizable = SKUnitSubcategoryProPlan.default
|
||||
@Published private(set) var selectedPricingIdentifier: UUID = SKUnit.unit(
|
||||
for: .pro,
|
||||
subcategory: SKUnitSubcategoryProPlan.default
|
||||
)!.pricing.first { $0.isDefaultSelected }!.id
|
||||
|
||||
init() {}
|
||||
|
||||
func select(category: SKUnitCategory) {
|
||||
self.category = category
|
||||
let units = SKUnit.units(for: category)
|
||||
let subcategoryExists = units
|
||||
.contains { $0.subcategory.subcategoryIdentifier == subcategory.subcategoryIdentifier }
|
||||
if !subcategoryExists {
|
||||
subcategory = units.first!.subcategory
|
||||
}
|
||||
_ = selectedPricingOption // ensure selectedPricingIdentifier is valid
|
||||
}
|
||||
|
||||
func select(subcategory: any SKUnitSubcategorizable) {
|
||||
let units = SKUnit.units(for: category)
|
||||
let subcategoryExists = units
|
||||
.contains { $0.subcategory.subcategoryIdentifier == subcategory.subcategoryIdentifier }
|
||||
if !subcategoryExists {
|
||||
let category = availableUnits
|
||||
.first { $0.subcategory.subcategoryIdentifier == subcategory.subcategoryIdentifier }!
|
||||
.category
|
||||
self.category = category
|
||||
} else {
|
||||
self.subcategory = subcategory
|
||||
}
|
||||
_ = selectedPricingOption // ensure selectedPricingIdentifier is valid
|
||||
}
|
||||
|
||||
func select(pricingOption option: SKUnitPricingOption) {
|
||||
selectedPricingIdentifier = option.id
|
||||
|
||||
let unit = availableUnits
|
||||
.first { unit in
|
||||
unit.pricing.contains { $0.id == option.id }
|
||||
}!
|
||||
category = unit.category
|
||||
subcategory = unit.subcategory
|
||||
|
||||
_ = selectedPricingOption // ensure selectedPricingIdentifier is valid
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension ViewModel {
|
||||
var selectedUnit: SKUnit {
|
||||
if let unit = SKUnit.unit(for: category, subcategory: subcategory) {
|
||||
return unit
|
||||
}
|
||||
let units = SKUnit.units(for: category)
|
||||
if let last = units.last {
|
||||
subcategory = last.subcategory
|
||||
return last
|
||||
}
|
||||
let item = availableUnits.first!
|
||||
category = item.category
|
||||
subcategory = item.subcategory
|
||||
return item
|
||||
}
|
||||
|
||||
var selectedPricingOption: SKUnitPricingOption {
|
||||
let item = selectedUnit.pricing
|
||||
.first { $0.id == selectedPricingIdentifier }
|
||||
if let item { return item }
|
||||
let defaultItem = selectedUnit.pricing.first { $0.isDefaultSelected }
|
||||
if let defaultItem {
|
||||
selectedPricingIdentifier = defaultItem.id
|
||||
return defaultItem
|
||||
}
|
||||
let lastItem = selectedUnit.pricing.last!
|
||||
selectedPricingIdentifier = lastItem.id
|
||||
return lastItem
|
||||
}
|
||||
|
||||
var availablePricingOptions: [SKUnitPricingOption] {
|
||||
selectedUnit.pricing
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//
|
||||
// AffinePaywallPageView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import AffineResources
|
||||
import SwiftUI
|
||||
|
||||
struct AffinePaywallPageView: View {
|
||||
@StateObject var viewModel = ViewModel()
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
CategorySelectionView(
|
||||
selectedTab: viewModel.category,
|
||||
onSelect: viewModel.select(category:)
|
||||
)
|
||||
Spacer()
|
||||
Button {
|
||||
viewModel.dismiss()
|
||||
} label: {
|
||||
Image(AffineIcons.close.rawValue)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(AffineColors.textSecondary.color)
|
||||
}
|
||||
ZStack(alignment: .topLeading) {
|
||||
Spacer()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
content
|
||||
.frame(maxWidth: .infinity)
|
||||
.transition(
|
||||
.opacity
|
||||
.combined(with: .scale(
|
||||
scale: 0.95,
|
||||
anchor: .init(x: 0.5, y: 0)
|
||||
))
|
||||
)
|
||||
}
|
||||
.animation(.spring.speed(2), value: viewModel.category)
|
||||
|
||||
PurchaseFooterView(viewModel: viewModel)
|
||||
.animation(.spring.speed(2), value: viewModel.selectedPricingIdentifier)
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
AffineColors.layerBackgroundSecondary.color
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var content: some View {
|
||||
switch viewModel.category {
|
||||
case .pro:
|
||||
SKUnitProDetailView(viewModel: viewModel)
|
||||
case .ai:
|
||||
SKUnitIntelligentDetailView(viewModel: viewModel)
|
||||
case .believer:
|
||||
SKUnitBelieverDetailView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
struct PreviewWrapper: View {
|
||||
@StateObject var viewModel = ViewModel()
|
||||
var body: some View {
|
||||
AffinePaywallPageView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
return PreviewWrapper()
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// File.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
public enum Paywall {
|
||||
@MainActor
|
||||
public static func presentWall(
|
||||
toController controller: UIViewController,
|
||||
type: String
|
||||
) {
|
||||
let viewModel = ViewModel()
|
||||
switch type {
|
||||
// TODO: FIGURE OUT PAYWALL TYPES
|
||||
default:
|
||||
break
|
||||
}
|
||||
let view = AffinePaywallPageView(viewModel: viewModel)
|
||||
let hostingController = UIHostingController(rootView: view)
|
||||
hostingController.modalPresentationStyle = .overFullScreen
|
||||
hostingController.modalTransitionStyle = .coverVertical
|
||||
hostingController.preferredContentSize = CGSize(width: 555, height: 555) // for iPads
|
||||
controller.present(hostingController, animated: true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6 3.25C4.48122 3.25 3.25 4.48122 3.25 6V18C3.25 19.5188 4.48122 20.75 6 20.75H18C19.5188 20.75 20.75 19.5188 20.75 18V6C20.75 4.48122 19.5188 3.25 18 3.25H6ZM4.75 6C4.75 5.30964 5.30964 4.75 6 4.75H18C18.6904 4.75 19.25 5.30964 19.25 6V18C19.25 18.6904 18.6904 19.25 18 19.25H6C5.30964 19.25 4.75 18.6904 4.75 18V6ZM16.5303 9.53033C16.8232 9.23744 16.8232 8.76256 16.5303 8.46967C16.2374 8.17678 15.7626 8.17678 15.4697 8.46967L10.5 13.4393L9.03033 11.9697C8.73744 11.6768 8.26256 11.6768 7.96967 11.9697C7.67678 12.2626 7.67678 12.7374 7.96967 13.0303L9.96967 15.0303C10.2626 15.3232 10.7374 15.3232 11.0303 15.0303L16.5303 9.53033Z" fill="#1E96EB"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 806 B |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AI_CHECK.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.7786 4.72105C19.1493 4.09298 18.1284 4.09298 17.4991 4.72105L16.6144 5.60408L18.8918 7.87702L19.7786 6.9919C20.4071 6.36465 20.4071 5.3483 19.7786 4.72105ZM17.8301 8.93664L15.5526 6.6637L4.75 17.4451V19.7501H6.99534L17.8301 8.93664ZM16.4395 3.65934C17.6544 2.44689 19.6234 2.44689 20.8383 3.65934C22.0539 4.87262 22.0539 6.84033 20.8383 8.05361L7.83537 21.0309C7.69476 21.1712 7.50422 21.2501 7.30557 21.2501H4C3.58579 21.2501 3.25 20.9143 3.25 20.5001V17.134C3.25 16.9348 3.32922 16.7438 3.47019 16.6032L16.4395 3.65934Z" fill="#1E96EB"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 697 B |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AI_PEN.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 172 KiB |
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AI_PREVIEW_B.png",
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "AI_PREVIEW_B 1.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 135 KiB |
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AI_PREVIEW_C.png",
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "AI_PREVIEW_C 1.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 132 KiB |
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AI_PREVIEW_A 1.png",
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "AI_PREVIEW_A.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.75 4C3.75 3.58579 4.08579 3.25 4.5 3.25H20.5C20.9142 3.25 21.25 3.58579 21.25 4V6.66667C21.25 7.08088 20.9142 7.41667 20.5 7.41667C20.0858 7.41667 19.75 7.08088 19.75 6.66667V4.75H13.25V19.25H16.5C16.9142 19.25 17.25 19.5858 17.25 20C17.25 20.4142 16.9142 20.75 16.5 20.75H8.5C8.08579 20.75 7.75 20.4142 7.75 20C7.75 19.5858 8.08579 19.25 8.5 19.25H11.75V4.75H5.25V6.66667C5.25 7.08088 4.91421 7.41667 4.5 7.41667C4.08579 7.41667 3.75 7.08088 3.75 6.66667V4Z" fill="#1E96EB"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 633 B |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AI_TEXT.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 238 KiB |
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Image.png",
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "BELIVER_ICON.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 227 KiB |
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// ViewOffsetKey.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct ViewOffsetKey: @MainActor PreferenceKey {
|
||||
static var defaultValue: CGPoint = .zero
|
||||
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/configuration/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
@@ -0,0 +1,23 @@
|
||||
// swift-tools-version: 5.9
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "AffineResources",
|
||||
products: [
|
||||
.library(
|
||||
name: "AffineResources",
|
||||
targets: ["AffineResources"]
|
||||
),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "AffineResources",
|
||||
resources: [
|
||||
.process("Resources/Icons.xcassets"),
|
||||
.process("Resources/Colors.xcassets"),
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
// The Swift Programming Language
|
||||
// https://docs.swift.org/swift-book
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
public enum AffineColors: String, CaseIterable {
|
||||
case buttonPrimary = "affine.button.primary"
|
||||
case iconActivated = "affine.icon.activated"
|
||||
case iconPrimary = "affine.icon.primary"
|
||||
case layerBackgroundPrimary = "affine.layer.background.primary"
|
||||
case layerBackgroundSecondary = "affine.layer.background.secondary"
|
||||
case layerBorder = "affine.layer.border"
|
||||
case layerPureWhite = "affine.layer.pureWhite"
|
||||
case textEmphasis = "affine.text.emphasis"
|
||||
case textLink = "affine.text.link"
|
||||
case textListDotAndNumber = "affine.text.listDotAndNumber"
|
||||
case textPlaceholder = "affine.text.placeholder"
|
||||
case textPrimary = "affine.text.primary"
|
||||
case textPureWhite = "affine.text.pureWhite"
|
||||
case textSecondary = "affine.text.secondary"
|
||||
case textTertiary = "affine.text.tertiary"
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
public var color: Color {
|
||||
Color(rawValue, bundle: .module)
|
||||
}
|
||||
|
||||
public var uiColor: UIColor {
|
||||
UIColor(named: rawValue, in: .module, compatibleWith: nil) ?? .clear
|
||||
}
|
||||
}
|
||||
|
||||
public enum AffineIcons: String, CaseIterable {
|
||||
case arrowDown = "ArrowDown"
|
||||
case arrowUpBig = "ArrowUpBig"
|
||||
case box = "Box"
|
||||
case broom = "Broom"
|
||||
case bubble = "Bubble"
|
||||
case calendar = "Calendar"
|
||||
case camera = "Camera"
|
||||
case checkCircle = "CheckCircle"
|
||||
case close = "Close"
|
||||
case image = "Image"
|
||||
case more = "More"
|
||||
case page = "Page"
|
||||
case plus = "Plus"
|
||||
case settings = "Settings"
|
||||
case think = "Think"
|
||||
case tools = "Tools"
|
||||
case upload = "Upload"
|
||||
case web = "Web"
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
public var image: Image {
|
||||
Image(rawValue, bundle: .module)
|
||||
}
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
public var uiImage: UIImage {
|
||||
UIImage(named: rawValue, in: .module, with: .none) ?? UIImage()
|
||||
}
|
||||
}
|
||||