Skip to content

Commit c6f41c0

Browse files
committed
2 parents 539c047 + b9b2e9f commit c6f41c0

140 files changed

Lines changed: 16263 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitattributes

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Auto detect text files and perform LF normalization
2+
* text=auto

.github/workflows/swift.yml

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: iOS Build
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
concurrency:
10+
group: ${{ github.workflow }}-${{ github.ref }}
11+
cancel-in-progress: true
12+
13+
jobs:
14+
build:
15+
name: Build (Simulator)
16+
runs-on: macos-latest
17+
steps:
18+
- name: Checkout
19+
uses: actions/checkout@v5
20+
21+
- name: Select Xcode
22+
uses: maxim-lobanov/setup-xcode@v1
23+
with:
24+
xcode-version: latest-stable
25+
26+
- name: Resolve Xcode project and verify scheme
27+
run: |
28+
set -euo pipefail
29+
ROOT="$GITHUB_WORKSPACE"
30+
PROJ="$ROOT/VideoEditor.xcodeproj"
31+
if [[ ! -f "$PROJ/project.pbxproj" ]]; then
32+
PROJ=$(find "$ROOT" -name "VideoEditor.xcodeproj" -type d 2>/dev/null | head -1 || true)
33+
fi
34+
if [[ -z "${PROJ:-}" ]] || [[ ! -f "$PROJ/project.pbxproj" ]]; then
35+
echo "::error::VideoEditor.xcodeproj not found under $ROOT"
36+
echo "Discovered .xcodeproj paths (max depth 8):"
37+
find "$ROOT" -maxdepth 8 -name "*.xcodeproj" -type d 2>/dev/null || true
38+
echo "Repository root listing:"
39+
ls -la "$ROOT"
40+
exit 1
41+
fi
42+
echo "Using Xcode project: $PROJ"
43+
echo "XCODEPROJ=$PROJ" >> "$GITHUB_ENV"
44+
SCHEME_FILE="$PROJ/xcshareddata/xcschemes/VideoEditor.xcscheme"
45+
if [[ ! -f "$SCHEME_FILE" ]]; then
46+
echo "::error::Shared scheme missing: $SCHEME_FILE"
47+
echo "In Xcode: Product → Scheme → Manage Schemes → enable Shared for VideoEditor, then commit xcshareddata/xcschemes/."
48+
exit 1
49+
fi
50+
xcodebuild -list -project "$PROJ"
51+
52+
- name: Build
53+
run: |
54+
set -euo pipefail
55+
DERIVED_DATA="$RUNNER_TEMP/DerivedData-VideoEditor"
56+
mkdir -p "$DERIVED_DATA"
57+
xcodebuild \
58+
-project "$XCODEPROJ" \
59+
-scheme VideoEditor \
60+
-configuration Debug \
61+
-destination 'generic/platform=iOS Simulator' \
62+
-derivedDataPath "$DERIVED_DATA" \
63+
build

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.DS_Store

App/AppDelegate.swift

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
//
2+
// AppDelegate
3+
// VideoEditor
4+
// Created by Coder ACJHP on 27.03.2026.
5+
6+
7+
import UIKit
8+
import CoreData
9+
10+
/// Entry point for the process (`@main`). Owns the Core Data stack; UI flows are driven by `SceneDelegate` and scene-based lifecycle.
11+
@main
12+
class AppDelegate: UIResponder, UIApplicationDelegate {
13+
14+
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
15+
// Hook for one-time process setup (e.g. analytics, remote config). Scene-specific UI setup belongs in SceneDelegate.
16+
return true
17+
}
18+
19+
// MARK: UISceneSession Lifecycle
20+
21+
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
22+
// Tells the system which storyboard / delegate class to use for this scene. Name must match an entry in Info.plist under Application Scene Manifest.
23+
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
24+
}
25+
26+
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
27+
// Called after the user removes a window/scene from the app switcher. Tear down resources tied to those sessions only if you created any outside the scene itself.
28+
}
29+
30+
// MARK: - Core Data stack
31+
32+
/// Lazily loads the persistent store on first access so cold launch stays fast until something touches the database.
33+
lazy var persistentContainer: NSPersistentContainer = {
34+
let container = NSPersistentContainer(name: "VideoEditor")
35+
container.loadPersistentStores { _, error in
36+
if let error = error as NSError? {
37+
// Replace this with proper production-grade error handling.
38+
// fatalError is intentional here to surface misconfigured
39+
// data model issues during development.
40+
fatalError("Unresolved Core Data error: \(error), \(error.userInfo)")
41+
}
42+
}
43+
return container
44+
}()
45+
46+
// MARK: - Core Data Saving Support
47+
48+
/// Persists pending changes on the main queue context. Call from lifecycle hooks (e.g. scene background) or after batched edits.
49+
func saveContext() {
50+
let context = persistentContainer.viewContext
51+
guard context.hasChanges else { return }
52+
do {
53+
try context.save()
54+
} catch {
55+
let nserror = error as NSError
56+
fatalError("Unresolved Core Data save error: \(nserror), \(nserror.userInfo)")
57+
}
58+
}
59+
}

