Compare commits
9 Commits
146edfb3dc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d50eaf250d | |||
| d268bc5696 | |||
| 8bd4f24774 | |||
| 5c6a829a4d | |||
| 8c66fa9b82 | |||
| 79f90cb287 | |||
| ad65453fcf | |||
| 84131576cf | |||
| 96d8a66a97 |
@@ -0,0 +1,29 @@
|
||||
---
|
||||
date: 2026-03-01
|
||||
topic: ios-share-extension-v1
|
||||
---
|
||||
|
||||
# iOS Share Extension v1
|
||||
|
||||
## What We're Building
|
||||
Projeye ikinci bir frontend olarak native iOS uygulaması eklenecek. Uygulamanın v1 ana işlevi, Netflix uygulamasından paylaşılan içerik linkini almak ve backend API'ye göndererek metadata sonucunu kullanıcıya göstermek.
|
||||
|
||||
Akış: Netflix içerik sayfası -> Paylaş -> bizim iOS app (Share Extension) -> URL alma -> backend `/api/getinfo` isteği -> sonuçları metin olarak gösterme (`title`, `year`, vb.).
|
||||
|
||||
## Why This Approach
|
||||
Share Extension seçimi, iOS paylaşım menüsüne doğal şekilde entegre olur ve kullanıcı davranışıyla birebir örtüşür. Deep link tabanlı alternatiflere göre daha güvenilir URL yakalama sağlar ve v1 için en düşük sürtünmeyle çalışır.
|
||||
|
||||
V1 kapsamını sadece “link al, API çağır, sonucu göster” ile sınırlamak, YAGNI prensibine uygundur ve ürünü hızlıca canlı doğrulamaya taşır.
|
||||
|
||||
## Key Decisions
|
||||
- Entegrasyon tipi: Share Extension (zorunlu).
|
||||
- Kapsam: Sadece Netflix paylaşım linki ile `/api/getinfo` çağrısı.
|
||||
- Yetkilendirme: `X-API-Key` olarak `API_KEY_MOBILE` kullanılacak.
|
||||
- Görsellik: UI/UX tasarımı v1 sonrası iterasyona bırakılacak.
|
||||
|
||||
## Open Questions
|
||||
- Share Extension URL’yi doğrudan API’ye mi gönderecek, yoksa ana app’e handoff edip ana app mi çağrı yapacak?
|
||||
- Başarısız API yanıtlarında kullanıcıya minimum hangi hata metni gösterilecek?
|
||||
|
||||
## Next Steps
|
||||
-> `/workflows:plan`
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
date: 2026-03-01
|
||||
topic: tmdb-cast-strict-matching
|
||||
---
|
||||
|
||||
# TMDB Cast Bazlı Katı Eşleşme
|
||||
|
||||
## What We're Building
|
||||
TMDB arama akışına opsiyonel `cast` alanı eklenecek. İstekte `cast` verildiğinde sistem, mevcut `title/year/seasonYear/seasonNumber/type` ile adayları bulduktan sonra ilk 5 adayı cast bilgisi ile doğrulayacak.
|
||||
|
||||
Cast doğrulaması katı olacak: verilen cast adı adayın oyuncu listesinde yoksa aday elenecek. İlk 5 adayda hiç eşleşme bulunmazsa boş sonuç dönülecek. `cast` verilmediğinde mevcut davranış korunacak.
|
||||
|
||||
## Why This Approach
|
||||
Kullanıcı beklentisi yanlış eşleşmeleri azaltmak ve “başlık + tek oyuncu adı” ile daha doğru içeriği seçmek. Katı filtreleme, özellikle benzer isimli yapımlarda hatalı ilk sonucu engeller.
|
||||
|
||||
Top 5 doğrulama, doğruluk ve API maliyetini dengeler. `cast` alanını opsiyonel tutmak, mevcut istemcilerle geriye dönük uyumluluğu korur.
|
||||
|
||||
## Key Decisions
|
||||
- `cast` alanı opsiyonel: Eski entegrasyonlar bozulmaz.
|
||||
- Cast eşleşmesi katı: Eşleşme yoksa sonuç dönmez.
|
||||
- Doğrulama kapsamı Top 5: Aşırı API çağrısından kaçınılır.
|
||||
- Eşleşme modu esnek normalize: büyük/küçük harf, Türkçe karakter varyasyonları ve boşluk farklılıkları tolere edilir.
|
||||
|
||||
## Open Questions
|
||||
- Cast eşleşmesi yokken yanıt sadece `results: []` mı olmalı, yoksa `reason` gibi açıklayıcı bir alan eklenmeli mi?
|
||||
|
||||
## Next Steps
|
||||
-> `/workflows:plan`
|
||||
@@ -558,7 +558,7 @@ export function MoviesPage() {
|
||||
const pageTitle = useMemo(() => {
|
||||
if (typeFilter === 'movie') return 'Film Arşivi'
|
||||
if (typeFilter === 'tvshow') return 'Dizi Arşivi'
|
||||
return 'Film ve Dizi Arşivi'
|
||||
return 'Ratebubble'
|
||||
}, [typeFilter])
|
||||
|
||||
const allGenres = useMemo(() => {
|
||||
|
||||
39
ios/README.md
Normal file
39
ios/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Ratebubble iOS (v1)
|
||||
|
||||
Bu klasör, Ratebubble için iOS ana uygulama + Share Extension iskeletini içerir.
|
||||
|
||||
## Hedef
|
||||
- Netflix uygulamasından paylaşılan URL'yi almak
|
||||
- Ana app'e handoff etmek
|
||||
- Backend `/api/getinfo` endpointine gönderip sonucu göstermek
|
||||
|
||||
## Gereksinimler
|
||||
- Xcode 15+
|
||||
- iOS 16+
|
||||
- (Opsiyonel) XcodeGen
|
||||
|
||||
## Proje Oluşturma (XcodeGen)
|
||||
|
||||
```bash
|
||||
cd ios
|
||||
xcodegen generate
|
||||
open Ratebubble.xcodeproj
|
||||
```
|
||||
|
||||
Eğer `xcodegen` kurulu değilse:
|
||||
|
||||
```bash
|
||||
brew install xcodegen
|
||||
```
|
||||
|
||||
## Yapılandırma
|
||||
`Ratebubble/Resources/Config.xcconfig` dosyasında:
|
||||
- `API_BASE_URL`
|
||||
- `MOBILE_API_KEY`
|
||||
- `APP_GROUP_ID`
|
||||
- `APP_URL_SCHEME`
|
||||
|
||||
değerlerini ortamına göre güncelle.
|
||||
|
||||
## Not
|
||||
Share Extension, URL'yi App Group `UserDefaults` içine yazar ve custom URL scheme ile ana app'i açar.
|
||||
454
ios/Ratebubble.xcodeproj/project.pbxproj
Normal file
454
ios/Ratebubble.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,454 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 63;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
0315885AA91662FE48BBC594 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E2C9211EC8E30B83ABBCFB /* ContentView.swift */; };
|
||||
0C602ACFD0DC100ECCFA84A5 /* SharedPayloadStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CACEBEAEDF343884A3712AB1 /* SharedPayloadStore.swift */; };
|
||||
17157E8CF084270023A56C06 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA40D68C79904DEE4125D874 /* Models.swift */; };
|
||||
186EA66540EFA4CEA949617D /* RatebubbleShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7539736C403588176FB7D80C /* RatebubbleShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
3993F1B14739F8177B844EE0 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3DD3EAEF55CD1A6EF04D923 /* APIClient.swift */; };
|
||||
3E1D36E3AE2CDBD9C402E921 /* RatebubbleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC8FF0D6783D4BFC590D370 /* RatebubbleApp.swift */; };
|
||||
77D601458525635F705EA471 /* SharedPayloadStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CACEBEAEDF343884A3712AB1 /* SharedPayloadStore.swift */; };
|
||||
886AF155C91E366210524E45 /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37749607968C1A751317B7BD /* MainViewModel.swift */; };
|
||||
A547A283AD74B06B25FDB424 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72C1C571628107632B4A2F90 /* ShareViewController.swift */; };
|
||||
B31B1B7EC5BAE4FB9011C94C /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3DD3EAEF55CD1A6EF04D923 /* APIClient.swift */; };
|
||||
F670AE07545EBB4ABE2889F7 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA40D68C79904DEE4125D874 /* Models.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
2FFA444A9840D8FEE9B59CB3 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 7944D79A93FF32A326D78AB8 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = D66EABC12ABB12431BD3554C;
|
||||
remoteInfo = RatebubbleShare;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
87D7EB893873C1953A4B8483 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
186EA66540EFA4CEA949617D /* RatebubbleShare.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
19F8E500E3A70D57454E49A6 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = "<group>"; };
|
||||
37749607968C1A751317B7BD /* MainViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewModel.swift; sourceTree = "<group>"; };
|
||||
72C1C571628107632B4A2F90 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||
7539736C403588176FB7D80C /* RatebubbleShare.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = RatebubbleShare.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
7FEF3C0785A60EA1B560627C /* Ratebubble.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ratebubble.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
96E2C9211EC8E30B83ABBCFB /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
AA40D68C79904DEE4125D874 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
|
||||
CACEBEAEDF343884A3712AB1 /* SharedPayloadStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedPayloadStore.swift; sourceTree = "<group>"; };
|
||||
D3DD3EAEF55CD1A6EF04D923 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
||||
FEC8FF0D6783D4BFC590D370 /* RatebubbleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatebubbleApp.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
0EF0C634DF5D5383BE2D7771 /* Shared */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D3DD3EAEF55CD1A6EF04D923 /* APIClient.swift */,
|
||||
AA40D68C79904DEE4125D874 /* Models.swift */,
|
||||
CACEBEAEDF343884A3712AB1 /* SharedPayloadStore.swift */,
|
||||
);
|
||||
name = Shared;
|
||||
path = Ratebubble/Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
108289137F406D4F63BFC3AE /* ShareExtension */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
72C1C571628107632B4A2F90 /* ShareViewController.swift */,
|
||||
);
|
||||
name = ShareExtension;
|
||||
path = Ratebubble/ShareExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1C0E0078471B0855017A64DD = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EF496B275DF9294D162CFA6E /* App */,
|
||||
BFD8EEAEAAE9601A16238D2D /* Resources */,
|
||||
0EF0C634DF5D5383BE2D7771 /* Shared */,
|
||||
108289137F406D4F63BFC3AE /* ShareExtension */,
|
||||
E032C44ACA0299B26854540A /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BFD8EEAEAAE9601A16238D2D /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
19F8E500E3A70D57454E49A6 /* Config.xcconfig */,
|
||||
);
|
||||
name = Resources;
|
||||
path = Ratebubble/Resources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E032C44ACA0299B26854540A /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7FEF3C0785A60EA1B560627C /* Ratebubble.app */,
|
||||
7539736C403588176FB7D80C /* RatebubbleShare.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EF496B275DF9294D162CFA6E /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
96E2C9211EC8E30B83ABBCFB /* ContentView.swift */,
|
||||
37749607968C1A751317B7BD /* MainViewModel.swift */,
|
||||
FEC8FF0D6783D4BFC590D370 /* RatebubbleApp.swift */,
|
||||
);
|
||||
name = App;
|
||||
path = Ratebubble/App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
1E5B7142527B8E64D5BB475A /* Ratebubble */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = DE6F9D82ED742462C54ED81A /* Build configuration list for PBXNativeTarget "Ratebubble" */;
|
||||
buildPhases = (
|
||||
21317B3B7EEF00F8D3448F1A /* Sources */,
|
||||
87D7EB893873C1953A4B8483 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
B360B60BC3BB55F1DEFD5F02 /* PBXTargetDependency */,
|
||||
);
|
||||
name = Ratebubble;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = Ratebubble;
|
||||
productReference = 7FEF3C0785A60EA1B560627C /* Ratebubble.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
D66EABC12ABB12431BD3554C /* RatebubbleShare */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 3BCC0C2B27341BF3ADDCB3B9 /* Build configuration list for PBXNativeTarget "RatebubbleShare" */;
|
||||
buildPhases = (
|
||||
B64AC19652E4EA372111002C /* Sources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = RatebubbleShare;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = RatebubbleShare;
|
||||
productReference = 7539736C403588176FB7D80C /* RatebubbleShare.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
7944D79A93FF32A326D78AB8 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1430;
|
||||
};
|
||||
buildConfigurationList = BA6BB0478ED529686B10D5F3 /* Build configuration list for PBXProject "Ratebubble" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
Base,
|
||||
en,
|
||||
);
|
||||
mainGroup = 1C0E0078471B0855017A64DD;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
1E5B7142527B8E64D5BB475A /* Ratebubble */,
|
||||
D66EABC12ABB12431BD3554C /* RatebubbleShare */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
21317B3B7EEF00F8D3448F1A /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B31B1B7EC5BAE4FB9011C94C /* APIClient.swift in Sources */,
|
||||
0315885AA91662FE48BBC594 /* ContentView.swift in Sources */,
|
||||
886AF155C91E366210524E45 /* MainViewModel.swift in Sources */,
|
||||
17157E8CF084270023A56C06 /* Models.swift in Sources */,
|
||||
3E1D36E3AE2CDBD9C402E921 /* RatebubbleApp.swift in Sources */,
|
||||
77D601458525635F705EA471 /* SharedPayloadStore.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B64AC19652E4EA372111002C /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
3993F1B14739F8177B844EE0 /* APIClient.swift in Sources */,
|
||||
F670AE07545EBB4ABE2889F7 /* Models.swift in Sources */,
|
||||
A547A283AD74B06B25FDB424 /* ShareViewController.swift in Sources */,
|
||||
0C602ACFD0DC100ECCFA84A5 /* SharedPayloadStore.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
B360B60BC3BB55F1DEFD5F02 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = D66EABC12ABB12431BD3554C /* RatebubbleShare */;
|
||||
targetProxy = 2FFA444A9840D8FEE9B59CB3 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
03B172804432CFDEF0B15A28 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 19F8E500E3A70D57454E49A6 /* Config.xcconfig */;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = Ratebubble/Resources/RatebubbleShare.entitlements;
|
||||
DEVELOPMENT_TEAM = S34SFUY9SC;
|
||||
INFOPLIST_FILE = "Ratebubble/Resources/RatebubbleShare-Info.plist";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.wisecolt.ratebubble.share;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
2C4CEF1731FBB13FC87F825C /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.wisecolt.ratebubble;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.9;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
3AE70298876BFAD681CF451B /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 19F8E500E3A70D57454E49A6 /* Config.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = Ratebubble/Resources/Ratebubble.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
DEVELOPMENT_TEAM = S34SFUY9SC;
|
||||
INFOPLIST_FILE = "Ratebubble/Resources/Ratebubble-Info.plist";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
3B575860868E0B1AF5B7D410 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
"DEBUG=1",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.wisecolt.ratebubble;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.9;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
5C1878476CD3B8F15D53C417 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 19F8E500E3A70D57454E49A6 /* Config.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = Ratebubble/Resources/Ratebubble.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
DEVELOPMENT_TEAM = S34SFUY9SC;
|
||||
INFOPLIST_FILE = "Ratebubble/Resources/Ratebubble-Info.plist";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
A005F6D39148A83B59423F17 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 19F8E500E3A70D57454E49A6 /* Config.xcconfig */;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = Ratebubble/Resources/RatebubbleShare.entitlements;
|
||||
DEVELOPMENT_TEAM = S34SFUY9SC;
|
||||
INFOPLIST_FILE = "Ratebubble/Resources/RatebubbleShare-Info.plist";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.wisecolt.ratebubble.share;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
3BCC0C2B27341BF3ADDCB3B9 /* Build configuration list for PBXNativeTarget "RatebubbleShare" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A005F6D39148A83B59423F17 /* Debug */,
|
||||
03B172804432CFDEF0B15A28 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
BA6BB0478ED529686B10D5F3 /* Build configuration list for PBXProject "Ratebubble" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
3B575860868E0B1AF5B7D410 /* Debug */,
|
||||
2C4CEF1731FBB13FC87F825C /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
DE6F9D82ED742462C54ED81A /* Build configuration list for PBXNativeTarget "Ratebubble" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
3AE70298876BFAD681CF451B /* Debug */,
|
||||
5C1878476CD3B8F15D53C417 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 7944D79A93FF32A326D78AB8 /* Project object */;
|
||||
}
|
||||
7
ios/Ratebubble.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
ios/Ratebubble.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
Binary file not shown.
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2620"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1E5B7142527B8E64D5BB475A"
|
||||
BuildableName = "Ratebubble.app"
|
||||
BlueprintName = "Ratebubble"
|
||||
ReferencedContainer = "container:Ratebubble.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1E5B7142527B8E64D5BB475A"
|
||||
BuildableName = "Ratebubble.app"
|
||||
BlueprintName = "Ratebubble"
|
||||
ReferencedContainer = "container:Ratebubble.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1E5B7142527B8E64D5BB475A"
|
||||
BuildableName = "Ratebubble.app"
|
||||
BlueprintName = "Ratebubble"
|
||||
ReferencedContainer = "container:Ratebubble.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,97 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2620"
|
||||
wasCreatedForAppExtension = "YES"
|
||||
version = "2.0">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D66EABC12ABB12431BD3554C"
|
||||
BuildableName = "RatebubbleShare.appex"
|
||||
BlueprintName = "RatebubbleShare"
|
||||
ReferencedContainer = "container:Ratebubble.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1E5B7142527B8E64D5BB475A"
|
||||
BuildableName = "Ratebubble.app"
|
||||
BlueprintName = "Ratebubble"
|
||||
ReferencedContainer = "container:Ratebubble.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = ""
|
||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||
launchStyle = "0"
|
||||
askForAppToLaunch = "Yes"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1E5B7142527B8E64D5BB475A"
|
||||
BuildableName = "Ratebubble.app"
|
||||
BlueprintName = "Ratebubble"
|
||||
ReferencedContainer = "container:Ratebubble.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
askForAppToLaunch = "Yes"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1E5B7142527B8E64D5BB475A"
|
||||
BuildableName = "Ratebubble.app"
|
||||
BlueprintName = "Ratebubble"
|
||||
ReferencedContainer = "container:Ratebubble.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
418
ios/Ratebubble/App/ContentView.swift
Normal file
418
ios/Ratebubble/App/ContentView.swift
Normal file
@@ -0,0 +1,418 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var viewModel = MainViewModel()
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@State private var selectedRating: Double = 0
|
||||
@State private var commentDraft = ""
|
||||
@State private var comments: [CommentItem] = []
|
||||
@State private var interactionKey = ""
|
||||
|
||||
private let hoverHaptic = UIImpactFeedbackGenerator(style: .light)
|
||||
private let submitHaptic = UIImpactFeedbackGenerator(style: .medium)
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.05, green: 0.05, blue: 0.07),
|
||||
Color(red: 0.02, green: 0.02, blue: 0.03)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 16) {
|
||||
urlComposerCard
|
||||
|
||||
if viewModel.isLoading {
|
||||
loadingCard
|
||||
}
|
||||
|
||||
if let error = viewModel.errorMessage {
|
||||
errorCard(error)
|
||||
}
|
||||
|
||||
if let result = viewModel.result {
|
||||
resultCard(result)
|
||||
ratingCard
|
||||
commentsCard
|
||||
} else if !viewModel.isLoading {
|
||||
emptyCard
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text("Ratebubble")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.onAppear {
|
||||
hoverHaptic.prepare()
|
||||
submitHaptic.prepare()
|
||||
viewModel.consumeSharedURLIfAny()
|
||||
}
|
||||
.onOpenURL { _ in viewModel.consumeSharedURLIfAny() }
|
||||
.onChange(of: scenePhase) { phase in
|
||||
if phase == .active { viewModel.consumeSharedURLIfAny() }
|
||||
}
|
||||
}
|
||||
|
||||
private var urlComposerCard: some View {
|
||||
card {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Paylaşılan Link")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "link")
|
||||
.foregroundStyle(.white.opacity(0.65))
|
||||
TextField("https://www.netflix.com/title/...", text: $viewModel.sharedURL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.keyboardType(.URL)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 11)
|
||||
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
Button {
|
||||
Task { await viewModel.fetch() }
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text(viewModel.isLoading ? "Analiz Ediliyor..." : "İçeriği Analiz Et")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 11)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.red.opacity(0.95), Color.orange.opacity(0.9)],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
),
|
||||
in: RoundedRectangle(cornerRadius: 12)
|
||||
)
|
||||
}
|
||||
.disabled(viewModel.isLoading)
|
||||
.foregroundStyle(.white)
|
||||
.opacity(viewModel.isLoading ? 0.7 : 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var loadingCard: some View {
|
||||
card {
|
||||
HStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text("İçerik çözülüyor ve metadata hazırlanıyor...")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private func errorCard(_ message: String) -> some View {
|
||||
card {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Bir sorun oluştu")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.red.opacity(0.95))
|
||||
Text(message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.white.opacity(0.82))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private func resultCard(_ result: GetInfoResponse) -> some View {
|
||||
let key = contentKey(result)
|
||||
|
||||
return card {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
providerBadge(result.provider)
|
||||
Text(result.title)
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(3)
|
||||
|
||||
Text(metaLine(result))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.white.opacity(0.65))
|
||||
|
||||
if !result.genres.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(result.genres, id: \.self) { genre in
|
||||
Text(genre)
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.white.opacity(0.09), in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let plot = result.plot, !plot.isEmpty {
|
||||
Text(plot)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.white.opacity(0.84))
|
||||
.lineLimit(6)
|
||||
}
|
||||
}
|
||||
.onAppear { prepareInteractionState(for: result, key: key) }
|
||||
.onChange(of: key) { _ in prepareInteractionState(for: result, key: key) }
|
||||
}
|
||||
}
|
||||
|
||||
private var ratingCard: some View {
|
||||
card {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Puanla")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text("Yarım yıldız da seçebilirsin. Sürükleyerek hızlı puan ver.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
|
||||
StarRatingBar(
|
||||
rating: $selectedRating,
|
||||
onChanged: { changed in
|
||||
guard changed else { return }
|
||||
hoverHaptic.impactOccurred(intensity: 0.8)
|
||||
hoverHaptic.prepare()
|
||||
}
|
||||
)
|
||||
.frame(height: 46)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private var commentsCard: some View {
|
||||
card {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Yorumlar")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if comments.isEmpty {
|
||||
Text("Henüz yorum yok. İlk yorumu sen yaz.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.white.opacity(0.58))
|
||||
} else {
|
||||
ForEach(comments) { item in
|
||||
CommentBubble(item: item)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .trailing, spacing: 8) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
TextEditor(text: $commentDraft)
|
||||
.scrollContentBackground(.hidden)
|
||||
.foregroundStyle(.white)
|
||||
.frame(minHeight: 88, maxHeight: 120)
|
||||
.padding(8)
|
||||
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
if commentDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
Text("Yorumunu yaz...")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.white.opacity(0.38))
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 18)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
submitComment()
|
||||
} label: {
|
||||
Text("Gönder")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 9)
|
||||
.background(Color.red.opacity(0.9), in: Capsule())
|
||||
}
|
||||
.disabled(commentDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
.opacity(commentDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? 0.45 : 1)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyCard: some View {
|
||||
card {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Hazır")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text("Paylaşımdan gelen bir içerik varsa otomatik doldurulur. İstersen yukarıdan URL girip analiz başlat.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.white.opacity(0.66))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private func providerBadge(_ provider: String) -> some View {
|
||||
Text(provider.uppercased())
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 5)
|
||||
.background(
|
||||
provider == "netflix" ? Color.red.opacity(0.92) :
|
||||
(provider == "primevideo" ? Color.cyan.opacity(0.82) : Color.white.opacity(0.2)),
|
||||
in: Capsule()
|
||||
)
|
||||
}
|
||||
|
||||
private func metaLine(_ result: GetInfoResponse) -> String {
|
||||
var parts: [String] = []
|
||||
if let year = result.year { parts.append(String(year)) }
|
||||
parts.append(result.type == "movie" ? "Film" : "Dizi")
|
||||
if let currentSeason = result.currentSeason { parts.append("Sezon \(currentSeason)") }
|
||||
return parts.joined(separator: " • ")
|
||||
}
|
||||
|
||||
private func contentKey(_ result: GetInfoResponse) -> String {
|
||||
"\(result.provider)|\(result.title)|\(result.year.map(String.init) ?? "-")"
|
||||
}
|
||||
|
||||
private func prepareInteractionState(for result: GetInfoResponse, key: String) {
|
||||
guard interactionKey != key else { return }
|
||||
interactionKey = key
|
||||
selectedRating = 0
|
||||
commentDraft = ""
|
||||
comments = [
|
||||
CommentItem(user: "deniz", body: "Sinematografi çok temiz, finali de iyi bağlamışlar.", time: "2 saat önce"),
|
||||
CommentItem(user: "melis", body: "\(result.title) için tempo yer yer düşse de genel deneyim çok keyifli.", time: "Dün")
|
||||
]
|
||||
}
|
||||
|
||||
private func submitComment() {
|
||||
let trimmed = commentDraft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
comments.insert(CommentItem(user: "sen", body: trimmed, time: "Şimdi"), at: 0)
|
||||
commentDraft = ""
|
||||
submitHaptic.impactOccurred(intensity: 0.5)
|
||||
submitHaptic.prepare()
|
||||
}
|
||||
|
||||
private func card<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
|
||||
content()
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color.white.opacity(0.06))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(Color.white.opacity(0.08), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CommentItem: Identifiable {
|
||||
let id = UUID()
|
||||
let user: String
|
||||
let body: String
|
||||
let time: String
|
||||
}
|
||||
|
||||
private struct CommentBubble: View {
|
||||
let item: CommentItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("@\(item.user)")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text(item.body)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.white.opacity(0.82))
|
||||
Text(item.time)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
private struct StarRatingBar: View {
|
||||
@Binding var rating: Double
|
||||
let onChanged: (Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
HStack(spacing: 4) {
|
||||
ForEach(1...5, id: \.self) { idx in
|
||||
Image(systemName: iconName(for: idx))
|
||||
.font(.system(size: 30, weight: .medium))
|
||||
.foregroundStyle(iconColor(for: idx))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
let changed = updateRating(from: value.location.x, width: geo.size.width)
|
||||
onChanged(changed)
|
||||
}
|
||||
)
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("Puan")
|
||||
.accessibilityValue("\(String(format: "%.1f", rating)) yıldız")
|
||||
}
|
||||
|
||||
private func iconName(for index: Int) -> String {
|
||||
let value = Double(index)
|
||||
if rating >= value { return "star.fill" }
|
||||
if rating >= value - 0.5 { return "star.leadinghalf.filled" }
|
||||
return "star"
|
||||
}
|
||||
|
||||
private func iconColor(for index: Int) -> Color {
|
||||
let value = Double(index)
|
||||
return rating >= value - 0.5
|
||||
? Color(red: 0.96, green: 0.74, blue: 0.20)
|
||||
: Color.white.opacity(0.20)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func updateRating(from x: CGFloat, width: CGFloat) -> Bool {
|
||||
guard width > 0 else { return false }
|
||||
let clampedX = min(max(x, 0), width - 0.001)
|
||||
let raw = (clampedX / width) * 5.0
|
||||
let stepped = max(0.5, min(5.0, (raw * 2).rounded(.up) / 2))
|
||||
guard abs(stepped - rating) > 0.001 else { return false }
|
||||
rating = stepped
|
||||
return true
|
||||
}
|
||||
}
|
||||
36
ios/Ratebubble/App/MainViewModel.swift
Normal file
36
ios/Ratebubble/App/MainViewModel.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class MainViewModel: ObservableObject {
|
||||
@Published var sharedURL: String = ""
|
||||
@Published var isLoading: Bool = false
|
||||
@Published var result: GetInfoResponse?
|
||||
@Published var errorMessage: String?
|
||||
|
||||
func consumeSharedURLIfAny() {
|
||||
guard let incoming = SharedPayloadStore.consumeIncomingURL(), !incoming.isEmpty else {
|
||||
return
|
||||
}
|
||||
sharedURL = incoming
|
||||
Task { await fetch() }
|
||||
}
|
||||
|
||||
func fetch() async {
|
||||
guard !sharedURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
errorMessage = "Paylaşılan URL boş olamaz."
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
result = nil
|
||||
|
||||
do {
|
||||
result = try await APIClient.shared.getInfo(url: sharedURL)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
10
ios/Ratebubble/App/RatebubbleApp.swift
Normal file
10
ios/Ratebubble/App/RatebubbleApp.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct RatebubbleApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
5
ios/Ratebubble/Resources/Config.xcconfig
Normal file
5
ios/Ratebubble/Resources/Config.xcconfig
Normal file
@@ -0,0 +1,5 @@
|
||||
SLASH = /
|
||||
API_BASE_URL = http:$(SLASH)$(SLASH)192.168.1.124:3000
|
||||
MOBILE_API_KEY = mobile-app-key-change-me-in-production
|
||||
APP_GROUP_ID = group.net.wisecolt.ratebubble
|
||||
APP_URL_SCHEME = ratebubble
|
||||
39
ios/Ratebubble/Resources/Ratebubble-Info.plist
Normal file
39
ios/Ratebubble/Resources/Ratebubble-Info.plist
Normal file
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>$(APP_URL_SCHEME)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>API_BASE_URL</key>
|
||||
<string>$(API_BASE_URL)</string>
|
||||
<key>MOBILE_API_KEY</key>
|
||||
<string>$(MOBILE_API_KEY)</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
</plist>
|
||||
10
ios/Ratebubble/Resources/Ratebubble.entitlements
Normal file
10
ios/Ratebubble/Resources/Ratebubble.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>$(APP_GROUP_ID)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
44
ios/Ratebubble/Resources/RatebubbleShare-Info.plist
Normal file
44
ios/Ratebubble/Resources/RatebubbleShare-Info.plist
Normal file
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Ratebubble Share</string>
|
||||
<key>API_BASE_URL</key>
|
||||
<string>$(API_BASE_URL)</string>
|
||||
<key>MOBILE_API_KEY</key>
|
||||
<string>$(MOBILE_API_KEY)</string>
|
||||
<key>APP_GROUP_ID</key>
|
||||
<string>$(APP_GROUP_ID)</string>
|
||||
<key>APP_URL_SCHEME</key>
|
||||
<string>$(APP_URL_SCHEME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<string>TRUEPREDICATE</string>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
10
ios/Ratebubble/Resources/RatebubbleShare.entitlements
Normal file
10
ios/Ratebubble/Resources/RatebubbleShare.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>$(APP_GROUP_ID)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
838
ios/Ratebubble/ShareExtension/ShareViewController.swift
Normal file
838
ios/Ratebubble/ShareExtension/ShareViewController.swift
Normal file
@@ -0,0 +1,838 @@
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
final class ShareViewController: UIViewController, UITextViewDelegate {
|
||||
|
||||
private enum ViewState {
|
||||
case loading
|
||||
case success(GetInfoResponse)
|
||||
case error(String)
|
||||
}
|
||||
|
||||
private struct CommentItem {
|
||||
let user: String
|
||||
let body: String
|
||||
let time: String
|
||||
}
|
||||
|
||||
private var selectedRating = 0.0
|
||||
private var starButtons: [UIButton] = []
|
||||
private var comments: [CommentItem] = []
|
||||
|
||||
private let headerView = UIView()
|
||||
private let headerLabel = UILabel()
|
||||
private let closeButton = UIButton(type: .system)
|
||||
|
||||
private let overlayView = UIView()
|
||||
private let spinner = UIActivityIndicatorView(style: .large)
|
||||
private let overlayLabel = UILabel()
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
|
||||
private let backdropContainer = UIView()
|
||||
private let backdropImageView = UIImageView()
|
||||
private let gradientLayer = CAGradientLayer()
|
||||
private let providerBadge = UILabel()
|
||||
private let heroTitleLabel = UILabel()
|
||||
private let heroMetaLabel = UILabel()
|
||||
|
||||
private let genreScroll = UIScrollView()
|
||||
private let genreStack = UIStackView()
|
||||
private let plotLabel = UILabel()
|
||||
private let castLabel = UILabel()
|
||||
|
||||
private let commentsListStack = UIStackView()
|
||||
private let commentTextView = UITextView()
|
||||
private let commentPlaceholderLabel = UILabel()
|
||||
private let submitCommentButton = UIButton(type: .system)
|
||||
private let starsRow = UIStackView()
|
||||
|
||||
private let hoverHaptic = UIImpactFeedbackGenerator(style: .light)
|
||||
private var dismissPanStartTransform: CGAffineTransform = .identity
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
overrideUserInterfaceStyle = .dark
|
||||
// Prevent system pull-down dismiss that only closes this extension UI.
|
||||
// We'll handle downward dismiss ourselves and always call completeRequest.
|
||||
isModalInPresentation = true
|
||||
view.backgroundColor = UIColor(red: 0.04, green: 0.04, blue: 0.06, alpha: 1)
|
||||
|
||||
setupHeader()
|
||||
setupScrollView()
|
||||
setupOverlay()
|
||||
setupFeedback()
|
||||
|
||||
Task { await handleIncomingShare() }
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
gradientLayer.frame = backdropContainer.bounds
|
||||
}
|
||||
|
||||
private func setupFeedback() {
|
||||
hoverHaptic.prepare()
|
||||
}
|
||||
|
||||
private func setupHeader() {
|
||||
headerView.backgroundColor = UIColor(red: 0.06, green: 0.06, blue: 0.08, alpha: 0.96)
|
||||
headerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(headerView)
|
||||
|
||||
headerLabel.text = "Ratebubble"
|
||||
headerLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
headerLabel.textColor = .white
|
||||
headerLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
headerView.addSubview(headerLabel)
|
||||
|
||||
closeButton.setTitle("Kapat", for: .normal)
|
||||
closeButton.setTitleColor(.systemGray2, for: .normal)
|
||||
closeButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
closeButton.accessibilityLabel = "Ekranı kapat"
|
||||
closeButton.accessibilityHint = "Paylaşım ekranını kapatır"
|
||||
closeButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
|
||||
headerView.addSubview(closeButton)
|
||||
|
||||
let dismissPan = UIPanGestureRecognizer(target: self, action: #selector(handleDismissPan(_:)))
|
||||
dismissPan.maximumNumberOfTouches = 1
|
||||
headerView.addGestureRecognizer(dismissPan)
|
||||
|
||||
let separator = UIView()
|
||||
separator.backgroundColor = UIColor.white.withAlphaComponent(0.08)
|
||||
separator.translatesAutoresizingMaskIntoConstraints = false
|
||||
headerView.addSubview(separator)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
headerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||
headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
headerView.heightAnchor.constraint(equalToConstant: 46),
|
||||
|
||||
headerLabel.centerXAnchor.constraint(equalTo: headerView.centerXAnchor),
|
||||
headerLabel.centerYAnchor.constraint(equalTo: headerView.centerYAnchor),
|
||||
|
||||
closeButton.trailingAnchor.constraint(equalTo: headerView.trailingAnchor, constant: -16),
|
||||
closeButton.centerYAnchor.constraint(equalTo: headerView.centerYAnchor),
|
||||
|
||||
separator.leadingAnchor.constraint(equalTo: headerView.leadingAnchor),
|
||||
separator.trailingAnchor.constraint(equalTo: headerView.trailingAnchor),
|
||||
separator.bottomAnchor.constraint(equalTo: headerView.bottomAnchor),
|
||||
separator.heightAnchor.constraint(equalToConstant: 0.5)
|
||||
])
|
||||
}
|
||||
|
||||
private func setupScrollView() {
|
||||
scrollView.alwaysBounceVertical = true
|
||||
scrollView.keyboardDismissMode = .interactive
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
scrollView.isHidden = true
|
||||
view.addSubview(scrollView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
scrollView.topAnchor.constraint(equalTo: headerView.bottomAnchor),
|
||||
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||
])
|
||||
|
||||
setupBackdrop()
|
||||
setupContentStack()
|
||||
setupMetadataSection()
|
||||
setupRatingSection()
|
||||
setupCommentsSection()
|
||||
}
|
||||
|
||||
private func setupBackdrop() {
|
||||
backdropContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
backdropContainer.clipsToBounds = true
|
||||
scrollView.addSubview(backdropContainer)
|
||||
|
||||
backdropImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
backdropImageView.contentMode = .scaleAspectFill
|
||||
backdropImageView.backgroundColor = UIColor(red: 0.10, green: 0.10, blue: 0.12, alpha: 1)
|
||||
backdropImageView.alpha = 0
|
||||
backdropContainer.addSubview(backdropImageView)
|
||||
|
||||
gradientLayer.colors = [
|
||||
UIColor.clear.cgColor,
|
||||
UIColor.black.withAlphaComponent(0.40).cgColor,
|
||||
UIColor.black.withAlphaComponent(0.92).cgColor
|
||||
]
|
||||
gradientLayer.locations = [0.25, 0.55, 1.0]
|
||||
backdropContainer.layer.addSublayer(gradientLayer)
|
||||
|
||||
providerBadge.font = .systemFont(ofSize: 10, weight: .bold)
|
||||
providerBadge.textColor = .white
|
||||
providerBadge.layer.cornerRadius = 10
|
||||
providerBadge.layer.masksToBounds = true
|
||||
providerBadge.textAlignment = .center
|
||||
providerBadge.translatesAutoresizingMaskIntoConstraints = false
|
||||
backdropContainer.addSubview(providerBadge)
|
||||
|
||||
heroTitleLabel.font = .systemFont(ofSize: 28, weight: .heavy)
|
||||
heroTitleLabel.textColor = .white
|
||||
heroTitleLabel.numberOfLines = 2
|
||||
heroTitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
backdropContainer.addSubview(heroTitleLabel)
|
||||
|
||||
heroMetaLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
heroMetaLabel.textColor = UIColor.white.withAlphaComponent(0.78)
|
||||
heroMetaLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
backdropContainer.addSubview(heroMetaLabel)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
backdropContainer.topAnchor.constraint(equalTo: scrollView.topAnchor),
|
||||
backdropContainer.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
|
||||
backdropContainer.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
|
||||
backdropContainer.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
|
||||
backdropContainer.heightAnchor.constraint(equalToConstant: 248),
|
||||
|
||||
backdropImageView.topAnchor.constraint(equalTo: backdropContainer.topAnchor),
|
||||
backdropImageView.leadingAnchor.constraint(equalTo: backdropContainer.leadingAnchor),
|
||||
backdropImageView.trailingAnchor.constraint(equalTo: backdropContainer.trailingAnchor),
|
||||
backdropImageView.bottomAnchor.constraint(equalTo: backdropContainer.bottomAnchor),
|
||||
|
||||
providerBadge.leadingAnchor.constraint(equalTo: backdropContainer.leadingAnchor, constant: 16),
|
||||
providerBadge.bottomAnchor.constraint(equalTo: heroTitleLabel.topAnchor, constant: -10),
|
||||
providerBadge.heightAnchor.constraint(equalToConstant: 20),
|
||||
providerBadge.widthAnchor.constraint(greaterThanOrEqualToConstant: 78),
|
||||
|
||||
heroTitleLabel.leadingAnchor.constraint(equalTo: backdropContainer.leadingAnchor, constant: 16),
|
||||
heroTitleLabel.trailingAnchor.constraint(equalTo: backdropContainer.trailingAnchor, constant: -16),
|
||||
heroTitleLabel.bottomAnchor.constraint(equalTo: heroMetaLabel.topAnchor, constant: -5),
|
||||
|
||||
heroMetaLabel.leadingAnchor.constraint(equalTo: backdropContainer.leadingAnchor, constant: 16),
|
||||
heroMetaLabel.trailingAnchor.constraint(equalTo: backdropContainer.trailingAnchor, constant: -16),
|
||||
heroMetaLabel.bottomAnchor.constraint(equalTo: backdropContainer.bottomAnchor, constant: -16)
|
||||
])
|
||||
}
|
||||
|
||||
private func setupContentStack() {
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 16
|
||||
contentStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
scrollView.addSubview(contentStack)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
contentStack.topAnchor.constraint(equalTo: backdropContainer.bottomAnchor, constant: 14),
|
||||
contentStack.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 14),
|
||||
contentStack.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -14),
|
||||
contentStack.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -28),
|
||||
contentStack.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -30)
|
||||
])
|
||||
}
|
||||
|
||||
private func setupMetadataSection() {
|
||||
let card = makeSectionCard()
|
||||
let stack = makeCardStack()
|
||||
|
||||
genreScroll.showsHorizontalScrollIndicator = false
|
||||
genreScroll.translatesAutoresizingMaskIntoConstraints = false
|
||||
genreScroll.heightAnchor.constraint(equalToConstant: 32).isActive = true
|
||||
|
||||
genreStack.axis = .horizontal
|
||||
genreStack.spacing = 8
|
||||
genreStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
genreScroll.addSubview(genreStack)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
genreStack.topAnchor.constraint(equalTo: genreScroll.contentLayoutGuide.topAnchor),
|
||||
genreStack.leadingAnchor.constraint(equalTo: genreScroll.contentLayoutGuide.leadingAnchor),
|
||||
genreStack.trailingAnchor.constraint(equalTo: genreScroll.contentLayoutGuide.trailingAnchor),
|
||||
genreStack.bottomAnchor.constraint(equalTo: genreScroll.contentLayoutGuide.bottomAnchor),
|
||||
genreStack.heightAnchor.constraint(equalTo: genreScroll.frameLayoutGuide.heightAnchor)
|
||||
])
|
||||
|
||||
plotLabel.font = .systemFont(ofSize: 14)
|
||||
plotLabel.textColor = UIColor.white.withAlphaComponent(0.84)
|
||||
plotLabel.numberOfLines = 6
|
||||
|
||||
castLabel.font = .systemFont(ofSize: 13, weight: .regular)
|
||||
castLabel.textColor = UIColor.white.withAlphaComponent(0.66)
|
||||
castLabel.numberOfLines = 2
|
||||
|
||||
stack.addArrangedSubview(genreScroll)
|
||||
stack.addArrangedSubview(plotLabel)
|
||||
stack.addArrangedSubview(castLabel)
|
||||
card.addSubview(stack)
|
||||
pinCardStack(stack, in: card)
|
||||
contentStack.addArrangedSubview(card)
|
||||
}
|
||||
|
||||
private func setupRatingSection() {
|
||||
let card = makeSectionCard()
|
||||
let stack = makeCardStack()
|
||||
|
||||
let title = UILabel()
|
||||
title.text = "Puanla"
|
||||
title.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
title.textColor = .white
|
||||
|
||||
let subtitle = UILabel()
|
||||
subtitle.text = "Puanını istediğin zaman değiştirebilirsin."
|
||||
subtitle.font = .systemFont(ofSize: 12, weight: .regular)
|
||||
subtitle.textColor = UIColor.white.withAlphaComponent(0.55)
|
||||
|
||||
starsRow.axis = .horizontal
|
||||
starsRow.alignment = .center
|
||||
starsRow.distribution = .fillEqually
|
||||
starsRow.spacing = 4
|
||||
starsRow.translatesAutoresizingMaskIntoConstraints = false
|
||||
starsRow.isUserInteractionEnabled = true
|
||||
starsRow.heightAnchor.constraint(equalToConstant: 48).isActive = true
|
||||
|
||||
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handleStarPan(_:)))
|
||||
panGesture.maximumNumberOfTouches = 1
|
||||
starsRow.addGestureRecognizer(panGesture)
|
||||
|
||||
let symbolConfig = UIImage.SymbolConfiguration(pointSize: 30, weight: .medium)
|
||||
for i in 1...5 {
|
||||
let button = UIButton(type: .system)
|
||||
button.setImage(UIImage(systemName: "star", withConfiguration: symbolConfig), for: .normal)
|
||||
button.tintColor = UIColor.white.withAlphaComponent(0.18)
|
||||
button.tag = i
|
||||
button.addTarget(self, action: #selector(starTapped(_:forEvent:)), for: .touchUpInside)
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.heightAnchor.constraint(equalToConstant: 46).isActive = true
|
||||
button.accessibilityLabel = "\(i) yıldız"
|
||||
button.accessibilityHint = "Puanı ayarlar"
|
||||
starButtons.append(button)
|
||||
starsRow.addArrangedSubview(button)
|
||||
}
|
||||
|
||||
stack.addArrangedSubview(title)
|
||||
stack.addArrangedSubview(subtitle)
|
||||
stack.addArrangedSubview(starsRow)
|
||||
|
||||
card.addSubview(stack)
|
||||
pinCardStack(stack, in: card)
|
||||
contentStack.addArrangedSubview(card)
|
||||
}
|
||||
|
||||
private func setupCommentsSection() {
|
||||
let card = makeSectionCard()
|
||||
let stack = makeCardStack()
|
||||
|
||||
let title = UILabel()
|
||||
title.text = "Yorumlar"
|
||||
title.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
title.textColor = .white
|
||||
|
||||
commentsListStack.axis = .vertical
|
||||
commentsListStack.spacing = 10
|
||||
commentsListStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let composerContainer = UIView()
|
||||
composerContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
composerContainer.backgroundColor = UIColor.white.withAlphaComponent(0.06)
|
||||
composerContainer.layer.cornerRadius = 14
|
||||
composerContainer.layer.borderWidth = 1
|
||||
composerContainer.layer.borderColor = UIColor.white.withAlphaComponent(0.08).cgColor
|
||||
|
||||
commentTextView.backgroundColor = .clear
|
||||
commentTextView.textColor = .white
|
||||
commentTextView.font = .systemFont(ofSize: 14)
|
||||
commentTextView.tintColor = .systemRed
|
||||
commentTextView.textContainerInset = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
|
||||
commentTextView.translatesAutoresizingMaskIntoConstraints = false
|
||||
commentTextView.delegate = self
|
||||
commentTextView.isScrollEnabled = true
|
||||
commentTextView.heightAnchor.constraint(equalToConstant: 96).isActive = true
|
||||
|
||||
commentPlaceholderLabel.text = "Yorumunu yaz..."
|
||||
commentPlaceholderLabel.textColor = UIColor.white.withAlphaComponent(0.38)
|
||||
commentPlaceholderLabel.font = .systemFont(ofSize: 14)
|
||||
commentPlaceholderLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
submitCommentButton.setTitle("Gönder", for: .normal)
|
||||
submitCommentButton.setTitleColor(.white, for: .normal)
|
||||
submitCommentButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold)
|
||||
submitCommentButton.backgroundColor = UIColor.systemRed.withAlphaComponent(0.92)
|
||||
submitCommentButton.layer.cornerRadius = 10
|
||||
submitCommentButton.contentEdgeInsets = UIEdgeInsets(top: 10, left: 18, bottom: 10, right: 18)
|
||||
submitCommentButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
submitCommentButton.addTarget(self, action: #selector(submitCommentTapped), for: .touchUpInside)
|
||||
submitCommentButton.isEnabled = false
|
||||
submitCommentButton.alpha = 0.5
|
||||
submitCommentButton.accessibilityLabel = "Yorumu gönder"
|
||||
|
||||
composerContainer.addSubview(commentTextView)
|
||||
composerContainer.addSubview(commentPlaceholderLabel)
|
||||
composerContainer.addSubview(submitCommentButton)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
commentTextView.topAnchor.constraint(equalTo: composerContainer.topAnchor),
|
||||
commentTextView.leadingAnchor.constraint(equalTo: composerContainer.leadingAnchor),
|
||||
commentTextView.trailingAnchor.constraint(equalTo: composerContainer.trailingAnchor),
|
||||
|
||||
commentPlaceholderLabel.leadingAnchor.constraint(equalTo: commentTextView.leadingAnchor, constant: 14),
|
||||
commentPlaceholderLabel.topAnchor.constraint(equalTo: commentTextView.topAnchor, constant: 12),
|
||||
|
||||
submitCommentButton.topAnchor.constraint(equalTo: commentTextView.bottomAnchor, constant: 8),
|
||||
submitCommentButton.trailingAnchor.constraint(equalTo: composerContainer.trailingAnchor, constant: -10),
|
||||
submitCommentButton.bottomAnchor.constraint(equalTo: composerContainer.bottomAnchor, constant: -10)
|
||||
])
|
||||
|
||||
stack.addArrangedSubview(title)
|
||||
stack.addArrangedSubview(commentsListStack)
|
||||
stack.addArrangedSubview(composerContainer)
|
||||
card.addSubview(stack)
|
||||
pinCardStack(stack, in: card)
|
||||
contentStack.addArrangedSubview(card)
|
||||
}
|
||||
|
||||
private func pinCardStack(_ stack: UIStackView, in card: UIView) {
|
||||
NSLayoutConstraint.activate([
|
||||
stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
|
||||
stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
|
||||
stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
|
||||
stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -14)
|
||||
])
|
||||
}
|
||||
|
||||
private func makeSectionCard() -> UIView {
|
||||
let card = UIView()
|
||||
card.backgroundColor = UIColor.white.withAlphaComponent(0.05)
|
||||
card.layer.cornerRadius = 16
|
||||
card.layer.borderWidth = 1
|
||||
card.layer.borderColor = UIColor.white.withAlphaComponent(0.07).cgColor
|
||||
return card
|
||||
}
|
||||
|
||||
private func makeCardStack() -> UIStackView {
|
||||
let stack = UIStackView()
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 12
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
return stack
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func apply(_ state: ViewState) {
|
||||
switch state {
|
||||
case .loading:
|
||||
overlayView.isHidden = false
|
||||
scrollView.isHidden = true
|
||||
spinner.startAnimating()
|
||||
overlayLabel.text = "İçerik analiz ediliyor..."
|
||||
|
||||
case .success(let info):
|
||||
if info.provider == "netflix" {
|
||||
providerBadge.text = " NETFLIX "
|
||||
providerBadge.backgroundColor = UIColor(red: 0.90, green: 0.11, blue: 0.15, alpha: 0.95)
|
||||
} else if info.provider == "primevideo" {
|
||||
providerBadge.text = " PRIME VIDEO "
|
||||
providerBadge.backgroundColor = UIColor(red: 0.05, green: 0.62, blue: 0.90, alpha: 0.95)
|
||||
} else {
|
||||
providerBadge.text = " \(info.provider.uppercased()) "
|
||||
providerBadge.backgroundColor = UIColor.white.withAlphaComponent(0.20)
|
||||
}
|
||||
|
||||
heroTitleLabel.text = info.title
|
||||
var parts: [String] = []
|
||||
if let year = info.year { parts.append("\(year)") }
|
||||
parts.append(info.type == "movie" ? "Film" : "Dizi")
|
||||
if let season = info.currentSeason { parts.append("Sezon \(season)") }
|
||||
heroMetaLabel.text = parts.joined(separator: " • ")
|
||||
|
||||
genreStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
if info.genres.isEmpty {
|
||||
genreScroll.isHidden = true
|
||||
} else {
|
||||
genreScroll.isHidden = false
|
||||
info.genres.forEach { genreStack.addArrangedSubview(makeChip($0)) }
|
||||
}
|
||||
|
||||
if let plot = info.plot, !plot.isEmpty {
|
||||
plotLabel.text = plot
|
||||
plotLabel.isHidden = false
|
||||
} else {
|
||||
plotLabel.text = nil
|
||||
plotLabel.isHidden = true
|
||||
}
|
||||
|
||||
if info.cast.isEmpty {
|
||||
castLabel.text = nil
|
||||
castLabel.isHidden = true
|
||||
} else {
|
||||
castLabel.text = "Oyuncular: \(info.cast.prefix(7).joined(separator: ", "))"
|
||||
castLabel.isHidden = false
|
||||
}
|
||||
|
||||
renderComments()
|
||||
|
||||
if let urlString = info.backdrop, let imageURL = URL(string: urlString) {
|
||||
Task {
|
||||
guard let (data, _) = try? await URLSession.shared.data(from: imageURL),
|
||||
let image = UIImage(data: data) else { return }
|
||||
await MainActor.run {
|
||||
self.backdropImageView.image = image
|
||||
UIView.animate(withDuration: 0.35) {
|
||||
self.backdropImageView.alpha = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
backdropImageView.image = nil
|
||||
backdropImageView.alpha = 0
|
||||
}
|
||||
|
||||
scrollView.isHidden = false
|
||||
spinner.stopAnimating()
|
||||
UIView.animate(withDuration: 0.20, animations: {
|
||||
self.overlayView.alpha = 0
|
||||
}, completion: { _ in
|
||||
self.overlayView.isHidden = true
|
||||
self.overlayView.alpha = 1
|
||||
})
|
||||
|
||||
case .error(let message):
|
||||
overlayView.isHidden = false
|
||||
scrollView.isHidden = true
|
||||
spinner.stopAnimating()
|
||||
overlayLabel.text = message
|
||||
}
|
||||
}
|
||||
|
||||
private func makeChip(_ text: String) -> UIView {
|
||||
var cfg = UIButton.Configuration.filled()
|
||||
cfg.title = text
|
||||
cfg.baseForegroundColor = .white
|
||||
cfg.baseBackgroundColor = UIColor.white.withAlphaComponent(0.10)
|
||||
cfg.cornerStyle = .capsule
|
||||
cfg.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)
|
||||
cfg.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attrs in
|
||||
var attrs = attrs
|
||||
attrs.font = UIFont.systemFont(ofSize: 12, weight: .semibold)
|
||||
return attrs
|
||||
}
|
||||
let button = UIButton(configuration: cfg)
|
||||
button.isUserInteractionEnabled = false
|
||||
return button
|
||||
}
|
||||
|
||||
@objc private func starTapped(_ sender: UIButton, forEvent event: UIEvent?) {
|
||||
let touchLocation = event?.allTouches?.first?.location(in: sender) ?? CGPoint(x: sender.bounds.midX, y: sender.bounds.midY)
|
||||
let isLeftHalf = touchLocation.x < sender.bounds.midX
|
||||
let rating = Double(sender.tag - 1) + (isLeftHalf ? 0.5 : 1.0)
|
||||
updateRating(to: rating, animatedFrom: sender, withHaptic: true)
|
||||
}
|
||||
|
||||
@objc private func handleStarPan(_ gesture: UIPanGestureRecognizer) {
|
||||
let point = gesture.location(in: starsRow)
|
||||
guard starsRow.bounds.width > 0 else { return }
|
||||
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
hoverHaptic.prepare()
|
||||
fallthrough
|
||||
case .changed:
|
||||
let clampedX = min(max(point.x, 0), starsRow.bounds.width - 0.001)
|
||||
let ratio = clampedX / starsRow.bounds.width
|
||||
let starCount = Double(max(starButtons.count, 1))
|
||||
let rawValue = ratio * starCount
|
||||
let halfStepped = max(0.5, min(starCount, (rawValue * 2).rounded(.up) / 2))
|
||||
let value = halfStepped
|
||||
updateRating(to: value, animatedFrom: nil, withHaptic: true)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func updateRating(to newValue: Double, animatedFrom sourceButton: UIButton?, withHaptic: Bool) {
|
||||
let maxRating = Double(starButtons.count)
|
||||
let clamped = min(max(newValue, 0.5), maxRating)
|
||||
guard abs(clamped - selectedRating) > 0.001 else { return }
|
||||
selectedRating = clamped
|
||||
refreshStars(animatedFrom: sourceButton)
|
||||
if withHaptic {
|
||||
hoverHaptic.impactOccurred(intensity: 1.0)
|
||||
hoverHaptic.prepare()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshStars(animatedFrom sourceButton: UIButton? = nil) {
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 30, weight: .medium)
|
||||
for button in starButtons {
|
||||
let buttonValue = Double(button.tag)
|
||||
let imageName: String
|
||||
if selectedRating >= buttonValue {
|
||||
imageName = "star.fill"
|
||||
} else if selectedRating >= (buttonValue - 0.5) {
|
||||
imageName = "star.leadinghalf.filled"
|
||||
} else {
|
||||
imageName = "star"
|
||||
}
|
||||
|
||||
button.setImage(UIImage(systemName: imageName, withConfiguration: config), for: .normal)
|
||||
button.tintColor = imageName == "star" ? UIColor.white.withAlphaComponent(0.18) : UIColor(red: 0.96, green: 0.74, blue: 0.20, alpha: 1.0)
|
||||
|
||||
let isActive = (selectedRating >= (buttonValue - 0.5))
|
||||
if isActive && sourceButton === button {
|
||||
UIView.animate(withDuration: 0.10, animations: {
|
||||
button.transform = CGAffineTransform(scaleX: 1.18, y: 1.18)
|
||||
}, completion: { _ in
|
||||
UIView.animate(withDuration: 0.10) {
|
||||
button.transform = .identity
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupOverlay() {
|
||||
overlayView.backgroundColor = UIColor(red: 0.04, green: 0.04, blue: 0.06, alpha: 0.96)
|
||||
overlayView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(overlayView)
|
||||
|
||||
spinner.color = .white
|
||||
spinner.translatesAutoresizingMaskIntoConstraints = false
|
||||
spinner.hidesWhenStopped = true
|
||||
overlayView.addSubview(spinner)
|
||||
|
||||
overlayLabel.font = .systemFont(ofSize: 15, weight: .medium)
|
||||
overlayLabel.textColor = UIColor.white.withAlphaComponent(0.82)
|
||||
overlayLabel.textAlignment = .center
|
||||
overlayLabel.numberOfLines = 0
|
||||
overlayLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
overlayView.addSubview(overlayLabel)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
overlayView.topAnchor.constraint(equalTo: headerView.bottomAnchor),
|
||||
overlayView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
overlayView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
overlayView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
|
||||
spinner.centerXAnchor.constraint(equalTo: overlayView.centerXAnchor),
|
||||
spinner.centerYAnchor.constraint(equalTo: overlayView.centerYAnchor, constant: -18),
|
||||
|
||||
overlayLabel.topAnchor.constraint(equalTo: spinner.bottomAnchor, constant: 12),
|
||||
overlayLabel.leadingAnchor.constraint(equalTo: overlayView.leadingAnchor, constant: 24),
|
||||
overlayLabel.trailingAnchor.constraint(equalTo: overlayView.trailingAnchor, constant: -24)
|
||||
])
|
||||
|
||||
spinner.startAnimating()
|
||||
overlayLabel.text = "İçerik analiz ediliyor..."
|
||||
}
|
||||
|
||||
private func renderComments() {
|
||||
commentsListStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
guard !comments.isEmpty else {
|
||||
let emptyState = UILabel()
|
||||
emptyState.text = "Henüz yorum yok. İlk yorumu sen yaz."
|
||||
emptyState.textColor = UIColor.white.withAlphaComponent(0.55)
|
||||
emptyState.font = .systemFont(ofSize: 13, weight: .medium)
|
||||
emptyState.numberOfLines = 0
|
||||
commentsListStack.addArrangedSubview(emptyState)
|
||||
return
|
||||
}
|
||||
for item in comments {
|
||||
commentsListStack.addArrangedSubview(makeCommentBubble(item))
|
||||
}
|
||||
}
|
||||
|
||||
private func makeCommentBubble(_ item: CommentItem) -> UIView {
|
||||
let bubble = UIView()
|
||||
bubble.backgroundColor = UIColor.white.withAlphaComponent(0.07)
|
||||
bubble.layer.cornerRadius = 12
|
||||
bubble.layer.borderWidth = 1
|
||||
bubble.layer.borderColor = UIColor.white.withAlphaComponent(0.06).cgColor
|
||||
|
||||
let userLabel = UILabel()
|
||||
userLabel.font = .systemFont(ofSize: 12, weight: .semibold)
|
||||
userLabel.textColor = .white
|
||||
userLabel.text = "@\(item.user)"
|
||||
userLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let bodyLabel = UILabel()
|
||||
bodyLabel.font = .systemFont(ofSize: 13, weight: .regular)
|
||||
bodyLabel.textColor = UIColor.white.withAlphaComponent(0.83)
|
||||
bodyLabel.numberOfLines = 0
|
||||
bodyLabel.text = item.body
|
||||
bodyLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let timeLabel = UILabel()
|
||||
timeLabel.font = .systemFont(ofSize: 11, weight: .regular)
|
||||
timeLabel.textColor = UIColor.white.withAlphaComponent(0.50)
|
||||
timeLabel.text = item.time
|
||||
timeLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
bubble.addSubview(userLabel)
|
||||
bubble.addSubview(bodyLabel)
|
||||
bubble.addSubview(timeLabel)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
userLabel.topAnchor.constraint(equalTo: bubble.topAnchor, constant: 10),
|
||||
userLabel.leadingAnchor.constraint(equalTo: bubble.leadingAnchor, constant: 12),
|
||||
userLabel.trailingAnchor.constraint(equalTo: bubble.trailingAnchor, constant: -12),
|
||||
|
||||
bodyLabel.topAnchor.constraint(equalTo: userLabel.bottomAnchor, constant: 6),
|
||||
bodyLabel.leadingAnchor.constraint(equalTo: bubble.leadingAnchor, constant: 12),
|
||||
bodyLabel.trailingAnchor.constraint(equalTo: bubble.trailingAnchor, constant: -12),
|
||||
|
||||
timeLabel.topAnchor.constraint(equalTo: bodyLabel.bottomAnchor, constant: 8),
|
||||
timeLabel.leadingAnchor.constraint(equalTo: bubble.leadingAnchor, constant: 12),
|
||||
timeLabel.trailingAnchor.constraint(equalTo: bubble.trailingAnchor, constant: -12),
|
||||
timeLabel.bottomAnchor.constraint(equalTo: bubble.bottomAnchor, constant: -10)
|
||||
])
|
||||
|
||||
return bubble
|
||||
}
|
||||
|
||||
@objc private func submitCommentTapped() {
|
||||
let text = commentTextView.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
|
||||
comments.insert(CommentItem(user: "sen", body: text, time: "Şimdi"), at: 0)
|
||||
commentTextView.text = ""
|
||||
textViewDidChange(commentTextView)
|
||||
renderComments()
|
||||
hoverHaptic.impactOccurred(intensity: 0.35)
|
||||
hoverHaptic.prepare()
|
||||
|
||||
submitCommentButton.setTitle("Gönderildi", for: .normal)
|
||||
submitCommentButton.backgroundColor = UIColor.systemGreen.withAlphaComponent(0.9)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) {
|
||||
self.submitCommentButton.setTitle("Gönder", for: .normal)
|
||||
self.submitCommentButton.backgroundColor = UIColor.systemRed.withAlphaComponent(0.92)
|
||||
}
|
||||
}
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
commentPlaceholderLabel.isHidden = !textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
let hasText = !textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
submitCommentButton.alpha = hasText ? 1.0 : 0.5
|
||||
submitCommentButton.isEnabled = hasText
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleIncomingShare() async {
|
||||
apply(.loading)
|
||||
|
||||
let items = extensionContext?.inputItems.compactMap { $0 as? NSExtensionItem } ?? []
|
||||
let providers = items.flatMap { $0.attachments ?? [] }
|
||||
guard !providers.isEmpty else {
|
||||
apply(.error("Paylaşılan içerik okunamadı."))
|
||||
return
|
||||
}
|
||||
|
||||
for provider in providers {
|
||||
if let extracted = await extractURL(from: provider), isSupportedStreamingURL(extracted) {
|
||||
let normalized = normalizeURL(extracted)
|
||||
SharedPayloadStore.saveIncomingURL(normalized.absoluteString)
|
||||
do {
|
||||
let info = try await APIClient.shared.getInfo(url: normalized.absoluteString)
|
||||
apply(.success(info))
|
||||
} catch {
|
||||
apply(.error("Bilgiler alınamadı.\n\(error.localizedDescription)"))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
apply(.error("Geçerli bir Netflix/Prime Video linki bulunamadı."))
|
||||
}
|
||||
|
||||
@objc private func closeTapped() {
|
||||
extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||
}
|
||||
|
||||
@objc private func handleDismissPan(_ gesture: UIPanGestureRecognizer) {
|
||||
let translation = gesture.translation(in: view)
|
||||
let velocity = gesture.velocity(in: view)
|
||||
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
dismissPanStartTransform = headerView.transform
|
||||
case .changed:
|
||||
let downY = max(0, translation.y)
|
||||
let progress = min(downY / 140.0, 1.0)
|
||||
headerView.transform = dismissPanStartTransform.translatedBy(x: 0, y: downY * 0.3)
|
||||
headerView.alpha = 1.0 - (progress * 0.25)
|
||||
case .ended, .cancelled, .failed:
|
||||
let shouldDismiss = translation.y > 90 || velocity.y > 900
|
||||
if shouldDismiss {
|
||||
closeTapped()
|
||||
return
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: 0.18) {
|
||||
self.headerView.transform = .identity
|
||||
self.headerView.alpha = 1.0
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func extractURL(from provider: NSItemProvider) async -> URL? {
|
||||
if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
|
||||
return await withCheckedContinuation { continuation in
|
||||
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in
|
||||
if let url = item as? URL {
|
||||
continuation.resume(returning: url)
|
||||
return
|
||||
}
|
||||
if let raw = item as? String {
|
||||
continuation.resume(returning: Self.firstURL(in: raw))
|
||||
return
|
||||
}
|
||||
continuation.resume(returning: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if provider.hasItemConformingToTypeIdentifier(UTType.text.identifier) {
|
||||
return await withCheckedContinuation { continuation in
|
||||
provider.loadItem(forTypeIdentifier: UTType.text.identifier, options: nil) { item, _ in
|
||||
if let raw = item as? String, let url = Self.firstURL(in: raw) {
|
||||
continuation.resume(returning: url)
|
||||
return
|
||||
}
|
||||
continuation.resume(returning: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func normalizeURL(_ url: URL) -> URL {
|
||||
let host = url.host?.lowercased() ?? ""
|
||||
guard host == "app.primevideo.com" else { return url }
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
if let gti = components?.queryItems?.first(where: { $0.name == "gti" }) {
|
||||
components?.queryItems = [gti]
|
||||
} else {
|
||||
components?.queryItems = nil
|
||||
}
|
||||
return components?.url ?? url
|
||||
}
|
||||
|
||||
private func isSupportedStreamingURL(_ url: URL) -> Bool {
|
||||
let host = url.host?.lowercased() ?? ""
|
||||
let netflixHosts = ["www.netflix.com", "netflix.com", "www.netflix.com.tr", "netflix.com.tr"]
|
||||
let primeHosts = ["www.primevideo.com", "primevideo.com", "www.amazon.com", "amazon.com", "app.primevideo.com"]
|
||||
|
||||
guard netflixHosts.contains(host) || primeHosts.contains(host) else { return false }
|
||||
let path = url.path.lowercased()
|
||||
if path.contains("/title/") || path.contains("/watch/") || path.contains("/detail/") {
|
||||
return true
|
||||
}
|
||||
return !path.isEmpty && path != "/"
|
||||
}
|
||||
|
||||
private static func firstURL(in raw: String) -> URL? {
|
||||
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let url = URL(string: text), url.scheme?.isEmpty == false {
|
||||
return url
|
||||
}
|
||||
guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
|
||||
return nil
|
||||
}
|
||||
return detector.firstMatch(in: text, options: [], range: NSRange(text.startIndex..., in: text))?.url
|
||||
}
|
||||
}
|
||||
65
ios/Ratebubble/Shared/APIClient.swift
Normal file
65
ios/Ratebubble/Shared/APIClient.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
|
||||
enum APIClientError: LocalizedError {
|
||||
case invalidBaseURL
|
||||
case invalidResponse
|
||||
case server(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidBaseURL:
|
||||
return "API_BASE_URL geçersiz."
|
||||
case .invalidResponse:
|
||||
return "Sunucudan geçerli yanıt alınamadı."
|
||||
case .server(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class APIClient {
|
||||
static let shared = APIClient()
|
||||
|
||||
private init() {}
|
||||
|
||||
private var baseURL: URL? {
|
||||
guard let raw = Bundle.main.object(forInfoDictionaryKey: "API_BASE_URL") as? String else {
|
||||
return nil
|
||||
}
|
||||
return URL(string: raw)
|
||||
}
|
||||
|
||||
private var mobileAPIKey: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "MOBILE_API_KEY") as? String
|
||||
?? "mobile-dev-key-change-me"
|
||||
}
|
||||
|
||||
func getInfo(url: String) async throws -> GetInfoResponse {
|
||||
guard let baseURL else { throw APIClientError.invalidBaseURL }
|
||||
|
||||
var request = URLRequest(url: baseURL.appending(path: "/api/getinfo"))
|
||||
request.httpMethod = "POST"
|
||||
request.timeoutInterval = 20
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue(mobileAPIKey, forHTTPHeaderField: "X-API-Key")
|
||||
request.httpBody = try JSONEncoder().encode(GetInfoRequest(url: url))
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw APIClientError.invalidResponse
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
let envelope = try decoder.decode(APIEnvelope<GetInfoResponse>.self, from: data)
|
||||
|
||||
if (200..<300).contains(http.statusCode), envelope.success, let payload = envelope.data {
|
||||
return payload
|
||||
}
|
||||
|
||||
if let errorMessage = envelope.error?.message {
|
||||
throw APIClientError.server(errorMessage)
|
||||
}
|
||||
|
||||
throw APIClientError.server("İstek başarısız oldu (\(http.statusCode)).")
|
||||
}
|
||||
}
|
||||
29
ios/Ratebubble/Shared/Models.swift
Normal file
29
ios/Ratebubble/Shared/Models.swift
Normal file
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
struct GetInfoRequest: Encodable {
|
||||
let url: String
|
||||
}
|
||||
|
||||
struct APIErrorPayload: Decodable, Error {
|
||||
let code: String
|
||||
let message: String
|
||||
}
|
||||
|
||||
struct APIEnvelope<T: Decodable>: Decodable {
|
||||
let success: Bool
|
||||
let data: T?
|
||||
let error: APIErrorPayload?
|
||||
}
|
||||
|
||||
struct GetInfoResponse: Decodable {
|
||||
let provider: String
|
||||
let title: String
|
||||
let year: Int?
|
||||
let plot: String?
|
||||
let ageRating: String?
|
||||
let type: String
|
||||
let genres: [String]
|
||||
let cast: [String]
|
||||
let backdrop: String?
|
||||
let currentSeason: Int?
|
||||
}
|
||||
31
ios/Ratebubble/Shared/SharedPayloadStore.swift
Normal file
31
ios/Ratebubble/Shared/SharedPayloadStore.swift
Normal file
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
enum SharedConfig {
|
||||
static var appGroupID: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "APP_GROUP_ID") as? String
|
||||
?? "group.net.wisecolt.ratebubble"
|
||||
}
|
||||
|
||||
static var appURLScheme: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "APP_URL_SCHEME") as? String
|
||||
?? "ratebubble"
|
||||
}
|
||||
}
|
||||
|
||||
enum SharedKeys {
|
||||
static let incomingURL = "incoming_shared_url"
|
||||
}
|
||||
|
||||
enum SharedPayloadStore {
|
||||
static func saveIncomingURL(_ url: String) {
|
||||
guard let defaults = UserDefaults(suiteName: SharedConfig.appGroupID) else { return }
|
||||
defaults.set(url, forKey: SharedKeys.incomingURL)
|
||||
defaults.synchronize()
|
||||
}
|
||||
|
||||
static func consumeIncomingURL() -> String? {
|
||||
guard let defaults = UserDefaults(suiteName: SharedConfig.appGroupID) else { return nil }
|
||||
defer { defaults.removeObject(forKey: SharedKeys.incomingURL) }
|
||||
return defaults.string(forKey: SharedKeys.incomingURL)
|
||||
}
|
||||
}
|
||||
58
ios/project.yml
Normal file
58
ios/project.yml
Normal file
@@ -0,0 +1,58 @@
|
||||
name: Ratebubble
|
||||
options:
|
||||
deploymentTarget:
|
||||
iOS: 16.0
|
||||
configs:
|
||||
Debug: debug
|
||||
Release: release
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: net.wisecolt.ratebubble
|
||||
SWIFT_VERSION: 5.9
|
||||
targets:
|
||||
Ratebubble:
|
||||
type: application
|
||||
platform: iOS
|
||||
deploymentTarget: "16.0"
|
||||
sources:
|
||||
- path: Ratebubble/App
|
||||
- path: Ratebubble/Shared
|
||||
configFiles:
|
||||
Debug: Ratebubble/Resources/Config.xcconfig
|
||||
Release: Ratebubble/Resources/Config.xcconfig
|
||||
info:
|
||||
path: Ratebubble/Resources/Ratebubble-Info.plist
|
||||
properties:
|
||||
UILaunchScreen: {}
|
||||
CFBundleURLTypes:
|
||||
- CFBundleTypeRole: Editor
|
||||
CFBundleURLSchemes:
|
||||
- $(APP_URL_SCHEME)
|
||||
entitlements:
|
||||
path: Ratebubble/Resources/Ratebubble.entitlements
|
||||
properties:
|
||||
com.apple.security.application-groups:
|
||||
- $(APP_GROUP_ID)
|
||||
dependencies:
|
||||
- target: RatebubbleShare
|
||||
|
||||
RatebubbleShare:
|
||||
type: app-extension
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: net.wisecolt.ratebubble.share
|
||||
platform: iOS
|
||||
deploymentTarget: "16.0"
|
||||
sources:
|
||||
- path: Ratebubble/ShareExtension
|
||||
- path: Ratebubble/Shared
|
||||
configFiles:
|
||||
Debug: Ratebubble/Resources/Config.xcconfig
|
||||
Release: Ratebubble/Resources/Config.xcconfig
|
||||
info:
|
||||
path: Ratebubble/Resources/RatebubbleShare-Info.plist
|
||||
entitlements:
|
||||
path: Ratebubble/Resources/RatebubbleShare.entitlements
|
||||
properties:
|
||||
com.apple.security.application-groups:
|
||||
- $(APP_GROUP_ID)
|
||||
13
ios/scripts/bootstrap.sh
Executable file
13
ios/scripts/bootstrap.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if ! command -v xcodegen >/dev/null 2>&1; then
|
||||
echo "xcodegen bulunamadı. Kurulum: brew install xcodegen"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
xcodegen generate
|
||||
|
||||
echo "Tamamlandı: ios/Ratebubble.xcodeproj oluşturuldu."
|
||||
@@ -19,6 +19,7 @@ const tmdbSearchSchema = z.object({
|
||||
type: z.enum(['movie', 'tv', 'multi']).optional(),
|
||||
seasonYear: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(),
|
||||
seasonNumber: z.coerce.number().int().min(1).max(100).optional(),
|
||||
cast: z.string().trim().min(1).max(120).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -59,7 +60,7 @@ router.post(
|
||||
return;
|
||||
}
|
||||
|
||||
const { query, year, type, seasonYear, seasonNumber } = result.data;
|
||||
const { query, year, type, seasonYear, seasonNumber, cast } = result.data;
|
||||
|
||||
try {
|
||||
const searchResult = await TmdbService.search({
|
||||
@@ -68,6 +69,7 @@ router.post(
|
||||
type: type || 'multi',
|
||||
seasonYear,
|
||||
seasonNumber,
|
||||
cast,
|
||||
});
|
||||
|
||||
const response: ApiResponse<TmdbSearchResponse> = {
|
||||
@@ -106,6 +108,7 @@ router.post(
|
||||
const movieSearchSchema = z.object({
|
||||
query: z.string().trim().min(1).max(200),
|
||||
year: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(),
|
||||
cast: z.string().trim().min(1).max(120).optional(),
|
||||
});
|
||||
|
||||
const result = movieSearchSchema.safeParse(req.body);
|
||||
@@ -128,10 +131,10 @@ router.post(
|
||||
return;
|
||||
}
|
||||
|
||||
const { query, year } = result.data;
|
||||
const { query, year, cast } = result.data;
|
||||
|
||||
try {
|
||||
const searchResult = await TmdbService.searchMovies(query, year);
|
||||
const searchResult = await TmdbService.searchMovies(query, year, cast);
|
||||
|
||||
const response: ApiResponse<TmdbSearchResponse> = {
|
||||
success: true,
|
||||
@@ -171,6 +174,7 @@ router.post(
|
||||
year: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(),
|
||||
seasonYear: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(),
|
||||
seasonNumber: z.coerce.number().int().min(1).max(100).optional(),
|
||||
cast: z.string().trim().min(1).max(120).optional(),
|
||||
});
|
||||
|
||||
const result = tvSearchSchema.safeParse(req.body);
|
||||
@@ -193,10 +197,10 @@ router.post(
|
||||
return;
|
||||
}
|
||||
|
||||
const { query, year, seasonYear, seasonNumber } = result.data;
|
||||
const { query, year, seasonYear, seasonNumber, cast } = result.data;
|
||||
|
||||
try {
|
||||
const searchResult = await TmdbService.searchTv(query, year, seasonNumber, seasonYear);
|
||||
const searchResult = await TmdbService.searchTv(query, year, seasonNumber, seasonYear, cast);
|
||||
|
||||
const response: ApiResponse<TmdbSearchResponse> = {
|
||||
success: true,
|
||||
|
||||
@@ -52,11 +52,85 @@ const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
|
||||
* TMDB Image Base URL
|
||||
*/
|
||||
const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p/original';
|
||||
const CAST_FILTER_CANDIDATE_LIMIT = 5;
|
||||
|
||||
/**
|
||||
* TMDB Service for movie/TV show search
|
||||
*/
|
||||
export class TmdbService {
|
||||
private static normalizeCastName(name: string): string {
|
||||
return name
|
||||
.normalize('NFKC')
|
||||
.trim()
|
||||
.replace(/[-‐‑‒–—―'’`.]/g, ' ')
|
||||
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.toLocaleLowerCase('tr')
|
||||
.replace(/[ıİ]/g, 'i')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '');
|
||||
}
|
||||
|
||||
private static isCastNameMatch(candidate: string, requested: string): boolean {
|
||||
const normalizedCandidate = this.normalizeCastName(candidate);
|
||||
const normalizedRequested = this.normalizeCastName(requested);
|
||||
|
||||
if (!normalizedCandidate || !normalizedRequested) return false;
|
||||
if (normalizedCandidate === normalizedRequested) return true;
|
||||
|
||||
// Secondary strictness: allow spacing variants like "eun jin" vs "eunjin"
|
||||
const compactCandidate = normalizedCandidate.replace(/\s+/g, '');
|
||||
const compactRequested = normalizedRequested.replace(/\s+/g, '');
|
||||
return compactCandidate === compactRequested;
|
||||
}
|
||||
|
||||
private static async getCreditsCastNames(
|
||||
mediaType: 'movie' | 'tv',
|
||||
tmdbId: number
|
||||
): Promise<string[]> {
|
||||
const url = `${TMDB_BASE_URL}/${mediaType}/${tmdbId}/credits`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json() as { cast?: Array<{ name?: string | null }> };
|
||||
const castNames = Array.isArray(data.cast) ? data.cast : [];
|
||||
|
||||
return castNames
|
||||
.map((item) => (typeof item?.name === 'string' ? item.name : ''))
|
||||
.filter((name) => name.length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static async filterResultsByCast(
|
||||
results: TmdbSearchResult[],
|
||||
castName: string
|
||||
): Promise<TmdbSearchResult[]> {
|
||||
const normalizedRequested = this.normalizeCastName(castName);
|
||||
if (!normalizedRequested) return [];
|
||||
|
||||
const candidates = results.slice(0, CAST_FILTER_CANDIDATE_LIMIT);
|
||||
const matched: TmdbSearchResult[] = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const castNames = await this.getCreditsCastNames(candidate.type, candidate.id);
|
||||
const hasMatch = castNames.some((name) => this.isCastNameMatch(name, castName));
|
||||
if (hasMatch) {
|
||||
matched.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return matched;
|
||||
}
|
||||
/**
|
||||
* Get common headers for TMDB API requests
|
||||
*/
|
||||
@@ -265,7 +339,11 @@ export class TmdbService {
|
||||
/**
|
||||
* Search for movies
|
||||
*/
|
||||
static async searchMovies(query: string, year?: number): Promise<TmdbSearchResponse> {
|
||||
static async searchMovies(
|
||||
query: string,
|
||||
year?: number,
|
||||
cast?: string
|
||||
): Promise<TmdbSearchResponse> {
|
||||
const params = new URLSearchParams({
|
||||
query,
|
||||
language: 'tr-TR',
|
||||
@@ -292,15 +370,19 @@ export class TmdbService {
|
||||
|
||||
const data: TmdbRawResponse = await response.json();
|
||||
|
||||
const results = data.results
|
||||
const normalizedResults = data.results
|
||||
.map((r) => this.normalizeMovie(r as TmdbRawMovie))
|
||||
.filter((r): r is TmdbSearchResult => r !== null);
|
||||
|
||||
const results = cast
|
||||
? await this.filterResultsByCast(normalizedResults, cast)
|
||||
: normalizedResults;
|
||||
|
||||
return {
|
||||
page: data.page,
|
||||
results,
|
||||
totalPages: data.total_pages,
|
||||
totalResults: data.total_results,
|
||||
totalResults: results.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -315,7 +397,8 @@ export class TmdbService {
|
||||
query: string,
|
||||
year?: number,
|
||||
seasonNumber?: number,
|
||||
seasonYear?: number
|
||||
seasonYear?: number,
|
||||
cast?: string
|
||||
): Promise<TmdbSearchResponse> {
|
||||
const params = new URLSearchParams({
|
||||
query,
|
||||
@@ -354,6 +437,10 @@ export class TmdbService {
|
||||
results = await this.filterAndEnrichTvResultsBySeason(results, seasonNumber, seasonYear);
|
||||
}
|
||||
|
||||
if (cast) {
|
||||
results = await this.filterResultsByCast(results, cast);
|
||||
}
|
||||
|
||||
return {
|
||||
page: data.page,
|
||||
results,
|
||||
@@ -365,7 +452,11 @@ export class TmdbService {
|
||||
/**
|
||||
* Multi search (movies, TV shows, and people)
|
||||
*/
|
||||
static async searchMulti(query: string, year?: number): Promise<TmdbSearchResponse> {
|
||||
static async searchMulti(
|
||||
query: string,
|
||||
year?: number,
|
||||
cast?: string
|
||||
): Promise<TmdbSearchResponse> {
|
||||
const params = new URLSearchParams({
|
||||
query,
|
||||
language: 'tr-TR',
|
||||
@@ -393,16 +484,20 @@ export class TmdbService {
|
||||
const data: TmdbRawResponse = await response.json();
|
||||
|
||||
// Filter out person results and normalize
|
||||
const results = data.results
|
||||
const normalizedResults = data.results
|
||||
.filter((r) => r.media_type !== 'person')
|
||||
.map((r) => this.normalizeResult(r))
|
||||
.filter((r): r is TmdbSearchResult => r !== null);
|
||||
|
||||
const results = cast
|
||||
? await this.filterResultsByCast(normalizedResults, cast)
|
||||
: normalizedResults;
|
||||
|
||||
return {
|
||||
page: data.page,
|
||||
results,
|
||||
totalPages: data.total_pages,
|
||||
totalResults: data.total_results,
|
||||
totalResults: results.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -411,17 +506,17 @@ export class TmdbService {
|
||||
* @param request Search request with query, year, type, and optional season parameters
|
||||
*/
|
||||
static async search(request: TmdbSearchRequest): Promise<TmdbSearchResponse> {
|
||||
const { query, year, type = 'multi', seasonYear, seasonNumber } = request;
|
||||
const { query, year, type = 'multi', seasonYear, seasonNumber, cast } = request;
|
||||
|
||||
switch (type) {
|
||||
case 'movie':
|
||||
return this.searchMovies(query, year);
|
||||
return this.searchMovies(query, year, cast);
|
||||
case 'tv':
|
||||
// For TV shows, use season parameters if provided
|
||||
return this.searchTv(query, year, seasonNumber, seasonYear);
|
||||
return this.searchTv(query, year, seasonNumber, seasonYear, cast);
|
||||
case 'multi':
|
||||
default:
|
||||
return this.searchMulti(query, year);
|
||||
return this.searchMulti(query, year, cast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +219,7 @@ export interface TmdbSearchRequest {
|
||||
type?: 'movie' | 'tv' | 'multi';
|
||||
seasonYear?: number;
|
||||
seasonNumber?: number;
|
||||
cast?: string;
|
||||
}
|
||||
|
||||
export interface TmdbSearchResult {
|
||||
|
||||
@@ -10,6 +10,7 @@ const NETFLIX_HOSTS = new Set([
|
||||
const PRIME_HOSTS = new Set([
|
||||
'www.primevideo.com',
|
||||
'primevideo.com',
|
||||
'app.primevideo.com', // iOS uygulama paylaşım linkleri
|
||||
]);
|
||||
|
||||
export interface ParsedContentUrl {
|
||||
@@ -31,11 +32,20 @@ export function parseSupportedContentUrl(rawUrl: string): ParsedContentUrl | nul
|
||||
}
|
||||
|
||||
if (PRIME_HOSTS.has(hostname)) {
|
||||
const detailIdMatch = parsedUrl.pathname.match(/\/detail\/([A-Za-z0-9]+)/);
|
||||
if (!detailIdMatch) return null;
|
||||
const id = detailIdMatch[1];
|
||||
if (!id) return null;
|
||||
return { provider: 'primevideo', id };
|
||||
// Standart web URL: /detail/TITLE/ID veya /-/tr/detail/ID
|
||||
// GTI formatı nokta ve tire içerebilir: amzn1.dv.gti.UUID
|
||||
const detailIdMatch = parsedUrl.pathname.match(/\/detail\/([A-Za-z0-9._-]+)/);
|
||||
if (detailIdMatch?.[1]) {
|
||||
return { provider: 'primevideo', id: detailIdMatch[1] };
|
||||
}
|
||||
|
||||
// iOS uygulama paylaşım linki: /detail?gti=amzn1.dv.gti.UUID
|
||||
if (parsedUrl.pathname === '/detail') {
|
||||
const gti = parsedUrl.searchParams.get('gti');
|
||||
if (gti) return { provider: 'primevideo', id: gti };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user