App/AppRouter.swift

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
//
2+
// AppRouter
3+
// VideoEditor
4+
// Created by Coder ACJHP on 27.03.2026.
5+
6+
7+
import Foundation
8+
import UIKit
9+
10+
enum Route: String, CaseIterable {
11+
case landing
12+
case editor
13+
case export
14+
case audioBottomSheet
15+
case stickerBottomSheet
16+
case textBottomSheet
17+
}
18+
19+
// MARK: - RouterDelegate Protocol
20+
// Abstracts all navigation operations behind a protocol.
21+
// ViewControllers depend on this protocol instead of AppRouter directly,
22+
// making them testable and decoupled from the concrete router implementation.
23+
@MainActor
24+
protocol RouterDelegate: AnyObject {
25+
func navigate(to route: Route, animated: Bool) // Push
26+
func present(to route: Route, animated: Bool) // Modal present
27+
func presentBottomSheet(
28+
to route: Route,
29+
config: SheetConfiguration,
30+
animated: Bool
31+
) // BottomSheet
32+
/// Presents a pre-built controller as a sheet (e.g. editor-owned `AudioBottomSheetViewController` with callbacks).
33+
func presentBottomSheet(
34+
_ viewController: UIViewController,
35+
config: SheetConfiguration,
36+
animated: Bool
37+
)
38+
func pop(animated: Bool)
39+
func dismiss(animated: Bool)
40+
func makeViewController(baseRoute route: Route) -> UIViewController // Factory
41+
/// Pushes the editor pre-loaded with a specific project. Prefer this over
42+
/// `navigate(to: .editor)` because it properly injects the domain model.
43+
func navigateToEditor(with project: EditingProject, animated: Bool)
44+
/// Pushes export with the current timeline snapshot. Prefer over `navigate(to: .export)`.
45+
func navigateToExport(with project: EditingProject, animated: Bool)
46+
/// After a successful export, pushes the preview + share screen. Back pops export and result together.
47+
func pushExportedVideoResult(fileURL: URL, animated: Bool)
48+
/// Simple modal alert (e.g. export failure) presented from the navigation stack’s top view controller.
49+
func presentAlert(title: String, message: String)
50+
}
51+
52+
@MainActor
53+
class AppRouter: RouterDelegate {
54+
55+
private let controller: UINavigationController
56+
private let thumbnailService: ThumbnailGenerating
57+
58+
init(
59+
controller: UINavigationController,
60+
thumbnailService: ThumbnailGenerating
61+
) {
62+
self.controller = controller
63+
self.thumbnailService = thumbnailService
64+
}
65+
66+
func navigate(to route: Route, animated: Bool) {
67+
let destinationVC = makeViewController(baseRoute: route)
68+
controller.pushViewController(destinationVC, animated: animated)
69+
}
70+
71+
func pop(animated: Bool) {
72+
controller.popViewController(animated: animated)
73+
}
74+
75+
func present(to route: Route, animated: Bool) {
76+
let destinationVC = makeViewController(baseRoute: route)
77+
// Wraps in a new NavigationController so the modal has its own navigation stack
78+
let nav = UINavigationController(rootViewController: destinationVC)
79+
controller.present(nav, animated: animated)
80+
}
81+
82+
func dismiss(animated: Bool) {
83+
controller.dismiss(animated: animated)
84+
}
85+
86+
func presentBottomSheet(to route: Route, config configuration: SheetConfiguration, animated: Bool) {
87+
let bottomSheet = makeViewController(baseRoute: route)
88+
bottomSheet.modalPresentationStyle = .pageSheet
89+
bottomSheet.isModalInPresentation = !configuration.isDismissable
90+
91+
if let sheet = bottomSheet.sheetPresentationController {
92+
sheet.detents = configuration.detents
93+
sheet.selectedDetentIdentifier = configuration.selectedIdentifier
94+
sheet.prefersGrabberVisible = configuration.prefersGrabber
95+
sheet.prefersScrollingExpandsWhenScrolledToEdge = configuration.prefersScrollExpand
96+
sheet.preferredCornerRadius = configuration.cornerRadius
97+
98+
if let largestUndimmed = configuration.largestUndimmedIdentifier {
99+
sheet.largestUndimmedDetentIdentifier = largestUndimmed
100+
}
101+
}
102+
103+
controller.present(bottomSheet, animated: animated, completion: nil)
104+
}
105+
106+
func presentBottomSheet(_ viewController: UIViewController, config configuration: SheetConfiguration, animated: Bool) {
107+
viewController.modalPresentationStyle = .pageSheet
108+
viewController.isModalInPresentation = !configuration.isDismissable
109+
110+
if let sheet = viewController.sheetPresentationController {
111+
sheet.detents = configuration.detents
112+
sheet.selectedDetentIdentifier = configuration.selectedIdentifier
113+
sheet.prefersGrabberVisible = configuration.prefersGrabber
114+
sheet.prefersScrollingExpandsWhenScrolledToEdge = configuration.prefersScrollExpand
115+
sheet.preferredCornerRadius = configuration.cornerRadius
116+
117+
if let largestUndimmed = configuration.largestUndimmedIdentifier {
118+
sheet.largestUndimmedDetentIdentifier = largestUndimmed
119+
}
120+
}
121+
122+
controller.present(viewController, animated: animated, completion: nil)
123+
}
124+
125+
func navigateToEditor(with project: EditingProject, animated: Bool) {
126+
let viewModel = EditorViewModel(project: project)
127+
let editorVC = EditorViewController(
128+
router: self,
129+
viewModel: viewModel,
130+
thumbnailGenerator: thumbnailService
131+
)
132+
controller.pushViewController(editorVC, animated: animated)
133+
}
134+
135+
func navigateToExport(with project: EditingProject, animated: Bool) {
136+
let exporter = PhotoLibraryMovieExporter(
137+
compositionBuilder: PreviewTimelineCompositionBuilder(),
138+
mediaPermissions: SystemMediaPermissionService()
139+
)
140+
let exportVC = ExportViewController(
141+
router: self,
142+
project: project,
143+
movieExporter: exporter
144+
)
145+
controller.pushViewController(exportVC, animated: animated)
146+
}
147+
148+
func pushExportedVideoResult(fileURL: URL, animated: Bool) {
149+
let resultVC = ExportedVideoResultViewController(fileURL: fileURL) { [weak self] in
150+
self?.popExportResultFlow(animated: animated)
151+
}
152+
controller.pushViewController(resultVC, animated: animated)
153+
}
154+
155+
func presentAlert(title: String, message: String) {
156+
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
157+
alert.addAction(UIAlertAction(title: String(localized: "OK"), style: .default))
158+
let presenter = controller.topViewController ?? controller
159+
presenter.present(alert, animated: true)
160+
}
161+
162+
/// Pops the export result screen and the export screen, returning to the screen below export (typically the editor).
163+
private func popExportResultFlow(animated: Bool) {
164+
let stack = controller.viewControllers
165+
guard let exportIndex = stack.firstIndex(where: { $0 is ExportViewController }), exportIndex > 0 else {
166+
if !stack.isEmpty { controller.popViewController(animated: animated) }
167+
return
168+
}
169+
controller.popToViewController(stack[exportIndex - 1], animated: animated)
170+
}
171+
172+
// MARK: ViewController Factory
173+
// Single place responsible for creating ViewControllers and injecting the router.
174+
// Adding a new screen only requires a new Route case and an entry here.
175+
func makeViewController(baseRoute route: Route) -> UIViewController {
176+
switch route {
177+
case .landing:
178+
return LandingViewController(router: self, thumbnailService: thumbnailService)
179+
case .editor:
180+
// Fallback with an empty project; prefer navigateToEditor(with:animated:) for real use.
181+
let viewModel = EditorViewModel(project: EditingProject(name: "New Project"))
182+
return EditorViewController(
183+
router: self,
184+
viewModel: viewModel,
185+
thumbnailGenerator: thumbnailService
186+
)
187+
case .export:
188+
let exporter = PhotoLibraryMovieExporter(
189+
compositionBuilder: PreviewTimelineCompositionBuilder(),
190+
mediaPermissions: SystemMediaPermissionService()
191+
)
192+
return ExportViewController(
193+
router: self,
194+
project: EditingProject(name: String(localized: "Untitled"), tracks: []),
195+
movieExporter: exporter
196+
)
197+
case .audioBottomSheet:
198+
return AudioBottomSheetViewController(router: self)
199+
case .stickerBottomSheet:
200+
return StickerBottomSheetViewController(router: self)
201+
case .textBottomSheet:
202+
return TextBottomSheetViewController(
203+
initialDescriptor: TextOverlayDescriptor.defaultNew(text: ""),
204+
onComplete: { _ in }
205+
)
206+
}
207+
}
208+
}

0 commit comments

Comments
 (0)