diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index e226a1e..0000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,10 +0,0 @@ - -## ๐Ÿ“‹ ์„ค๋ช… - -- ์ด์Šˆ์—์„œ ๊ตฌํ˜„ํ•  ๋‚ด์šฉ ์ž‘์„ฑ - -## โœ… ์ฒดํฌ๋ฆฌ์ŠคํŠธ - -> ๊ตฌํ˜„ํ•ด์•ผํ•˜๋Š” ์ด์Šˆ ์ฒดํฌ๋ฆฌ์ŠคํŠธ -- [ ] ๊ตฌํ˜„๋˜์ง€ ์•Š์€ ๋‚ด์šฉ -- [x] ๊ตฌํ˜„ ์™„๋ฃŒ๋œ ๋‚ด์šฉ diff --git "a/.github/ISSUE_TEMPLATE/\354\235\264\354\212\210-\355\205\234\355\224\214\353\246\277.md" b/.github/ISSUE_TEMPLATE/issue_template.md similarity index 92% rename from ".github/ISSUE_TEMPLATE/\354\235\264\354\212\210-\355\205\234\355\224\214\353\246\277.md" rename to .github/ISSUE_TEMPLATE/issue_template.md index 9ee2ecf..5a3d8c8 100644 --- "a/.github/ISSUE_TEMPLATE/\354\235\264\354\212\210-\355\205\234\355\224\214\353\246\277.md" +++ b/.github/ISSUE_TEMPLATE/issue_template.md @@ -1,5 +1,5 @@ --- -name: ์ด์Šˆ ํ…œํ”Œ๋ฆฟ +name: ISSUE_TEMPLATE about: ๊ณตํ†ต์ ์œผ๋กœ ์‚ฌ์šฉ๋˜๋Š” ์ด์Šˆ ํ…œํ”Œ๋ฆฟ title: '' labels: '' diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 3616c6d..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,18 +0,0 @@ - -## โ— ๋ฐฐ๊ฒฝ -> ์ž‘์—… ๋ฐฐ๊ฒฝ์— ๋Œ€ํ•œ ์„ค๋ช…์„ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค. -> Issue์— ๋Œ€ํ•œ ๋งํฌ๋ฅผ ์ฒจ๋ถ€ํ•ฉ๋‹ˆ๋‹ค. - -## ๐Ÿ”ง ์ž‘์—… ๋‚ด์—ญ -> ์ž‘์—…ํ•œ ๋‚ด์šฉ๋“ค์„ ๋‚˜์—ดํ•ฉ๋‹ˆ๋‹ค. -> ๊ฐ„๊ฒฐํ•˜๊ฒŒ ๋ฆฌ์ŠคํŠธ ์—…ํ•˜๊ณ , ์ž์„ธํ•œ ์„ค๋ช…์€ ์•„๋ž˜ ๋ฆฌ๋ทฐ ๋…ธํŠธ์—์„œ ํ•ฉ๋‹ˆ๋‹ค. - -## ๐Ÿงช ํ…Œ์ŠคํŠธ ๋ฐฉ๋ฒ• -> ๋™์ž‘์„ ํ…Œ์ŠคํŠธํ•  ์ˆ˜ ์žˆ๋Š” ๋ฐฉ๋ฒ•์„ ์„ค๋ช…ํ•ฉ๋‹ˆ๋‹ค. -> ์•ฑ ์‹คํ–‰ ๋ฐฉ๋ฒ•์ผ ์ˆ˜ ์žˆ๊ณ , ์œ ๋‹› ํ…Œ์ŠคํŠธ ์‹คํ–‰ ๋ฐฉ๋ฒ•์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. - -## ๐Ÿ“ ๋ฆฌ๋ทฐ ๋…ธํŠธ -> ์ž‘์—… ๋‚ด์—ญ์— ๋Œ€ํ•œ ์ž์„ธํ•œ ์„ค๋ช…์„ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค. - -## ๐Ÿ“ธ ์Šคํฌ๋ฆฐ์ƒท -> ์ž‘์—…ํ•œ ๋‚ด์šฉ์— ๋Œ€ํ•œ ์Šคํฌ๋ฆฐ์ƒท, ์˜์ƒ ๋“ฑ์„ ์ฒจ๋ถ€ํ•ฉ๋‹ˆ๋‹ค. diff --git a/.github/workflows/Xcode_build_test.yml b/.github/workflows/Xcode_build_test.yml new file mode 100644 index 0000000..d00e860 --- /dev/null +++ b/.github/workflows/Xcode_build_test.yml @@ -0,0 +1,136 @@ +name: Xcode_build_test + +env: + WORKSPACE: iOS/MusicSpot.xcworkspace + +on: + pull_request: + branches: + - 'iOS/release' + - 'iOS/epic/**' + types: [assigned, labeled, opened, synchronize, reopened] + +jobs: + prepare-matrix: + runs-on: macos-13 + outputs: + matrix: ${{ steps.generate-matrix.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + + - name: Setup Xcode + if: ${{ !env.ACT }} + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '15.0.1' + + - name: Generate matrix + id: generate-matrix + run: | + matrix="{\"include\":[" + first_entry=true + for scheme in $(xcodebuild -workspace ${{ env.WORKSPACE }} -list | grep -A 100 "Schemes:" | grep -v "Schemes:" | sed '/^$/d' | sed 's/^[ \t]*//'); do + if [[ $scheme != *"-Package" ]] && [[ $scheme != *"Tests" ]]; then + if [ "$first_entry" = true ]; then + first_entry=false + else + matrix+="," + fi + matrix+="{\"scheme\":\"$scheme\"}" + fi + done + matrix+="]}" + echo "matrix=$matrix" >> $GITHUB_OUTPUT + + xcode-build: + needs: prepare-matrix + runs-on: macos-13 + strategy: + fail-fast: false + matrix: ${{fromJson(needs.prepare-matrix.outputs.matrix)}} + steps: + - uses: actions/checkout@v4 + + - name: Create secret file + env: + API_SECRET: ${{ secrets.API_SECRET }} + run: | + echo $API_SECRET | base64 -D -o iOS/MSData/Sources/MSData/Resources/APIInfo.plist + + - name: Setup Xcode + if: ${{ !env.ACT }} + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '15.0.1' + + - name: ๐Ÿ› ๏ธ Build ${{ matrix.scheme }} + run: | + echo "๐Ÿ› ๏ธ Building ${{ matrix.scheme }}" + xcodebuild \ + -workspace ${{ env.WORKSPACE }} \ + -scheme ${{ matrix.scheme }} \ + -sdk 'iphonesimulator' \ + -destination 'platform=iOS Simulator,OS=17.0.1,name=iPhone 15 Pro' \ + clean build + + prepare-test-matrix: + runs-on: macos-13 + outputs: + matrix: ${{ steps.generate-test-matrix.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + + - name: Setup Xcode + if: ${{ !env.ACT }} + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '15.0.1' + + - name: Generate test matrix + id: generate-test-matrix + run: | + matrix="{\"include\":[" + first_entry=true + for scheme in $(xcodebuild -workspace ${{ env.WORKSPACE }} -list | grep -A 100 "Schemes:" | grep -v "Schemes:" | sed '/^$/d' | sed 's/^[ \t]*//'); do + if [[ $scheme == *"Tests" ]]; then + if [ "$first_entry" = true ]; then + first_entry=false + else + matrix+="," + fi + matrix+="{\"scheme\":\"$scheme\"}" + fi + done + matrix+="]}" + echo "matrix=$matrix" >> $GITHUB_OUTPUT + + xcode-test: + needs: prepare-test-matrix + runs-on: macos-13 + strategy: + fail-fast: false + matrix: ${{fromJson(needs.prepare-test-matrix.outputs.matrix)}} + steps: + - uses: actions/checkout@v4 + + - name: Create secret file + env: + API_SECRET: ${{ secrets.API_SECRET }} + run: | + echo $API_SECRET | base64 -D -o iOS/MSData/Sources/MSData/Resources/APIInfo.plist + + - name: Setup Xcode + if: ${{ !env.ACT }} + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '15.0.1' + + - name: ๐Ÿงช Test ${{ matrix.scheme }} + run: | + echo "๐Ÿงช Testing ${{ matrix.scheme }}" + xcodebuild \ + -workspace ${{ env.WORKSPACE }} \ + -scheme ${{ matrix.scheme }} \ + -sdk 'iphonesimulator' \ + -destination 'platform=iOS Simulator,OS=17.0.1,name=iPhone 15 Pro' \ + test diff --git a/.gitignore b/.gitignore index 651fa0a..9054ea0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -# Created by https://www.toptal.com/developers/gitignore/api/macos,windows,xcode,swift,swiftpackagemanager -# Edit at https://www.toptal.com/developers/gitignore?templates=macos,windows,xcode,swift,swiftpackagemanager +# Created by https://www.toptal.com/developers/gitignore/api/macos +# Edit at https://www.toptal.com/developers/gitignore?templates=macos ### macOS ### # General @@ -167,6 +167,7 @@ $RECYCLE.BIN/ node_modules + # compiled output dist node_modules @@ -203,4 +204,5 @@ lerna-debug.log* !.vscode/launch.json !.vscode/extensions.json -.env \ No newline at end of file +.env + diff --git a/BE/musicspot/public/release.html b/BE/musicspot/public/release.html index 38f4896..56247ce 100644 --- a/BE/musicspot/public/release.html +++ b/BE/musicspot/public/release.html @@ -6,10 +6,26 @@ ๋ฐฐํฌ ํŽ˜์ด์ง€ - 0.1.1 - ๋ฐฐํฌ 2023.12.08 +
+ 0.1.1 + ๋ฐฐํฌ 2023.12.08 +
+
+ 0.5.0 + ๋ฐฐํฌ 2023.12.11 +
+
+ 1.0.0 + ๋ฐฐํฌ 2023.12.14(์ตœ์‹ ) +
diff --git a/BE/musicspot/src/journey/controller/journey.controller.ts b/BE/musicspot/src/journey/controller/journey.controller.ts index add9ad3..61a929c 100644 --- a/BE/musicspot/src/journey/controller/journey.controller.ts +++ b/BE/musicspot/src/journey/controller/journey.controller.ts @@ -32,8 +32,10 @@ import { } from '../dto/journeyRecord/journeyRecord.dto'; import { StartJourneyResDTO } from '../dto/journeyStart/journeyStart.dto'; import { DeleteJourneyReqDTO } from '../dto/journeyDelete.dto'; + import { Journey } from '../entities/journey.entity'; + @Controller('journey') @ApiTags('journey ๊ด€๋ จ API') export class JourneyController { @@ -133,11 +135,12 @@ export class JourneyController { }) @ApiCreatedResponse({ description: '์‚ฌ์šฉ์ž๊ฐ€ ์ง„ํ–‰์ค‘์ด์—ˆ๋˜ ์—ฌ์ • ์ •๋ณด', - type: Journey, + type: LastJourneyResDTO, }) @Get('last') async loadLastData(@Body('userId') userId) { return await this.journeyService.getLastJourneyByUserId(userId); + } @ApiOperation({ diff --git a/BE/musicspot/src/journey/dto/journeyLast.dto.ts b/BE/musicspot/src/journey/dto/journeyLast.dto.ts new file mode 100644 index 0000000..0b8a5da --- /dev/null +++ b/BE/musicspot/src/journey/dto/journeyLast.dto.ts @@ -0,0 +1,86 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsDateString } from 'class-validator'; +import { UUID } from 'crypto'; + +export class SpotDTO { + @ApiProperty({ description: '์—ฌ์ • ID', example: '65649c91380cafcab8869ed2' }) + readonly journeyId: string; + + @ApiProperty({ description: 'spot ์œ„์น˜', example: [37.555913, 126.972313] }) + readonly coordinate: number[]; + + @ApiProperty({ description: '๊ธฐ๋ก ์‹œ๊ฐ„', example: '2023-11-22T12:00:00Z' }) + readonly timestamp: string; + + @ApiProperty({ + description: 'presigned url', + example: + 'https://music-spot-storage.kr.object.ncloudstorage.com/path/name?AWSAccessKeyId=key&Expires=sec&Signature=signature', + }) + readonly photoUrl: string; +} + +class journeyMetadataDto { + @ApiProperty({ + description: '์—ฌ์ • ์‹œ์ž‘ ์‹œ๊ฐ„', + example: '2023-11-22T15:30:00.000+09:00', + }) + readonly startTimestamp: string; + + @ApiProperty({ + description: '์—ฌ์ • ์ข…๋ฃŒ ์‹œ๊ฐ„', + example: '2023-11-22T15:30:00.000+09:00', + }) + readonly endTimestamp: string; +} + +export class JourneyDTO { + @ApiProperty({ description: '์—ฌ์ • ID', example: '65649c91380cafcab8869ed2' }) + readonly _id: string; + + @ApiProperty({ description: '์—ฌ์ • ์ œ๋ชฉ', example: '์—ฌ์ • ์ œ๋ชฉ' }) + readonly title: string; + + @ApiProperty({ type: [SpotDTO], description: 'spot ๋ฐฐ์—ด' }) + readonly spots: SpotDTO[]; + + @ApiProperty({ + description: '์œ„์น˜ ์ขŒํ‘œ ๋ฐฐ์—ด', + example: [ + [37.775, 122.4195], + [37.7752, 122.4197], + [37.7754, 122.4199], + ], + }) + readonly coordinates: number[][]; + + @ApiProperty({ description: '์—ฌ์ • ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ', type: journeyMetadataDto }) + readonly journeyMetadata: journeyMetadataDto; +} + +export class LastJourneyResDTO { + @ApiProperty({ description: '์—ฌ์ • ID', example: '65649c91380cafcab8869ed2' }) + readonly _id: string; + + @ApiProperty({ description: '์—ฌ์ • ์ œ๋ชฉ', example: '์—ฌ์ • ์ œ๋ชฉ' }) + readonly title: string; + + @ApiProperty({ type: [SpotDTO], description: 'spot ๋ฐฐ์—ด' }) + readonly spots: SpotDTO[]; + + @ApiProperty({ + description: '์œ„์น˜ ์ขŒํ‘œ ๋ฐฐ์—ด', + example: [ + [37.775, 122.4195], + [37.7752, 122.4197], + [37.7754, 122.4199], + ], + }) + readonly coordinates: number[][]; + + @ApiProperty({ description: '์—ฌ์ • ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ', type: journeyMetadataDto }) + readonly journeyMetadata: journeyMetadataDto; + + @ApiProperty({ description: '์—ฌ์ • ๋งˆ๋ฌด๋ฆฌ ์—ฌ๋ถ€' }) + readonly isRecording: boolean; +} diff --git a/BE/musicspot/src/journey/service/journey.service.ts b/BE/musicspot/src/journey/service/journey.service.ts index 74a06bb..072869a 100644 --- a/BE/musicspot/src/journey/service/journey.service.ts +++ b/BE/musicspot/src/journey/service/journey.service.ts @@ -21,7 +21,6 @@ export class JourneyService{ async insertJourneyData(startJourneyDTO:StartJourneyReqDTO){ const startPoint = startJourneyDTO.coordinate.join(' '); - const lineStringOfCoordinates = `LINESTRING(${startPoint}, ${startPoint})` const returnedData = await this.journeyRepository.save({...startJourneyDTO, coordinates: lineStringOfCoordinates}) @@ -45,6 +44,7 @@ export class JourneyService{ throw new JourneyNotFoundException(); } + const originCoordinates =originData.coordinates; const newCoordinates = originData.coordinates = originCoordinates.slice(0, -1) + ',' +endJourneyDTO.coordinates.map((item)=> `${item[0]} ${item[1]}`).join(',') + ')' const newJourneyData = {...originData, ...endJourneyDTO, song : JSON.stringify(song), coordinates: newCoordinates}; @@ -87,6 +87,7 @@ export class JourneyService{ } + async getJourneyByCoordinationRange(checkJourneyDTO) { let { userId, minCoordinate, maxCoordinate } = checkJourneyDTO; if (!(Array.isArray(minCoordinate) && Array.isArray(maxCoordinate))) { diff --git a/BE/musicspot/src/spot/service/spot.service.ts b/BE/musicspot/src/spot/service/spot.service.ts index 8fa8206..f556a5c 100644 --- a/BE/musicspot/src/spot/service/spot.service.ts +++ b/BE/musicspot/src/spot/service/spot.service.ts @@ -9,14 +9,13 @@ import { bucketName, makePresignedUrl, } from '../../common/s3/objectStorage'; + import { JourneyNotFoundException, coordinateNotCorrectException } from 'src/filters/journey.exception'; import { is1DArray, parseCoordinateFromDtoToGeo } from 'src/common/util/coordinate.util'; import { SpotRepository } from '../repository/spot.repository'; import { RecordSpotResDTO } from '../dto/recordSpot.dto'; import { JourneyRepository } from 'src/journey/repository/journey.repository'; - - @Injectable() export class SpotService { constructor( @@ -37,6 +36,7 @@ export class SpotService { throw new SpotRecordFail(); } } + async insertToSpot(spotData){ const point = `POINT(${parseCoordinateFromDtoToGeo(spotData.coordinate)})`; @@ -101,6 +101,7 @@ export class SpotService { } return spot.photoKey; + } } diff --git a/README.md b/README.md index 15c61b5..bbe72c5 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,9 @@ -

-
-
- ๐ŸŽถ MusicSpot -
-

+ + + +***
- ๋‹น์‹ ์˜ ์—ฌ์ •์„ ์Œ์•…๊ณผ ํ•จ๊ป˜ ๊ธฐ์–ตํ•˜๋‹ค.
๋„ค์ด๋ฒ„ ๋ถ€์ŠคํŠธ์บ ํ”„ ์›นใƒป๋ชจ๋ฐ”์ผ 8๊ธฐ ๊ทธ๋ฃน ํ”„๋กœ์ ํŠธ
2023.11.06 ~ 2023.12.15
@@ -15,26 +12,76 @@


- โญ๏ธ ํ”„๋กœ์ ํŠธ ์†Œ๊ฐœ + โญ๏ธ ํ”„๋กœ์ ํŠธ ์†Œ๊ฐœ โญ

Team.๊ณผ์—ด์€ ์ง€๋„์— ๊ด€์‹ฌ์„ ๊ฐ–๊ณ ์žˆ๋Š” ์‚ฌ๋žŒ๋“ค๋กœ ์ด๋ฃจ์–ด์ ธ ์žˆ์Šต๋‹ˆ๋‹ค.
- ์ง€๋„๋ฅผ ํ™œ์šฉํ•ด์„œ ์Œ์•…๊ณผ ํ•จ๊ป˜ ์—ฌ์ •์„ ๊ธฐ๋กํ•˜๋Š” ์•ฑ, MusicSpot๋ฅผ ์†Œ๊ฐœํ•ฉ๋‹ˆ๋‹ค. + ์—ฌ์ •๋™์•ˆ ์‚ฌ์ง„๊ณผ ์Œ์•…์œผ๋กœ ์ง€๋„์— ํ”์ ์„ ๋‚จ๊ธฐ๋Š” ์•ฑ, MusicSpot์„ ์†Œ๊ฐœํ•ฉ๋‹ˆ๋‹ค.

## ๐Ÿ”ฅ Team. ๊ณผ์—ด ๐Ÿ”ฅ -|S023 ์œค๋™์ฃผ|S034 ์ „๋ฏผ๊ฑด|S045 ์ด์ฐฝ์ค€|J037 ๊น€ํƒœ์šฐ|J131 ์ž„์ •ํ›ˆ| +| S023 ์œค๋™์ฃผ | S034 ์ „๋ฏผ๊ฑด | S045 ์ด์ฐฝ์ค€ | J037 ๊น€ํƒœ์šฐ | J131 ์ž„์ •ํ›ˆ | |:-:|:-:|:-:|:-:|:-:| |||||| |[@yoondj98](https://github.com/yoondj98)|[@PushedGun](https://github.com/PushedGun)|[@SwiftyJunnos](https://github.com/SwiftyJunnos)|[@twoo1999](https://github.com/twoo1999)|[@vvans](https://github.com/vvans)|
+## ๐Ÿ—บ๏ธ ์ฃผ์š” ๊ธฐ๋Šฅ +### ๐Ÿƒโ€โ™‚๏ธ ์—ฌ์ • ๊ธฐ๋ก +![MusicSpot แ„‹แ…ขแ†ธ แ„‰แ…ฉแ„€แ…ข 001](https://github.com/boostcampwm2023/iOS01-MusicSpot/assets/138548400/d8e6663e-ed95-4757-858c-2cead6dfa02c) + +### ๐Ÿ“ธ ์ŠคํŒŸ! +![MusicSpot แ„‹แ…ขแ†ธ แ„‰แ…ฉแ„€แ…ข 002](https://github.com/boostcampwm2023/iOS01-MusicSpot/assets/138548400/f9deb9f5-49c1-4f24-b692-0ea3016bf910) + +### ๐ŸŽถ ์Œ์•… ์ถ”๊ฐ€ +![MusicSpot แ„‹แ…ขแ†ธ แ„‰แ…ฉแ„€แ…ข 003](https://github.com/boostcampwm2023/iOS01-MusicSpot/assets/138548400/22766d78-380d-4d9d-b3ab-a105b7835854) + +### ๐ŸŒ  ์ง€๋‚œ ์—ฌ์ • +![MusicSpot แ„‹แ…ขแ†ธ แ„‰แ…ฉแ„€แ…ข 004](https://github.com/boostcampwm2023/iOS01-MusicSpot/assets/138548400/06e129ea-7883-4c98-a39f-41867df2bb0f) + +## ๐Ÿงฐ ๊ธฐ์ˆ  ์Šคํƒ + +### iOS + + + +### BE + + + +## ๐Ÿš€ ๊ธฐ์ˆ ์  ๋„์ „๊ธฐ + +### ๐ŸŽ iOS +| ํ‚ค์›Œ๋“œ | ์ œ๋ชฉ | +| :-: | :- | +| XCFramework, Package | [๐Ÿ“ฆ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ-ํ”„๋ ˆ์ž„์›Œํฌ-ํŒจํ‚ค์ง€ ๋ฌด์Šจ ์ฐจ์ด์ธ๋ฐ?](https://www.nomatterjun.vision/blog/Swift/22.Library_Framework_Package) | +| `URLProtocol` | [๐ŸŒ ๋„คํŠธ์›Œํ‚น์ด ํ…Œ์ŠคํŠธ๊ฐ€ ๋œ๋‹ค๊ณ !?](https://www.nomatterjun.vision/blog/Swift/23.URLProtocol) | +| GitHub Actions, matrix | [๐Ÿญ ๋ชจ๋“ˆ๋กœ ๋‚˜๋‰œ ๊ตฌ์กฐ์—์„œ CI๋Š” ์–ด๋–ค ํ˜•ํƒœ์ด๋ฉด ์ข‹์„๊นŒ?](https://www.nomatterjun.vision/blog/Swift/24.MusicSpot_CI) | +| Boundary Model, Domain Layer | [๐ŸŽซ ๋ฐ์ดํ„ฐ ์ž…๊ตญ์‹ฌ์‚ฌ๊ฐ€ ๋„ˆ๋ฌด ์–ด๋ ค์›Œ์š”!](https://www.nomatterjun.vision/blog/Swift/25.DomainLayer) | +| `*codingContainer`, `Codable` | [๐Ÿ•บ ์ธ์ฝ”๋”ฉ, ๋””์ฝ”๋”ฉ ์˜ˆ๋ฏผํ•˜๋„ค~ ํ™”๋‚ฌ๋„ค~](https://www.nomatterjun.vision/blog/Swift/26.DecodingContainer) | +| ์ถ”์ƒํ™” | [๐Ÿง‘โ€๐Ÿ”ง ์ถ”์ƒํ™”๋กœ ํ˜‘์—…ํ•˜๊ธฐ](https://www.nomatterjun.vision/blog/Swift/27.Abstraction_Coop) | + +### ๐Ÿ’พ BE +| ํ‚ค์›Œ๋“œ | ์ œ๋ชฉ | +| :-: | :- | +| `NoSQL` | [ NoSQL์ด ๋ญ์•ผ?](https://www.notion.so/musicspot/NoSQL-16c8fa4b1ff84f86a321c87aa66e1504) | +| `DocumentDB`, `MongoDB` | [ ๋„ˆ๋ฌด ์ž์œ ๋กœ์šด ๊ตฌ์กฐ๋Š” ์ดˆ๋ณด ๊ฐœ๋ฐœ์ž๋ฅผ ๋ถˆ์•ˆํ•˜๊ฒŒ ํ•ด์š”!](https://www.notion.so/musicspot/03e6b9a70d7f4097b3256c19b8098b85) | +| `CI/CD`, `Docker` | [ Docker(???: ์ด๋ฏธ์ง€ ์ €์žฅํ•˜๋Š”๊ฑฐ ์•„๋‹ˆ์•ผ?) ](https://www.notion.so/musicspot/Docker-121ba909e2e240dc94f25ac88c38a516) | +| `CI/CD`,`Docker` | [ Docker์™€ ์นœํ•ด์ง€๊ธฐ ](https://www.notion.so/musicspot/Docker-6a845439466f431ea4281fc09d938648) | +| `SSH ์—ฐ๊ฒฐ`, 'ํŠธ๋Ÿฌ๋ธ” ์ŠˆํŒ…' | [ SSH์—ฐ๊ฒฐ์ด ์•ˆ ๋ผ์š”(๊ทผ๋ฐ ์ž˜ ๋ผ์š” ](https://www.notion.so/musicspot/ssh-fb93c27023e0406bb9bde19d661a6f4c) | +| `HTTP`, `HTTPS`, `CA` | [ HTTPS(์„ ํƒ์ด ์•„๋‹Œ ํ•„์ˆ˜โ€ฆ!) ](https://www.notion.so/musicspot/HTTPS-244427527b894f85938ab6f1d3a19bc9) | +| `Domain`, `๊ฐ€๋น„์•„` | [ ๋„๋ฉ”์ธ ์‚ฌ๊ธฐ(์ „์„ธ ์‚ฌ๊ธฐ์˜ ๊ทธ ์‚ฌ๊ธฐ ์•„๋‹˜) ](https://www.notion.so/musicspot/8dd48b218be042fcbb9237aff9bc32fe) | +| `HTTPS`, `NGINX`, `ํ”„๋ก์‹œ` | [ Nginx๋ฅผ ํ†ตํ•œ HTTPS์—ฐ๊ฒฐ ](https://www.notion.so/musicspot/Nginx-HTTPS-bcf75adc83bb414089c697ddf9d6e97b) | +| `CI/CD`, `github action` | [ github action ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค. ](https://www.notion.so/musicspot/github-action-687b4b951adf4e83a1564d06a366ffbb) | + + + ## ๐Ÿ“” ๋ฌธ์„œ | ๊ทธ๋ผ์šด๋“œ ๋ฃฐ | ๊ธฐํš/๋””์ž์ธ | ํ…œํ”Œ๋ฆฟ | ํšŒ์˜๋ก | diff --git a/iOS/.githooks/pre-commit b/iOS/.githooks/pre-commit index 2703e87..1a24852 100755 --- a/iOS/.githooks/pre-commit +++ b/iOS/.githooks/pre-commit @@ -1,3 +1 @@ #!/bin/sh - -echo "iOS" \ No newline at end of file diff --git a/iOS/.gitignore b/iOS/.gitignore new file mode 100644 index 0000000..2db6a42 --- /dev/null +++ b/iOS/.gitignore @@ -0,0 +1,139 @@ +# Created by https://www.toptal.com/developers/gitignore/api/macos,xcode,swiftpackagemanager,swift +# Edit at https://www.toptal.com/developers/gitignore?templates=macos,xcode,swiftpackagemanager,swift + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Swift ### +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# Pods/ +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +### SwiftPackageManager ### +Packages +xcuserdata + + +### Xcode ### + +## Xcode 8 and earlier + +### Xcode Patch ### +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +/*.gcno +**/xcshareddata/WorkspaceSettings.xcsettings + +# End of https://www.toptal.com/developers/gitignore/api/macos,xcode,swiftpackagemanager,swift + +### Secrets ### + +APIInfo.plist diff --git a/iOS/.swiftlint.yml b/iOS/.swiftlint.yml new file mode 100644 index 0000000..ce5a558 --- /dev/null +++ b/iOS/.swiftlint.yml @@ -0,0 +1,43 @@ +# ํ•œ ๋ผ์ธ ๊ธธ์ด ์ œํ•œ +line_length: + warning: 120 #default: 120 + error: 200 + +# ๊ฐ•์ œ ํ˜•๋ณ€ํ™˜ +force_cast: error +force_try: error + +# ์˜๋ฏธ์—†๋Š” ๊ณต๋ฐฑ/์ค„ ๋ฐ”๊ฟˆ +trailing_newline: warning +trailing_whitespace: + severity: warning + ignores_empty_lines: true + +# ํ•จ์ˆ˜ ๊ธธ์ด ์ œํ•œ +function_body_length: + warning: 50 + error: 100 + +# ํƒ€์ž… ์ง€์ • ์‹œ, ์ด๋ฆ„ ์กฐ๊ฑด +type_name: + min_length: + warning: 3 + error: 0 + max_length: + warning: 40 + error: 1000 + +# ์ค‘์ฒฉ ํƒ€์ž… +nesting: + type_level: + warning: 3 + error: 4 + +# ๊ฐœ๋ฐœ ์ค‘์— ์ ์—ˆ๋˜ print๋ฌธ ๊ฒฝ๊ณ ํ‘œ์‹œ +custom_rules: + disable_print: + included: ".*\\.swift" + name: "print usage" + regex: "((\\bprint)|(Swift\\.print))\\s*\\(" + message: "Prefer os_log over print" + severity: warning \ No newline at end of file diff --git a/iOS/Features/Home/.gitignore b/iOS/Features/Home/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/iOS/Features/Home/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/iOS/Features/Home/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/iOS/Features/Home/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/iOS/Features/Home/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/iOS/Features/Home/HomeDemo/HomeDemo/AppDelegate.swift b/iOS/Features/Home/HomeDemo/HomeDemo/AppDelegate.swift new file mode 100644 index 0000000..5a8b339 --- /dev/null +++ b/iOS/Features/Home/HomeDemo/HomeDemo/AppDelegate.swift @@ -0,0 +1,27 @@ +// +// AppDelegate.swift +// HomeDemo +// +// Created by ์ด์ฐฝ์ค€ on 2023.11.29. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions) -> UISceneConfiguration { + return UISceneConfiguration(name: "Default Configuration", + sessionRole: connectingSceneSession.role) + } + +} diff --git a/iOS/Features/Home/HomeDemo/HomeDemo/Assets.xcassets/AccentColor.colorset/Contents.json b/iOS/Features/Home/HomeDemo/HomeDemo/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/iOS/Features/Home/HomeDemo/HomeDemo/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Features/Home/HomeDemo/HomeDemo/Assets.xcassets/AppIcon.appiconset/Contents.json b/iOS/Features/Home/HomeDemo/HomeDemo/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/iOS/Features/Home/HomeDemo/HomeDemo/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Features/Home/HomeDemo/HomeDemo/Assets.xcassets/Contents.json b/iOS/Features/Home/HomeDemo/HomeDemo/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iOS/Features/Home/HomeDemo/HomeDemo/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Features/Home/HomeDemo/HomeDemo/Base.lproj/LaunchScreen.storyboard b/iOS/Features/Home/HomeDemo/HomeDemo/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/iOS/Features/Home/HomeDemo/HomeDemo/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/Features/Home/HomeDemo/HomeDemo/Base.lproj/Main.storyboard b/iOS/Features/Home/HomeDemo/HomeDemo/Base.lproj/Main.storyboard new file mode 100644 index 0000000..25a7638 --- /dev/null +++ b/iOS/Features/Home/HomeDemo/HomeDemo/Base.lproj/Main.storyboard @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/Features/Home/HomeDemo/HomeDemo/Info.plist b/iOS/Features/Home/HomeDemo/HomeDemo/Info.plist new file mode 100644 index 0000000..dd3c9af --- /dev/null +++ b/iOS/Features/Home/HomeDemo/HomeDemo/Info.plist @@ -0,0 +1,25 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + + diff --git a/iOS/Features/Home/HomeDemo/HomeDemo/SceneDelegate.swift b/iOS/Features/Home/HomeDemo/HomeDemo/SceneDelegate.swift new file mode 100644 index 0000000..c757abd --- /dev/null +++ b/iOS/Features/Home/HomeDemo/HomeDemo/SceneDelegate.swift @@ -0,0 +1,26 @@ +// +// SceneDelegate.swift +// HomeDemo +// +// Created by ์ด์ฐฝ์ค€ on 2023.11.29. +// + +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + // MARK: - Properties + + var window: UIWindow? + + // MARK: - Scene + + func scene(_ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = (scene as? UIWindowScene) else { return } + let window = UIWindow(windowScene: windowScene) + defer { self.window = window } + } + +} diff --git a/iOS/Features/Home/Package.swift b/iOS/Features/Home/Package.swift new file mode 100644 index 0000000..71aeb42 --- /dev/null +++ b/iOS/Features/Home/Package.swift @@ -0,0 +1,101 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +// MARK: - Constants + +private extension String { + + static let package = "FeatureHome" + + var fromRootPath: String { + return "../../" + self + } + + var fromCurrentPath: String { + return "../" + self + } + +} + +private enum Target { + + static let home = "Home" + static let navigateMap = "NavigateMap" + +} + +private enum Dependency { + + static let journeyList = "JourneyList" + static let msDomain = "MSDomain" + static let msData = "MSData" + static let msUIKit = "MSUIKit" + static let msKeychainStorage = "MSKeychainStorage" + static let msCoreKit = "MSCoreKit" + static let msUserDefaults = "MSUserDefaults" + static let msLogger = "MSLogger" + static let msFoundation = "MSFoundation" + +} + +// MARK: - Package + +let package = Package( + name: .package, + platforms: [ + .iOS(.v15) + ], + products: [ + .library(name: Target.home, + targets: [Target.home]), + .library(name: Target.navigateMap, + targets: [Target.navigateMap]) + ], + dependencies: [ + .package(name: Dependency.journeyList, + path: Dependency.journeyList.fromCurrentPath), + .package(name: Dependency.msDomain, + path: Dependency.msDomain.fromRootPath), + .package(name: Dependency.msData, + path: Dependency.msData.fromRootPath), + .package(name: Dependency.msUIKit, + path: Dependency.msUIKit.fromRootPath), + .package(name: Dependency.msCoreKit, + path: Dependency.msCoreKit.fromRootPath), + .package(name: Dependency.msFoundation, + path: Dependency.msFoundation.fromRootPath) + ], + targets: [ + .target(name: Target.home, + dependencies: [ + .product(name: Dependency.journeyList, + package: Dependency.journeyList), + .target(name: Target.navigateMap), + .product(name: Dependency.msDomain, + package: Dependency.msDomain), + .product(name: Dependency.msData, + package: Dependency.msData), + .product(name: Dependency.msKeychainStorage, + package: Dependency.msCoreKit), + .product(name: Dependency.msUserDefaults, + package: Dependency.msFoundation), + .product(name: Dependency.msLogger, + package: Dependency.msFoundation) + ]), + .target(name: Target.navigateMap, + dependencies: [ + .product(name: Dependency.msDomain, + package: Dependency.msDomain), + .product(name: Dependency.msData, + package: Dependency.msData), + .product(name: Dependency.msUIKit, + package: Dependency.msUIKit), + .product(name: Dependency.msUserDefaults, + package: Dependency.msFoundation), + .product(name: Dependency.msLogger, + package: Dependency.msFoundation) + ]) + ] +) diff --git a/iOS/Features/Home/Sources/Home/Presentation/Coordinator/HomeNavigationDelegate.swift b/iOS/Features/Home/Sources/Home/Presentation/Coordinator/HomeNavigationDelegate.swift new file mode 100644 index 0000000..21b137b --- /dev/null +++ b/iOS/Features/Home/Sources/Home/Presentation/Coordinator/HomeNavigationDelegate.swift @@ -0,0 +1,17 @@ +// +// HomeNavigationDelegate.swift +// Home +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.05. +// + +import Foundation + +import MSDomain + +public protocol HomeNavigationDelegate: AnyObject { + + func navigateToSpot(spotCoordinate coordinate: Coordinate) + func navigateToSelectSong(lastCoordinate: Coordinate) + +} diff --git a/iOS/Features/Home/Sources/Home/Presentation/HomeViewController.swift b/iOS/Features/Home/Sources/Home/Presentation/HomeViewController.swift new file mode 100644 index 0000000..853e4e7 --- /dev/null +++ b/iOS/Features/Home/Sources/Home/Presentation/HomeViewController.swift @@ -0,0 +1,304 @@ +// +// HomeBottomSheetViewController.swift +// Home +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.01. +// + +import Combine +import UIKit + +import JourneyList +import MSConstants +import MSDesignSystem +import MSDomain +import MSUIKit +import MSUserDefaults +import NavigateMap + +public typealias HomeBottomSheetViewController += MSBottomSheetViewController + +public final class HomeViewController: HomeBottomSheetViewController { + + // MARK: - Constants + + private enum Typo { + + static let startButtonTitle = "์‹œ์ž‘ํ•˜๊ธฐ" + static let refreshButtonTitle = "์—ฌ๊ธฐ์„œ ๋‹ค์‹œ ๊ฒ€์ƒ‰" + + } + + private enum Metric { + + static let startButtonBottomInset: CGFloat = 16.0 + + enum RefreshButton { + static let topSpacing: CGFloat = 80.0 + static let horizontalEdgeInsets: CGFloat = 24.0 + static let verticalEdgeInsets: CGFloat = 10.0 + } + + } + + // MARK: - UI Components + + private let startButton: MSButton = { + let button = MSButton.primary() + button.cornerStyle = .rounded + button.title = Typo.startButtonTitle + return button + }() + + private let refreshButton: UIButton = { + var configuration = UIButton.Configuration.filled() + var container = AttributeContainer() + container.font = .msFont(.boldCaption) + configuration.attributedTitle = AttributedString(Typo.refreshButtonTitle, attributes: container) + configuration.contentInsets = NSDirectionalEdgeInsets(top: Metric.RefreshButton.verticalEdgeInsets, + leading: Metric.RefreshButton.horizontalEdgeInsets, + bottom: Metric.RefreshButton.verticalEdgeInsets, + trailing: Metric.RefreshButton.horizontalEdgeInsets) + configuration.baseBackgroundColor = .msColor(.secondaryButtonBackground).withAlphaComponent(0.8) + configuration.baseForegroundColor = .msColor(.secondaryButtonTypo) + configuration.cornerStyle = .capsule + + let button = UIButton(configuration: configuration) + return button + }() + + private lazy var recordJourneyButtonStackView: RecordJourneyButtonStackView = { + let buttonView = RecordJourneyButtonStackView() + buttonView.delegate = self + buttonView.isHidden = true + return buttonView + }() + + // MARK: - Properties + + private let viewModel: HomeViewModel + + public weak var navigationDelegate: HomeNavigationDelegate? + + private var cancellables: Set = [] + + // MARK: - Initializer + + public init(viewModel: HomeViewModel, + contentViewController: MapViewController, + bottomSheetViewController: JourneyListViewController) { + self.viewModel = viewModel + super.init(contentViewController: contentViewController, + bottomSheetViewController: bottomSheetViewController) + } + + required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - Life Cycle + + public override func viewDidLoad() { + super.viewDidLoad() + + self.configureLayout() + self.configureAction() + self.bind() + self.viewModel.trigger(.viewNeedsLoaded) + } + + public override func viewIsAppearing(_ animated: Bool) { + super.viewIsAppearing(animated) + + self.navigationController?.isNavigationBarHidden = true + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + self.viewModel.trigger(.viewNeedsReloaded) + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + // ํ™”๋ฉด ์‹œ์ž‘ ์‹œ ์ƒˆ๋กœ๊ณ ์นจ ๋ฒ„ํŠผ ๊ธฐ๋Šฅ ํ•œ๋ฒˆ ์‹คํ–‰ + self.refreshButton.sendActions(for: .touchUpInside) + } + + // MARK: - Combine Binding + + private func bind() { + self.viewModel.state.startedJourney + .receive(on: DispatchQueue.main) + .sink { [weak self] startedJourney in + self?.contentViewController.journeyShouldStarted(startedJourney) + } + .store(in: &self.cancellables) + + self.viewModel.state.visibleJourneys + .sink { [weak self] visibleJourneys in + self?.contentViewController.visibleJourneysDidUpdated(visibleJourneys) + self?.bottomSheetViewController.visibleJourneysDidUpdated(visibleJourneys) + } + .store(in: &self.cancellables) + + self.viewModel.state.isRecording + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] isRecording in + if isRecording { + self?.hideBottomSheet() + } else { + self?.showBottomSheet() + self?.contentViewController.journeyShouldStopped(isCancelling: false) + } + self?.updateButtonMode(isRecording: isRecording) + } + .store(in: &self.cancellables) + + self.viewModel.state.isStartButtonLoading + .receive(on: DispatchQueue.main) + .sink { [weak self] isStartButtonLoading in + self?.startButton.configuration?.showsActivityIndicator = isStartButtonLoading + } + .store(in: &self.cancellables) + + self.viewModel.state.isRefreshButtonHidden + .removeDuplicates(by: { $0 == $1 }) + .receive(on: DispatchQueue.main) + .sink { [weak self] isHidden in + self?.refreshButton.isHidden = isHidden + } + .store(in: &self.cancellables) + + self.viewModel.state.overlaysShouldBeCleared + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.contentViewController.clearOverlays() + self?.contentViewController.clearAnnotations() + } + .store(in: &self.cancellables) + } + + // MARK: - Functions + + private func updateButtonMode(isRecording: Bool) { + UIView.transition(with: startButton, duration: 0.5, + options: .transitionCrossDissolve, + animations: { + self.startButton.isHidden = isRecording + }) + UIView.transition(with: recordJourneyButtonStackView, duration: 0.5, + options: .transitionCrossDissolve, + animations: { + self.recordJourneyButtonStackView.isHidden = !isRecording + }) + + if isRecording { + self.setUserLocationToCenter() + } + } + + private func setUserLocationToCenter() { + + } + +} + +// MARK: - Buttons + +extension HomeViewController: RecordJourneyButtonViewDelegate { + + private func configureAction() { + let startButtonAction = UIAction { [weak self] _ in + guard let userLocation = self?.contentViewController.userLocation else { return } + + let coordinate = Coordinate(latitude: userLocation.coordinate.latitude, + longitude: userLocation.coordinate.longitude) + self?.viewModel.trigger(.startButtonDidTap(coordinate)) + } + self.startButton.addAction(startButtonAction, for: .touchUpInside) + + let refreshButtonAction = UIAction { [weak self] _ in + guard let coordinates = self?.contentViewController.visibleCoordinates else { return } + + self?.viewModel.trigger(.refreshButtonDidTap(visibleCoordinates: coordinates)) + } + self.refreshButton.addAction(refreshButtonAction, for: .touchUpInside) + } + + public func backButtonDidTap(_ button: MSRectButton) { + guard self.viewModel.state.isRecording.value == true else { return } + + self.viewModel.trigger(.backButtonDidTap) + self.contentViewController.journeyShouldStopped(isCancelling: true) + } + + public func spotButtonDidTap(_ button: MSRectButton) { + guard self.viewModel.state.isRecording.value == true else { return } + + guard let currentUserCoordiante = self.contentViewController.currentUserCoordinate else { + return + } + + self.navigationDelegate?.navigateToSpot(spotCoordinate: currentUserCoordiante) + } + + public func nextButtonDidTap(_ button: MSRectButton) { + guard self.viewModel.state.isRecording.value == true else { return } + + guard let currentUserCoordiante = self.contentViewController.currentUserCoordinate else { + return + } + + self.navigationDelegate?.navigateToSelectSong(lastCoordinate: currentUserCoordiante) + } + +} + +// MARK: - MapViewController + +extension HomeViewController: MapViewControllerDelegate { + + public func mapViewControllerDidChangeVisibleRegion(_ mapViewController: MapViewController) { + self.viewModel.trigger(.mapViewDidChange) + } + +} + +// MARK: - UI Configuration + +private extension HomeViewController { + + func configureLayout() { + let bottomSheetTopAnchor = self.bottomSheetViewController.view.topAnchor + + self.view.addSubview(self.startButton) + self.startButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.startButton.centerXAnchor.constraint(equalTo: self.view.centerXAnchor), + self.startButton.bottomAnchor.constraint(equalTo: bottomSheetTopAnchor, + constant: -Metric.startButtonBottomInset) + ]) + + self.view.addSubview(self.recordJourneyButtonStackView) + self.recordJourneyButtonStackView.translatesAutoresizingMaskIntoConstraints = false + let safeAreaBottomAnchor = self.view.safeAreaLayoutGuide.bottomAnchor + NSLayoutConstraint.activate([ + self.recordJourneyButtonStackView.centerXAnchor.constraint(equalTo: self.view.centerXAnchor), + self.recordJourneyButtonStackView.bottomAnchor.constraint(equalTo: safeAreaBottomAnchor, + constant: -Metric.startButtonBottomInset) + ]) + + self.view.insertSubview(self.refreshButton, belowSubview: self.bottomSheetViewController.view) + self.refreshButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.refreshButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, + constant: Metric.RefreshButton.topSpacing), + self.refreshButton.centerXAnchor.constraint(equalTo: self.view.centerXAnchor) + ]) + } + +} diff --git a/iOS/Features/Home/Sources/Home/Presentation/HomeViewModel.swift b/iOS/Features/Home/Sources/Home/Presentation/HomeViewModel.swift new file mode 100644 index 0000000..45d3d61 --- /dev/null +++ b/iOS/Features/Home/Sources/Home/Presentation/HomeViewModel.swift @@ -0,0 +1,161 @@ +// +// HomeViewModel.swift +// Home +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import Combine +import Foundation + +import MSConstants +import MSData +import MSDomain +import MSImageFetcher +#if DEBUG +import MSKeychainStorage +#endif +import MSLogger +import MSUserDefaults + +public final class HomeViewModel { + + public enum Action { + case viewNeedsLoaded + case viewNeedsReloaded + case startButtonDidTap(Coordinate) + case refreshButtonDidTap(visibleCoordinates: (minCoordinate: Coordinate, maxCoordinate: Coordinate)) + case backButtonDidTap + case mapViewDidChange + } + + public struct State { + // Passthrough + public var startedJourney = PassthroughSubject() + public var visibleJourneys = PassthroughSubject<[Journey], Never>() + public var overlaysShouldBeCleared = PassthroughSubject() + + // CurrentValue + public var isRecording = CurrentValueSubject(false) + public var isRefreshButtonHidden = CurrentValueSubject(false) + public var isStartButtonLoading = CurrentValueSubject(false) + } + + // MARK: - Properties + + public var state = State() + + private var journeyRepository: JourneyRepository + private let userRepository: UserRepository + + #if DEBUG + private let keychain = MSKeychainStorage() + #endif + + @UserDefaultsWrapped(UserDefaultsKey.isFirstLaunch, defaultValue: false) + private var isFirstLaunch: Bool + + // MARK: - Initializer + + public init(journeyRepository: JourneyRepository, + userRepository: UserRepository) { + self.journeyRepository = journeyRepository + self.userRepository = userRepository + } + + // MARK: - Functions + + func trigger(_ action: Action) { + switch action { + case .viewNeedsLoaded: + #if DEBUG + let firstLaunchMessage = self.isFirstLaunch ? "์•ฑ์ด ์ฒ˜์Œ ์‹คํ–‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค." : "์•ฑ ์ฒซ ์‹คํ–‰์ด ์•„๋‹™๋‹ˆ๋‹ค." + MSLogger.make(category: .userDefaults).debug("\(firstLaunchMessage)") + #endif + + self.createNewUserWhenFirstLaunch() + case .viewNeedsReloaded: + let isRecording = self.journeyRepository.fetchIsRecording() + self.state.isRecording.send(isRecording) + case .startButtonDidTap(let coordinate): + #if DEBUG + MSLogger.make(category: .home).debug("Start ๋ฒ„ํŠผ ํƒญ: \(coordinate)") + #endif + self.startJourney(at: coordinate) + self.state.isRefreshButtonHidden.send(true) + case .refreshButtonDidTap(visibleCoordinates: (let minCoordinate, let maxCoordinate)): + self.state.isRefreshButtonHidden.send(true) + self.fetchJourneys(minCoordinate: minCoordinate, maxCoordinate: maxCoordinate) + case .backButtonDidTap: + self.state.isRecording.send(false) + self.state.isRefreshButtonHidden.send(false) + self.state.overlaysShouldBeCleared.send(true) + case .mapViewDidChange: + if self.state.isRecording.value == false { + self.state.isRefreshButtonHidden.send(false) + } + } + } + +} + +// MARK: - Privates + +private extension HomeViewModel { + + private var shouldSignIn: Bool { + return self.isFirstLaunch || self.userRepository.fetchUUID() == nil + } + + func createNewUserWhenFirstLaunch() { + guard self.shouldSignIn else { return } + + Task { + let result = await self.userRepository.createUser() + switch result { + case .success(let userID): + #if DEBUG + MSLogger.make(category: .home).log("\(userID) ์œ ์ €๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") + #endif + self.isFirstLaunch = false + case .failure(let error): + MSLogger.make(category: .home).error("\(error)") + } + } + } + + func startJourney(at coordinate: Coordinate) { + Task { + self.state.isStartButtonLoading.send(true) + defer { self.state.isStartButtonLoading.send(false) } + + guard let userID = self.userRepository.fetchUUID() else { return } + + let result = await self.journeyRepository.startJourney(at: coordinate, userID: userID) + switch result { + case .success(let recordingJourney): + self.state.startedJourney.send(recordingJourney) + self.state.isRecording.send(true) + case .failure(let error): + MSLogger.make(category: .home).error("\(error)") + } + } + } + + func fetchJourneys(minCoordinate: Coordinate, maxCoordinate: Coordinate) { + guard let userID = self.userRepository.fetchUUID() else { return } + + Task { + let result = await self.journeyRepository.fetchJourneyList(userID: userID, + minCoordinate: minCoordinate, + maxCoordinate: maxCoordinate) + switch result { + case .success(let journeys): + self.state.visibleJourneys.send(journeys) + case .failure(let error): + MSLogger.make(category: .home).error("\(error)") + } + } + } + +} diff --git a/iOS/Features/Home/Sources/Home/Presentation/RecordJourneyButtonStackView.swift b/iOS/Features/Home/Sources/Home/Presentation/RecordJourneyButtonStackView.swift new file mode 100644 index 0000000..673f4b4 --- /dev/null +++ b/iOS/Features/Home/Sources/Home/Presentation/RecordJourneyButtonStackView.swift @@ -0,0 +1,149 @@ +// +// RecordJourneyButtonView.swift +// NavigateMap +// +// Created by ์œค๋™์ฃผ on 12/2/23. +// + +import UIKit + +import MSDesignSystem +import MSUIKit + +public protocol RecordJourneyButtonViewDelegate: AnyObject { + + func backButtonDidTap(_ button: MSRectButton) + func spotButtonDidTap(_ button: MSRectButton) + func nextButtonDidTap(_ button: MSRectButton) + +} + +public final class RecordJourneyButtonStackView: UIView { + + // MARK: - Constants + + private enum Typo { + + static let spotButtonTitle = "์ŠคํŒŸ!" + + } + + private enum Metric { + + static let stackViewSpacing: CGFloat = 50.0 + + } + + // MARK: - UI Components + + private let buttonStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = Metric.stackViewSpacing + stackView.alignment = .center + return stackView + }() + + private let backButton: MSRectButton = { + let button = MSRectButton.small(isBrandColored: false) + button.image = .msIcon(.arrowLeft) + return button + }() + + private let spotButton: MSRectButton = { + let button = MSRectButton.large(isBrandColored: true) + button.title = Typo.spotButtonTitle + return button + }() + + private let nextButton: MSRectButton = { + let button = MSRectButton.small(isBrandColored: false) + button.image = .msIcon(.check) + return button + }() + + // MARK: - Properties + + public var delegate: RecordJourneyButtonViewDelegate? + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: frame) + + self.configureStyle() + self.configureLayout() + self.configureAction() + } + + required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - UI Configuration + + private func configureStyle() { } + + private func configureLayout() { + self.addSubview(self.buttonStackView) + self.buttonStackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.buttonStackView.topAnchor.constraint(equalTo: self.topAnchor), + self.buttonStackView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + self.buttonStackView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + self.buttonStackView.trailingAnchor.constraint(equalTo: self.trailingAnchor) + ]) + + [ + self.backButton, + self.spotButton, + self.nextButton + ].forEach { + self.buttonStackView.addArrangedSubview($0) + } + } + + // MARK: - Action Configuration + + private func configureAction() { + self.configureBackButtonAction() + self.configureSpotButtonAction() + self.configureNextButtonAction() + } + + private func configureBackButtonAction() { + let backButtonAction = UIAction { _ in + self.backButtonDidTap() + } + self.backButton.addAction(backButtonAction, for: .touchUpInside) + } + + private func configureSpotButtonAction() { + let spotButtonAction = UIAction { _ in + self.spotButtonDidTap() + } + self.spotButton.addAction(spotButtonAction, for: .touchUpInside) + } + + private func configureNextButtonAction() { + let nextButtonAction = UIAction { _ in + self.nextButtonDidTap() + } + self.nextButton.addAction(nextButtonAction, for: .touchUpInside) + } + + // MARK: - Functions + + private func backButtonDidTap() { + self.delegate?.backButtonDidTap(self.backButton) + } + + private func spotButtonDidTap() { + self.delegate?.spotButtonDidTap(self.spotButton) + } + + private func nextButtonDidTap() { + self.delegate?.nextButtonDidTap(self.nextButton) + } + +} diff --git a/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/MapViewController+EventListener.swift b/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/MapViewController+EventListener.swift new file mode 100644 index 0000000..15d962f --- /dev/null +++ b/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/MapViewController+EventListener.swift @@ -0,0 +1,59 @@ +// +// MapViewController+EventListener.swift +// NavigateMap +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.10. +// + +import Foundation + +import MSData +import MSDomain +import MSLogger + +// MARK: - NavigateMap + +extension MapViewController { + + public func visibleJourneysDidUpdated(_ visibleJourneys: [Journey]) { + guard let viewModel = self.viewModel as? NavigateMapViewModel else { return } + + viewModel.trigger(.visibleJourneysDidUpdated(visibleJourneys)) + } + +} + +// MARK: - RecordJourney + +extension MapViewController { + + public func journeyShouldStarted(_ startedJourney: RecordingJourney) { + guard self.viewModel is NavigateMapViewModel else { + MSLogger.make(category: .home).error("์—ฌ์ •์ด ์‹œ์ž‘๋˜์–ด์•ผ ํ•˜์ง€๋งŒ ์ด๋ฏธ Map์—์„œ RecordJourneyViewModel์„ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.") + return + } + + let userRepository = UserRepositoryImplementation() + let journeyRepository = JourneyRepositoryImplementation() + let recordJourneyViewModel = RecordJourneyViewModel(startedJourney: startedJourney, + userRepository: userRepository, + journeyRepository: journeyRepository) + self.swapViewModel(to: recordJourneyViewModel) + } + + public func journeyShouldStopped(isCancelling: Bool) { + guard let viewModel = self.viewModel as? RecordJourneyViewModel else { + MSLogger.make(category: .home).error("์—ฌ์ •์ด ์ข…๋ฃŒ๋˜์–ด์•ผ ํ•˜์ง€๋งŒ ์ด๋ฏธ Map์—์„œ NavigateMapViewModel์„ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.") + return + } + + if isCancelling { + viewModel.trigger(.recordingDidCancelled) + } + + let journeyRepository = JourneyRepositoryImplementation() + let navigateMapViewModel = NavigateMapViewModel(repository: journeyRepository) + self.swapViewModel(to: navigateMapViewModel) + } + +} diff --git a/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/MapViewController.swift b/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/MapViewController.swift new file mode 100644 index 0000000..fece4c9 --- /dev/null +++ b/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/MapViewController.swift @@ -0,0 +1,442 @@ +// +// MapViewController.swift +// Home +// +// Created by ์œค๋™์ฃผ on 11/21/23. +// + +import Combine +import CoreLocation +import MapKit +import UIKit + +import MSDomain +import MSImageFetcher +import MSLogger +import MSUIKit + +public final class MapViewController: UIViewController { + + // MARK: - Constants + + private enum Typo { + + static let locationAlertTitle = "์œ„์น˜ ๊ถŒํ•œ" + static let locationAlertMessage = "์œ„์น˜ ๊ถŒํ•œ์„ ํ—ˆ์šฉํ•˜์‹œ์ง€ ์•Š์•„์„œ ์œ„์น˜ ์ ‘๊ทผ์ด ๋ถˆ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค." + static let locationAlertCancel = "์ทจ์†Œ" + static let locationAlertSettings = "์„ค์ •" + + } + + private enum Metric { + + static let buttonStackTopSpacing: CGFloat = 50.0 + static let buttonStackTrailingSpacing: CGFloat = 16.0 + static let lineWidth: CGFloat = 5.0 + + } + + // MARK: - UI Components + + private lazy var mapView: MKMapView = { + let mapView = MKMapView() + mapView.delegate = self + mapView.showsUserLocation = true + mapView.userTrackingMode = .follow + mapView.showsCompass = false + + mapView.register(CustomAnnotationView.self, + forAnnotationViewWithReuseIdentifier: CustomAnnotationView.identifier) + mapView.register(ClusterAnnotationView.self, + forAnnotationViewWithReuseIdentifier: MKMapViewDefaultClusterAnnotationViewReuseIdentifier) + + return mapView + }() + + /// HomeMap ๋‚ด ์šฐ์ƒ๋‹จ ๋ฒ„ํŠผ View + private lazy var buttonStackView: ButtonStackView = { + let stackView = ButtonStackView() + stackView.delegate = self + return stackView + }() + + // MARK: - Properties + + public weak var delegate: MapViewControllerDelegate? + var viewModel: (any MapViewModel)? + + private let locationManager = CLLocationManager() + + private var cancellables: Set = [] + + private var timeRemaining: Int { + let calendar = Calendar.current + return calendar.component(.second, from: .now) % 5 + } + + /// ์ง€๋„ ์ƒ์— ๋ณด์ด๋Š” ์ขŒํ‘œ ๋ฒ”์œ„ + /// - Parameters: + /// - minCoordinate: ์šฐ์ธก ํ•˜๋‹จ ์ขŒํ‘œ + /// - maxCoordinate: ์ขŒ์ธก ์ƒ๋‹จ ์ขŒํ‘œ + public var visibleCoordinates: (minCoordinate: Coordinate, maxCoordinate: Coordinate) { + let region = self.mapView.region + let minCoordinate = Coordinate(latitude: region.center.latitude + region.span.latitudeDelta / 2, + longitude: region.center.longitude - region.span.longitudeDelta / 2) + let maxCoordinate = Coordinate(latitude: region.center.latitude - region.span.latitudeDelta / 2, + longitude: region.center.longitude + region.span.longitudeDelta / 2) + return (minCoordinate: minCoordinate, maxCoordinate: maxCoordinate) + } + + public var userLocation: CLLocation? { + return self.locationManager.location + } + + public var currentUserCoordinate: Coordinate? { + guard let currentUserCoordinate2D = self.userLocation?.coordinate else { return nil } + let coordinate = Coordinate(latitude: currentUserCoordinate2D.latitude, + longitude: currentUserCoordinate2D.longitude) + return coordinate + } + + // MARK: - Initializer + + public init(viewModel: any MapViewModel, + nibName nibNameOrNil: String? = nil, + bundle nibBundleOrNil: Bundle? = nil) { + self.viewModel = viewModel + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + } + + required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - Life Cycle + + public override func viewDidLoad() { + super.viewDidLoad() + + self.configureLayout() + self.configureCoreLocation() + self.bind(viewModel) + } + + // MARK: - Combine Binding + + func swapViewModel(to viewModel: any MapViewModel) { + self.viewModel = nil + self.cancellables.forEach { $0.cancel() } + + self.bind(viewModel) + } + + private func bind(_ viewModel: (any MapViewModel)?) { + self.viewModel = viewModel + + if let navigateMapViewModel = viewModel as? NavigateMapViewModel { + self.bind(navigateMapViewModel) + #if DEBUG + MSLogger.make(category: .home).debug("Map์— NavigateMapViewModel์„ ๋ฐ”์ธ๋”ฉ ํ–ˆ์Šต๋‹ˆ๋‹ค.") + #endif + } + + if let recordJourneyViewModel = viewModel as? RecordJourneyViewModel { + self.bind(recordJourneyViewModel) + #if DEBUG + MSLogger.make(category: .home).debug("Map์— RecordJourneyViewModel์„ ๋ฐ”์ธ๋”ฉ ํ–ˆ์Šต๋‹ˆ๋‹ค.") + #endif + } + } + + private func bind(_ viewModel: NavigateMapViewModel) { + viewModel.state.visibleJourneys + .receive(on: DispatchQueue.main) + .sink { [weak self] journeys in + self?.clearAnnotations() + self?.addAnnotations(with: journeys) + self?.drawPolyLinesToMap(with: journeys) + } + .store(in: &self.cancellables) + } + + private func bind(_ viewModel: RecordJourneyViewModel) { + viewModel.state.previousCoordinate + .zip(viewModel.state.currentCoordinate) + .compactMap { previousCoordinate, currentCoordinate -> (CLLocationCoordinate2D, CLLocationCoordinate2D)? in + guard let previousCoordinate = previousCoordinate, + let currentCoordinate = currentCoordinate else { + return nil + } + return (previousCoordinate, currentCoordinate) + } + .sink { [weak self] previousCoordinate, currentCoordinate in + let points = [previousCoordinate, currentCoordinate] + self?.drawPolylineToMap(using: points) + } + .store(in: &self.cancellables) + } + + // MARK: - Functions: Annotation + + /// ์‹๋ณ„์ž๋ฅผ ๊ฐ–๊ณ  Annotation view ์ƒ์„ฑ + func addAnnotationView(using annotation: CustomAnnotation, + on mapView: MKMapView) -> MKAnnotationView { + return mapView.dequeueReusableAnnotationView(withIdentifier: CustomAnnotationView.identifier, + for: annotation) + } + + private func addAnnotations(with journeys: [Journey]) { + let datas = journeys.flatMap { journey in + journey.spots.map { (location: journey.title, spot: $0) } + } + + Task { + await withThrowingTaskGroup(of: Void.self) { group in + for (location, spot) in datas { + group.addTask { + let imageFetcher = MSImageFetcher.shared + guard let photoData = await imageFetcher.fetchImage(from: spot.photoURL, + forKey: spot.photoURL.paath()) else { + throw ImageFetchError.imageFetchFailed + } + + let coordinate = CLLocationCoordinate2D(latitude: spot.coordinate.latitude, + longitude: spot.coordinate.longitude) + + await self.addAnnotation(title: location, + coordinate: coordinate, + photoData: photoData) + } + } + } + } + } + + private func addAnnotation(title: String, + coordinate: CLLocationCoordinate2D, + photoData: Data) { + let annotation = CustomAnnotation(title: title, + coordinate: coordinate, + photoData: photoData) + self.mapView.addAnnotation(annotation) + } + + public func addSpotInRecording(spot: Spot) { + Task { + + let imageFetcher = MSImageFetcher.shared + guard let photoData = await imageFetcher.fetchImage(from: spot.photoURL, + forKey: spot.photoURL.paath()) else { + throw ImageFetchError.imageFetchFailed + } + + let coordinate = CLLocationCoordinate2D(latitude: spot.coordinate.latitude, + longitude: spot.coordinate.longitude) + + self.addAnnotation(title: "", + coordinate: coordinate, + photoData: photoData) + } + } + + // MARK: - Functions: Polyline + + private func drawPolyLinesToMap(with journeys: [Journey]) { + Task { + await withTaskGroup(of: Void.self) { group in + for journey in journeys { + group.addTask { + let coordinates = journey.coordinates.map { + CLLocationCoordinate2D(latitude: $0.latitude, + longitude: $0.longitude) + } + await self.drawPolylineToMap(using: coordinates) + } + } + } + } + } + + private func drawPolylineToMap(using coordinates: [CLLocationCoordinate2D]) { + let polyline = MKPolyline(coordinates: coordinates, + count: coordinates.count) + self.mapView.addOverlay(polyline) + } + + // MARK: - Functions + + public func clearOverlays() { + let overlays = self.mapView.overlays + self.mapView.removeOverlays(overlays) + } + + public func clearAnnotations() { + let annotations = self.mapView.annotations + self.mapView.removeAnnotations(annotations) + } + +} + +// MARK: - UI Configuration + +private extension MapViewController { + + func configureLayout() { + self.view = self.mapView + + self.view.addSubview(self.buttonStackView) + self.buttonStackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.buttonStackView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, + constant: Metric.buttonStackTopSpacing), + self.buttonStackView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, + constant: -Metric.buttonStackTrailingSpacing) + ]) + } + + func configureCoreLocation() { + self.locationManager.delegate = self + self.locationManager.requestWhenInUseAuthorization() + self.locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters + } + +} + +// MARK: - CLLocationManager + +extension MapViewController: CLLocationManagerDelegate { + + public func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + Task.detached { + await self.handleAuthorizationChange(manager) + } + } + + public func locationManager(_ manager: CLLocationManager, + didUpdateLocations locations: [CLLocation]) { + guard self.timeRemaining == .zero, + let newCurrentLocation = locations.last, + let recordJourneyViewModel = self.viewModel as? RecordJourneyViewModel else { + return + } + + let previousCoordinate = (self.viewModel as? RecordJourneyViewModel)?.state.previousCoordinate.value + + if let previousCoordinate = previousCoordinate { + if !self.isDistanceOver5AndUnder50(coordinate1: previousCoordinate, + coordinate2: newCurrentLocation.coordinate) { + return + } + } + + let coordinate2D = CLLocationCoordinate2D(latitude: newCurrentLocation.coordinate.latitude, + longitude: newCurrentLocation.coordinate.longitude) + + recordJourneyViewModel.trigger(.locationDidUpdated(coordinate2D)) + recordJourneyViewModel.trigger(.locationsShouldRecorded([coordinate2D])) + } + + private func handleAuthorizationChange(_ manager: CLLocationManager) { + switch manager.authorizationStatus { + case .notDetermined: + manager.requestWhenInUseAuthorization() + case .restricted, .denied: + let sheet = UIAlertController(title: Typo.locationAlertTitle, + message: Typo.locationAlertMessage, + preferredStyle: .alert) + let cancelAction = UIAlertAction(title: Typo.locationAlertCancel, style: .cancel) + let settingsAction = UIAlertAction(title: Typo.locationAlertSettings, style: .default) { _ in + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } + sheet.addAction(cancelAction) + sheet.addAction(settingsAction) + DispatchQueue.main.async { + self.present(sheet, animated: true) + } + case .authorizedAlways, .authorizedWhenInUse: + manager.startUpdatingLocation() + self.checkAccuracyAuthorizationStatus(manager) + @unknown default: + MSLogger.make(category: .home).error("์ž˜๋ชป๋œ ์œ„์น˜ ๊ถŒํ•œ์ž…๋‹ˆ๋‹ค.") + } + } + + /// ์ œ๊ณต๋˜๋Š” ์œ„์น˜ ์ •๋ณด์˜ ์ •ํ™•๋„์— ๋”ฐ๋ฅธ ๋™์ž‘์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. + private func checkAccuracyAuthorizationStatus(_ manager: CLLocationManager) { + switch manager.accuracyAuthorization { + case .fullAccuracy: + MSLogger.make(category: .home).log("์œ„์น˜ ์ •๋ณด ์ •ํ™•๋„๊ฐ€ ๋†’์Šต๋‹ˆ๋‹ค.") + case .reducedAccuracy: + MSLogger.make(category: .home).log("์œ„์น˜ ์ •๋ณด ์ •ํ™•๋„๊ฐ€ ๋‚ฎ์Šต๋‹ˆ๋‹ค.") + default: + MSLogger.make(category: .home).error("์ž˜๋ชป๋œ ์œ„์น˜ ์ •๋ณด ์ •ํ™•๋„์— ๋Œ€ํ•œ ๊ฐ’์ž…๋‹ˆ๋‹ค.") + } + } + + private func isDistanceOver5AndUnder50(coordinate1: CLLocationCoordinate2D, + coordinate2: CLLocationCoordinate2D) -> Bool { + let location1 = CLLocation(latitude: coordinate1.latitude, longitude: coordinate1.longitude) + let location2 = CLLocation(latitude: coordinate2.latitude, longitude: coordinate2.longitude) + MSLogger.make(category: .navigateMap).log("์ด๋™ํ•œ ๊ฑฐ๋ฆฌ: \(location1.distance(from: location2))") + return 5 <= location1.distance(from: location2) && location1.distance(from: location2) <= 50 + } + +} + +// MARK: - MKMapView + +extension MapViewController: MKMapViewDelegate { + + /// ํ˜„์žฌ๊นŒ์ง€์˜ polyline๋“ค์„ ์ง€๋„ ์œ„์— ๊ทธ๋ฆผ + public func mapView(_ mapView: MKMapView, + rendererFor overlay: MKOverlay) -> MKOverlayRenderer { + guard let polyLine = overlay as? MKPolyline else { return MKOverlayRenderer() } + + let renderer = MKPolylineRenderer(polyline: polyLine) + renderer.strokeColor = .msColor(.musicSpot) + renderer.lineWidth = Metric.lineWidth + renderer.alpha = 1.0 + + return renderer + } + + public func mapView(_ mapView: MKMapView, + viewFor annotation: MKAnnotation) -> MKAnnotationView? { + guard !(annotation is MKUserLocation) else { return nil } + + if annotation is MKClusterAnnotation { + return ClusterAnnotationView(annotation: annotation, + reuseIdentifier: MKMapViewDefaultClusterAnnotationViewReuseIdentifier) + } + + let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: CustomAnnotationView.identifier, + for: annotation) + return annotationView + } + + public func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) { + self.delegate?.mapViewControllerDidChangeVisibleRegion(self) + } + +} + +// MARK: - ButtonView + +extension MapViewController: ButtonStackViewDelegate { + + public func mapButtonDidTap() { + MSLogger.make(category: .navigateMap).debug("ํ˜„์žฌ ์ง€๋„์—์„œ ๋ณด์ด๋Š” ๋ฒ”์œ„ ๋‚ด์˜ ๋ชจ๋“  Spot๋“ค์„ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค.") + } + + public func userLocationButtonDidTap() { + switch self.mapView.userTrackingMode { + case .none, .followWithHeading: + self.mapView.setUserTrackingMode(.follow, animated: true) + case .follow: + self.mapView.setUserTrackingMode(.followWithHeading, animated: true) + @unknown default: break + } + } + +} diff --git a/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/MapViewControllerDelegate.swift b/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/MapViewControllerDelegate.swift new file mode 100644 index 0000000..54df534 --- /dev/null +++ b/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/MapViewControllerDelegate.swift @@ -0,0 +1,14 @@ +// +// MapViewControllerDelegate.swift +// NavigateMap +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.10. +// + +import Foundation + +public protocol MapViewControllerDelegate: AnyObject { + + func mapViewControllerDidChangeVisibleRegion(_ mapViewController: MapViewController) + +} diff --git a/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/MapViewModel.swift b/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/MapViewModel.swift new file mode 100644 index 0000000..4084bfa --- /dev/null +++ b/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/MapViewModel.swift @@ -0,0 +1,17 @@ +// +// MapViewModel.swift +// NavigateMap +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.10. +// + +import Foundation + +public protocol MapViewModel: AnyObject { + associatedtype Action + associatedtype State + + var state: State { get set } + + func trigger(_ action: Action) +} diff --git a/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/View/ButtonStackView.swift b/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/View/ButtonStackView.swift new file mode 100644 index 0000000..4a42c95 --- /dev/null +++ b/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/View/ButtonStackView.swift @@ -0,0 +1,99 @@ +// +// ButtonStackView.swift +// Home +// +// Created by ์œค๋™์ฃผ on 11/22/23. +// + +import UIKit + +import MSUIKit + +public protocol ButtonStackViewDelegate: AnyObject { + + func mapButtonDidTap() + func userLocationButtonDidTap() + +} + +/// HomeMap ๋‚ด 3๊ฐœ ๋ฒ„ํŠผ StackView +final class ButtonStackView: UIStackView { + + // MARK: - Constants + + private enum Metric { + + static let spacing: CGFloat = 4.0 + + } + + // MARK: - UI Components + + private let mapButton: MSRectButton = { + let button = MSRectButton.small() + button.image = .msIcon(.map) + return button + }() + + private let userLocationButton: MSRectButton = { + let button = MSRectButton.small() + button.image = .msIcon(.location) + return button + }() + + // MARK: - Properties + + weak var delegate: ButtonStackViewDelegate? + + // MARK: - Life Cycle + + override init(frame: CGRect) { + super.init(frame: frame) + self.configureStyles() + self.configureLayout() + self.configureAction() + } + + required init(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - UI Configuration + + private func configureStyles() { + self.axis = .vertical + self.distribution = .fillEqually + self.spacing = Metric.spacing + } + + private func configureLayout() { + [ + self.mapButton, + self.userLocationButton + ].forEach { + self.addArrangedSubview($0) + } + } + + // MARK: - Configure: Action + + private func configureAction() { + self.configureMapButtonAction() + self.configureLocationButtonAction() + } + + private func configureMapButtonAction() { + let mapButtonAction = UIAction { [weak self] _ in + self?.delegate?.mapButtonDidTap() + } + self.mapButton.addAction(mapButtonAction, for: .touchUpInside) + } + + private func configureLocationButtonAction() { + let userLocationButtonAction = UIAction { [weak self] _ in + self?.delegate?.userLocationButtonDidTap() + } + self.userLocationButton.addAction(userLocationButtonAction, for: .touchUpInside) + } + +} diff --git a/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/View/Map/ClusterAnnotationView.swift b/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/View/Map/ClusterAnnotationView.swift new file mode 100644 index 0000000..edadb5b --- /dev/null +++ b/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/View/Map/ClusterAnnotationView.swift @@ -0,0 +1,102 @@ +// +// ClusterAnnotationView.swift +// Home +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import MapKit + +final class ClusterAnnotationView: MKAnnotationView { + + // MARK: - Constants + + private enum Metric { + + static let markerWidth: CGFloat = 43.0 + static let markerHeight: CGFloat = 53.0 + static let inset: CGFloat = 4 + static let thumbnailImageViewSize: CGFloat = Metric.markerWidth - Metric.inset * 2 + + } + + // MARK: - UI Components + + private let markerImageView: UIImageView = { + let imageView = UIImageView() + return imageView + }() + + private let thumbnailImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.tintColor = .msColor(.primaryTypo) + return imageView + }() + + // MARK: - Properties + + override var annotation: MKAnnotation? { + didSet { self.displayPriority = .defaultHigh } + } + + // MARK: - Object Lifecycle + + override init(annotation: MKAnnotation?, + reuseIdentifier: String?) { + super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) + self.configureLayout() + self.render() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - View Lifecycle + + override func prepareForDisplay() { + super.prepareForDisplay() + self.updateThumbnailImage() + } + + override func layoutSubviews() { + super.layoutSubviews() + bounds.size = self.markerImageView.bounds.size + } + + // MARK: - UI Configuration + + private func updateThumbnailImage() { + guard let annotation = self.annotation as? MKClusterAnnotation else {return} + self.thumbnailImageView.image = UIImage(systemName: "\(annotation.memberAnnotations.count).circle.fill") + } + + private func render() { + self.thumbnailImageView.layer.cornerRadius = Metric.thumbnailImageViewSize / 2 + self.thumbnailImageView.clipsToBounds = true + self.thumbnailImageView.layer.borderWidth = 1 + self.thumbnailImageView.layer.borderColor = .none + } + + private func configureLayout() { + [ + self.markerImageView, + self.thumbnailImageView + ].forEach { + self.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + } + NSLayoutConstraint.activate([ + self.markerImageView.widthAnchor.constraint(equalToConstant: Metric.markerWidth), + self.markerImageView.heightAnchor.constraint(equalToConstant: Metric.markerHeight), + + self.thumbnailImageView.topAnchor.constraint(equalTo: self.markerImageView.topAnchor, + constant: Metric.inset), + self.thumbnailImageView.widthAnchor.constraint(equalToConstant: Metric.thumbnailImageViewSize), + self.thumbnailImageView.heightAnchor.constraint(equalToConstant: Metric.thumbnailImageViewSize), + self.thumbnailImageView.centerXAnchor.constraint(equalTo: self.markerImageView.centerXAnchor) + ]) + } + +} diff --git a/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/View/Map/CustomAnnotation.swift b/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/View/Map/CustomAnnotation.swift new file mode 100644 index 0000000..46f9710 --- /dev/null +++ b/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/View/Map/CustomAnnotation.swift @@ -0,0 +1,29 @@ +// +// CustomAnnotation.swift +// Home +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import Foundation +import MapKit + +final class CustomAnnotation: NSObject, MKAnnotation { + + // MARK: - Properties + + @objc dynamic var coordinate: CLLocationCoordinate2D + var title: String? + var photoData: Data + + // MARK: - Initializer + + init(title: String, + coordinate: CLLocationCoordinate2D, + photoData: Data) { + self.title = title + self.coordinate = coordinate + self.photoData = photoData + } + +} diff --git a/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/View/Map/CustomAnnotationView.swift b/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/View/Map/CustomAnnotationView.swift new file mode 100644 index 0000000..2a5a046 --- /dev/null +++ b/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/View/Map/CustomAnnotationView.swift @@ -0,0 +1,110 @@ +// +// CustomAnnotationView.swift +// Home +// +// Created by ์œค๋™์ฃผ on 11/23/23. +// + +import CoreLocation +import MapKit +import UIKit + +import MSDesignSystem + +final class CustomAnnotationView: MKAnnotationView { + + // MARK: - Constants + + public static let identifier = "CustomAnnotationView" + + private enum Metric { + + static let markerWidth: CGFloat = 43.0 + static let markerHeight: CGFloat = 53.0 + static let inset: CGFloat = 4.0 + static let thumbnailImageViewSize: CGFloat = Metric.markerWidth - Metric.inset * 2 + + } + + // MARK: - UI Components + + private let markerImageView: UIImageView = { + let imageView = UIImageView() + return imageView + }() + + private let thumbnailImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + return imageView + }() + + // MARK: - Properties + + override var annotation: MKAnnotation? { + didSet { + self.clusteringIdentifier = "spotIdentifier" + } + } + + // MARK: - Initializer + + override init(annotation: MKAnnotation?, reuseIdentifier: String?) { + super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) + self.configureLayout() + self.renderThumbnailImageView() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - View Lifecycle + + override func prepareForDisplay() { + super.prepareForDisplay() + self.updateThumbnailImage() + } + + override func layoutSubviews() { + super.layoutSubviews() + self.bounds.size = self.markerImageView.bounds.size + } + + // MARK: - UI Configuration + + private func renderThumbnailImageView() { + self.thumbnailImageView.layer.cornerRadius = Metric.thumbnailImageViewSize / 2 + self.thumbnailImageView.clipsToBounds = true + self.thumbnailImageView.layer.borderWidth = 1 + self.thumbnailImageView.layer.borderColor = .none + } + + private func configureLayout() { + [ + self.markerImageView, + self.thumbnailImageView + ].forEach { + self.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + } + NSLayoutConstraint.activate([ + self.markerImageView.widthAnchor.constraint(equalToConstant: Metric.markerWidth), + self.markerImageView.heightAnchor.constraint(equalToConstant: Metric.markerHeight), + + self.thumbnailImageView.topAnchor.constraint(equalTo: self.markerImageView.topAnchor, + constant: Metric.inset), + self.thumbnailImageView.widthAnchor.constraint(equalToConstant: Metric.thumbnailImageViewSize), + self.thumbnailImageView.heightAnchor.constraint(equalToConstant: Metric.thumbnailImageViewSize), + self.thumbnailImageView.centerXAnchor.constraint(equalTo: self.markerImageView.centerXAnchor) + ]) + } + + // MARK: - Functions + + func updateThumbnailImage() { + guard let annotation = self.annotation as? CustomAnnotation else { return } + self.thumbnailImageView.image = UIImage(data: annotation.photoData) + } + +} diff --git a/iOS/Features/Home/Sources/NavigateMap/Presentation/NavigateMap/NavigateMapViewModel.swift b/iOS/Features/Home/Sources/NavigateMap/Presentation/NavigateMap/NavigateMapViewModel.swift new file mode 100644 index 0000000..95012da --- /dev/null +++ b/iOS/Features/Home/Sources/NavigateMap/Presentation/NavigateMap/NavigateMapViewModel.swift @@ -0,0 +1,56 @@ +// +// NavigateMapViewModel.swift +// Home +// +// Created by ์œค๋™์ฃผ on 11/26/23. +// + +import Combine +import CoreLocation +import Foundation + +import MSConstants +import MSData +import MSDomain +import MSLogger +import MSUserDefaults + +public final class NavigateMapViewModel: MapViewModel { + + public enum Action { + case viewNeedsLoaded + case visibleJourneysDidUpdated(_ visibleJourneys: [Journey]) + } + + public struct State { + // Passthrough + public var locationShouldAuthorized = PassthroughSubject() + + // CurrentValue + public var visibleJourneys = CurrentValueSubject<[Journey], Never>([]) + } + + // MARK: - Properties + + private let journeyRepository: JourneyRepository + + public var state = State() + + // MARK: - Initializer + + public init(repository: JourneyRepository) { + self.journeyRepository = repository + } + + // MARK: - Functions + + public func trigger(_ action: Action) { + switch action { + case .viewNeedsLoaded: + self.state.locationShouldAuthorized.send(true) + case .visibleJourneysDidUpdated(let visibleJourneys): + self.state.visibleJourneys.send(visibleJourneys) + } + } + +} diff --git a/iOS/Features/Home/Sources/NavigateMap/Presentation/RecordJourney/RecordJourneyViewModel.swift b/iOS/Features/Home/Sources/NavigateMap/Presentation/RecordJourney/RecordJourneyViewModel.swift new file mode 100644 index 0000000..1d4e0f7 --- /dev/null +++ b/iOS/Features/Home/Sources/NavigateMap/Presentation/RecordJourney/RecordJourneyViewModel.swift @@ -0,0 +1,92 @@ +// +// RecordJourneyViewModel.swift +// NavigateMap +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.10. +// + +import Combine +import CoreLocation +import Foundation + +import MSData +import MSDomain +import MSLogger + +public final class RecordJourneyViewModel: MapViewModel { + + public enum Action { + case viewNeedsLoaded + case locationDidUpdated(CLLocationCoordinate2D) + case locationsShouldRecorded([CLLocationCoordinate2D]) + case recordingDidCancelled + } + + public struct State { + // CurrentValue + public var previousCoordinate = CurrentValueSubject(nil) + public var currentCoordinate = CurrentValueSubject(nil) + public var recordingJourney: CurrentValueSubject + } + + // MARK: - Properties + + private let userRepository: UserRepository + private var journeyRepository: JourneyRepository + + public var state: State + + // MARK: - Initializer + + public init(startedJourney: RecordingJourney, + userRepository: UserRepository, + journeyRepository: JourneyRepository) { + self.userRepository = userRepository + self.journeyRepository = journeyRepository + self.state = State(recordingJourney: CurrentValueSubject(startedJourney)) + } + + // MARK: - Functions + + public func trigger(_ action: Action) { + switch action { + case .viewNeedsLoaded: + #if DEBUG + MSLogger.make(category: .home).debug("View Did load.") + #endif + case .locationDidUpdated(let coordinate): + let previousCoordinate = self.state.currentCoordinate.value + self.state.previousCoordinate.send(previousCoordinate) + self.state.currentCoordinate.send(coordinate) + case .locationsShouldRecorded(let coordinates): + Task { + let recordingJourney = self.state.recordingJourney.value + let coordinates = coordinates.map { Coordinate(latitude: $0.latitude, longitude: $0.longitude) } + let result = await self.journeyRepository.recordJourney(journeyID: recordingJourney.id, + at: coordinates) + switch result { + case .success(let recordingJourney): + self.state.recordingJourney.send(recordingJourney) + case .failure(let error): + MSLogger.make(category: .home).error("\(error)") + } + } + case .recordingDidCancelled: + Task { + guard let userID = self.userRepository.fetchUUID() else { return } + + let recordingJourney = self.state.recordingJourney.value + let result = await self.journeyRepository.deleteJourney(recordingJourney, userID: userID) + switch result { + case .success(let cancelledJourney): + #if DEBUG + MSLogger.make(category: .home).debug("์—ฌ์ •์ด ์ทจ์†Œ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค: \(cancelledJourney)") + #endif + case .failure(let error): + MSLogger.make(category: .home).error("\(error)") + } + } + } + } + +} diff --git a/iOS/Features/JourneyList/.gitignore b/iOS/Features/JourneyList/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/iOS/Features/JourneyList/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/iOS/Features/JourneyList/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/iOS/Features/JourneyList/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/iOS/Features/JourneyList/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/AppDelegate.swift b/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/AppDelegate.swift new file mode 100644 index 0000000..bffcb06 --- /dev/null +++ b/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/AppDelegate.swift @@ -0,0 +1,26 @@ +// +// AppDelegate.swift +// JourneyListDemo +// +// Created by ์ด์ฐฝ์ค€ on 2023.11.29. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions) -> UISceneConfiguration { + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + +} diff --git a/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/Assets.xcassets/AccentColor.colorset/Contents.json b/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/Assets.xcassets/AppIcon.appiconset/Contents.json b/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/Assets.xcassets/Contents.json b/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/Base.lproj/LaunchScreen.storyboard b/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..7df10c0 --- /dev/null +++ b/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/Info.plist b/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/Info.plist new file mode 100644 index 0000000..0eb786d --- /dev/null +++ b/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/Info.plist @@ -0,0 +1,23 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/SceneDelegate.swift b/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/SceneDelegate.swift new file mode 100644 index 0000000..156e00f --- /dev/null +++ b/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/SceneDelegate.swift @@ -0,0 +1,40 @@ +// +// SceneDelegate.swift +// JourneyListDemo +// +// Created by ์ด์ฐฝ์ค€ on 2023.11.29. +// + +import UIKit + +import JourneyList +import MSData +import MSDesignSystem +import MSUIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + // MARK: - Properties + + var window: UIWindow? + + // MARK: - Functions + + func scene(_ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = (scene as? UIWindowScene) else { return } + let window = UIWindow(windowScene: windowScene) + defer { self.window = window } + + MSFont.registerFonts() + + let journeyRepository = JourneyRepositoryImplementation() + let testViewModel = JourneyListViewModel(repository: journeyRepository) + let testViewController = JourneyListViewController(viewModel: testViewModel) + + window.rootViewController = testViewController + window.makeKeyAndVisible() + } + +} diff --git a/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/ko.lproj/LaunchScreen.strings b/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/ko.lproj/LaunchScreen.strings new file mode 100644 index 0000000..7d76103 --- /dev/null +++ b/iOS/Features/JourneyList/JourneyListDemo/JourneyListDemo/ko.lproj/LaunchScreen.strings @@ -0,0 +1,3 @@ + +/* Class = "UILabel"; text = "์—ฌ์ • ๋ฆฌ์ŠคํŠธ ๋ฐ๋ชจ์•ฑ"; ObjectID = "xdL-fV-0ho"; */ +"xdL-fV-0ho.text" = "์—ฌ์ • ๋ฆฌ์ŠคํŠธ ๋ฐ๋ชจ์•ฑ"; diff --git a/iOS/Features/JourneyList/Package.swift b/iOS/Features/JourneyList/Package.swift new file mode 100644 index 0000000..deef4df --- /dev/null +++ b/iOS/Features/JourneyList/Package.swift @@ -0,0 +1,72 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +// MARK: - Constants + +private extension String { + + static let package = "FeatureJourneyList" + + var fromRootPath: String { + return "../../" + self + } + +} + +private enum Target { + + static let journeyList = "JourneyList" + +} + +private enum Dependency { + + static let msCacheStorage = "MSCacheStorage" + static let msImageFetcher = "MSImageFetcher" + static let msCoreKit = "MSCoreKit" + static let msUIKit = "MSUIKit" + static let msData = "MSData" + static let msLogger = "MSLogger" + static let msFoundation = "MSFoundation" + +} + +// MARK: - Package + +let package = Package( + name: .package, + platforms: [ + .iOS(.v15) + ], + products: [ + .library(name: Target.journeyList, + targets: [Target.journeyList]) + ], + dependencies: [ + .package(name: Dependency.msUIKit, + path: Dependency.msUIKit.fromRootPath), + .package(name: Dependency.msCoreKit, + path: Dependency.msCoreKit.fromRootPath), + .package(name: Dependency.msData, + path: Dependency.msData.fromRootPath), + .package(name: Dependency.msFoundation, + path: Dependency.msFoundation.fromRootPath) + ], + targets: [ + .target(name: Target.journeyList, + dependencies: [ + .product(name: Dependency.msCacheStorage, + package: Dependency.msCoreKit), + .product(name: Dependency.msImageFetcher, + package: Dependency.msCoreKit), + .product(name: Dependency.msUIKit, + package: Dependency.msUIKit), + .product(name: Dependency.msData, + package: Dependency.msData), + .product(name: Dependency.msLogger, + package: Dependency.msFoundation) + ]) + ] +) diff --git a/iOS/Features/JourneyList/Sources/JourneyList/Presentation/Coordinator/JourneyListNavigationDelegate.swift b/iOS/Features/JourneyList/Sources/JourneyList/Presentation/Coordinator/JourneyListNavigationDelegate.swift new file mode 100644 index 0000000..a22da5a --- /dev/null +++ b/iOS/Features/JourneyList/Sources/JourneyList/Presentation/Coordinator/JourneyListNavigationDelegate.swift @@ -0,0 +1,16 @@ +// +// JourneyListNavigationDelegate.swift +// Home +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.05. +// + +import Foundation + +import MSDomain + +public protocol JourneyListNavigationDelegate: AnyObject { + + func navigateToRewindJourney(with urls: [URL], music: Music) + +} diff --git a/iOS/Features/JourneyList/Sources/JourneyList/Presentation/JourneyListViewController+EventListener.swift b/iOS/Features/JourneyList/Sources/JourneyList/Presentation/JourneyListViewController+EventListener.swift new file mode 100644 index 0000000..98fde6d --- /dev/null +++ b/iOS/Features/JourneyList/Sources/JourneyList/Presentation/JourneyListViewController+EventListener.swift @@ -0,0 +1,18 @@ +// +// JourneyListViewController+EventListener.swift +// JourneyList +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.10. +// + +import Foundation + +import MSDomain + +extension JourneyListViewController { + + public func visibleJourneysDidUpdated(_ visibleJourneys: [Journey]) { + self.viewModel.trigger(.visibleJourneysDidUpdated(visibleJourneys)) + } + +} diff --git a/iOS/Features/JourneyList/Sources/JourneyList/Presentation/JourneyListViewController.swift b/iOS/Features/JourneyList/Sources/JourneyList/Presentation/JourneyListViewController.swift new file mode 100644 index 0000000..d673682 --- /dev/null +++ b/iOS/Features/JourneyList/Sources/JourneyList/Presentation/JourneyListViewController.swift @@ -0,0 +1,214 @@ +// +// JourneyListViewController.swift +// JourneyList +// +// Created by ์ด์ฐฝ์ค€ on 11/22/23. +// + +import Combine +import UIKit + +import MSCacheStorage +import MSData +import MSDomain +import MSUIKit + +public final class JourneyListViewController: BaseViewController { + + typealias JourneyListDataSource = UICollectionViewDiffableDataSource + typealias JourneyListHeaderRegistration = UICollectionView.SupplementaryRegistration + typealias JourneyCellRegistration = UICollectionView.CellRegistration + typealias JourneySnapshot = NSDiffableDataSourceSnapshot + + // MARK: - Constants + + private enum Typo { + + static func subtitle(numberOfJourneys: Int) -> String { + return "ํ˜„์žฌ ์œ„์น˜์— \(numberOfJourneys)๊ฐœ์˜ ์—ฌ์ •์ด ์žˆ์Šต๋‹ˆ๋‹ค." + } + + } + + private enum Metric { + + static let collectionViewHorizontalInset: CGFloat = 10.0 + static let collectionViewVerticalInset: CGFloat = 24.0 + static let interGroupSpacing: CGFloat = 12.0 + + } + + // MARK: - Properties + + public weak var navigationDelegate: JourneyListNavigationDelegate? + + private let cache: MSCacheStorage + + private(set) var viewModel: JourneyListViewModel + + private var dataSource: JourneyListDataSource? + + private var currentSnapshot: JourneySnapshot? { + return self.dataSource?.snapshot() + } + + private var cancellables: Set = [] + + // MARK: - UI Components + + private lazy var collectionView: MSCollectionView = { + let collectionView = MSCollectionView(frame: .zero, + collectionViewLayout: UICollectionViewLayout()) + collectionView.backgroundColor = .clear + collectionView.delegate = self + return collectionView + }() + + // MARK: - Initializer + + public init(viewModel: JourneyListViewModel, + cache: MSCacheStorage = MSCacheStorage(), + nibName nibNameOrNil: String? = nil, + bundle nibBundleOrNil: Bundle? = nil) { + self.viewModel = viewModel + self.cache = cache + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + } + + required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - Life Cycle + + public override func viewDidLoad() { + super.viewDidLoad() + self.configureStyle() + self.configureLayout() + self.configureCollectionView() + self.bind() + } + + // MARK: - Combine Binding + + private func bind() { + self.viewModel.state.journeys + .receive(on: DispatchQueue.main) + .sink { [weak self] journeys in + guard let self = self else { return } + + let emptySnapshot = JourneySnapshot() + self.dataSource?.apply(emptySnapshot, animatingDifferences: true) + var snapshot = JourneySnapshot() + snapshot.appendSections([.zero]) + snapshot.appendItems(journeys, toSection: .zero) + self.dataSource?.apply(snapshot, animatingDifferences: true) + } + .store(in: &self.cancellables) + } + + // MARK: - UI Configuration + + public override func configureStyle() { + super.configureStyle() + self.view.backgroundColor = .msColor(.primaryBackground) + } + + public override func configureLayout() { + self.view.addSubview(self.collectionView) + self.collectionView.translatesAutoresizingMaskIntoConstraints = false + let bottomAnchor = self.collectionView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor) + bottomAnchor.priority = .defaultLow + NSLayoutConstraint.activate([ + self.collectionView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor), + self.collectionView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, + constant: Metric.collectionViewHorizontalInset), + bottomAnchor, + self.collectionView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, + constant: -Metric.collectionViewHorizontalInset) + ]) + } + +} + +// MARK: - CollectionView + +extension JourneyListViewController: UICollectionViewDelegate { + + private func configureCollectionView() { + let layout = UICollectionViewCompositionalLayout(sectionProvider: { _, _ in + return self.configureSection() + }) + + self.collectionView.setCollectionViewLayout(layout, animated: false) + self.dataSource = self.configureDataSource() + } + + private func configureSection() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalHeight(1.0)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(JourneyCell.estimatedHeight)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, + subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = Metric.interGroupSpacing + + let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(JourneyListHeaderView.estimatedHight)) + let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .top) + header.pinToVisibleBounds = true + + section.boundarySupplementaryItems = [header] + + return section + } + + private func configureDataSource() -> JourneyListDataSource { + let cellRegistration = JourneyCellRegistration { cell, indexPath, itemIdentifier in + let cellModel = JourneyCellModel(location: itemIdentifier.title, + date: itemIdentifier.date.start, + songTitle: itemIdentifier.music.title, + songArtist: itemIdentifier.music.artist) + cell.update(with: cellModel) + let photoURLs = itemIdentifier.spots + .map { $0.photoURL } + + cell.updateImages(with: photoURLs, for: indexPath) + } + + let headerRegistration = JourneyListHeaderRegistration(elementKind: UICollectionView.elementKindSectionHeader, + handler: { header, _, _ in + guard let numberOfItems = self.currentSnapshot?.numberOfItems else { return } + header.update(numberOfJourneys: numberOfItems) + }) + + let dataSource = JourneyListDataSource(collectionView: self.collectionView, + cellProvider: { collectionView, indexPath, item in + return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, + for: indexPath, + item: item) + }) + + dataSource.supplementaryViewProvider = { collectionView, _, indexPath in + return collectionView.dequeueConfiguredReusableSupplementary(using: headerRegistration, + for: indexPath) + } + + return dataSource + } + + public func collectionView(_ collectionView: UICollectionView, + didSelectItemAt indexPath: IndexPath) { + guard let journey = self.dataSource?.itemIdentifier(for: indexPath) else { return } + + let spotPhotoURLs = journey.spots.map { $0.photoURL } + self.navigationDelegate?.navigateToRewindJourney(with: spotPhotoURLs, music: journey.music) + } + +} diff --git a/iOS/Features/JourneyList/Sources/JourneyList/Presentation/JourneyListViewModel.swift b/iOS/Features/JourneyList/Sources/JourneyList/Presentation/JourneyListViewModel.swift new file mode 100644 index 0000000..312780b --- /dev/null +++ b/iOS/Features/JourneyList/Sources/JourneyList/Presentation/JourneyListViewModel.swift @@ -0,0 +1,52 @@ +// +// JourneyListViewModel.swift +// JourneyList +// +// Created by ์ด์ฐฝ์ค€ on 11/23/23. +// + +import Combine +import Foundation + +import MSData +import MSDomain +import MSLogger + +public final class JourneyListViewModel { + + public enum Action { + case viewNeedsLoaded + case visibleJourneysDidUpdated([Journey]) + } + + public struct State { + // CurrentValue + public var journeys = CurrentValueSubject<[Journey], Never>([]) + } + + // MARK: - Properties + + public var state = State() + + private let repository: JourneyRepository + + // MARK: - Initializer + + public init(repository: JourneyRepository) { + self.repository = repository + } + + // MARK: - Functions + + public func trigger(_ action: Action) { + switch action { + case .viewNeedsLoaded: + #if DEBUG + MSLogger.make(category: .journeyList).debug("View Did Load.") + #endif + case .visibleJourneysDidUpdated(let visibleJourneys): + self.state.journeys.send(visibleJourneys) + } + } + +} diff --git a/iOS/Features/JourneyList/Sources/JourneyList/Presentation/VIew/JourneyListHeaderView.swift b/iOS/Features/JourneyList/Sources/JourneyList/Presentation/VIew/JourneyListHeaderView.swift new file mode 100644 index 0000000..b115cb7 --- /dev/null +++ b/iOS/Features/JourneyList/Sources/JourneyList/Presentation/VIew/JourneyListHeaderView.swift @@ -0,0 +1,116 @@ +// +// JourneyListHeaderView.swift +// JourneyList +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.02. +// + +import UIKit + +import MSDesignSystem +import MSUIKit + +public final class JourneyListHeaderView: UICollectionReusableView { + + // MARK: - Constants + + static let elementKind: String = "JourneyListHeaderView" + static let estimatedHight: CGFloat = 46.0 + Metric.verticalInset + + private enum Typo { + + static let title: String = "์ง€๋‚œ ์—ฌ์ •" + static func subtitle(numberOfJourneys: Int) -> String { + return "ํ˜„์žฌ ์œ„์น˜์— \(numberOfJourneys)๊ฐœ์˜ ์—ฌ์ •์ด ์žˆ์Šต๋‹ˆ๋‹ค." + } + + } + + private enum Metric { + + static let titleStackSpacing: CGFloat = 4.0 + static let horizontalInset: CGFloat = 16.0 + static let verticalInset: CGFloat = 24.0 + + } + + // MARK: - UI Components + + private let titleStack: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = Metric.titleStackSpacing + stackView.distribution = .fillProportionally + return stackView + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.font = .msFont(.headerTitle) + label.textColor = .msColor(.primaryTypo) + label.text = Typo.title + return label + }() + + private let subtitleLabel: UILabel = { + let label = UILabel() + label.font = .msFont(.caption) + label.textColor = .msColor(.secondaryTypo) + label.text = Typo.subtitle(numberOfJourneys: 0) + return label + }() + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: frame) + self.configureStyles() + self.configureLayout() + } + + public required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - Functions + + @MainActor + func update(numberOfJourneys: Int) { + self.subtitleLabel.text = Typo.subtitle(numberOfJourneys: numberOfJourneys) + } + +} + +// MARK: - UI Configuration + +private extension JourneyListHeaderView { + + func configureStyles() { + self.backgroundColor = .msColor(.primaryBackground) + } + + func configureLayout() { + self.addSubview(self.titleStack) + + [ + self.titleLabel, + self.subtitleLabel + ].forEach { + self.titleStack.addArrangedSubview($0) + } + self.titleStack.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + self.heightAnchor.constraint(equalToConstant: JourneyListHeaderView.estimatedHight), + + self.titleStack.topAnchor.constraint(equalTo: self.topAnchor), + self.titleStack.leadingAnchor.constraint(equalTo: self.leadingAnchor, + constant: Metric.horizontalInset), + self.titleStack.bottomAnchor.constraint(lessThanOrEqualTo: self.bottomAnchor, + constant: -Metric.verticalInset), + self.titleStack.trailingAnchor.constraint(equalTo: self.trailingAnchor, + constant: Metric.horizontalInset) + ]) + } + +} diff --git a/iOS/Features/JourneyList/Sources/JourneyList/Presentation/VIew/MSCollectionView.swift b/iOS/Features/JourneyList/Sources/JourneyList/Presentation/VIew/MSCollectionView.swift new file mode 100644 index 0000000..20b5798 --- /dev/null +++ b/iOS/Features/JourneyList/Sources/JourneyList/Presentation/VIew/MSCollectionView.swift @@ -0,0 +1,25 @@ +// +// MSCollectionView.swift +// JourneyList +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.02. +// + +import UIKit + +final class MSCollectionView: UICollectionView { + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + // Cell + let headers = self.visibleSupplementaryViews(ofKind: UICollectionView.elementKindSectionHeader) + let cells = self.visibleCells + for cell in cells where cell.frame.contains(point) { + for header in headers where !header.frame.contains(point) { + return super.hitTest(point, with: event) + } + } + + return nil + } + +} diff --git a/iOS/Features/RewindJourney/.gitignore b/iOS/Features/RewindJourney/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/iOS/Features/RewindJourney/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/iOS/Features/RewindJourney/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/iOS/Features/RewindJourney/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/iOS/Features/RewindJourney/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/iOS/Features/RewindJourney/Package.swift b/iOS/Features/RewindJourney/Package.swift new file mode 100644 index 0000000..0d75526 --- /dev/null +++ b/iOS/Features/RewindJourney/Package.swift @@ -0,0 +1,84 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +// MARK: - Constants + +private extension String { + + static let package = "FeatureRewindJourney" + + var testTarget: String { + return self + "Tests" + } + + var fromRootPath: String { + return "../../" + self + } + +} + +private enum Target { + + static let rewindJourney = "RewindJourney" + +} + +private enum Dependency { + + static let msDomain = "MSDomain" + static let msData = "MSData" + static let msDesignsystem = "MSDesignSystem" + static let msUIKit = "MSUIKit" + static let msNetworking = "MSNetworking" + static let msCoreKit = "MSCoreKit" + static let msExtension = "MSExtension" + static let msLogger = "MSLogger" + static let msFoundation = "MSFoundation" + +} + +// MARK: - Package + +let package = Package( + name: .package, + platforms: [ + .iOS(.v15) + ], + products: [ + .library(name: Target.rewindJourney, + targets: [Target.rewindJourney]) + ], + dependencies: [ + .package(name: Dependency.msDomain, + path: Dependency.msDomain.fromRootPath), + .package(name: Dependency.msData, + path: Dependency.msData.fromRootPath), + .package(name: Dependency.msUIKit, + path: Dependency.msUIKit.fromRootPath), + .package(name: Dependency.msCoreKit, + path: Dependency.msCoreKit.fromRootPath), + .package(name: Dependency.msFoundation, + path: Dependency.msFoundation.fromRootPath) + ], + targets: [ + .target(name: Target.rewindJourney, + dependencies: [ + .product(name: Dependency.msDomain, + package: Dependency.msDomain), + .product(name: Dependency.msUIKit, + package: Dependency.msUIKit), + .product(name: Dependency.msDesignsystem, + package: Dependency.msUIKit), + .product(name: Dependency.msLogger, + package: Dependency.msFoundation), + .product(name: Dependency.msExtension, + package: Dependency.msFoundation), + .product(name: Dependency.msNetworking, + package: Dependency.msCoreKit), + .product(name: Dependency.msData, + package: Dependency.msData) + ]) + ] +) diff --git a/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo.xcodeproj/project.pbxproj b/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo.xcodeproj/project.pbxproj new file mode 100644 index 0000000..763166a --- /dev/null +++ b/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo.xcodeproj/project.pbxproj @@ -0,0 +1,382 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + DDAA4DA62B173530002F0748 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAA4DA52B173530002F0748 /* AppDelegate.swift */; }; + DDAA4DA82B173530002F0748 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAA4DA72B173530002F0748 /* SceneDelegate.swift */; }; + DDAA4DAF2B173532002F0748 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DDAA4DAE2B173532002F0748 /* Assets.xcassets */; }; + DDAA4DB22B173532002F0748 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DDAA4DB02B173532002F0748 /* LaunchScreen.storyboard */; }; + DDAA4DBB2B173551002F0748 /* RewindJourney in Frameworks */ = {isa = PBXBuildFile; productRef = DDAA4DBA2B173551002F0748 /* RewindJourney */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + DDAA4DA22B173530002F0748 /* RewindJourneyDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RewindJourneyDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; + DDAA4DA52B173530002F0748 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + DDAA4DA72B173530002F0748 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + DDAA4DAE2B173532002F0748 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + DDAA4DB12B173532002F0748 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + DDAA4DB32B173532002F0748 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + DDAA4D9F2B173530002F0748 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DDAA4DBB2B173551002F0748 /* RewindJourney in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + DDAA4D992B173530002F0748 = { + isa = PBXGroup; + children = ( + DDAA4DA42B173530002F0748 /* RewindJourneyDemo */, + DDAA4DA32B173530002F0748 /* Products */, + ); + sourceTree = ""; + }; + DDAA4DA32B173530002F0748 /* Products */ = { + isa = PBXGroup; + children = ( + DDAA4DA22B173530002F0748 /* RewindJourneyDemo.app */, + ); + name = Products; + sourceTree = ""; + }; + DDAA4DA42B173530002F0748 /* RewindJourneyDemo */ = { + isa = PBXGroup; + children = ( + DDAA4DA52B173530002F0748 /* AppDelegate.swift */, + DDAA4DA72B173530002F0748 /* SceneDelegate.swift */, + DDAA4DAE2B173532002F0748 /* Assets.xcassets */, + DDAA4DB02B173532002F0748 /* LaunchScreen.storyboard */, + DDAA4DB32B173532002F0748 /* Info.plist */, + ); + path = RewindJourneyDemo; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + DDAA4DA12B173530002F0748 /* RewindJourneyDemo */ = { + isa = PBXNativeTarget; + buildConfigurationList = DDAA4DB62B173532002F0748 /* Build configuration list for PBXNativeTarget "RewindJourneyDemo" */; + buildPhases = ( + DDAA4D9E2B173530002F0748 /* Sources */, + DDAA4D9F2B173530002F0748 /* Frameworks */, + DDAA4DA02B173530002F0748 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = RewindJourneyDemo; + packageProductDependencies = ( + DDAA4DBA2B173551002F0748 /* RewindJourney */, + ); + productName = RewindJourneyDemo; + productReference = DDAA4DA22B173530002F0748 /* RewindJourneyDemo.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + DDAA4D9A2B173530002F0748 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + TargetAttributes = { + DDAA4DA12B173530002F0748 = { + CreatedOnToolsVersion = 15.0.1; + }; + }; + }; + buildConfigurationList = DDAA4D9D2B173530002F0748 /* Build configuration list for PBXProject "RewindJourneyDemo" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = DDAA4D992B173530002F0748; + packageReferences = ( + DDAA4DB92B173551002F0748 /* XCLocalSwiftPackageReference ".." */, + ); + productRefGroup = DDAA4DA32B173530002F0748 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + DDAA4DA12B173530002F0748 /* RewindJourneyDemo */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + DDAA4DA02B173530002F0748 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DDAA4DB22B173532002F0748 /* LaunchScreen.storyboard in Resources */, + DDAA4DAF2B173532002F0748 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + DDAA4D9E2B173530002F0748 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DDAA4DA62B173530002F0748 /* AppDelegate.swift in Sources */, + DDAA4DA82B173530002F0748 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + DDAA4DB02B173532002F0748 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + DDAA4DB12B173532002F0748 /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + DDAA4DB42B173532002F0748 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + 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; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + 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 = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + DDAA4DB52B173532002F0748 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + 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; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + 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 = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + DDAA4DB72B173532002F0748 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 26HTUC2WXR; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = RewindJourneyDemo/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = kr.codesquad.boostcamp8.RewindJourneyDemo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + DDAA4DB82B173532002F0748 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 26HTUC2WXR; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = RewindJourneyDemo/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = kr.codesquad.boostcamp8.RewindJourneyDemo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + DDAA4D9D2B173530002F0748 /* Build configuration list for PBXProject "RewindJourneyDemo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DDAA4DB42B173532002F0748 /* Debug */, + DDAA4DB52B173532002F0748 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DDAA4DB62B173532002F0748 /* Build configuration list for PBXNativeTarget "RewindJourneyDemo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DDAA4DB72B173532002F0748 /* Debug */, + DDAA4DB82B173532002F0748 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + DDAA4DB92B173551002F0748 /* XCLocalSwiftPackageReference ".." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ..; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + DDAA4DBA2B173551002F0748 /* RewindJourney */ = { + isa = XCSwiftPackageProductDependency; + productName = RewindJourney; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = DDAA4D9A2B173530002F0748 /* Project object */; +} diff --git a/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo.xcodeproj/xcshareddata/xcschemes/RewindJourneyDemo.xcscheme b/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo.xcodeproj/xcshareddata/xcschemes/RewindJourneyDemo.xcscheme new file mode 100644 index 0000000..50f5f3c --- /dev/null +++ b/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo.xcodeproj/xcshareddata/xcschemes/RewindJourneyDemo.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo/AppDelegate.swift b/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo/AppDelegate.swift new file mode 100644 index 0000000..d498076 --- /dev/null +++ b/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo/AppDelegate.swift @@ -0,0 +1,26 @@ +// +// AppDelegate.swift +// RewindJourneyDemo +// +// Created by ์ด์ฐฝ์ค€ on 2023.11.29. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions) -> UISceneConfiguration { + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + +} diff --git a/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo/Assets.xcassets/AccentColor.colorset/Contents.json b/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo/Assets.xcassets/AppIcon.appiconset/Contents.json b/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo/Assets.xcassets/Contents.json b/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo/Base.lproj/LaunchScreen.storyboard b/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo/Info.plist b/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo/Info.plist new file mode 100644 index 0000000..dd3c9af --- /dev/null +++ b/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo/Info.plist @@ -0,0 +1,25 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + + diff --git a/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo/SceneDelegate.swift b/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo/SceneDelegate.swift new file mode 100644 index 0000000..a5c9ce9 --- /dev/null +++ b/iOS/Features/RewindJourney/RewindJourneyDemo/RewindJourneyDemo/SceneDelegate.swift @@ -0,0 +1,39 @@ +// +// SceneDelegate.swift +// RewindJourneyDemo +// +// Created by ์ด์ฐฝ์ค€ on 2023.11.29. +// + +import UIKit + +import MSData +import MSDesignSystem +import RewindJourney + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + // MARK: - Properties + + var window: UIWindow? + + // MARK: - Scene + + func scene(_ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = (scene as? UIWindowScene) else { return } + let window = UIWindow(windowScene: windowScene) + defer { self.window = window } + + MSFont.registerFonts() + + let repository = SpotRepositoryImplementation() + let viewModel = RewindJourneyViewModel(repository: repository) + let viewController = RewindJourneyViewController(viewModel: viewModel) + + window.rootViewController = viewController + window.makeKeyAndVisible() + } + +} diff --git a/iOS/Features/RewindJourney/Sources/RewindJourney/Model/Spot.swift b/iOS/Features/RewindJourney/Sources/RewindJourney/Model/Spot.swift new file mode 100644 index 0000000..7257385 --- /dev/null +++ b/iOS/Features/RewindJourney/Sources/RewindJourney/Model/Spot.swift @@ -0,0 +1,24 @@ +// +// Spot.swift +// RewindJourney +// +// Created by ์ „๋ฏผ๊ฑด on 12/6/23. +// + +import Foundation + +import MSData + +struct Spot { + + let photoURL: URL + +} + +extension Spot { + + init(dto: SpotDTO) { + self.photoURL = dto.photoURL + } + +} diff --git a/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/Coordinator/RewindJourneyNavigationDelegate.swift b/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/Coordinator/RewindJourneyNavigationDelegate.swift new file mode 100644 index 0000000..8070c52 --- /dev/null +++ b/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/Coordinator/RewindJourneyNavigationDelegate.swift @@ -0,0 +1,14 @@ +// +// RewindJourneyNavigationDelegate.swift +// RewindJourney +// +// Created by ์ „๋ฏผ๊ฑด on 12/6/23. +// + +import Foundation + +public protocol RewindJourneyNavigationDelegate: AnyObject { + + func popToHomeMap() + +} diff --git a/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/MSMusicPlayerView.swift b/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/MSMusicPlayerView.swift new file mode 100644 index 0000000..77d4bf7 --- /dev/null +++ b/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/MSMusicPlayerView.swift @@ -0,0 +1,338 @@ +// +// MSMusicPlayerView.swift +// RewindJourney +// +// Created by ์ „๋ฏผ๊ฑด on 11/22/23. +// + +import Combine +import UIKit + +import MSDesignSystem +import MSDomain +import MSExtension +import MSImageFetcher +import MSLogger + +protocol MSMusicPlayerViewDelegate: AnyObject { + + func musicPlayerView(_ musicPlayerView: MSMusicPlayerView, didToggleMedia isPlaying: Bool) + +} + +final class MSMusicPlayerView: UIView { + + // MARK: - Constants + + private enum Metric { + + static let horizonalInset: CGFloat = 12.0 + static let cornerRadius: CGFloat = 8.0 + static let height: CGFloat = 68.0 + + // albumart view + enum AlbumArtView { + static let size: CGFloat = 52.0 + static let cornerRadius: CGFloat = 5.0 + } + + // playtime view + enum PlayTimeView { + static let spacing: CGFloat = 4.0 + static let iconSize: CGFloat = 24.0 + } + + } + + private enum Default { + + // titleView + enum TitleView { + static let title: String = "Attention" + static let subTitle: String = "NewJeans" + } + + // stackView + enum PlayTime { + static let time: String = "00 : 00" + } + + } + + // MARK: - Properties + + weak var delegate: MSMusicPlayerViewDelegate? + + let playbackPublisher = PassthroughSubject() + var timer: AnyCancellable? + + private var isPlaying: Bool = false + var playbackTime: TimeInterval = .zero + var duration: TimeInterval? + + // MARK: - UI Components + + private let progressView = UIView() + private var progressViewWidthConstraint: NSLayoutConstraint? + + private let albumArtView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.layer.cornerRadius = Metric.AlbumArtView.cornerRadius + imageView.clipsToBounds = true + imageView.backgroundColor = .msColor(.musicSpot) + return imageView + }() + + private let titleStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.alignment = .fill + return stackView + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.font = .msFont(.paragraph) + label.textColor = .msColor(.componentTypo) + label.text = Default.TitleView.title + return label + }() + + private let artistLabel: UILabel = { + let label = UILabel() + label.font = .msFont(.caption) + label.textColor = .msColor(.componentTypo) + label.text = Default.TitleView.subTitle + return label + }() + + private let playTimeStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = Metric.PlayTimeView.spacing + stackView.setContentHuggingPriority(.defaultHigh, for: .horizontal) + return stackView + }() + + private let playTimeLabel: UILabel = { + let label = UILabel() + label.font = .msFont(.caption) + label.textColor = .msColor(.componentTypo) + label.text = Default.PlayTime.time + label.setContentHuggingPriority(.defaultHigh, for: .horizontal) + return label + }() + + private let playTimeIconView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = .msColor(.componentTypo) + + if #available(iOS 17.0, *) { + imageView.image = UIImage(systemName: "waveform") + } else { + imageView.image = .msIcon(.voice) + } + + return imageView + }() + + private let controlButton: UIButton = { + var configuration = UIButton.Configuration.plain() + configuration.baseBackgroundColor = .clear + let button = UIButton(configuration: configuration) + return button + }() + + // MARK: - Initializer + + public override init(frame: CGRect) { + self.isPlaying = true + super.init(frame: frame) + + self.configureStyle() + self.configureLayout() + self.configureAction() + } + + required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based ๋กœ ๊ฐœ๋ฐœ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") + } + + deinit { + self.pause(playbackTime: .zero) + } + + // MARK: - Functions + + public func update(with music: Music) { + self.titleLabel.text = music.title + self.artistLabel.text = music.artist + if let albumCover = music.albumCover, + let coverURL = albumCover.url { + self.albumArtView.ms.setImage(with: coverURL, forKey: coverURL.paath()) + } + } + + public func togglePlayingStatus(to isPlaying: Bool) { + DispatchQueue.main.async { + self.progressView.backgroundColor = isPlaying ? .msColor(.musicSpot) : .msColor(.componentBackground) + + if #available(iOS 17.0, *) { + if isPlaying { + self.playTimeIconView.addSymbolEffect(.variableColor.cumulative.dimInactiveLayers.nonReversing) + } else { + self.playTimeIconView.removeAllSymbolEffects() + } + } + } + } + + public func play(progressingTimeInterval: TimeInterval = 0.1) { + self.timer = Timer.publish(every: progressingTimeInterval, on: .current, in: .common) + .autoconnect() + .receive(on: DispatchQueue.global(qos: .background)) + .sink { [weak self] _ in + guard let self = self else { return } + + self.playbackTime += progressingTimeInterval + DispatchQueue.main.async { + self.setProgress(to: self.playbackTime) + let minute = Int(self.playbackTime / 60.0) + let second = Int(self.playbackTime.truncatingRemainder(dividingBy: 60.0)) + self.playTimeLabel.text = String(format: "%02d : %02d", minute, second) + } + } + self.isPlaying = true + } + + public func pause(playbackTime: TimeInterval) { + self.timer?.cancel() + self.playbackTime = playbackTime + self.isPlaying = false + } + + private func setProgress(to time: TimeInterval, animted: Bool = true) { + guard let duration = self.duration else { return } + + let desiredWidth = (time * self.frame.width) / duration + + UIView.animate(withDuration: 0.1, + delay: .zero, + usingSpringWithDamping: 0.5, + initialSpringVelocity: 0.2, + options: [.curveEaseInOut]) { + self.progressViewWidthConstraint?.constant = desiredWidth + self.layoutIfNeeded() + } + } + +} + +// MARK: - UI Configuration + +private extension MSMusicPlayerView { + + func configureLayout() { + self.addSubview(self.progressView) + self.progressView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.progressView.topAnchor.constraint(equalTo: self.topAnchor), + self.progressView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + self.progressView.bottomAnchor.constraint(equalTo: self.bottomAnchor) + ]) + self.progressViewWidthConstraint = self.progressView.widthAnchor.constraint(equalToConstant: .zero) + self.progressViewWidthConstraint?.isActive = true + + self.configureAlbumArtViewLayout() + self.configurePlayTimeViewLayout() + self.configureTitleViewLayout() + + self.addSubview(self.controlButton) + self.controlButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.controlButton.topAnchor.constraint(equalTo: self.topAnchor), + self.controlButton.leadingAnchor.constraint(equalTo: self.leadingAnchor), + self.controlButton.bottomAnchor.constraint(equalTo: self.bottomAnchor), + self.controlButton.trailingAnchor.constraint(equalTo: self.trailingAnchor) + ]) + } + + func configureAlbumArtViewLayout() { + self.addSubview(self.albumArtView) + self.albumArtView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.albumArtView.centerYAnchor.constraint(equalTo: self.centerYAnchor), + self.albumArtView.leadingAnchor.constraint(equalTo: self.leadingAnchor, + constant: Metric.horizonalInset), + self.albumArtView.widthAnchor.constraint(equalToConstant: Metric.AlbumArtView.size), + self.albumArtView.heightAnchor.constraint(equalToConstant: Metric.AlbumArtView.size) + ]) + } + + func configureTitleViewLayout() { + self.addSubview(self.titleStackView) + self.titleStackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.titleStackView.centerYAnchor.constraint(equalTo: self.centerYAnchor), + self.titleStackView.leadingAnchor.constraint(equalTo: self.albumArtView.trailingAnchor, + constant: Metric.horizonalInset), + self.titleStackView.trailingAnchor.constraint(equalTo: self.playTimeStackView.leadingAnchor, + constant: -Metric.horizonalInset) + ]) + + [ + self.titleLabel, + self.artistLabel + ].forEach { + self.titleStackView.addArrangedSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + } + } + + func configurePlayTimeViewLayout() { + self.addSubview(self.playTimeStackView) + self.playTimeStackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.playTimeStackView.centerYAnchor.constraint(equalTo: self.centerYAnchor), + self.playTimeStackView.trailingAnchor.constraint(equalTo: self.trailingAnchor, + constant: -Metric.horizonalInset) + ]) + + [ + self.playTimeIconView, + self.playTimeLabel + ].forEach { + self.playTimeStackView.addArrangedSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + } + NSLayoutConstraint.activate([ + self.playTimeIconView.widthAnchor.constraint(equalToConstant: Metric.PlayTimeView.iconSize), + self.playTimeIconView.heightAnchor.constraint(equalToConstant: Metric.PlayTimeView.iconSize) + ]) + } + + // MARK: - UI Configuration: Style + + func configureStyle() { + self.clipsToBounds = true + self.layer.cornerRadius = Metric.cornerRadius + self.backgroundColor = .msColor(.componentBackground).withAlphaComponent(0.7) + self.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.heightAnchor.constraint(equalToConstant: Metric.height) + ]) + } + + // MARK: - UI Configuration: Button Action + + func configureAction() { + let action = UIAction { [weak self] _ in + guard let self = self else { return } + self.isPlaying.toggle() + self.delegate?.musicPlayerView(self, didToggleMedia: self.isPlaying) + } + self.controlButton.addAction(action, for: .touchUpInside) + } + +} diff --git a/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/MSProgressView.swift b/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/MSProgressView.swift new file mode 100644 index 0000000..7b76111 --- /dev/null +++ b/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/MSProgressView.swift @@ -0,0 +1,82 @@ +// +// MSProgressView.swift +// RewindJourney +// +// Created by ์ „๋ฏผ๊ฑด on 11/22/23. +// + +import UIKit +import Combine + +public final class MSProgressView: UIProgressView { + + // MARK: - Properties + + private let progressViewModel = MSProgressViewModel() + private var timerSubscriber: Set = [] + internal var percentage: Float = 0.0 { + didSet { + syncProgress(percentage: percentage) + } + } + internal var isHighlighted: Bool = false { + didSet { + if self.isHighlighted { + if self.isLeftOfCurrentHighlighting { + self.progressViewModel.stopTimer() + self.percentage = 1.0 + } else { + self.progressViewModel.startTimer() + } + } else { + self.progressViewModel.stopTimer() + self.percentage = 0.0 + } + } + } + internal var isLeftOfCurrentHighlighting: Bool = false + + // MARK: - Initializer + + public override init(frame: CGRect) { + super.init(frame: frame) + self.configureColor() + self.timerBinding() + } + + required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based ๋กœ ๊ฐœ๋ฐœ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") + } + + // MARK: - Configure + + private func configureColor() { + self.trackTintColor = .systemGray6 + self.tintColor = .white + } + + // MARK: - Functions: change progress + + private func syncProgress(percentage: Float) { + DispatchQueue.main.async { self.progress = percentage } + } + + // MARK: - Timer + + private func timerBinding() { + self.progressViewModel.timerPublisher + .sink { [weak self] currentPercentage in + self?.percentage = currentPercentage + } + .store(in: &timerSubscriber) + } + +} + +// MARK: - Preview + +@available(iOS 17, *) +#Preview { + let progressView = MSProgressView() + return progressView +} diff --git a/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/MSProgressViewModel.swift b/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/MSProgressViewModel.swift new file mode 100644 index 0000000..665d1b4 --- /dev/null +++ b/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/MSProgressViewModel.swift @@ -0,0 +1,57 @@ +// +// MSProgressViewModel.swift +// RewindJourney +// +// Created by ์ „๋ฏผ๊ฑด on 12/4/23. +// + +import Foundation +import Combine + +final class MSProgressViewModel { + + // MARK: - Constants + + private let timerDuration: Double = 4.0 + private let timerTimeInterval: Double = 0.01 + + // MARK: - Properties + + internal let timerPublisher = PassthroughSubject() + private var timer: AnyCancellable? + private var remainingTime: Double + + // MARK: Initializers + + init() { + self.remainingTime = self.timerDuration + } + + // MARK: - Functions: Timers + + internal func startTimer() { + self.timer = Timer.publish(every: self.timerTimeInterval, on: .current, in: .common) + .autoconnect() + .receive(on: DispatchQueue.global(qos: .background)) + .sink { [weak self] _ in + guard let remainingTime = self?.remainingTime, + let timerDuration = self?.timerDuration, + let timerTimeInterval = self?.timerTimeInterval else { return } + + self?.remainingTime -= timerTimeInterval + if remainingTime >= 0 { + let currentPercentage = ( timerDuration - remainingTime ) / timerDuration + self?.timerPublisher.send(Float(currentPercentage)) + } else { + self?.timerPublisher.send(1.0) + self?.stopTimer() + } + } + } + + internal func stopTimer() { + self.timer?.cancel() + self.remainingTime = self.timerDuration + } + +} diff --git a/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/RewindJourneyViewController+Gesture.swift b/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/RewindJourneyViewController+Gesture.swift new file mode 100644 index 0000000..9cebb18 --- /dev/null +++ b/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/RewindJourneyViewController+Gesture.swift @@ -0,0 +1,63 @@ +// +// RewindJourneyViewController+Gesture.swift +// RewindJourney +// +// Created by ์ „๋ฏผ๊ฑด on 12/6/23. +// + +import Foundation +import UIKit + +// MARK: - Constants + +private enum Metric { + + static let movedXPositionToBackScene: CGFloat = 50.0 + static let animationDuration: Double = 0.3 + +} + +// MARK: - Gesture + +internal extension RewindJourneyViewController { + + func configureLeftToRightSwipeGesture() { + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(panGestureDismiss(_:))) + self.view.addGestureRecognizer(panGesture) + } + + @objc + private func panGestureDismiss(_ sender: UIPanGestureRecognizer) { + let touchPoint = sender.location(in: self.view.window) + let width = self.view.frame.width + let height = self.view.frame.height + switch sender.state { + case .began: + self.initialTouchPoint = touchPoint + case .changed: + if touchPoint.x - self.initialTouchPoint.x > .zero { + self.view.frame = CGRect(x: touchPoint.x - self.initialTouchPoint.x, + y: .zero, + width: width, + height: height) + } + case .ended, .cancelled: + if touchPoint.x - self.initialTouchPoint.x > Metric.movedXPositionToBackScene { + self.navigationDelegate?.popToHomeMap() + } else { + self.view.frame = CGRect(x: .zero, + y: .zero, + width: width, + height: height) + } + default: + UIView.animate(withDuration: Metric.animationDuration) { + self.view.frame = CGRect(x: .zero, + y: .zero, + width: width, + height: height) + } + } + } + +} diff --git a/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/RewindJourneyViewController.swift b/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/RewindJourneyViewController.swift new file mode 100644 index 0000000..2d14369 --- /dev/null +++ b/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/RewindJourneyViewController.swift @@ -0,0 +1,378 @@ +// +// RewindJourneyViewController.swift +// RewindJourney +// +// Created by ์ „๋ฏผ๊ฑด on 11/22/23. +// + +import Combine +import UIKit +import MusicKit + +import MSData +import MSDesignSystem +import MSDomain +import MSExtension +import MSImageFetcher +import MSLogger +import MSUIKit + +public final class RewindJourneyViewController: UIViewController { + + // MARK: - Constants + + private enum Metric { + + // progress bar + enum Progressbar { + static let height: CGFloat = 4.0 + static let inset: CGFloat = 4.0 + static let defaultIndex: Int = 0 + } + + // stackView + enum StackView { + static let inset: CGFloat = 12.0 + } + + // musicPlayerView + enum MusicView { + static let horizontalInset: CGFloat = 12.0 + static let bottomInset: CGFloat = 34.0 + } + + } + + // MARK: - Properties + + public weak var navigationDelegate: RewindJourneyNavigationDelegate? + private let viewModel: RewindJourneyViewModel + + private var cancellables: Set = [] + private var presentingImageIndex: Int? { + didSet { + DispatchQueue.main.async { self.changeProgressViews() } + self.restartTimer() + } + } + + // MARK: - Properties: Timer + + private var timerSubscriber: Set = [] + + // MARK: - Properties: Gesture + + var initialTouchPoint = CGPoint(x: 0, y: 0) + + // MARK: - UI Components + + private let musicPlayer = ApplicationMusicPlayer.shared + + private let progressStackView = UIStackView() + private let presentImageView = UIImageView() + + private lazy var musicPlayerView: MSMusicPlayerView = { + let playerView = MSMusicPlayerView() + playerView.delegate = self + return playerView + }() + + private var progressViews: [MSProgressView]? + private var preHighlightenProgressView: MSProgressView? + private let leftTouchView = UIButton() + private let rightTouchView = UIButton() + + // MARK: - Initializer + + public init(viewModel: RewindJourneyViewModel, + nibName nibNameOrNil: String? = nil, + bundle nibBundleOrNil: Bundle? = nil) { + self.viewModel = viewModel + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + } + + required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - Life Cycle + + public override func viewDidLoad() { + super.viewDidLoad() + self.bind() + self.timerBinding() + self.configure() + self.viewModel.trigger(.viewNeedsLoaded) + } + + public override func viewDidAppear(_ animated: Bool) { + self.viewModel.trigger(.startAutoPlay) + } + + public override func viewDidDisappear(_ animated: Bool) { + self.viewModel.trigger(.stopAutoPlay) + self.musicPlayer.stop() + } + + // MARK: - Combine Binding + + private func bind() { + self.viewModel.state.photoURLs + .sink { [weak self] urls in + self?.configureProgressViewsLayout(urls: urls) + } + .store(in: &self.cancellables) + + self.viewModel.state.prefetchedMusic + .first() + .append(self.viewModel.state.selectedSong.compactMap { $0 }.map { Music($0) }) + .receive(on: DispatchQueue.main) + .sink { [weak self] music in + self?.musicPlayerView.update(with: music) + } + .store(in: &self.cancellables) + + self.viewModel.state.selectedSong + .compactMap { $0 } + .first() + .sink { [weak self] song in + self?.musicPlayer.queue = ApplicationMusicPlayer.Queue(for: [song]) + self?.musicPlayerView.duration = song.duration + Task { + try await self?.musicPlayer.prepareToPlay() + } + } + .store(in: &self.cancellables) + + self.viewModel.state.isSongPlaying + .sink { [weak self] isPlaying in + guard let self = self else { return } + Task { + if isPlaying { + try await self.musicPlayer.play() + self.musicPlayerView.play() + } else { + self.musicPlayer.pause() + self.musicPlayerView.pause(playbackTime: self.musicPlayer.playbackTime) + } + } + self.musicPlayerView.togglePlayingStatus(to: isPlaying) + } + .store(in: &self.cancellables) + } + + // MARK: - Timer + + private func timerBinding() { + self.viewModel.state.timerPublisher + .sink { [weak self] _ in + self?.rightTouchViewDidTap() + } + .store(in: &self.timerSubscriber) + } + + private func restartTimer() { + self.viewModel.trigger(.stopAutoPlay) + self.viewModel.trigger(.startAutoPlay) + } + + // MARK: - Actions + + private func leftTouchViewTapped() { + guard let presentingImageIndex = self.presentingImageIndex else { return } + + if presentingImageIndex > 0 { + let index = presentingImageIndex - 1 + self.presentingImageIndex = index + } + } + + private func rightTouchViewDidTap() { + guard let presentingImageIndex = self.presentingImageIndex else { return } + + if presentingImageIndex < self.viewModel.state.photoURLs.value.count - 1 { + let index = presentingImageIndex + 1 + self.presentingImageIndex = index + } + } +} + +// MARK: - MusicPlayerView + +extension RewindJourneyViewController: MSMusicPlayerViewDelegate { + + func musicPlayerView(_ musicPlayerView: MSMusicPlayerView, didToggleMedia isPlaying: Bool) { + self.viewModel.trigger(.toggleMusic(isPlaying: isPlaying)) + } + +} + +// MARK: - UI Configuration + +private extension RewindJourneyViewController { + + func configure() { + self.configureLayout() + self.configureStyle() + self.configureAction() + + self.configureLeftToRightSwipeGesture() + } + + // MARK: - UI Configuration: Layout + + func configureLayout() { + self.configurePresentImageViewLayout() + self.configureStackViewLayout() + self.configureTouchViewLayout() + self.configureMusicViewLayout() + } + + func configurePresentImageViewLayout() { + self.view.addSubview(self.presentImageView) + self.presentImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.presentImageView.topAnchor.constraint(equalTo: self.view.topAnchor), + self.presentImageView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + self.presentImageView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.presentImageView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor) + ]) + } + + func configureStackViewLayout() { + self.view.addSubview(self.progressStackView) + self.progressStackView.axis = .horizontal + self.progressStackView.spacing = Metric.Progressbar.inset + self.progressStackView.distribution = .fillEqually + self.progressStackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.progressStackView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor), + self.progressStackView.heightAnchor.constraint(equalToConstant: Metric.Progressbar.height), + self.progressStackView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, + constant: Metric.StackView.inset), + self.progressStackView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, + constant: -Metric.StackView.inset)]) + } + + @MainActor + func configureProgressViewsLayout(urls: [URL]) { + self.progressViews?.forEach { $0.removeFromSuperview() } + self.progressViews?.removeAll() + urls.forEach { _ in + let progressView = MSProgressView() + self.progressStackView.addArrangedSubview(progressView) + if self.progressViews == nil { self.progressViews = [] } + self.progressViews?.append(progressView) + } + } + + func configureTouchViewLayout() { + self.view.addSubview(self.leftTouchView) + self.view.addSubview(self.rightTouchView) + + self.leftTouchView.translatesAutoresizingMaskIntoConstraints = false + self.rightTouchView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.leftTouchView.topAnchor.constraint(equalTo: self.view.topAnchor), + self.rightTouchView.topAnchor.constraint(equalTo: self.view.topAnchor), + self.leftTouchView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + self.rightTouchView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + self.leftTouchView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.rightTouchView.leadingAnchor.constraint(equalTo: self.view.centerXAnchor), + self.leftTouchView.trailingAnchor.constraint(equalTo: self.view.centerXAnchor), + self.rightTouchView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor) + ]) + } + + func configureMusicViewLayout() { + self.view.addSubview(self.musicPlayerView) + self.musicPlayerView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.musicPlayerView.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, + constant: -Metric.MusicView.bottomInset), + self.musicPlayerView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, + constant: Metric.MusicView.horizontalInset), + self.musicPlayerView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, + constant: -Metric.MusicView.horizontalInset) + ]) + } + + // MARK: - UI Configuration: Style + + func configureStyle() { + self.view.backgroundColor = .msColor(.primaryBackground) + self.configurePresentImageViewStyle() + self.configureProgressbarsStyle() + } + + func configurePresentImageViewStyle() { + self.presentImageView.contentMode = .scaleAspectFit + } + + func configureProgressbarsStyle() { + self.presentingImageIndex = Metric.Progressbar.defaultIndex + } + + // MARK: - Configuration: Action + + func configureAction() { + self.configureLeftTouchViewAction() + self.configureRightTouchViewAction() + } + + private func configureLeftTouchViewAction() { + let action = UIAction { [weak self] _ in + self?.leftTouchViewTapped() + } + self.leftTouchView.addAction(action, for: .touchUpInside) + } + + func configureRightTouchViewAction() { + let action = UIAction { [weak self] _ in + self?.rightTouchViewDidTap() + } + self.rightTouchView.addAction(action, for: .touchUpInside) + } + + func changeProgressViews() { + let photoURLs = self.viewModel.state.photoURLs.value + guard let presentingIndex = self.presentingImageIndex, + photoURLs.count > presentingIndex else { + return + } + + let photoURL = photoURLs[presentingIndex] + self.presentImageView.ms.setImage(with: photoURL, forKey: photoURL.paath()) + self.preHighlightenProgressView = self.progressViews?[presentingIndex] + self.preHighlightenProgressView?.isHighlighted = false + + let minIndex: Int = .zero + let maxIndex = photoURLs.count - 1 + + for index in minIndex...maxIndex { + self.progressViews?[index].isLeftOfCurrentHighlighting = index < presentingIndex ? true : false + self.progressViews?[index].isHighlighted = index <= presentingIndex ? true : false + } + } + +} + +// MARK: - Preview + +#if DEBUG +import MSDomain + +@available(iOS 17, *) +#Preview { + MSFont.registerFonts() + let spotRepository = SpotRepositoryImplementation() + let songRepository = SongRepositoryImplementation() + let music = Music(id: UUID().uuidString, title: "Super Shy", artist: "NewJeans", albumCover: nil) + let rewindJourneyViewModel = RewindJourneyViewModel(photoURLs: [], + music: music, + spotRepository: spotRepository, + songRepository: songRepository) + let rewindJourneyViewController = RewindJourneyViewController(viewModel: rewindJourneyViewModel) + return rewindJourneyViewController +} +#endif diff --git a/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/RewindJourneyViewModel.swift b/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/RewindJourneyViewModel.swift new file mode 100644 index 0000000..8d7bcc0 --- /dev/null +++ b/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/RewindJourneyViewModel.swift @@ -0,0 +1,136 @@ +// +// RewindJourneyViewModel.swift +// RewindJourney +// +// Created by ์ „๋ฏผ๊ฑด on 11/30/23. +// + +import Combine +import Foundation +import MusicKit + +import MSData +import MSDomain +import MSExtension +import MSImageFetcher +import MSLogger + +public final class RewindJourneyViewModel { + + public enum Action { + case viewNeedsLoaded + case startAutoPlay + case stopAutoPlay + case toggleMusic(isPlaying: Bool) + } + + public struct State { + // Passthrough + let timerPublisher = PassthroughSubject() + + // CurrentValue + public let photoURLs: CurrentValueSubject<[URL], Never> + public let prefetchedMusic: CurrentValueSubject + public let selectedSong = CurrentValueSubject(nil) + public let isSongPlaying = CurrentValueSubject(false) + } + + // MARK: - Properties + + private let spotRepository: SpotRepository + private let songRepository: SongRepository + + public var state: State + + private var cancellables: Set = [] + + // MARK: - Properties: Timer + + private let timerTimeInterval: Double = 10.0 + private var timer: AnyCancellable? + + // MARK: - Initializer + + public init(photoURLs: [URL], + music: Music, + spotRepository: SpotRepository, + songRepository: SongRepository) { + self.spotRepository = spotRepository + self.songRepository = songRepository + self.state = State(photoURLs: CurrentValueSubject<[URL], Never>(photoURLs), + prefetchedMusic: CurrentValueSubject(music)) + } + +} + +// MARK: - Interface: Actions + +extension RewindJourneyViewModel { + + func trigger(_ action: Action) { + switch action { + case .viewNeedsLoaded: + Task { + let photoURLs = self.state.photoURLs.value + + for photoURL in photoURLs { + await withTaskGroup(of: Void.self) { group in + group.addTask { + await MSImageFetcher.shared.fetchImage(from: photoURL, forKey: photoURL.paath()) + } + } + } + } + let songID = self.state.prefetchedMusic.value.id + self.fetchMusic(byID: songID) + case .startAutoPlay: + self.startTimer() + case .stopAutoPlay: + self.stopTimer() + case .toggleMusic(let isPlaying): + self.state.isSongPlaying.send(isPlaying) + } + } + +} + +// MARK: - Functions: Music + +private extension RewindJourneyViewModel { + + func fetchMusic(byID id: String) { + Task { + let result = await self.songRepository.fetchSong(withID: id) + switch result { + case .success(let song): + #if DEBUG + MSLogger.make(category: .rewindJourney).debug("์Œ์•…์„ ์ฐพ์•˜์Šต๋‹ˆ๋‹ค: \(song)") + #endif + self.state.selectedSong.send(song) + self.state.isSongPlaying.send(true) + case .failure(let error): + MSLogger.make(category: .rewindJourney).error("\(error)") + } + } + } + +} + +// MARK: - Functions: Timer + +private extension RewindJourneyViewModel { + + func startTimer() { + self.timer = Timer.publish(every: self.timerTimeInterval, on: .main, in: .common) + .autoconnect() + .receive(on: DispatchQueue.global(qos: .background)) + .sink { [weak self] _ in + self?.state.timerPublisher.send() + } + } + + func stopTimer() { + self.timer?.cancel() + } + +} diff --git a/iOS/Features/SaveJourney/.gitignore b/iOS/Features/SaveJourney/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/iOS/Features/SaveJourney/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/iOS/Features/SaveJourney/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/iOS/Features/SaveJourney/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/iOS/Features/SaveJourney/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/iOS/Features/SaveJourney/Package.swift b/iOS/Features/SaveJourney/Package.swift new file mode 100644 index 0000000..c517c57 --- /dev/null +++ b/iOS/Features/SaveJourney/Package.swift @@ -0,0 +1,88 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +// MARK: - Constants + +private extension String { + + static let package = "FeatureSaveJourney" + + var testTarget: String { + return self + "Tests" + } + + var fromRootPath: String { + return "../../" + self + } + +} + +private enum Target { + + static let saveJourney = "SaveJourney" + +} + +private enum Dependency { + + static let msDomain = "MSDomain" + static let msData = "MSData" + + static let combineCocoa = "CombineCocoa" + static let msDesignsystem = "MSDesignSystem" + static let msUIKit = "MSUIKit" + + static let msImageFetcher = "MSImageFetcher" + static let msCoreKit = "MSCoreKit" + + static let msExtension = "MSExtension" + static let msLogger = "MSLogger" + static let msFoundation = "MSFoundation" + +} + +// MARK: - Package + +let package = Package( + name: .package, + platforms: [ + .iOS(.v15) + ], + products: [ + .library(name: Target.saveJourney, + targets: [Target.saveJourney]) + ], + dependencies: [ + .package(name: Dependency.msDomain, + path: Dependency.msDomain.fromRootPath), + .package(name: Dependency.msUIKit, + path: Dependency.msUIKit.fromRootPath), + .package(name: Dependency.msData, + path: Dependency.msData.fromRootPath), + .package(name: Dependency.msCoreKit, + path: Dependency.msCoreKit.fromRootPath), + .package(name: Dependency.msFoundation, + path: Dependency.msFoundation.fromRootPath) + ], + targets: [ + .target(name: Target.saveJourney, + dependencies: [ + .product(name: Dependency.msDomain, + package: Dependency.msDomain), + .product(name: Dependency.msData, + package: Dependency.msData), + .product(name: Dependency.msUIKit, + package: Dependency.msUIKit), + .product(name: Dependency.combineCocoa, + package: Dependency.msUIKit), + .product(name: Dependency.msImageFetcher, + package: Dependency.msCoreKit), + .product(name: Dependency.msExtension, + package: Dependency.msFoundation), + .product(name: Dependency.msLogger, + package: Dependency.msFoundation) + ]) + ] +) diff --git a/iOS/Features/SaveJourney/SaveJourneyDemo/SaveJourneyDemo/AppDelegate.swift b/iOS/Features/SaveJourney/SaveJourneyDemo/SaveJourneyDemo/AppDelegate.swift new file mode 100644 index 0000000..558d379 --- /dev/null +++ b/iOS/Features/SaveJourney/SaveJourneyDemo/SaveJourneyDemo/AppDelegate.swift @@ -0,0 +1,26 @@ +// +// AppDelegate.swift +// SaveJourneyDemo +// +// Created by ์ด์ฐฝ์ค€ on 2023.11.29. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions) -> UISceneConfiguration { + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + +} diff --git a/iOS/Features/SaveJourney/SaveJourneyDemo/SaveJourneyDemo/Assets.xcassets/AccentColor.colorset/Contents.json b/iOS/Features/SaveJourney/SaveJourneyDemo/SaveJourneyDemo/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/iOS/Features/SaveJourney/SaveJourneyDemo/SaveJourneyDemo/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Features/SaveJourney/SaveJourneyDemo/SaveJourneyDemo/Assets.xcassets/AppIcon.appiconset/Contents.json b/iOS/Features/SaveJourney/SaveJourneyDemo/SaveJourneyDemo/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/iOS/Features/SaveJourney/SaveJourneyDemo/SaveJourneyDemo/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Features/SaveJourney/SaveJourneyDemo/SaveJourneyDemo/Assets.xcassets/Contents.json b/iOS/Features/SaveJourney/SaveJourneyDemo/SaveJourneyDemo/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iOS/Features/SaveJourney/SaveJourneyDemo/SaveJourneyDemo/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Features/SaveJourney/SaveJourneyDemo/SaveJourneyDemo/Base.lproj/LaunchScreen.storyboard b/iOS/Features/SaveJourney/SaveJourneyDemo/SaveJourneyDemo/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..ea13b37 --- /dev/null +++ b/iOS/Features/SaveJourney/SaveJourneyDemo/SaveJourneyDemo/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/Features/SaveJourney/SaveJourneyDemo/SaveJourneyDemo/Info.plist b/iOS/Features/SaveJourney/SaveJourneyDemo/SaveJourneyDemo/Info.plist new file mode 100644 index 0000000..0185a10 --- /dev/null +++ b/iOS/Features/SaveJourney/SaveJourneyDemo/SaveJourneyDemo/Info.plist @@ -0,0 +1,25 @@ + + + + + NSAppleMusicUsageDescription + ์Œ์•… ์ข€ ์“ธ๊ฒŒ์š” + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/iOS/Features/SaveJourney/SaveJourneyDemo/SaveJourneyDemo/SceneDelegate.swift b/iOS/Features/SaveJourney/SaveJourneyDemo/SaveJourneyDemo/SceneDelegate.swift new file mode 100644 index 0000000..2f0b8f7 --- /dev/null +++ b/iOS/Features/SaveJourney/SaveJourneyDemo/SaveJourneyDemo/SceneDelegate.swift @@ -0,0 +1,42 @@ +// +// SceneDelegate.swift +// SaveJourneyDemo +// +// Created by ์ด์ฐฝ์ค€ on 2023.11.29. +// + +import UIKit + +import MSData +import MSDesignSystem +import SaveJourney + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + // MARK: - Properties + + var window: UIWindow? + + // MARK: - Functions + + func scene(_ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = (scene as? UIWindowScene) else { return } + let window = UIWindow(windowScene: windowScene) + defer { self.window = window } + + MSFont.registerFonts() + + let song = Song(title: "OMG", artist: "NewJeans", albumArtURL: URL(string: "https://naver.com")!) + let spotRepository = SpotRepositoryImplementation() + let saveJourneyViewModel = SaveJourneyViewModel(selectedSong: song, + spotRepository: spotRepository) + let saveJourneyViewController = SaveJourneyViewController(viewModel: saveJourneyViewModel) + let navigationViewController = UINavigationController(rootViewController: saveJourneyViewController) + + window.rootViewController = navigationViewController + window.makeKeyAndVisible() + } + +} diff --git a/iOS/Features/SaveJourney/Sources/SaveJourney/Model/SpotCellModel.swift b/iOS/Features/SaveJourney/Sources/SaveJourney/Model/SpotCellModel.swift new file mode 100644 index 0000000..edc5399 --- /dev/null +++ b/iOS/Features/SaveJourney/Sources/SaveJourney/Model/SpotCellModel.swift @@ -0,0 +1,28 @@ +// +// SpotCellModel.swift +// SaveJourney +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.04. +// + +import Foundation + +public struct SpotCellModel: Hashable { + + // MARK: - Properties + + let location: String + let date: Date + let photoURL: URL + + // MARK: - Initializer + + public init(location: String, + date: Date, + photoURL: URL) { + self.location = location + self.date = date + self.photoURL = photoURL + } + +} diff --git a/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/ButtonStateFactor.swift b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/ButtonStateFactor.swift new file mode 100644 index 0000000..66d02eb --- /dev/null +++ b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/ButtonStateFactor.swift @@ -0,0 +1,15 @@ +// +// ButtonStateFactor.swift +// SaveJourney +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.09. +// + +import Foundation + +final class ButtonStateFactor: ObservableObject { + + @Published var canBecomeSubscriber: Bool = false + @Published var isMusicPlaying: Bool = false + +} diff --git a/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/ConfirmTitleAlertViewController.swift b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/ConfirmTitleAlertViewController.swift new file mode 100644 index 0000000..9fc7513 --- /dev/null +++ b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/ConfirmTitleAlertViewController.swift @@ -0,0 +1,124 @@ +// +// ConfirmTitleAlertViewController.swift +// SaveJourney +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.04. +// + +import Combine +import UIKit + +import CombineCocoa +import MSUIKit + +protocol AlertViewControllerDelegate: AnyObject { + + func titleDidConfirmed(_ title: String) + +} + +final class ConfirmTitleAlertViewController: MSAlertViewController { + + // MARK: - Constants + + private enum Typo { + + static let title = "์—ฌ์ • ์ด๋ฆ„" + static let placeholder = "๋งˆ์ง€๋ง‰์œผ๋กœ ์—ฌ์ •์˜ ์ด๋ฆ„์„ ์ •ํ•ด์ฃผ์„ธ์š”." + + } + + private enum Metric { + + static let horizontalInset: CGFloat = 12.0 + static let textFieldCenterOffset: CGFloat = 15.0 + + } + + // MARK: - UI Components + + private let textField: MSTextField = { + let textField = MSTextField() + textField.imageStyle = .none + textField.clearButtonMode = .whileEditing + textField.enablesReturnKeyAutomatically = true + textField.placeholder = Typo.placeholder + return textField + }() + + // MARK: - Properties + + weak var delegate: AlertViewControllerDelegate? + + private var cancellables: Set = [] + + // MARK: - Life Cycle + + override func viewDidLoad() { + super.viewDidLoad() + self.configureButtonActions() + self.bind() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + self.textField.becomeFirstResponder() + } + + // MARK: - Combine Binding + + private func bind() { + self.textField.textPublisher + .receive(on: DispatchQueue.main) + .map { $0.isEmpty } + .removeDuplicates() + .sink { [weak self] isTextEmpty in + self?.updateDoneButton(isEnabled: !isTextEmpty) + } + .store(in: &self.cancellables) + } + + // MARK: - Helpers + + override func dismissBottomSheet() { + super.dismissBottomSheet() + self.textField.resignFirstResponder() + } + + private func configureButtonActions() { + self.cancelButtonAction = UIAction { [weak self] _ in + self?.dismissBottomSheet() + } + + self.doneButtonAction = UIAction { [weak self] _ in + guard let title = self?.textField.text else { return } + self?.dismissBottomSheet() + self?.delegate?.titleDidConfirmed(title) + } + } + + // MARK: - UI Configuration + + override func configureStyles() { + super.configureStyles() + + self.updateTitle(Typo.title) + } + + override func configureLayout() { + super.configureLayout() + + self.containerView.addSubview(self.textField) + self.textField.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.textField.centerYAnchor.constraint(equalTo: self.containerView.centerYAnchor, + constant: -Metric.textFieldCenterOffset), + self.textField.leadingAnchor.constraint(equalTo: self.containerView.leadingAnchor, + constant: Metric.horizontalInset), + self.textField.trailingAnchor.constraint(equalTo: self.containerView.trailingAnchor, + constant: -Metric.horizontalInset) + ]) + } + +} diff --git a/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/Coordinator/SaveJourneyNavigationDelegate.swift b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/Coordinator/SaveJourneyNavigationDelegate.swift new file mode 100644 index 0000000..3e61e82 --- /dev/null +++ b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/Coordinator/SaveJourneyNavigationDelegate.swift @@ -0,0 +1,16 @@ +// +// SaveJourneyNavigationDelegate.swift +// SaveJourney +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.07. +// + +import Foundation + +import MSDomain + +public protocol SaveJourneyNavigationDelegate: AnyObject { + + func popToHome(with endedJourney: Journey) + +} diff --git a/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/SaveJourneySection.swift b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/SaveJourneySection.swift new file mode 100644 index 0000000..6e7cf9d --- /dev/null +++ b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/SaveJourneySection.swift @@ -0,0 +1,57 @@ +// +// SaveJourneySection.swift +// SaveJourney +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.04. +// + +import UIKit + +import MSDomain + +// MARK: - Section + +enum SaveJourneySection: Hashable { + case music + case spot + case empty + + var itemSize: NSCollectionLayoutSize { + switch self { + case .music: + return NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalHeight(1.0)) + case .spot: + return NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5), + heightDimension: .fractionalWidth(0.5)) + case .empty: + return NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalHeight(1.0)) + } + } + + var groupSize: NSCollectionLayoutSize { + switch self { + case .music: + return NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(SaveJourneyMusicCell.estimatedHeight)) + case .spot: + return NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalWidth(0.5)) + case .empty: + return NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalWidth(1.0)) + } + } + +} + +// MARK: - Item + +enum SaveJourneyItem: Hashable { + + case music(Music) + case spot(Spot) + case empty + +} diff --git a/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/SaveJourneyViewController+CollectionView.swift b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/SaveJourneyViewController+CollectionView.swift new file mode 100644 index 0000000..80f8f13 --- /dev/null +++ b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/SaveJourneyViewController+CollectionView.swift @@ -0,0 +1,210 @@ +// +// SaveJourneyViewController+CollectionView.swift +// SaveJourney +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.08. +// + +import CoreLocation +import UIKit + +import MSLogger + +// MARK: - Collection View + +extension SaveJourneyViewController { + + // MARK: - Constants + + private enum Typo { + + static let songSectionTitle = "ํ•จ๊ป˜ํ•œ ์Œ์•…" + static func spotSectionTitle(_ numberOfSpots: Int) -> String { + return "\(numberOfSpots)๊ฐœ์˜ ์ŠคํŒŸ" + } + + } + + private enum Metric { + + static let horizontalInset: CGFloat = 24.0 + static let verticalInset: CGFloat = 12.0 + static let innerSpacing: CGFloat = 4.0 + static let headerTopInset: CGFloat = 24.0 + + } + + // MARK: - Configuration: CollectionView + + func configureCollectionView() { + let layout = UICollectionViewCompositionalLayout(sectionProvider: { sectionIndex, _ in + guard let section = self.dataSource?.sectionIdentifier(for: sectionIndex) else { return .none } + return self.configureSection(for: section) + }) + layout.register(SaveJourneyBackgroundView.self, + forDecorationViewOfKind: SaveJourneyBackgroundView.elementKind) + + self.collectionView.setCollectionViewLayout(layout, animated: false) + let dataSource = self.configureDataSource() + self.setDataSource(dataSource) + } + + func configureSection(for section: SaveJourneySection) -> NSCollectionLayoutSection { + let layoutItem = NSCollectionLayoutItem(layoutSize: section.itemSize) + + let itemCount = section == .music ? 1 : 2 + let interItemSpacing: CGFloat = section == .music ? .zero : Metric.innerSpacing + let layoutGroup = NSCollectionLayoutGroup.horizontal(layoutSize: section.groupSize, + item: layoutItem, + count: itemCount) + layoutGroup.interItemSpacing = .fixed(interItemSpacing) + + let layoutSection = NSCollectionLayoutSection(group: layoutGroup) + layoutSection.interGroupSpacing = Metric.innerSpacing + layoutSection.contentInsets = NSDirectionalEdgeInsets(top: Metric.verticalInset, + leading: Metric.horizontalInset, + bottom: Metric.verticalInset, + trailing: Metric.horizontalInset) + + let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(SaveJourneyHeaderView.estimatedHeight)) + let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, + elementKind: SaveJourneyHeaderView.elementKind, + alignment: .top) + header.contentInsets = NSDirectionalEdgeInsets(top: -Metric.headerTopInset, + leading: .zero, + bottom: .zero, + trailing: .zero) + if section != .empty { + layoutSection.boundarySupplementaryItems = [header] + } + + let backgroundView = NSCollectionLayoutDecorationItem.background( + elementKind: SaveJourneyBackgroundView.elementKind) + layoutSection.decorationItems = [backgroundView] + + return layoutSection + } + + func configureDataSource() -> SaveJourneyDataSource { + let musicCellRegistration = MusicCellRegistration { cell, _, itemIdentifier in + cell.update(with: itemIdentifier) + } + + let spotCellRegistration = SpotCellRegistration { cell, _, itemIdentifier in + Task { + let location = try await itemIdentifier.locationFromSpotCoordinates() + + let cellModel = SpotCellModel(location: location, + date: itemIdentifier.timestamp, + photoURL: itemIdentifier.photoURL) + cell.update(with: cellModel) + } + } + + let emptyCellRegistration = EmptyCellRegistration { _, _, _ in } + + let headerRegistration = HeaderRegistration(elementKind: SaveJourneyHeaderView.elementKind, + handler: { header, _, indexPath in + switch indexPath.section { + case .zero: + header.update(with: Typo.songSectionTitle) + case 1: + guard let spots = self.viewModel.state.recordingJourney.value?.spots else { return } + header.update(with: Typo.spotSectionTitle(spots.count)) + default: + break + } + }) + + let dataSource = SaveJourneyDataSource(collectionView: self.collectionView, + cellProvider: { collectionView, indexPath, item in + switch item { + case .music(let song): + return collectionView.dequeueConfiguredReusableCell(using: musicCellRegistration, + for: indexPath, + item: song) + case .spot(let spot): + return collectionView.dequeueConfiguredReusableCell(using: spotCellRegistration, + for: indexPath, + item: spot) + case .empty: + return collectionView.dequeueConfiguredReusableCell(using: emptyCellRegistration, + for: indexPath, + item: UUID()) + } + }) + + dataSource.supplementaryViewProvider = { collectionView, _, indexPath in + return collectionView.dequeueConfiguredReusableSupplementary(using: headerRegistration, + for: indexPath) + } + + var snapshot = SaveJourneySnapshot() + snapshot.appendSections([.music, .spot, .empty]) + dataSource.apply(snapshot) + + return dataSource + } + +} + +// MARK: - CollectionView Scroll + +extension SaveJourneyViewController: UICollectionViewDelegate { + + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + // TODO: ๋งต UX ๊ฐœ์„  +// let offset = scrollView.contentOffset +// self.updateMapViewSize(by: offset) + } + + private func updateMapViewSize(by offset: CGPoint) { + let beginStretchingOffset = -self.view.frame.width + + self.mapViewHeightConstraint?.constant = self.view.frame.width + if offset.y < beginStretchingOffset { + let stretchingSize = offset.y.magnitude - self.view.frame.width + #if DEBUG + MSLogger.make(category: .uiKit).debug("MapView should stretch for: \(stretchingSize)") + #endif + + self.mapViewHeightConstraint?.constant += stretchingSize + } + + self.view.layoutIfNeeded() + } + +} + +// MARK: - Extension: Spot + +import MSDomain + +private extension Spot { + + func locationFromSpotCoordinates() async throws -> String { + let geocoder = CLGeocoder() + let location = CLLocation(latitude: self.coordinate.latitude, + longitude: self.coordinate.longitude) + + do { + let placemark = try await geocoder.reverseGeocodeLocation(location).first + + var addressComponents: [String] = [] + + if let locality = placemark?.locality { + addressComponents.append(locality) + } + + if let subLocality = placemark?.subLocality { + addressComponents.append(subLocality) + } + + return addressComponents.joined(separator: " ") + } catch { + throw error + } + } + +} diff --git a/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/SaveJourneyViewController.swift b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/SaveJourneyViewController.swift new file mode 100644 index 0000000..3fc18ca --- /dev/null +++ b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/SaveJourneyViewController.swift @@ -0,0 +1,381 @@ +// +// SaveJourneyViewController.swift +// SaveJourney +// +// Created by ์ด์ฐฝ์ค€ on 11/24/23. +// + +import Combine +import CoreLocation +import MapKit +import MusicKit +import StoreKit +import SwiftUI +import UIKit + +import MSDomain +import MSUIKit + +public final class SaveJourneyViewController: UIViewController { + + typealias SaveJourneyDataSource = UICollectionViewDiffableDataSource + typealias HeaderRegistration = UICollectionView.SupplementaryRegistration + typealias MusicCellRegistration = UICollectionView.CellRegistration + typealias SpotCellRegistration = UICollectionView.CellRegistration + typealias EmptyCellRegistration = UICollectionView.CellRegistration + typealias SaveJourneySnapshot = NSDiffableDataSourceSnapshot + typealias MusicSnapshot = NSDiffableDataSourceSectionSnapshot + typealias SpotSnapshot = NSDiffableDataSourceSectionSnapshot + typealias EmptySnapshot = NSDiffableDataSourceSectionSnapshot + + // MARK: - Constants + + private enum Typo { + + static let nextButtonTitle = "๋‹ค์Œ" + + } + + private enum Metric { + + static let collectionViewBottomSpacing: CGFloat = 150.0 + static let buttonSpacing: CGFloat = 4.0 + static let buttonBottomInset: CGFloat = 24.0 + static let lineWidth: CGFloat = 0.5 + static let mapSpanAssistance: CGFloat = 1.2 + + } + + // MARK: - Properties + + public weak var navigationDelegate: SaveJourneyNavigationDelegate? + + let viewModel: SaveJourneyViewModel + private(set) var dataSource: SaveJourneyDataSource? + + private var cancellables: Set = [] + + // MARK: - UI Components + + private let musicPlayer = ApplicationMusicPlayer.shared + + private lazy var mapView: MKMapView = { + let mapView = MKMapView() + mapView.clipsToBounds = true + mapView.delegate = self + return mapView + }() + + var mapViewHeightConstraint: NSLayoutConstraint? + + private(set) lazy var collectionView: UICollectionView = { + let collectionView = UICollectionView(frame: .zero, + collectionViewLayout: UICollectionViewLayout()) + collectionView.backgroundColor = .clear + collectionView.contentInset = UIEdgeInsets(top: self.view.frame.width, + left: .zero, + bottom: Metric.collectionViewBottomSpacing, + right: .zero) + collectionView.contentInsetAdjustmentBehavior = .never + collectionView.delegate = self + return collectionView + }() + + private let buttonStack: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = Metric.buttonSpacing + stackView.alignment = .center + return stackView + }() + + private let mediaControlButton: MSRectButton = { + let button = MSRectButton.small() + button.image = .msIcon(.play) + return button + }() + + private lazy var musicControlButton: UIHostingController = { + let stateFactor = self.viewModel.state.buttonStateFactors.value + let button = UIHostingController(rootView: MusicControlButton(stateFactor: stateFactor)) + return button + }() + + private lazy var nextButton: MSButton = { + let button = MSButton.primary() + button.title = Typo.nextButtonTitle + return button + }() + + // MARK: - Initializer + + public init(viewModel: SaveJourneyViewModel, + nibName nibNameOrNil: String? = nil, + bundle nibBundleOrNil: Bundle? = nil) { + self.viewModel = viewModel + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + } + + required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - Life Cycle + + public override func viewDidLoad() { + super.viewDidLoad() + self.configureStyles() + self.configureLayout() + self.configureComponents() + self.bind() + self.viewModel.trigger(.viewNeedsLoaded) + } + + public override func viewIsAppearing(_ animated: Bool) { + super.viewIsAppearing(animated) + self.navigationController?.isNavigationBarHidden = false + } + + public override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + self.musicPlayer.pause() + } + + // MARK: - Combine Binding + + private func bind() { + self.viewModel.state.selectedSong + .first() + .receive(on: DispatchQueue.main) + .sink { [weak self] song in + guard let self = self else { return } + + self.musicControlButton.rootView.song = song + Task { + self.musicPlayer.queue = ApplicationMusicPlayer.Queue(for: [song]) + try await self.musicPlayer.prepareToPlay() + self.viewModel.trigger(.musicControlButtonDidTap) + } + + var snapshot = MusicSnapshot() + let music = Music(song) + snapshot.append([.music(music)]) + self.dataSource?.apply(snapshot, to: .music) + } + .store(in: &self.cancellables) + + self.viewModel.state.buttonStateFactors + .map { $0.isMusicPlaying } + .receive(on: DispatchQueue.main) + .sink { [weak self] isMusicPlaying in + guard let self = self else { return } + + if isMusicPlaying, self.musicPlayer.isPreparedToPlay { + Task { + try await self.musicPlayer.play() + } + } else { + self.musicPlayer.pause() + } + } + .store(in: &self.cancellables) + + self.viewModel.state.recordingJourney + .compactMap { $0 } + .receive(on: DispatchQueue.main) + .sink { [weak self] recordingJourney in + guard let self = self else { return } + + let spots = recordingJourney.spots.compactMap { $0 } + self.updateSpotSectionSnapshot(with: spots) + self.updateMap(with: recordingJourney) + } + .store(in: &self.cancellables) + + self.viewModel.state.endJourneySucceed + .receive(on: DispatchQueue.main) + .sink { [weak self] endedJourney in + self?.navigationDelegate?.popToHome(with: endedJourney) + } + .store(in: &self.cancellables) + } + + // MARK: - Functions + + func setDataSource(_ dataSource: SaveJourneyDataSource) { + self.dataSource = dataSource + } + + private func updateSpotSectionSnapshot(with spots: [Spot]) { + if spots.isEmpty { + var emptySnapshot = EmptySnapshot() + emptySnapshot.append([.empty]) + self.dataSource?.apply(emptySnapshot, to: .empty) + } else { + var spotSnapshot = SpotSnapshot() + spotSnapshot.append(spots.map { .spot($0) }) + self.dataSource?.apply(spotSnapshot, to: .spot) + } + } + + // MARK: - Functions: Polyline + + private func updateMap(with recordingJourney: RecordingJourney) { + self.drawPolyLinesToMap(with: recordingJourney) + guard let region = self.focusToJourney(from: recordingJourney.coordinates) else { + return + } + self.mapView.setRegion(region, animated: true) + } + + private func drawPolyLinesToMap(with journey: RecordingJourney) { + Task { + let coordinates = journey.coordinates.map { + CLLocationCoordinate2D(latitude: $0.latitude, + longitude: $0.longitude) + } + self.drawPolylineToMap(using: coordinates) + } + } + + private func drawPolylineToMap(using coordinates: [CLLocationCoordinate2D]) { + let polyline = MKPolyline(coordinates: coordinates, + count: coordinates.count) + self.mapView.addOverlay(polyline) + } + + func focusToJourney(from coordinates: [Coordinate]) -> MKCoordinateRegion? { + guard !coordinates.isEmpty else { return nil } + + let (minLat, maxLat, minLon, maxLon) = coordinates.reduce((coordinates[0].latitude, + coordinates[0].latitude, + coordinates[0].longitude, + coordinates[0].longitude)) { result, coordinate in + (min(result.0, coordinate.latitude), + max(result.1, coordinate.latitude), + min(result.2, coordinate.longitude), + max(result.3, coordinate.longitude)) + } + + let center = CLLocationCoordinate2D(latitude: (minLat + maxLat) / 2, + longitude: (minLon + maxLon) / 2) + + let span = MKCoordinateSpan(latitudeDelta: (maxLat - minLat) * Metric.mapSpanAssistance, + longitudeDelta: (maxLon - minLon) * Metric.mapSpanAssistance) + + return MKCoordinateRegion(center: center, span: span) + } + +} + +// MARK: - MapView + +extension SaveJourneyViewController: MKMapViewDelegate { + + public func mapView(_ mapView: MKMapView, + rendererFor overlay: MKOverlay) -> MKOverlayRenderer { + guard let polyLine = overlay as? MKPolyline else { return MKOverlayRenderer() } + + let renderer = MKPolylineRenderer(polyline: polyLine) + renderer.strokeColor = .msColor(.musicSpot) + renderer.lineWidth = Metric.lineWidth + renderer.alpha = 1.0 + + return renderer + } + +} + +// MARK: - Buttons + +private extension SaveJourneyViewController { + + func configureButtons() { + self.musicControlButton.rootView.musicControlAction = { [weak self] in + self?.viewModel.trigger(.musicControlButtonDidTap) + } + + let nextButtonAction = UIAction { [weak self] _ in + self?.presentSaveJourney() + } + self.nextButton.addAction(nextButtonAction, for: .touchUpInside) + } + +} + +// MARK: - AlertViewController + +extension SaveJourneyViewController: AlertViewControllerDelegate { + + private func presentSaveJourney() { + let alert = ConfirmTitleAlertViewController() + alert.modalPresentationStyle = .overCurrentContext + alert.delegate = self + self.present(alert, animated: false) + } + + func titleDidConfirmed(_ title: String) { + self.viewModel.trigger(.titleDidConfirmed(title)) + } + +} + +// MARK: - UI Configuration + +private extension SaveJourneyViewController { + + func configureStyles() { + self.view.backgroundColor = .msColor(.primaryBackground) + } + + func configureLayout() { + self.view.addSubview(self.mapView) + self.mapView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.mapView.topAnchor.constraint(equalTo: self.view.topAnchor), + self.mapView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.mapView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor) + ]) + self.mapViewHeightConstraint = self.mapView.heightAnchor.constraint(equalTo: self.mapView.widthAnchor) + self.mapViewHeightConstraint?.isActive = true + + self.view.addSubview(self.collectionView) + self.collectionView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.collectionView.topAnchor.constraint(equalTo: self.view.topAnchor), + self.collectionView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.collectionView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + self.collectionView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor) + ]) + + self.view.addSubview(self.buttonStack) + self.buttonStack.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.buttonStack.leadingAnchor.constraint(equalTo: self.view.layoutMarginsGuide.leadingAnchor), + self.buttonStack.trailingAnchor.constraint(equalTo: self.view.layoutMarginsGuide.trailingAnchor), + self.buttonStack.bottomAnchor.constraint(equalTo: self.view.keyboardLayoutGuide.topAnchor, + constant: -Metric.buttonBottomInset) + ]) + + guard let musicControlButton = self.musicControlButton.view else { return } + self.addChild(self.musicControlButton) + self.view.addSubview(musicControlButton) + [ + musicControlButton, + self.nextButton + ].forEach { + self.buttonStack.addArrangedSubview($0) + } + self.musicControlButton.didMove(toParent: self) + NSLayoutConstraint.activate([ + self.musicControlButton.view.widthAnchor.constraint(equalToConstant: 52.0), + self.musicControlButton.view.heightAnchor.constraint(equalTo: self.nextButton.heightAnchor) + ]) + } + + func configureComponents() { + self.configureCollectionView() + self.configureButtons() + } + +} diff --git a/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/SaveJourneyViewModel.swift b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/SaveJourneyViewModel.swift new file mode 100644 index 0000000..3c7d1ba --- /dev/null +++ b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/SaveJourneyViewModel.swift @@ -0,0 +1,122 @@ +// +// SaveJourneyViewModel.swift +// SaveJourney +// +// Created by ์ด์ฐฝ์ค€ on 11/24/23. +// + +import Combine +import Foundation +import MusicKit + +import MSData +import MSDomain +import MSLogger + +public final class SaveJourneyViewModel { + + public enum Action { + case viewNeedsLoaded + case musicControlButtonDidTap + case titleDidConfirmed(String) + } + + public struct State { + // Passthrough + var endJourneySucceed = PassthroughSubject() + + // CurrentValue + /// Apple Music ๊ถŒํ•œ ์ƒํƒœ + var musicAuthorizatonStatus = CurrentValueSubject(.notDetermined) + var buttonStateFactors = CurrentValueSubject(ButtonStateFactor()) + + var recordingJourney = CurrentValueSubject(nil) + var selectedSong: CurrentValueSubject + } + + // MARK: - Properties + + private var journeyRepository: JourneyRepository + + public var state: State + + /// ์™„๋ฃŒ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅธ ์‹œ์ ์˜ ๋งˆ์ง€๋ง‰ ์ขŒํ‘œ + private let lastCoordiante: Coordinate + + // MARK: - Initializer + + public init(lastCoordinate: Coordinate, + selectedSong: Song, + journeyRepository: JourneyRepository) { + self.journeyRepository = journeyRepository + self.state = State(selectedSong: CurrentValueSubject(selectedSong)) + self.lastCoordiante = lastCoordinate + } + + // MARK: - Functions + + func trigger(_ action: Action) { + switch action { + case .viewNeedsLoaded: + Task { + let status = await MusicAuthorization.request() + #if DEBUG + MSLogger.make(category: .saveJourney).info("์Œ์•… ๊ถŒํ•œ ์ƒํƒœ: \(status)") + #endif + self.state.musicAuthorizatonStatus.send(status) + } + + Task { + let subscription = try await MusicSubscription.current + #if DEBUG + MSLogger.make(category: .saveJourney).debug("\(subscription)") + #endif + let stateFactors = self.state.buttonStateFactors.value + stateFactors.canBecomeSubscriber = subscription.canBecomeSubscriber + self.state.buttonStateFactors.send(stateFactors) + } + + guard let recordingJourney = self.journeyRepository.loadJourneyFromLocal() else { return } + self.state.recordingJourney.send(recordingJourney) + case .musicControlButtonDidTap: + let stateFactors = self.state.buttonStateFactors.value + stateFactors.isMusicPlaying.toggle() + self.state.buttonStateFactors.send(stateFactors) + case .titleDidConfirmed(let title): + self.endJourney(named: title) + } + } + +} + +// MARK: - Private Functions + +private extension SaveJourneyViewModel { + + func endJourney(named title: String) { + guard let recordingJourney = self.state.recordingJourney.value else { return } + guard let journeyID = self.journeyRepository.fetchRecordingJourneyID() else { return } + + let selectedSong = self.state.selectedSong.value + let coordinates = recordingJourney.coordinates + [self.lastCoordiante] + let journey = Journey(id: journeyID, + title: title, + date: (start: recordingJourney.startTimestamp, end: .now), + spots: recordingJourney.spots, + coordinates: coordinates, + music: Music(selectedSong)) + Task { + let result = await self.journeyRepository.endJourney(journey) + switch result { + case .success(let journeyID): + #if DEBUG + MSLogger.make(category: .saveJourney).log("\(journeyID)๊ฐ€ ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") + #endif + self.state.endJourneySucceed.send(journey) + case .failure(let error): + MSLogger.make(category: .saveJourney).error("\(error)") + } + } + } + +} diff --git a/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/View/MusicControlButton.swift b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/View/MusicControlButton.swift new file mode 100644 index 0000000..4184ce6 --- /dev/null +++ b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/View/MusicControlButton.swift @@ -0,0 +1,67 @@ +// +// MusicControlButton.swift +// SaveJourney +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.09. +// + +import MusicKit +import SwiftUI + +import MSDesignSystem + +struct MusicControlButton: View { + + // MARK: - State + + @ObservedObject var stateFactor: ButtonStateFactor + + @State var isShowingOffer = false + + // MARK: - Properties + + var song: Song? + var musicControlAction: (() -> Void)? + + var offerOptions: MusicSubscriptionOffer.Options { + var offerOptions = MusicSubscriptionOffer.Options() + offerOptions.itemID = self.song?.id + offerOptions.messageIdentifier = .join + return offerOptions + } + + // MARK: - View + + let haptic: UIImpactFeedbackGenerator = { + let haptic = UIImpactFeedbackGenerator(style: .medium) + haptic.prepare() + return haptic + }() + + var body: some View { + Button(action: self.showSubscriptionOffer) { + if self.stateFactor.canBecomeSubscriber { + Image(uiImage: .msIcon(.lock) ?? .actions) + } else { + let image: UIImage? = self.stateFactor.isMusicPlaying ? .msIcon(.pause) : .msIcon(.play) + Image(uiImage: image ?? .actions) + } + } + .frame(width: 52.0, height: 60.0, alignment: .center) + .musicSubscriptionOffer(isPresented: self.$isShowingOffer, options: self.offerOptions) + .foregroundStyle(Color(uiColor: .msColor(.secondaryButtonTypo))) + .background(Color(uiColor: .msColor(.secondaryButtonBackground))) + .cornerRadius(5.0) + } + + private func showSubscriptionOffer() { + self.haptic.impactOccurred() + + if self.stateFactor.canBecomeSubscriber { + self.isShowingOffer = true + } else { + self.musicControlAction?() + } + } + +} diff --git a/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/View/SaveJourneyBackgroundView.swift b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/View/SaveJourneyBackgroundView.swift new file mode 100644 index 0000000..b6a0974 --- /dev/null +++ b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/View/SaveJourneyBackgroundView.swift @@ -0,0 +1,39 @@ +// +// SaveJourneyBackgroundView.swift +// SaveJourney +// +// Created by ์ด์ฐฝ์ค€ on 11/24/23. +// + +import UIKit + +import MSDesignSystem + +final class SaveJourneyBackgroundView: UICollectionReusableView { + + // MARK: - Constants + + static let elementKind = "SaveJourneyBackgroundView" + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: frame) + self.configureStyles() + } + + required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + +} + +// MARK: - UI Configuration + +private extension SaveJourneyBackgroundView { + + func configureStyles() { + self.backgroundColor = .msColor(.primaryBackground) + } + +} diff --git a/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/View/SaveJourneyHeaderView.swift b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/View/SaveJourneyHeaderView.swift new file mode 100644 index 0000000..cdeb57f --- /dev/null +++ b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/View/SaveJourneyHeaderView.swift @@ -0,0 +1,62 @@ +// +// SaveJourneyHeaderView.swift +// SaveJourney +// +// Created by ์ด์ฐฝ์ค€ on 11/25/23. +// + +import UIKit + +import MSDesignSystem + +final class SaveJourneyHeaderView: UICollectionReusableView { + + // MARK: - Constants + + static let elementKind = "SaveJourneyHeaderView" + static let estimatedHeight: CGFloat = 33.0 + + // MARK: - UI Components + + private let titleLabel: UILabel = { + let label = UILabel() + label.font = .msFont(.duperTitle) + label.textColor = .msColor(.primaryTypo) + label.text = "ํ—ค๋”" + return label + }() + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: frame) + self.configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - Functions + + func update(with title: String) { + self.titleLabel.text = title + } + +} + +// MARK: - UI Configuration + +private extension SaveJourneyHeaderView { + + func configureLayout() { + self.addSubview(self.titleLabel) + self.titleLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.titleLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor), + self.titleLabel.bottomAnchor.constraint(equalTo: self.bottomAnchor), + self.titleLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor) + ]) + } + +} diff --git a/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/View/SaveJourneyMusicCell.swift b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/View/SaveJourneyMusicCell.swift new file mode 100644 index 0000000..97b71b8 --- /dev/null +++ b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/View/SaveJourneyMusicCell.swift @@ -0,0 +1,142 @@ +// +// SaveJourneyMusicCell.swift +// SaveJourney +// +// Created by ์ด์ฐฝ์ค€ on 11/25/23. +// + +import UIKit + +import MSDesignSystem +import MSDomain +import MSImageFetcher + +final class SaveJourneyMusicCell: UICollectionViewCell { + + // MARK: - Constants + + static let estimatedHeight: CGFloat = 152.0 + + private enum Metric { + + static let cornerRadius: CGFloat = 12.0 + static let imageViewCornerRadius: CGFloat = 5.0 + static let imageViewSize: CGFloat = 128.0 + static let stackViewSpacing: CGFloat = 4.0 + static let iconImageSize: CGFloat = 24.0 + + } + + // MARK: - UI Components + + private let albumArtImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.layer.cornerRadius = Metric.imageViewCornerRadius + imageView.clipsToBounds = true + imageView.backgroundColor = .msColor(.musicSpot) + return imageView + }() + + private let stackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = Metric.stackViewSpacing + stackView.alignment = .leading + return stackView + }() + + private let audioIconImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = .msIcon(.voice) + imageView.tintColor = .msColor(.primaryTypo) + return imageView + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.font = .msFont(.boldCaption) + label.textColor = .msColor(.primaryTypo) + label.text = "Super Shy" + return label + }() + + private let artistLabel: UILabel = { + let label = UILabel() + label.font = .msFont(.caption) + label.textColor = .msColor(.primaryTypo) + label.text = "NewJeans" + return label + }() + + // MARK: - Initializer + + public override init(frame: CGRect) { + super.init(frame: frame) + self.configureStyles() + self.configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - Functions + + func update(with cellModel: Music) { + self.titleLabel.text = cellModel.title + self.artistLabel.text = cellModel.artist + + guard let albumCoverURL = cellModel.albumCover?.url else { return } + self.albumArtImageView.ms.setImage(with: albumCoverURL, forKey: cellModel.id) + } + +} + +// MARK: - UI Configuration + +private extension SaveJourneyMusicCell { + + func configureStyles() { + self.backgroundColor = .msColor(.componentBackground) + self.layer.cornerRadius = Metric.cornerRadius + self.clipsToBounds = true + } + + func configureLayout() { + self.addSubview(self.albumArtImageView) + self.albumArtImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.albumArtImageView.centerYAnchor.constraint(equalTo: self.centerYAnchor), + self.albumArtImageView.leadingAnchor.constraint(equalTo: self.leadingAnchor, + constant: 12.0), + self.albumArtImageView.widthAnchor.constraint(equalToConstant: Metric.imageViewSize), + self.albumArtImageView.heightAnchor.constraint(equalToConstant: Metric.imageViewSize) + ]) + + self.addSubview(self.stackView) + self.stackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.stackView.centerYAnchor.constraint(equalTo: self.centerYAnchor), + self.stackView.leadingAnchor.constraint(equalTo: self.albumArtImageView.trailingAnchor, + constant: 12.0), + self.stackView.trailingAnchor.constraint(equalTo: self.trailingAnchor, + constant: 12.0) + ]) + + [ + self.audioIconImageView, + self.titleLabel, + self.artistLabel + ].forEach { + self.stackView.addArrangedSubview($0) + } + + self.audioIconImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.audioIconImageView.widthAnchor.constraint(equalToConstant: Metric.iconImageSize), + self.audioIconImageView.heightAnchor.constraint(equalToConstant: Metric.iconImageSize) + ]) + } + +} diff --git a/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/View/SpotCell.swift b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/View/SpotCell.swift new file mode 100644 index 0000000..630b11a --- /dev/null +++ b/iOS/Features/SaveJourney/Sources/SaveJourney/Presentation/View/SpotCell.swift @@ -0,0 +1,116 @@ +// +// SpotCell.swift +// SaveJourney +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.04. +// + +import UIKit + +import MSDesignSystem +import MSExtension +import MSImageFetcher + +final class SpotCell: UICollectionViewCell { + + // MARK: - Constants + + private enum Metric { + + static let cornerRadius: CGFloat = 5.0 + static let labelStackHorizontalSpacing: CGFloat = 8.0 + static let labelStackVerticalSpacing: CGFloat = 5.0 + + } + + // MARK: - UI Components + + private let imageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.backgroundColor = .msColor(.componentBackground) + return imageView + }() + + private let labelStack: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + return stackView + }() + + private let locationLabel: UILabel = { + let label = UILabel() + label.font = .msFont(.boldCaption) + label.textColor = .msColor(.primaryTypo) + label.numberOfLines = 1 + return label + }() + + private let dateLabel: UILabel = { + let label = UILabel() + label.font = .msFont(.caption) + label.textColor = .msColor(.secondaryTypo) + label.numberOfLines = 1 + return label + }() + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: frame) + self.configureStyles() + self.configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - Functions + + @MainActor + func update(with cellModel: SpotCellModel) { + self.locationLabel.text = cellModel.location + self.dateLabel.text = cellModel.date.formatted(date: .abbreviated, time: .omitted) + self.imageView.ms.setImage(with: cellModel.photoURL, + forKey: cellModel.photoURL.paath()) + } + +} + +private extension SpotCell { + + func configureStyles() { + self.layer.cornerRadius = Metric.cornerRadius + self.clipsToBounds = true + } + + func configureLayout() { + self.addSubview(self.imageView) + self.imageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.imageView.topAnchor.constraint(equalTo: self.topAnchor), + self.imageView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + self.imageView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + self.imageView.trailingAnchor.constraint(equalTo: self.trailingAnchor) + ]) + + self.imageView.addSubview(self.labelStack) + self.labelStack.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.labelStack.leadingAnchor.constraint(equalTo: self.imageView.leadingAnchor, + constant: Metric.labelStackHorizontalSpacing), + self.labelStack.bottomAnchor.constraint(equalTo: self.imageView.bottomAnchor, + constant: -Metric.labelStackVerticalSpacing), + self.labelStack.trailingAnchor.constraint(lessThanOrEqualTo: self.imageView.trailingAnchor) + ]) + + [ + self.locationLabel, + self.dateLabel + ].forEach { + self.labelStack.addArrangedSubview($0) + } + } + +} diff --git a/iOS/Features/SelectSong/.gitignore b/iOS/Features/SelectSong/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/iOS/Features/SelectSong/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/iOS/Features/SelectSong/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/iOS/Features/SelectSong/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/iOS/Features/SelectSong/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/iOS/Features/SelectSong/Package.swift b/iOS/Features/SelectSong/Package.swift new file mode 100644 index 0000000..fe8cbb9 --- /dev/null +++ b/iOS/Features/SelectSong/Package.swift @@ -0,0 +1,85 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +// MARK: - Constants + +private extension String { + + static let package = "FeatureSelectSong" + + var testTarget: String { + return self + "Tests" + } + + var fromRootPath: String { + return "../../" + self + } + +} + +private enum Target { + + static let selectSong = "SelectSong" + +} + +private enum Dependency { + + static let msDomain = "MSDomain" + static let msData = "MSData" + + static let msImageFetcher = "MSImageFetcher" + static let msCoreKit = "MSCoreKit" + + static let combineCocoa = "CombineCocoa" + static let msDesignsystem = "MSDesignSystem" + static let msUIKit = "MSUIKit" + + static let msLogger = "MSLogger" + static let msFoundation = "MSFoundation" + +} + +// MARK: - Package + +let package = Package( + name: .package, + platforms: [ + .iOS(.v15) + ], + products: [ + .library(name: Target.selectSong, + targets: [Target.selectSong]) + ], + dependencies: [ + .package(name: Dependency.msDomain, + path: Dependency.msDomain.fromRootPath), + .package(name: Dependency.msData, + path: Dependency.msData.fromRootPath), + .package(name: Dependency.msCoreKit, + path: Dependency.msCoreKit.fromRootPath), + .package(name: Dependency.msUIKit, + path: Dependency.msUIKit.fromRootPath), + .package(name: Dependency.msFoundation, + path: Dependency.msFoundation.fromRootPath) + ], + targets: [ + .target(name: Target.selectSong, + dependencies: [ + .product(name: Dependency.msDomain, + package: Dependency.msDomain), + .product(name: Dependency.msData, + package: Dependency.msData), + .product(name: Dependency.msImageFetcher, + package: Dependency.msCoreKit), + .product(name: Dependency.msUIKit, + package: Dependency.msUIKit), + .product(name: Dependency.combineCocoa, + package: Dependency.msUIKit), + .product(name: Dependency.msLogger, + package: Dependency.msFoundation) + ]) + ] +) diff --git a/iOS/Features/SelectSong/SelectSongDemo/SelectSongDemo/AppDelegate.swift b/iOS/Features/SelectSong/SelectSongDemo/SelectSongDemo/AppDelegate.swift new file mode 100644 index 0000000..1f2d009 --- /dev/null +++ b/iOS/Features/SelectSong/SelectSongDemo/SelectSongDemo/AppDelegate.swift @@ -0,0 +1,26 @@ +// +// AppDelegate.swift +// SelectSongDemo +// +// Created by ์ด์ฐฝ์ค€ on 2023.11.29. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions) -> UISceneConfiguration { + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + +} diff --git a/iOS/Features/SelectSong/SelectSongDemo/SelectSongDemo/Assets.xcassets/AccentColor.colorset/Contents.json b/iOS/Features/SelectSong/SelectSongDemo/SelectSongDemo/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/iOS/Features/SelectSong/SelectSongDemo/SelectSongDemo/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Features/SelectSong/SelectSongDemo/SelectSongDemo/Assets.xcassets/AppIcon.appiconset/Contents.json b/iOS/Features/SelectSong/SelectSongDemo/SelectSongDemo/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/iOS/Features/SelectSong/SelectSongDemo/SelectSongDemo/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Features/SelectSong/SelectSongDemo/SelectSongDemo/Assets.xcassets/Contents.json b/iOS/Features/SelectSong/SelectSongDemo/SelectSongDemo/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iOS/Features/SelectSong/SelectSongDemo/SelectSongDemo/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Features/SelectSong/SelectSongDemo/SelectSongDemo/Base.lproj/LaunchScreen.storyboard b/iOS/Features/SelectSong/SelectSongDemo/SelectSongDemo/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..d135086 --- /dev/null +++ b/iOS/Features/SelectSong/SelectSongDemo/SelectSongDemo/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/Features/SelectSong/SelectSongDemo/SelectSongDemo/Info.plist b/iOS/Features/SelectSong/SelectSongDemo/SelectSongDemo/Info.plist new file mode 100644 index 0000000..0eb786d --- /dev/null +++ b/iOS/Features/SelectSong/SelectSongDemo/SelectSongDemo/Info.plist @@ -0,0 +1,23 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/iOS/Features/SelectSong/SelectSongDemo/SelectSongDemo/SceneDelegate.swift b/iOS/Features/SelectSong/SelectSongDemo/SelectSongDemo/SceneDelegate.swift new file mode 100644 index 0000000..933d73c --- /dev/null +++ b/iOS/Features/SelectSong/SelectSongDemo/SelectSongDemo/SceneDelegate.swift @@ -0,0 +1,35 @@ +// +// SceneDelegate.swift +// SelectSongDemo +// +// Created by ์ด์ฐฝ์ค€ on 2023.11.29. +// + +import UIKit + +import MSData +import SelectSong + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = (scene as? UIWindowScene) else { return } + let window = UIWindow(windowScene: windowScene) + defer { self.window = window } + + let navigationController = UINavigationController(rootViewController: UIViewController()) + window.rootViewController = navigationController + + let songRepository = SongRepositoryImplementation() + let selectSongViewModel = SelectSongViewModel(repository: songRepository) + let selectSongViewController = SelectSongViewController(viewModel: selectSongViewModel) + navigationController.pushViewController(selectSongViewController, animated: true) + + window.makeKeyAndVisible() + } + +} diff --git a/iOS/Features/SelectSong/Sources/SelectSong/Presentation/Coordinator/SelectSongNavigationDelegate.swift b/iOS/Features/SelectSong/Sources/SelectSong/Presentation/Coordinator/SelectSongNavigationDelegate.swift new file mode 100644 index 0000000..a85afc5 --- /dev/null +++ b/iOS/Features/SelectSong/Sources/SelectSong/Presentation/Coordinator/SelectSongNavigationDelegate.swift @@ -0,0 +1,18 @@ +// +// SelectSongNavigationDelegate.swift +// SelectSong +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.05. +// + +import Foundation +import MusicKit + +import MSDomain + +public protocol SelectSongNavigationDelegate: AnyObject { + + func navigateToSaveJourney(lastCoordinate: Coordinate, + selectedSong: Song) + +} diff --git a/iOS/Features/SelectSong/Sources/SelectSong/Presentation/SelectSongViewController.swift b/iOS/Features/SelectSong/Sources/SelectSong/Presentation/SelectSongViewController.swift new file mode 100644 index 0000000..ed38132 --- /dev/null +++ b/iOS/Features/SelectSong/Sources/SelectSong/Presentation/SelectSongViewController.swift @@ -0,0 +1,248 @@ +// +// SelectSongViewController.swift +// SelectSong +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.03. +// + +import Combine +import MusicKit +import UIKit + +import CombineCocoa +import MSDesignSystem +import MSDomain +import MSLogger +import MSUIKit + +public final class SelectSongViewController: BaseViewController { + + typealias SongListDataSource = UICollectionViewDiffableDataSource + typealias SongListCellRegistration = UICollectionView.CellRegistration + typealias SongListSnapshot = NSDiffableDataSourceSnapshot + + // MARK: - Constants + + private enum Typo { + + static let title = "์Œ์•… ๊ฒ€์ƒ‰" + static let searchTextFieldPlaceholder = "์ถ”๊ฐ€ํ•  ์Œ์•…์„ ๊ฒ€์ƒ‰ํ•˜์„ธ์š”." + + } + + private enum Metric { + + static let searchTextFieldBottomSpacing: CGFloat = 16.0 + static let albumCoverSize: Int = 52 + + } + + // MARK: - Properties + + public weak var navigationDelegate: SelectSongNavigationDelegate? + + private let viewModel: SelectSongViewModel + + private var dataSource: SongListDataSource? + + private var cancellables: Set = [] + + // MARK: - UI Components + + private lazy var collectionView: UICollectionView = { + let collectionView = UICollectionView(frame: .zero, + collectionViewLayout: UICollectionViewLayout()) + collectionView.backgroundColor = .clear + collectionView.keyboardDismissMode = .onDrag + collectionView.delegate = self + return collectionView + }() + + private let searchTextField: MSTextField = { + let textField = MSTextField() + textField.imageStyle = .search + textField.clearButtonMode = .whileEditing + textField.enablesReturnKeyAutomatically = true + var container = AttributeContainer() + container.font = .msFont(.caption) + let attributedString = AttributedString(Typo.searchTextFieldPlaceholder, attributes: container) + textField.attributedPlaceholder = NSAttributedString(attributedString) + return textField + }() + + private let loadingIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .medium) + indicator.hidesWhenStopped = true + return indicator + }() + + // MARK: - Initializer + + public init(viewModel: SelectSongViewModel, + nibName nibNameOrNil: String? = nil, + bundle nibBundleOrNil: Bundle? = nil) { + self.viewModel = viewModel + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + } + + required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - Life Cycle + + public override func viewDidLoad() { + super.viewDidLoad() + self.configureCollectionView() + self.bind() + self.viewModel.trigger(.viewNeedsLoaded) + } + + public override func viewIsAppearing(_ animated: Bool) { + super.viewIsAppearing(animated) + + self.navigationController?.isNavigationBarHidden = false + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + self.searchTextField.becomeFirstResponder() + } + + // MARK: - Combine Binding + + private func bind() { + self.searchTextField.textPublisher + .debounce(for: 0.5, scheduler: DispatchQueue.main) + .sink { [weak viewModel = self.viewModel] text in + viewModel?.trigger(.searchTextFieldDidUpdate(text)) + } + .store(in: &self.cancellables) + + self.viewModel.state.isLoading + .receive(on: DispatchQueue.main) + .sink { [weak loadingIndicator = self.loadingIndicator] isLoading in + isLoading ? loadingIndicator?.startAnimating() : loadingIndicator?.stopAnimating() + } + .store(in: &self.cancellables) + + self.viewModel.state.songs + .receive(on: DispatchQueue.main) + .sink { [weak self] songs in + guard let self = self else { return } + + var snapshot = SongListSnapshot() + snapshot.appendSections([.zero]) + snapshot.appendItems(songs, toSection: .zero) + self.dataSource?.apply(snapshot, animatingDifferences: true) + + self.collectionView.setContentOffset(.zero, animated: true) + } + .store(in: &self.cancellables) + } + + // MARK: - UI Configuration + + public override func configureStyle() { + super.configureStyle() + + self.title = Typo.title + } + + public override func configureLayout() { + self.view.addSubview(self.collectionView) + self.collectionView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.collectionView.topAnchor.constraint(equalTo: self.view.topAnchor), + self.collectionView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.collectionView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor) + ]) + + self.view.addSubview(self.searchTextField) + self.searchTextField.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.searchTextField.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leadingAnchor), + self.searchTextField.bottomAnchor.constraint(equalTo: self.view.keyboardLayoutGuide.topAnchor, + constant: -Metric.searchTextFieldBottomSpacing), + self.searchTextField.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor), + self.collectionView.bottomAnchor.constraint(equalTo: self.searchTextField.topAnchor, + constant: -Metric.searchTextFieldBottomSpacing) + ]) + + self.view.addSubview(self.loadingIndicator) + self.loadingIndicator.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.loadingIndicator.centerXAnchor.constraint(equalTo: self.view.centerXAnchor), + self.loadingIndicator.centerYAnchor.constraint(equalTo: self.collectionView.centerYAnchor) + ]) + } + +} + +// MARK: - CollectionView Configuration + +private extension SelectSongViewController { + + func configureCollectionView() { + let layout = UICollectionViewCompositionalLayout(sectionProvider: { _, _ in + return self.configureSection() + }) + + self.collectionView.setCollectionViewLayout(layout, animated: false) + self.dataSource = self.configureDataSource() + } + + private func configureSection() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalHeight(1.0)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(SongListCell.estimatedHeight)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, + subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + + return section + } + + private func configureDataSource() -> SongListDataSource { + let cellRegistration = SongListCellRegistration { cell, _, itemIdentifier in + let cellModel = SongListCellModel(id: itemIdentifier.id.rawValue, + title: itemIdentifier.title, + artist: itemIdentifier.artistName, + albumArtURL: itemIdentifier.artwork?.url(width: Metric.albumCoverSize, + height: Metric.albumCoverSize)) + cell.update(with: cellModel) + } + + let dataSource = SongListDataSource(collectionView: self.collectionView, + cellProvider: { collectionView, indexPath, itemIdentifier in + return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, + for: indexPath, + item: itemIdentifier) + }) + + return dataSource + } + +} + +// MARK: - CollectionView + +extension SelectSongViewController: UICollectionViewDelegate { + + public func collectionView(_ collectionView: UICollectionView, + didSelectItemAt indexPath: IndexPath) { + guard let item = self.dataSource?.itemIdentifier(for: indexPath) else { return } + + #if DEBUG + MSLogger.make(category: .selectSong).log("\(item.title) selected") + #endif + self.navigationDelegate?.navigateToSaveJourney(lastCoordinate: self.viewModel.state.lastCoordinate, + selectedSong: item) + } + +} diff --git a/iOS/Features/SelectSong/Sources/SelectSong/Presentation/SelectSongViewModel.swift b/iOS/Features/SelectSong/Sources/SelectSong/Presentation/SelectSongViewModel.swift new file mode 100644 index 0000000..b242b56 --- /dev/null +++ b/iOS/Features/SelectSong/Sources/SelectSong/Presentation/SelectSongViewModel.swift @@ -0,0 +1,117 @@ +// +// SelectSongViewModel.swift +// SelectSong +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.03. +// + +import Combine +import Foundation + +import MediaPlayer +import MSData +import MSDomain +import MSLogger +import MusicKit + +public final class SelectSongViewModel { + + public enum Action { + case viewNeedsLoaded + case searchTextFieldDidUpdate(String) + } + + public struct State { + var songs = CurrentValueSubject<[Song], Never>([]) + var isLoading = CurrentValueSubject(false) + + let lastCoordinate: Coordinate + } + + // MARK: - Properties + + private let repository: SongRepository + + public var state: State + + let lastCoordinate: Coordinate + + // MARK: - Initializer + + public init(lastCoordinate: Coordinate, + repository: SongRepository) { + self.repository = repository + self.state = State(lastCoordinate: lastCoordinate) + self.lastCoordinate = lastCoordinate + } + + // MARK: - Functions + + func trigger(_ action: Action) { + switch action { + case .viewNeedsLoaded: + Task { + let status = await MusicAuthorization.request() + #if DEBUG + MSLogger.make(category: .saveJourney).info("์Œ์•… ๊ถŒํ•œ ์ƒํƒœ: \(status)") + #endif + + if #available(iOS 16.0, *) { + Task { + self.state.isLoading.send(true) + defer { self.state.isLoading.send(false) } + + let songs = await self.fetchSongByRank() + self.state.songs.send(songs) + } + } + } + case .searchTextFieldDidUpdate(let text): + if #available(iOS 16.0, *), text.isEmpty { + Task { + let songs = await self.fetchSongByRank() + self.state.songs.send(songs) + } + return + } + + Task { + let songs = await self.fetchSong(byTitle: text) + self.state.songs.send(songs) + } + } + } + +} + +// MARK: - Private Functions + +private extension SelectSongViewModel { + + func fetchSong(byTitle title: String) async -> [Song] { + self.state.isLoading.send(true) + defer { self.state.isLoading.send(false) } + + let result = await self.repository.fetchSongList(with: title) + switch result { + case .success(let songCollection): + return songCollection.map { $0 } + case .failure(let error): + MSLogger.make(category: .selectSong).error("\(error)") + return [] + } + } + + @available(iOS 16.0, *) + func fetchSongByRank() async -> [Song] { + let result = await self.repository.fetchSongListByRank() + switch result { + case .success(let chart): + return chart.map { $0 } + case .failure(let error): + MSLogger.make(category: .selectSong).error("\(error)") + return [] + } + } + +} diff --git a/iOS/Features/Spot/.gitignore b/iOS/Features/Spot/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/iOS/Features/Spot/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/iOS/Features/Spot/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/iOS/Features/Spot/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/iOS/Features/Spot/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/iOS/Features/Spot/Package.swift b/iOS/Features/Spot/Package.swift new file mode 100644 index 0000000..76630c0 --- /dev/null +++ b/iOS/Features/Spot/Package.swift @@ -0,0 +1,86 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +// MARK: - Constants + +private extension String { + + static let package = "FeatureSpot" + + var testTarget: String { + return self + "Tests" + } + + var fromRootPath: String { + return "../../" + self + } + +} + +private enum Target { + + static let spot = "Spot" + +} + +private enum Dependency { + + // package + static let msUIKit = "MSUIKit" + static let msFoundation = "MSFoundation" + static let msCoreKit = "MSCoreKit" + + // library + static let msDesignsystem = "MSDesignSystem" + static let msLogger = "MSLogger" + static let msNetworking = "MSNetworking" + + // package = library + static let msData = "MSData" + static let msDomain = "MSDomain" + +} + +// MARK: - Package + +let package = Package( + name: .package, + platforms: [ + .iOS(.v15) + ], + products: [ + .library(name: Target.spot, + targets: [Target.spot]) + ], + dependencies: [ + .package(name: Dependency.msUIKit, + path: Dependency.msUIKit.fromRootPath), + .package(name: Dependency.msFoundation, + path: Dependency.msFoundation.fromRootPath), + .package(name: Dependency.msCoreKit, + path: Dependency.msCoreKit.fromRootPath), + .package(name: Dependency.msData, + path: Dependency.msData.fromRootPath), + .package(name: Dependency.msDomain, + path: Dependency.msDomain.fromRootPath) + ], + targets: [ + .target(name: Target.spot, + dependencies: [ + .product(name: Dependency.msDomain, + package: Dependency.msDomain), + .product(name: Dependency.msUIKit, + package: Dependency.msUIKit), + .product(name: Dependency.msDesignsystem, + package: Dependency.msUIKit), + .product(name: Dependency.msLogger, + package: Dependency.msFoundation), + .product(name: Dependency.msNetworking, + package: Dependency.msCoreKit), + .product(name: Dependency.msData, + package: Dependency.msData) + ]) + ] +) diff --git a/iOS/Features/Spot/Sources/Spot/Presentation/CameraView.swift b/iOS/Features/Spot/Sources/Spot/Presentation/CameraView.swift new file mode 100644 index 0000000..af748ad --- /dev/null +++ b/iOS/Features/Spot/Sources/Spot/Presentation/CameraView.swift @@ -0,0 +1,41 @@ +// +// CameraView.swift +// Spot +// +// Created by ์ „๋ฏผ๊ฑด on 11/28/23. +// + +import AVFoundation +import UIKit + +import MSLogger + +final class CameraView: UIView { + + private enum Metric { + static let cornerRadius: CGFloat = 15.0 + } + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: frame) + self.configureStyle() + } + + required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + +} + +// MARK: - UI Configuration: Style + +private extension CameraView { + + func configureStyle() { + self.layer.cornerRadius = Metric.cornerRadius + self.clipsToBounds = true + } + +} diff --git a/iOS/Features/Spot/Sources/Spot/Presentation/Coordinator/SpotNavigationDelegate.swift b/iOS/Features/Spot/Sources/Spot/Presentation/Coordinator/SpotNavigationDelegate.swift new file mode 100644 index 0000000..c6cae92 --- /dev/null +++ b/iOS/Features/Spot/Sources/Spot/Presentation/Coordinator/SpotNavigationDelegate.swift @@ -0,0 +1,20 @@ +// +// SpotNavigationDelegate.swift +// Spot +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.05. +// + +import UIKit + +import MSDomain + +public protocol SpotNavigationDelegate: AnyObject { + + func presentPhotos(from viewController: UIViewController) + func presentSpotSave(using image: UIImage, coordinate: Coordinate) + func dismissToSpot() + func popToHome() + func popToHomeWithSpot(spot: Spot) + +} diff --git a/iOS/Features/Spot/Sources/Spot/Presentation/SpotSaveViewController.swift b/iOS/Features/Spot/Sources/Spot/Presentation/SpotSaveViewController.swift new file mode 100644 index 0000000..23bcb97 --- /dev/null +++ b/iOS/Features/Spot/Sources/Spot/Presentation/SpotSaveViewController.swift @@ -0,0 +1,273 @@ +// +// SpotViewController.swift +// Spot +// +// Created by ์ „๋ฏผ๊ฑด on 11/22/23. +// + +import Combine +import UIKit + +import MSData +import MSDesignSystem +import MSLogger +import MSUIKit + +public final class SpotSaveViewController: UIViewController { + + // MARK: - Constants + + private enum Metric { + + // image view + enum ImageView { + static let inset: CGFloat = 4.0 + static let defaultIndex: Int = 0 + } + + // text view + enum TextView { + static let height: CGFloat = 290.0 + } + + // labels + enum TextLabel { + static let height: CGFloat = 24.0 + static let topInset: CGFloat = 30.0 + } + + enum SubTextLabel { + static let height: CGFloat = 42.0 + static let topInset: CGFloat = 2.0 + } + + // buttons + enum Button { + static let height: CGFloat = 120.0 + static let width: CGFloat = 120.0 + static let insetFromCenterX: CGFloat = 26.0 + static let bottomInset: CGFloat = 55.0 + } + + } + private enum Default { + + static let text: String = "์ด ์‚ฌ์ง„์„ ์ŠคํŒŸ! ํ• ๊นŒ์š”?" + static let subText: String = "ํ™•์ •๋œ ์ŠคํŒŸ์€ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์—†์œผ๋ฉฐ \n ์‚ญ์ œ๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค." + + } + + // MARK: - Properties + + public weak var navigationDelegate: SpotNavigationDelegate? + private let viewModel: SpotSaveViewModel + private let image: UIImage + + private var cancellables: Set = [] + + // MARK: - UI Components + + private let imageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + return imageView + }() + private let textView = UIView() + private let textLabel = UILabel() + private let subTextLabel = UILabel() + private let cancelButton = MSRectButton.large(isBrandColored: false) + private let completeButton = MSRectButton.large() + + // MARK: - Initializer + + public init(image: UIImage, + viewModel: SpotSaveViewModel, + nibName nibNameOrNil: String? = nil, + bundle nibBundleOrNil: Bundle? = nil) { + self.image = image + self.viewModel = viewModel + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + } + + required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - Life Cycle + + public override func viewDidLoad() { + super.viewDidLoad() + self.configure() + self.bind() + } + + // MARK: - Configure + + private func configure() { + self.configureLayout() + self.configureStyles() + self.configureAction() + self.configureState() + } + + // MARK: - Combine Binding + + private func bind() { + self.viewModel.state.spot + .receive(on: DispatchQueue.main) + .sink { [weak self] spot in + guard let spot = spot else { return } + self?.navigationDelegate?.popToHomeWithSpot(spot: spot) + } + .store(in: &self.cancellables) + } + + // MARK: - UI Components: Layout + + private func configureLayout() { + self.configureTextViewLayout() + self.configureImageViewLayout() + self.configureLabelsLayout() + self.configureButtonsLayout() + } + + private func configureTextViewLayout() { + self.view.addSubview(self.textView) + self.textView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.textView.heightAnchor.constraint(equalToConstant: Metric.TextView.height), + self.textView.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor), + self.textView.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leadingAnchor), + self.textView.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor) + ]) + } + + private func configureImageViewLayout() { + self.view.addSubview(self.imageView) + self.imageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.imageView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor), + self.imageView.bottomAnchor.constraint(equalTo: self.textView.topAnchor), + self.imageView.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leadingAnchor), + self.imageView.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor) + ]) + } + + private func configureLabelsLayout() { + let labels: [UILabel] = [self.textLabel, self.subTextLabel] + labels.forEach { label in + self.view.addSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerXAnchor) + ]) + } + NSLayoutConstraint.activate([ + self.textLabel.heightAnchor.constraint(equalToConstant: Metric.TextLabel.height), + self.subTextLabel.heightAnchor.constraint(equalToConstant: Metric.SubTextLabel.height), + + self.textLabel.topAnchor.constraint(equalTo: self.imageView.bottomAnchor, + constant: Metric.TextLabel.topInset), + self.subTextLabel.topAnchor.constraint(equalTo: self.textLabel.bottomAnchor, + constant: Metric.SubTextLabel.topInset) + ]) + self.subTextLabel.textAlignment = .center + } + + private func configureButtonsLayout() { + let buttons: [MSRectButton] = [self.cancelButton, self.completeButton] + buttons.forEach { button in + self.view.addSubview(button) + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.heightAnchor.constraint(equalToConstant: Metric.Button.height), + button.widthAnchor.constraint(equalToConstant: Metric.Button.width), + button.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, + constant: -Metric.Button.bottomInset) + ]) + } + NSLayoutConstraint.activate([ + self.cancelButton.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerXAnchor, + constant: -Metric.Button.insetFromCenterX), + self.completeButton.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerXAnchor, + constant: Metric.Button.insetFromCenterX) + ]) + } + + // MARK: - UI Components: Style + + private func configureStyles() { + self.view.backgroundColor = .msColor(.primaryBackground) + self.configureLabelsStyle() + self.configureButtonsStyle() + } + + private func configureLabelsStyle() { + self.textLabel.font = .msFont(.subtitle) + self.subTextLabel.font = .msFont(.caption) + self.subTextLabel.textColor = .msColor(.secondaryTypo) + } + + private func configureButtonsStyle() { + self.cancelButton.image = .msIcon(.close) + self.completeButton.image = .msIcon(.check) + } + + // MARK: - Configure: Action + + private func configureAction() { + self.configureCancelAction() + self.configureCompleteAction() + } + + private func configureCancelAction() { + let cancelButtonAction = UIAction { [weak self] _ in + self?.cancelButtonDidTap() + } + self.cancelButton.addAction(cancelButtonAction, for: .touchUpInside) + } + + private func configureCompleteAction() { + let completeButtonAction = UIAction { [weak self] _ in + self?.completeButtonDidTap() + } + self.completeButton.addAction(completeButtonAction, for: .touchUpInside) + } + + // MARK: - Configure: State + + private func configureState() { + self.configureImageViewState() + self.configureLabelsState() + } + + private func configureImageViewState() { + self.imageView.image = self.image + } + + private func configureLabelsState() { + self.textLabel.text = Default.text + self.subTextLabel.text = Default.subText + + let multiLineConstant = 0 + self.subTextLabel.numberOfLines = multiLineConstant + } + + // MARK: - Button Actions + + private func cancelButtonDidTap() { + self.navigationDelegate?.dismissToSpot() + } + + private func completeButtonDidTap() { + guard let jpegData = self.image.jpegData(compressionQuality: 0.1) else { + MSLogger.make(category: .spot).debug("ํ˜„์žฌ ์ด๋ฏธ์ง€๋ฅผ Data๋กœ ๋ณ€ํ™˜ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + return + } + + self.viewModel.trigger(.startUploadSpot(jpegData)) + self.navigationDelegate?.popToHome() + } + +} diff --git a/iOS/Features/Spot/Sources/Spot/Presentation/SpotSaveViewModel.swift b/iOS/Features/Spot/Sources/Spot/Presentation/SpotSaveViewModel.swift new file mode 100644 index 0000000..bea4c31 --- /dev/null +++ b/iOS/Features/Spot/Sources/Spot/Presentation/SpotSaveViewModel.swift @@ -0,0 +1,76 @@ +// +// SpotSaveViewModel.swift +// Spot +// +// Created by ์ „๋ฏผ๊ฑด on 11/29/23. +// + +import Combine +import Foundation + +import MSData +import MSDomain +import MSLogger + +public final class SpotSaveViewModel { + + public enum Action { + case startUploadSpot(Data) + } + + public struct State { + public var spot = PassthroughSubject() + } + + // MARK: - Properties + + private let journeyRepository: JourneyRepository + private let spotRepository: SpotRepository + + private var cancellables: Set = [] + + private let coordinate: Coordinate + + public var state = State() + + // MARK: - Initializer + + public init(journeyRepository: JourneyRepository, + spotRepository: SpotRepository, + coordinate: Coordinate) { + self.journeyRepository = journeyRepository + self.spotRepository = spotRepository + self.coordinate = coordinate + } + +} + +// MARK: - Interface: Actions + +internal extension SpotSaveViewModel { + + func trigger(_ action: Action) { + switch action { + case .startUploadSpot(let data): + Task { + guard let recordingJourneyID = self.journeyRepository.fetchRecordingJourneyID() else { + MSLogger.make(category: .spot).error("recoding ์ค‘์ธ journeyID๋ฅผ ์ฐพ์ง€ ๋ชปํ•˜์˜€์Šต๋‹ˆ๋‹ค.") + return + } + let spot = CreateSpotRequestDTO(journeyId: recordingJourneyID, + coordinate: CoordinateDTO(self.coordinate), + timestamp: .now, + photoData: data) + let result = await self.spotRepository.upload(spot: spot) + switch result { + case .success(let spot): + self.state.spot.send(spot) + MSLogger.make(category: .network).debug("์„ฑ๊ณต์ ์œผ๋กœ ์—…๋กœ๋“œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค: \(spot)") + case .failure(let error): + MSLogger.make(category: .network).error("\(error): ์—…๋กœ๋“œ์— ์‹คํŒจํ•˜์˜€์Šต๋‹ˆ๋‹ค.") + } + } + } + } + +} diff --git a/iOS/Features/Spot/Sources/Spot/Presentation/SpotViewController+Gesture.swift b/iOS/Features/Spot/Sources/Spot/Presentation/SpotViewController+Gesture.swift new file mode 100644 index 0000000..35aabbd --- /dev/null +++ b/iOS/Features/Spot/Sources/Spot/Presentation/SpotViewController+Gesture.swift @@ -0,0 +1,63 @@ +// +// SpotViewController+Gesture.swift +// Spot +// +// Created by ์ „๋ฏผ๊ฑด on 12/10/23. +// + +import Foundation +import UIKit + +// MARK: - Constants + +private enum Metric { + + static let movedXPositionToBackScene: CGFloat = 50.0 + static let animationDuration: Double = 0.3 + +} + +// MARK: - Gesture + +internal extension SpotViewController { + + func configureLeftToRightSwipeGesture() { + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(panGestureDismiss(_:))) + self.view.addGestureRecognizer(panGesture) + } + + @objc + private func panGestureDismiss(_ sender: UIPanGestureRecognizer) { + let touchPoint = sender.location(in: self.view.window) + let width = self.view.frame.width + let height = self.view.frame.height + switch sender.state { + case .began: + self.initialTouchPoint = touchPoint + case .changed: + if touchPoint.x - self.initialTouchPoint.x > .zero { + self.view.frame = CGRect(x: touchPoint.x - self.initialTouchPoint.x, + y: .zero, + width: width, + height: height) + } + case .ended, .cancelled: + if touchPoint.x - self.initialTouchPoint.x > Metric.movedXPositionToBackScene { + self.navigationDelegate?.popToHome() + } else { + self.view.frame = CGRect(x: .zero, + y: .zero, + width: width, + height: height) + } + default: + UIView.animate(withDuration: Metric.animationDuration) { + self.view.frame = CGRect(x: .zero, + y: .zero, + width: width, + height: height) + } + } + } + +} diff --git a/iOS/Features/Spot/Sources/Spot/Presentation/SpotViewController.swift b/iOS/Features/Spot/Sources/Spot/Presentation/SpotViewController.swift new file mode 100644 index 0000000..b7ee563 --- /dev/null +++ b/iOS/Features/Spot/Sources/Spot/Presentation/SpotViewController.swift @@ -0,0 +1,365 @@ +// +// SpotViewController.swift +// Spot +// +// Created by ์ „๋ฏผ๊ฑด on 11/22/23. +// + +import UIKit + +import MSData +import MSDesignSystem +import MSDomain +import MSLogger +import MSUIKit + +public final class SpotViewController: UIViewController, UINavigationControllerDelegate { + + // MARK: - Constants + + private enum Metric { + + // Camera View + enum CameraView { + static let bottomInset: CGFloat = 50.0 + } + + // Shot Button + enum ShotButton { + static let height: CGFloat = 70.0 + static let width: CGFloat = 70.0 + static let bottomInset: CGFloat = 60.0 + static let radius: CGFloat = ShotButton.width / 2 + static let borderWidth: CGFloat = 5.0 + } + + // Gallery Button + enum GalleryButton { + static let height: CGFloat = 35.0 + static let width: CGFloat = 35.0 + static let bottomInset: CGFloat = .zero + static let leadingInset: CGFloat = 30.0 + static let radius: CGFloat = 6.0 + } + + // Swap Button + enum SwapButton { + static let height: CGFloat = 35.0 + static let width: CGFloat = 35.0 + static let bottomInset: CGFloat = .zero + static let trailingInset: CGFloat = 30.0 + static let radius: CGFloat = SwapButton.width / 2 + } + + // Back Button + enum BackButton { + static let height: CGFloat = 35.0 + static let width: CGFloat = 35.0 + static let inset: CGFloat = 10.0 + } + } + + // MARK: - Properties + + public weak var navigationDelegate: SpotNavigationDelegate? + private let viewModel: SpotViewModel + + // MARK: - Properties: Gesture + + var initialTouchPoint = CGPoint(x: 0, y: 0) + + // MARK: - Properties: Haptic + + private let haptic = UIImpactFeedbackGenerator(style: .medium) + + // MARK: - UI Components + + private let backButton = UIButton() + private let cameraView = CameraView() + private let shotButton = UIButton() + private let galleryButton = UIButton() + private let swapButton = UIButton() + + // MARK: - Life Cycle + + public override func viewDidLoad() { + super.viewDidLoad() + self.configure() + self.haptic.prepare() + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + self.viewModel.startCamera() + } + + // MARK: - Initializer + + public init(viewModel: SpotViewModel, + nibName nibNameOrNil: String? = nil, + bundle nibBundleOrNil: Bundle? = nil) { + self.viewModel = viewModel + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + } + + required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - Configuration + + private func configure() { + self.configureLayout() + self.configureStyles() + self.configureAction() + self.configureDelegate() + } + +} + +// MARK: - UI Configuration: Layout + +private extension SpotViewController { + + func configureLayout() { + [ + self.cameraView, + self.shotButton, + self.galleryButton, + self.swapButton, + self.backButton + ].forEach { + self.view.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + } + + self.configureNavigationBarBackgroundViewLayout() + self.configureCameraViewLayout() + self.configureShotButtonLayout() + self.configureGalleryButtonLayout() + self.configureSwapButtonLayout() + } + + func configureNavigationBarBackgroundViewLayout() { + NSLayoutConstraint.activate([ + self.backButton.heightAnchor.constraint(equalToConstant: Metric.BackButton.height), + self.backButton.widthAnchor.constraint(equalToConstant: Metric.BackButton.width), + + self.backButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, + constant: Metric.BackButton.inset), + self.backButton.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, + constant: Metric.BackButton.inset) + ]) + } + + func configureCameraViewLayout() { + NSLayoutConstraint.activate([ + self.cameraView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor), + self.cameraView.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leadingAnchor), + self.cameraView.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, + constant: -Metric.CameraView.bottomInset), + self.cameraView.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor) + ]) + } + + func configureShotButtonLayout() { + NSLayoutConstraint.activate([ + self.shotButton.heightAnchor.constraint(equalToConstant: Metric.ShotButton.height), + self.shotButton.widthAnchor.constraint(equalToConstant: Metric.ShotButton.width), + + self.shotButton.centerXAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerXAnchor), + self.shotButton.centerYAnchor.constraint(equalTo: self.cameraView.safeAreaLayoutGuide.bottomAnchor, + constant: -Metric.ShotButton.bottomInset) + ]) + } + + func configureGalleryButtonLayout() { + NSLayoutConstraint.activate([ + self.galleryButton.heightAnchor.constraint(equalToConstant: Metric.GalleryButton.height), + self.galleryButton.widthAnchor.constraint(equalToConstant: Metric.GalleryButton.width), + + self.galleryButton.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leadingAnchor, + constant: Metric.GalleryButton.leadingInset), + self.galleryButton.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, + constant: -Metric.GalleryButton.bottomInset) + ]) + } + + func configureSwapButtonLayout() { + NSLayoutConstraint.activate([ + self.swapButton.heightAnchor.constraint(equalToConstant: Metric.SwapButton.height), + self.swapButton.widthAnchor.constraint(equalToConstant: Metric.SwapButton.width), + + self.swapButton.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor, + constant: -Metric.SwapButton.trailingInset), + self.swapButton.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, + constant: -Metric.SwapButton.bottomInset) + ]) + } + +} + + // MARK: UI Configuration: Style + +private extension SpotViewController { + + func configureStyles() { + self.view.backgroundColor = .black + + var shotButtonConfiguration = UIButton.Configuration.filled() + shotButtonConfiguration.baseBackgroundColor = .msColor(.componentBackground) + self.shotButton.configuration = shotButtonConfiguration + self.shotButton.layer.cornerRadius = Metric.ShotButton.radius + self.shotButton.layer.borderColor = UIColor.msColor(.musicSpot).cgColor + self.shotButton.layer.borderWidth = Metric.ShotButton.borderWidth + self.shotButton.clipsToBounds = true + + var galleryButtonConfiguration = UIButton.Configuration.filled() + galleryButtonConfiguration.baseBackgroundColor = .darkGray + galleryButtonConfiguration.baseForegroundColor = .white + galleryButtonConfiguration.image = UIImage(systemName: "photo.fill") + self.galleryButton.configuration = galleryButtonConfiguration + self.galleryButton.layer.cornerRadius = Metric.GalleryButton.radius + self.galleryButton.clipsToBounds = true + + var swapButtonConfiguration = UIButton.Configuration.filled() + swapButtonConfiguration.baseBackgroundColor = .darkGray + swapButtonConfiguration.image = UIImage(systemName: "arrow.triangle.2.circlepath") + swapButtonConfiguration.baseForegroundColor = .white + self.swapButton.configuration = swapButtonConfiguration + self.swapButton.layer.cornerRadius = Metric.SwapButton.radius + self.swapButton.clipsToBounds = true + + var backButtonConfiguration = UIButton.Configuration.filled() + backButtonConfiguration.baseBackgroundColor = .darkGray + backButtonConfiguration.baseForegroundColor = .white + backButtonConfiguration.image = UIImage(systemName: "xmark") + self.backButton.configuration = backButtonConfiguration + self.backButton.alpha = 0.5 + self.backButton.layer.cornerRadius = Metric.SwapButton.radius + self.backButton.clipsToBounds = true + } + +} + +// MARK: - Configuration: Actions + +private extension SpotViewController { + + func configureAction() { + self.configureCameraSetting() + self.configureShotButtonAction() + self.configureSwapButtonAction() + self.configureBackButtonAction() + self.configureGalleryButtonAction() + + self.configureLeftToRightSwipeGesture() + } + + func configureCameraSetting() { + self.viewModel.preset(screen: self.cameraView) + } + + func configureShotButtonAction() { + let shotButtonAction = UIAction(handler: { _ in + self.shotButtonTapped() + }) + self.shotButton.addAction(shotButtonAction, for: .touchUpInside) + } + + func configureSwapButtonAction() { + let swapButtonAction = UIAction(handler: { _ in + self.swapButtonTapped() + }) + self.swapButton.addAction(swapButtonAction, for: .touchUpInside) + } + + func configureGalleryButtonAction() { + let galleryButtonAction = UIAction(handler: { _ in + self.galleryButtonTapped() + }) + self.galleryButton.addAction(galleryButtonAction, for: .touchUpInside) + } + + func configureBackButtonAction() { + let backButtonAction = UIAction(handler: { _ in + self.backButtonTapped() + }) + self.backButton.addAction(backButtonAction, for: .touchUpInside) + } + +} + +// MARK: - Button Actions + +private extension SpotViewController { + + func shotButtonTapped() { + self.viewModel.shot() + self.haptic.impactOccurred() + } + + func swapButtonTapped() { + self.viewModel.swap() + } + + func galleryButtonTapped() { + self.navigationDelegate?.presentPhotos(from: self) + } + + func backButtonTapped() { + self.navigationDelegate?.popToHome() + } + +} + +// MARK: - Configuration: Delegate + +private extension SpotViewController { + + func configureDelegate() { + self.viewModel.delegate = self + } + +} + +// MARK: - Delegate: ShotDelegate + +extension SpotViewController: ShotDelegate { + + func update(imageData: Data?) { + guard let imageData, let image = UIImage(data: imageData) else { + MSLogger.make(category: .camera).error("์ดฌ์˜๋œ image๊ฐ€ ์ €์žฅ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.") + return + } + + self.cameraView.layer.contents = imageData + self.presentSpotSaveViewController(with: image, coordinate: self.viewModel.coordinate) + } + +} + +// MARK: - Delegate: ImagePickerDelegate + +extension SpotViewController: UIImagePickerControllerDelegate { + + public func imagePickerController(_ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + guard let image = info[UIImagePickerController.InfoKey.editedImage] as? UIImage else { + return + } + self.presentSpotSaveViewController(with: image, coordinate: self.viewModel.coordinate) + } + +} + +// MARK: - Functions + +private extension SpotViewController { + + func presentSpotSaveViewController(with image: UIImage, coordinate: Coordinate) { + self.viewModel.stopCamera() + self.navigationDelegate?.presentSpotSave(using: image, coordinate: coordinate) + } + +} diff --git a/iOS/Features/Spot/Sources/Spot/Presentation/SpotViewModel.swift b/iOS/Features/Spot/Sources/Spot/Presentation/SpotViewModel.swift new file mode 100644 index 0000000..ce2d960 --- /dev/null +++ b/iOS/Features/Spot/Sources/Spot/Presentation/SpotViewModel.swift @@ -0,0 +1,164 @@ +// +// CameraViewModel.swift +// Spot +// +// Created by ์ „๋ฏผ๊ฑด on 11/22/23. +// + +import AVFoundation + +import MSDomain +import MSLogger + +protocol ShotDelegate: AnyObject { + + func update(imageData: Data?) + +} + +public final class SpotViewModel: NSObject { + + // MARK: - Type: SwapMode + + enum SwapMode { + + case front + case back + + var device: AVCaptureDevice? { + guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, + for: .video, + position: self == .front ? .front : .back) else { + MSLogger.make(category: .camera).error("ํ•ด๋‹น ์œ„์น˜์˜ camera device๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.") + return nil + } + return device + } + + var input: AVCaptureDeviceInput? { + guard let device = self.device, + let input = try? AVCaptureDeviceInput(device: device) else { + MSLogger.make(category: .camera).error("ํ•ด๋‹น device๋กœ input์„ ์ƒ์„ฑํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + return nil + } + return input + } + + } + + // MARK: - Properties + + weak var delegate: ShotDelegate? + var swapMode: SwapMode = .back { + didSet { + self.configureSwapMode() + } + } + + private let session = AVCaptureSession() + var input: AVCaptureDeviceInput? + let output = AVCapturePhotoOutput() + + private(set) var coordinate: Coordinate + + // MARK: - Initializer + + public init(coordinate: Coordinate) { + self.coordinate = coordinate + } + +} + +// MARK: - Interface + +internal extension SpotViewModel { + + func preset(screen: Positionable) { + self.presetCamera(screen: screen) + } + + func shot() { + let settings = AVCapturePhotoSettings() + self.output.capturePhoto(with: settings, delegate: self) + } + + func swap() { + self.swapMode = self.swapMode == .back ? .front : .back + } + + func gallery() { + guard let input = self.swapMode.input else { + return + } + self.session.beginConfiguration() + self.session.inputs.forEach { input in + self.session.removeInput(input) + } + self.session.addInput(input) + self.session.commitConfiguration() + } + + func stopCamera() { + DispatchQueue.global(qos: .background).async { + self.session.stopRunning() + } + } + + func startCamera() { + DispatchQueue.global(qos: .background).async { + self.session.startRunning() + } + } + +} + +// MARK: - Actions: Camera + +private extension SpotViewModel { + + func presetCamera(screen: Positionable) { + guard let input = self.swapMode.input else { return } + self.session.sessionPreset = .photo + self.session.addInput(input) + self.session.addOutput(self.output) + + let previewLayer = AVCaptureVideoPreviewLayer(session: self.session) + + self.startCamera() + DispatchQueue.main.async { + previewLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill + previewLayer.frame = screen.bounds + screen.layer.addSublayer(previewLayer) + } + } + + func configureSwapMode() { + guard let input = self.swapMode.input else { + return + } + self.session.beginConfiguration() + self.session.inputs.forEach { + self.session.removeInput($0) + } + self.session.addInput(input) + self.session.commitConfiguration() + } + +} + +// MARK: - Delegate: Camera + +extension SpotViewModel: AVCapturePhotoCaptureDelegate { + + public func photoOutput(_ output: AVCapturePhotoOutput, + didFinishProcessingPhoto photo: AVCapturePhoto, + error: Error?) { + guard let imageData = photo.fileDataRepresentation() else { + MSLogger.make(category: .camera).debug("image Data๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.") + return + } + self.stopCamera() + self.delegate?.update(imageData: imageData) + } + +} diff --git a/iOS/Features/Spot/Sources/Spot/Protocol/Positionable.swift b/iOS/Features/Spot/Sources/Spot/Protocol/Positionable.swift new file mode 100644 index 0000000..45ab629 --- /dev/null +++ b/iOS/Features/Spot/Sources/Spot/Protocol/Positionable.swift @@ -0,0 +1,19 @@ +// +// Positionable.swift +// Spot +// +// Created by ์ „๋ฏผ๊ฑด on 11/29/23. +// + +import Foundation +import UIKit +import QuartzCore + +public protocol Positionable: AnyObject { + + var bounds: CGRect { get set } + var layer: CALayer { get } + +} + +extension UIView: Positionable { } diff --git a/iOS/Features/Spot/SpotDemo/SpotDemo/AppDelegate.swift b/iOS/Features/Spot/SpotDemo/SpotDemo/AppDelegate.swift new file mode 100644 index 0000000..928908b --- /dev/null +++ b/iOS/Features/Spot/SpotDemo/SpotDemo/AppDelegate.swift @@ -0,0 +1,21 @@ +// +// AppDelegate.swift +// SpotDemo +// +// Created by ์ด์ฐฝ์ค€ on 2023.11.29. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions) -> UISceneConfiguration { + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + +} diff --git a/iOS/Features/Spot/SpotDemo/SpotDemo/Assets.xcassets/AccentColor.colorset/Contents.json b/iOS/Features/Spot/SpotDemo/SpotDemo/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/iOS/Features/Spot/SpotDemo/SpotDemo/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Features/Spot/SpotDemo/SpotDemo/Assets.xcassets/AppIcon.appiconset/Contents.json b/iOS/Features/Spot/SpotDemo/SpotDemo/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/iOS/Features/Spot/SpotDemo/SpotDemo/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Features/Spot/SpotDemo/SpotDemo/Assets.xcassets/Contents.json b/iOS/Features/Spot/SpotDemo/SpotDemo/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iOS/Features/Spot/SpotDemo/SpotDemo/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Features/Spot/SpotDemo/SpotDemo/Base.lproj/LaunchScreen.storyboard b/iOS/Features/Spot/SpotDemo/SpotDemo/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/iOS/Features/Spot/SpotDemo/SpotDemo/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/Features/Spot/SpotDemo/SpotDemo/Base.lproj/Main.storyboard b/iOS/Features/Spot/SpotDemo/SpotDemo/Base.lproj/Main.storyboard new file mode 100644 index 0000000..25a7638 --- /dev/null +++ b/iOS/Features/Spot/SpotDemo/SpotDemo/Base.lproj/Main.storyboard @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/Features/Spot/SpotDemo/SpotDemo/Info.plist b/iOS/Features/Spot/SpotDemo/SpotDemo/Info.plist new file mode 100644 index 0000000..c52b344 --- /dev/null +++ b/iOS/Features/Spot/SpotDemo/SpotDemo/Info.plist @@ -0,0 +1,29 @@ + + + + + NSAppleMusicUsageDescription + using photos in album + NSCameraUsageDescription + take photo + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + + diff --git a/iOS/Features/Spot/SpotDemo/SpotDemo/SceneDelegate.swift b/iOS/Features/Spot/SpotDemo/SpotDemo/SceneDelegate.swift new file mode 100644 index 0000000..e08c933 --- /dev/null +++ b/iOS/Features/Spot/SpotDemo/SpotDemo/SceneDelegate.swift @@ -0,0 +1,39 @@ +// +// SceneDelegate.swift +// SpotDemo +// +// Created by ์ด์ฐฝ์ค€ on 2023.11.29. +// + +import UIKit + +import MSData +import MSDesignSystem +import MSDomain +import Spot + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = (scene as? UIWindowScene) else { return } + + window = UIWindow(windowScene: windowScene) + MSFont.registerFonts() +// let spotVM = SpotViewModel() +// let spotVC = SpotViewController(viewModel: spotVM) + + let spotRepo = SpotRepositoryImplementation() + let spotSaveVM = SpotSaveViewModel(repository: spotRepo, + journeyID: "6571bef418be25527c66dc04", + coordinate: Coordinate(latitude: 10, longitude: 10)) + let spotSaveVC = SpotSaveViewController(viewModel: spotSaveVM) + + window?.rootViewController = spotSaveVC + window?.makeKeyAndVisible() + } + +} diff --git a/iOS/Features/Spot/SpotDemo/SpotDemo/ViewController.swift b/iOS/Features/Spot/SpotDemo/SpotDemo/ViewController.swift new file mode 100644 index 0000000..82c12bf --- /dev/null +++ b/iOS/Features/Spot/SpotDemo/SpotDemo/ViewController.swift @@ -0,0 +1,12 @@ +// +// ViewController.swift +// SpotDemo +// +// Created by ์ด์ฐฝ์ค€ on 2023.11.29. +// + +import UIKit + +class ViewController: UIViewController { + +} diff --git a/iOS/MSCoreKit/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/iOS/MSCoreKit/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/iOS/MSCoreKit/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/iOS/MSCoreKit/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/iOS/MSCoreKit/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/iOS/MSCoreKit/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/iOS/MSCoreKit/.swiftpm/xcode/xcshareddata/xcschemes/MSCacheStorageTests.xcscheme b/iOS/MSCoreKit/.swiftpm/xcode/xcshareddata/xcschemes/MSCacheStorageTests.xcscheme new file mode 100644 index 0000000..326b091 --- /dev/null +++ b/iOS/MSCoreKit/.swiftpm/xcode/xcshareddata/xcschemes/MSCacheStorageTests.xcscheme @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/MSCoreKit/.swiftpm/xcode/xcshareddata/xcschemes/MSFetcherTests.xcscheme b/iOS/MSCoreKit/.swiftpm/xcode/xcshareddata/xcschemes/MSFetcherTests.xcscheme new file mode 100644 index 0000000..f299dc2 --- /dev/null +++ b/iOS/MSCoreKit/.swiftpm/xcode/xcshareddata/xcschemes/MSFetcherTests.xcscheme @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/MSCoreKit/.swiftpm/xcode/xcshareddata/xcschemes/MSNetworkingTests.xcscheme b/iOS/MSCoreKit/.swiftpm/xcode/xcshareddata/xcschemes/MSNetworkingTests.xcscheme new file mode 100644 index 0000000..5e9ef94 --- /dev/null +++ b/iOS/MSCoreKit/.swiftpm/xcode/xcshareddata/xcschemes/MSNetworkingTests.xcscheme @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/MSCoreKit/.swiftpm/xcode/xcshareddata/xcschemes/MSPersistentStorageTests.xcscheme b/iOS/MSCoreKit/.swiftpm/xcode/xcshareddata/xcschemes/MSPersistentStorageTests.xcscheme new file mode 100644 index 0000000..e9cfe80 --- /dev/null +++ b/iOS/MSCoreKit/.swiftpm/xcode/xcshareddata/xcschemes/MSPersistentStorageTests.xcscheme @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/MSCoreKit/Package.swift b/iOS/MSCoreKit/Package.swift new file mode 100644 index 0000000..2063e38 --- /dev/null +++ b/iOS/MSCoreKit/Package.swift @@ -0,0 +1,124 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +// MARK: - Constants + +private extension String { + + static let package = "MSCoreKit" + + var testTarget: String { + return self + "Tests" + } + + var fromRootPath: String { + return "../" + self + } + +} + +private enum Target { + + static let msImageFetcher = "MSImageFetcher" + static let msPersistentStorage = "MSPersistentStorage" + static let msNetworking = "MSNetworking" + static let msFetcher = "MSFetcher" + static let msCacheStorage = "MSCacheStorage" + static let msKeychainStorage = "MSKeychainStorage" + static let msMapKit = "MSMapKit" + +} + +private enum Dependency { + + static let msConstants = "MSConstants" + static let msLogger = "MSLogger" + static let msFoundation = "MSFoundation" + +} + +// MARK: - Package + +let package = Package( + name: .package, + platforms: [ + .iOS(.v15) + ], + products: [ + .library(name: Target.msImageFetcher, + targets: [Target.msImageFetcher]), + .library(name: Target.msPersistentStorage, + targets: [Target.msPersistentStorage]), + .library(name: Target.msNetworking, + targets: [Target.msNetworking]), + .library(name: Target.msFetcher, + targets: [Target.msFetcher]), + .library(name: Target.msCacheStorage, + targets: [Target.msCacheStorage]), + .library(name: Target.msKeychainStorage, + targets: [Target.msKeychainStorage]) + ], + dependencies: [ + .package(name: Dependency.msFoundation, + path: Dependency.msFoundation.fromRootPath) + ], + targets: [ + // Codes + .target(name: Target.msImageFetcher, + dependencies: [ + .target(name: Target.msCacheStorage), + .product(name: Dependency.msLogger, + package: Dependency.msFoundation) + ]), + .target(name: Target.msPersistentStorage, + dependencies: [ + .product(name: Dependency.msLogger, + package: Dependency.msFoundation), + .product(name: Dependency.msConstants, + package: Dependency.msFoundation) + ]), + .target(name: Target.msNetworking, + dependencies: [ + .product(name: Dependency.msLogger, + package: Dependency.msFoundation) + ]), + .target(name: Target.msFetcher, + dependencies: [ + .target(name: Target.msPersistentStorage), + .target(name: Target.msNetworking) + ]), + .target(name: Target.msCacheStorage, + dependencies: [ + .product(name: Dependency.msConstants, + package: Dependency.msFoundation) + ]), + .target(name: Target.msKeychainStorage, + dependencies: [ + .product(name: Dependency.msLogger, + package: Dependency.msFoundation), + .product(name: Dependency.msConstants, + package: Dependency.msFoundation) + ]), + + // Tests + .testTarget(name: Target.msPersistentStorage.testTarget, + dependencies: [ + .target(name: Target.msPersistentStorage) + ]), + .testTarget(name: Target.msNetworking.testTarget, + dependencies: [ + .target(name: Target.msNetworking) + ]), + .testTarget(name: Target.msFetcher.testTarget, + dependencies: [ + .target(name: Target.msFetcher) + ]), + .testTarget(name: Target.msCacheStorage.testTarget, + dependencies: [ + .target(name: Target.msCacheStorage) + ]) + ], + swiftLanguageVersions: [.v5] +) diff --git a/iOS/MSCoreKit/Sources/MSCacheStorage/MSCacheError.swift b/iOS/MSCoreKit/Sources/MSCacheStorage/MSCacheError.swift new file mode 100644 index 0000000..8bd1911 --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSCacheStorage/MSCacheError.swift @@ -0,0 +1,15 @@ +// +// MSCacheError.swift +// MSCoreKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.02. +// + +import Foundation + +public enum MSCacheError: Error { + + case memoryFail + case diskFail + +} diff --git a/iOS/MSCoreKit/Sources/MSCacheStorage/MSCacheStorage.swift b/iOS/MSCoreKit/Sources/MSCacheStorage/MSCacheStorage.swift new file mode 100644 index 0000000..c787b92 --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSCacheStorage/MSCacheStorage.swift @@ -0,0 +1,197 @@ +// +// MSCacheStorage.swift +// MSCoreKit +// +// Created by ์ด์ฐฝ์ค€ on 11/14/23. +// + +import Foundation + +import MSConstants + +public final class MSCacheStorage: CacheStorage { + + public typealias Key = String + public typealias Value = Data + + // MARK: - Properties + + private let memory: Cache + private let disk: FileManager + + // MARK: - Initializer + + public init(cache: Cache = Cache(), + fileManager: FileManager = .default) { + self.memory = cache + self.disk = fileManager + } + + // MARK: - Read + + /// ์บ์‹ฑ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ต๋‹ˆ๋‹ค. + /// **๋ฉ”๋ชจ๋ฆฌ** ์บ์‹œ์—์„œ ๊ฐ’์„ ์šฐ์„ ์ ์œผ๋กœ ์ฐพ์€ ํ›„, ์กด์žฌํ•˜๋ฉด ํ•ด๋‹น ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + /// ๊ทธ ํ›„์— **๋””์Šคํฌ** ์บ์‹œ์—์„œ ๊ฐ’์„ ์ฐพ์•„ ์กด์žฌํ•  ๊ฒฝ์šฐ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + /// - Parameters: + /// - key: ์บ์‹ฑ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ์ฐพ๊ธฐ ์œ„ํ•œ ์บ์‹œ Key + /// - Returns: ์บ์‹ฑ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์„ ๊ฒฝ์šฐ `nil` ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + public func data(forKey key: Key) -> Value? { + // Memory Cache + if let memoryData = self.memory.object(forKey: key as NSString) { // memory hit + return memoryData as Data + } + + // Disk Cache + if let cacheURL = self.cacheURL(forCache: key), + let diskData = self.disk.contents(atPath: cacheURL.path) { // disk hit + return diskData + } + + return nil + } + + // MARK: - Create + + /// ๋ฐ์ดํ„ฐ๋ฅผ ์บ์‹ฑํ•ฉ๋‹ˆ๋‹ค. + /// - Parameters: + /// - value: ์บ์‹ฑํ•  ๋ฐ์ดํ„ฐ + /// - key: ๋ฐ์ดํ„ฐ๋ฅผ ์บ์‹ฑํ•˜๊ธฐ ์œ„ํ•œ ์บ์‹œ Key + /// - Returns: + /// Result ํƒ€์ž…์˜ ์บ์‹ฑ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + /// ์„ฑ๊ณตํ–ˆ์„ ๊ฒฝ์šฐ `.success`๋ฅผ, + /// ๋ฉ”๋ชจ๋ฆฌ ์บ์‹ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•  ๊ฒฝ์šฐ `.memoryFail`์„, + /// ๋””์Šคํฌ ์บ์‹ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•  ๊ฒฝ์šฐ `.diskFail`์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + @discardableResult + public func cache(_ value: Value, forKey key: Key) -> Result { + // Memory Cache + self.memory.setObject(value as NSData, + forKey: key as NSString, + cost: value.count) + if self.memory.object(forKey: key as NSString) == nil { + return .failure(.memoryFail) + } + + // Disk Cache + guard let cacheURL = self.cacheURL(forCache: key) else { + return .failure(.diskFail) + } + let cacheURLString: String + if #available(iOS 16.0, *) { + cacheURLString = cacheURL.path() + } else { + cacheURLString = cacheURL.path + } + if self.disk.createFile(atPath: cacheURLString, contents: value) == false { + return .failure(.diskFail) + } + + return .success(value) + } + + // MARK: - Delete + + /// ์บ์‹ฑ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ์ œ๊ฑฐํ•ฉ๋‹ˆ๋‹ค. + /// - Parameters: + /// - key: ์ œ๊ฑฐํ•  ์บ์‹œ๋ฅผ ์ฐพ๊ธฐ ์œ„ํ•œ ์บ์‹œ Key + /// - Throws: + /// ๋ฉ”๋ชจ๋ฆฌ ์บ์‹ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•  ๊ฒฝ์šฐ `.memoryFail` ์—๋Ÿฌ, + /// ๋””์Šคํฌ ์บ์‹ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•  ๊ฒฝ์šฐ `.diskFail` ์—๋Ÿฌ๋ฅผ throw ํ•ฉ๋‹ˆ๋‹ค. + public func remove(forKey key: Key) throws { + // Memory Cache + self.memory.removeObject(forKey: key as NSString) + if self.memory.object(forKey: key as NSString) != nil { + throw MSCacheError.memoryFail + } + + // Disk Cache + guard let cacheURL = self.cacheURL(forCache: key) else { + throw MSCacheError.diskFail + } + do { + try self.disk.removeItem(at: cacheURL) + } catch { + throw MSCacheError.diskFail + } + } + + // MARK: - Clean + + /// ์บ์‹ฑ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. + /// - Parameters: + /// - target: ์‚ญ์ œํ•  ๋Œ€์ƒ์„ ์„ ํƒํ•ฉ๋‹ˆ๋‹ค. + /// - `.all`: **๋””์Šคํฌ์™€ ๋ฉ”๋ชจ๋ฆฌ** ์บ์‹œ๋ฅผ ๋ชจ๋‘ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. + /// - `.disk`: **๋””์Šคํฌ**์— ์ €์žฅ๋œ ์บ์‹œ๋ฅผ ๋ชจ๋‘ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. + /// - `.memory`: **๋ฉ”๋ชจ๋ฆฌ**์— ์ €์žฅ๋œ ์บ์‹œ๋ฅผ ๋ชจ๋‘ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. + public func clean(_ target: CacheStorageTarget) throws { + switch target { + case .all: + try self.cleanAll() + case .memory: + self.cleanMemory() + case .disk: + try self.cleanDisk() + } + } + + private func cleanAll() throws { + self.cleanMemory() + try self.cleanDisk() + } + + private func cleanMemory() { + self.memory.removeAllObjects() + } + + private func cleanDisk() throws { + if let path = self.cacheDirectoryURL?.path { + try self.disk.removeItem(atPath: path) + } + } + +} + +// MARK: - URLs + +private extension MSCacheStorage { + + var cacheDirectoryURL: URL? { + let directoryURL: URL? + + if #available(iOS 16.0, *) { + let cacheDirectoryURL = try? self.disk.url(for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: .cachesDirectory, + create: false) + directoryURL = cacheDirectoryURL? + .appending(path: Constants.appBundleIdentifier, directoryHint: .isDirectory) + } else { + let cacheDirectoryURL = self.disk + .urls(for: .cachesDirectory, in: .userDomainMask) + .first + directoryURL = cacheDirectoryURL? + .appendingPathComponent(Constants.appBundleIdentifier, isDirectory: true) + } + + guard let directoryURL else { return nil } + do { + try self.disk.createDirectory(at: directoryURL, withIntermediateDirectories: true) + } catch { + return nil + } + + return directoryURL + } + + func cacheURL(forCache cache: String, fileExtension: String = "cache") -> URL? { + if #available(iOS 16.0, *) { + return self.cacheDirectoryURL? + .appending(component: cache, directoryHint: .notDirectory) + .appendingPathExtension(fileExtension) + } else { + return self.cacheDirectoryURL? + .appendingPathComponent(cache) + .appendingPathExtension(fileExtension) + } + } + +} diff --git a/iOS/MSCoreKit/Sources/MSCacheStorage/Protocol/CacheStorage.swift b/iOS/MSCoreKit/Sources/MSCacheStorage/Protocol/CacheStorage.swift new file mode 100644 index 0000000..1d2cc3f --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSCacheStorage/Protocol/CacheStorage.swift @@ -0,0 +1,30 @@ +// +// CacheStorage.swift +// MSCoreKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.11.28. +// + +import Foundation + +public protocol CacheStorage { + + associatedtype Key = String + associatedtype Value + typealias Cache = NSCache + + // MARK: - Functions + + func data(forKey key: Key) -> Value? + func cache(_ value: Value, forKey key: Key) -> Result + func remove(forKey key: Key) throws + + func clean(_ target: CacheStorageTarget) throws + +} + +public enum CacheStorageTarget { + case all + case memory + case disk +} diff --git a/iOS/MSCoreKit/Sources/MSFetcher/MSFetcher.swift b/iOS/MSCoreKit/Sources/MSFetcher/MSFetcher.swift new file mode 100644 index 0000000..32d05c6 --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSFetcher/MSFetcher.swift @@ -0,0 +1,6 @@ +// +// MSFetcher.swift +// MSCoreKit +// +// Created by ์ด์ฐฝ์ค€ on 11/14/23. +// diff --git a/iOS/MSCoreKit/Sources/MSImageFetcher/ImageFetchError.swift b/iOS/MSCoreKit/Sources/MSImageFetcher/ImageFetchError.swift new file mode 100644 index 0000000..67dc89e --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSImageFetcher/ImageFetchError.swift @@ -0,0 +1,14 @@ +// +// ImageFetchError.swift +// MSCoreKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import Foundation + +public enum ImageFetchError: Error { + + case imageFetchFailed + +} diff --git a/iOS/MSCoreKit/Sources/MSImageFetcher/MSImageFetcher.swift b/iOS/MSCoreKit/Sources/MSImageFetcher/MSImageFetcher.swift new file mode 100644 index 0000000..2f7b019 --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSImageFetcher/MSImageFetcher.swift @@ -0,0 +1,70 @@ +// +// MSImageFetcher.swift +// MSCoreKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.02. +// + +import Foundation + +import MSCacheStorage +import MSLogger + +public final class MSImageFetcher { + + typealias CacheTask = Task + + // MARK: - Singleton + + public static let shared = MSImageFetcher() + + private init(cache: MSCacheStorage = MSCacheStorage(), + session: URLSession = URLSession(configuration: .default)) { + self.cache = cache + self.session = session + } + + // MARK: - Properties + + private let cache: MSCacheStorage + private let session: URLSession + + private var cacheStorage: Set = [] + + // MARK: - Functions + + /// - Parameters: + /// - photoURL: ๊ฐ€์ ธ์˜ฌ ์‚ฌ์ง„์˜ URL + /// - key: ์‚ฌ์ง„์˜ ์บ์‹ฑ์„ ์ฒ˜๋ฆฌํ•˜๊ธฐ ์œ„ํ•œ key ๊ฐ’ + @discardableResult + public func fetchImage(from photoURL: URL, + forKey key: String) async -> Data? { + // 1. ์บ์‹ฑ๋œ ๊ฐ’์ด ์žˆ๋Š” ์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. + // 2. ์žˆ๋‹ค๋ฉด ์บ์‹ฑ๋œ ์ด๋ฏธ์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + if let imageData = self.cache.data(forKey: key) { + #if DEBUG + MSLogger.make(category: .imageFetcher) + .log("์ด๋ฏธ์ง€ ๋ฐ์ดํ„ฐ๋ฅผ ์บ์‹œ๋กœ๋ถ€ํ„ฐ ๋ถˆ๋Ÿฌ์™”์Šต๋‹ˆ๋‹ค: \(imageData)") + #endif + return imageData + } + // 3. ์—†๋‹ค๋ฉด ๋„คํŠธ์›Œํฌ ์š”์ฒญ์„ ํ•ฉ๋‹ˆ๋‹ค. + // 4. ๋„คํŠธ์›Œํฌ์—์„œ ๋ฐ›์•„์˜จ ์ด๋ฏธ์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + var request = URLRequest(url: photoURL) + request.httpMethod = "GET" + if let (data, _) = try? await self.session.data(for: request) { + #if DEBUG + MSLogger.make(category: .imageFetcher) + .log("์ด๋ฏธ์ง€ ๋ฐ์ดํ„ฐ๋ฅผ ๋„คํŠธ์›Œํฌ๋กœ๋ถ€ํ„ฐ ๋ถˆ๋Ÿฌ์™”์Šต๋‹ˆ๋‹ค: \(data)") + #endif + + // 5. ์ด๋ฏธ์ง€ ๋ฐ์ดํ„ฐ๋ฅผ ์บ์‹ฑํ•ฉ๋‹ˆ๋‹ค. + self.cache.cache(data, forKey: key) + + return data + } + + return nil + } + +} diff --git a/iOS/MSCoreKit/Sources/MSImageFetcher/MSImageFetcherWrapper.swift b/iOS/MSCoreKit/Sources/MSImageFetcher/MSImageFetcherWrapper.swift new file mode 100644 index 0000000..9f73f20 --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSImageFetcher/MSImageFetcherWrapper.swift @@ -0,0 +1,36 @@ +// +// MSImageFetcherWrapper.swift +// MSCoreKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.02. +// + +import MSCacheStorage + +public struct MSImageFetcherWrapper { + + // MARK: - Properties + + public let base: Base + + // MARK: - Initializer + + public init(base: Base) { + self.base = base + } + +} + +// MARK: - Compatible + +public protocol MSImageFetcherCompatible: AnyObject { } + +extension MSImageFetcherCompatible { + + // swiftlint: disable identifier_name + public var ms: MSImageFetcherWrapper { + return MSImageFetcherWrapper(base: self) + } + // swiftlint: enable identifier_name + +} diff --git a/iOS/MSCoreKit/Sources/MSKeychainStorage/MSKeychainStorage+Constants.swift b/iOS/MSCoreKit/Sources/MSKeychainStorage/MSKeychainStorage+Constants.swift new file mode 100644 index 0000000..df59054 --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSKeychainStorage/MSKeychainStorage+Constants.swift @@ -0,0 +1,24 @@ +// +// MSKeychainStorage+Constants.swift +// MSCoreKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import MSConstants + +extension MSKeychainStorage { + + enum KeychainConstants { + + static let service: String = "\(Constants.appBundleIdentifier).keychainManager" + + } + + public enum Accounts: String, CaseIterable { + + case userID = "MusicSpotUser.v1" + + } + +} diff --git a/iOS/MSCoreKit/Sources/MSKeychainStorage/MSKeychainStorage+Error.swift b/iOS/MSCoreKit/Sources/MSKeychainStorage/MSKeychainStorage+Error.swift new file mode 100644 index 0000000..2e48f97 --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSKeychainStorage/MSKeychainStorage+Error.swift @@ -0,0 +1,20 @@ +// +// MSKeychainStorage+Error.swift +// MSCoreKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import Foundation + +extension MSKeychainStorage { + + public enum KeychainError: Error { + + case fetchError + case creationError + case transactionError + + } + +} diff --git a/iOS/MSCoreKit/Sources/MSKeychainStorage/MSKeychainStorage+Transaction.swift b/iOS/MSCoreKit/Sources/MSKeychainStorage/MSKeychainStorage+Transaction.swift new file mode 100644 index 0000000..492530e --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSKeychainStorage/MSKeychainStorage+Transaction.swift @@ -0,0 +1,109 @@ +// +// MSKeychainStorage+Transaction.swift +// MSCoreKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import Foundation + +import MSLogger + +extension MSKeychainStorage { + + /// Keychain์œผ๋กœ๋ถ€ํ„ฐ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ต๋‹ˆ๋‹ค. + /// - Parameters: + /// - account: ๊ฐ€์ ธ์˜ฌ ํ‚ค์ฒด์ธ์˜ Key (`String`) + func fetch(account: String) throws -> Data? { + var result: AnyObject? + + let status = SecItemCopyMatching([ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: account, + kSecAttrService: KeychainConstants.service, + kSecReturnData: true + ] as NSDictionary, &result) + + switch status { + case errSecSuccess: + return result as? Data + case errSecItemNotFound: + return nil + default: + throw KeychainError.fetchError + } + } + + /// Keychain์— ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. + /// - Parameters: + /// - value: ์ €์žฅํ•  ๊ฐ’ (`Data`) + /// - account: ์ €์žฅํ•  ๊ฐ’์— ๋Œ€์‘๋˜๋Š” Key (`String`) + func add(value: Data, account: String) throws { + let status = SecItemAdd([ + kSecClass: kSecClassGenericPassword, // ๋ฐ์ดํ„ฐ ์ข…๋ฅ˜ + kSecAttrAccount: account, // ๋ฐ์ดํ„ฐ ํ‚ค + kSecValueData: value, // ๋ฐ์ดํ„ฐ ๋ฐธ๋ฅ˜ + kSecAttrService: KeychainConstants.service + ] as NSDictionary, nil) + + guard status == errSecSuccess else { + throw KeychainError.transactionError + } + } + + /// Keychain์— ์žˆ๋Š” ๋ฐ์ดํ„ฐ๋ฅผ ์—…๋ฐ์ดํŠธํ•ฉ๋‹ˆ๋‹ค. + /// - Parameters: + /// - value: ์—…๋ฐ์ดํŠธํ•  ๊ฐ’ + /// - account: ์—…๋ฐ์ดํŠธํ•  ๊ฐ’์— ๋Œ€์‘๋˜๋Š” Key ๊ฐ’ + func update(value: Data, account: String) throws { + let status = SecItemUpdate([ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: account, + kSecAttrService: KeychainConstants.service + ] as NSDictionary, [ + kSecValueData: value + ] as NSDictionary) + + guard status == errSecSuccess else { + throw KeychainError.transactionError + } + } + + /// Keychain์— ์žˆ๋Š” ๋ฐ์ดํ„ฐ๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. + /// - Parameters: + /// - account: ์‚ญ์ œํ•  ๊ฐ’์— ๋Œ€์‘๋˜๋Š” Key ๊ฐ’ + func remove(account: String) throws { + let status = SecItemDelete([ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: account, + kSecAttrService: KeychainConstants.service + ] as NSDictionary) + + guard status == errSecSuccess else { + throw KeychainError.transactionError + } + } + + /// Keychain์— account์— ๋Œ€์‘๋˜๋Š” ๊ฐ’์ด ์กด์žฌํ•˜๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. + /// - Parameters: + /// - account: ์กฐํšŒํ•  Key ๊ฐ’ + func exists(account: String) throws -> Bool { + let status = SecItemCopyMatching([ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: account, + kSecAttrService: KeychainConstants.service, + kSecReturnData: false + ] as NSDictionary, nil) + + switch status { + case errSecSuccess: + MSLogger.make(category: .keychain).log("\(account)์˜ ํ‚ค์ฒด์ธ ๊ฐ’์„ ์ฐพ์•˜์Šต๋‹ˆ๋‹ค.") + return true + case errSecItemNotFound: + return false + default: + throw KeychainError.creationError + } + } + +} diff --git a/iOS/MSCoreKit/Sources/MSKeychainStorage/MSKeychainStorage.swift b/iOS/MSCoreKit/Sources/MSKeychainStorage/MSKeychainStorage.swift new file mode 100644 index 0000000..619c1fb --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSKeychainStorage/MSKeychainStorage.swift @@ -0,0 +1,76 @@ +// +// MSKeychainStorage.swift +// MSCoreKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import Foundation + +public struct MSKeychainStorage { + + // MARK: - Properties + + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + // MARK: - Initializer + + public init() { } + + // MARK: - Functions + + /// Keychain์— ๋ฐ์ดํ„ฐ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. + /// + /// ๋งŒ์•ฝ ๊ฐ’์ด ์ด๋ฏธ ์กด์žฌํ•œ๋‹ค๋ฉด ์—…๋ฐ์ดํŠธํ•˜๊ณ , ์กด์žฌํ•˜์ง€ ์•Š๋Š”๋‹ค๋ฉด ์ƒˆ๋กœ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. + /// + /// - Parameters: + /// - value: Keychain์— ์ €์žฅํ•  ๋ฐ์ดํ„ฐ (`Data`) + /// - account: Keychain์— ์ €์žฅํ•  ๋ฐ์ดํ„ฐ์— ๋Œ€์‘๋˜๋Š” Key (`String`) + public func set(value: T, account: String) throws { + let encodedValue = try self.encoder.encode(value) + if try self.exists(account: account) { + try self.update(value: encodedValue, account: account) + } else { + try self.add(value: encodedValue, account: account) + } + } + + /// Keychain์—์„œ account Key์— ํ•ด๋‹น๋˜๋Š” ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ต๋‹ˆ๋‹ค. + /// + /// Keychain์— ๊ฐ’์ด ์กด์žฌํ•œ๋‹ค๋ฉด Optional๋กœ ๋ถˆ๋Ÿฌ์˜ค๊ณ , ์กด์žฌํ•˜์ง€ ์•Š๋Š”๋‹ค๋ฉด ์—๋Ÿฌ๋ฅผ ๋ฐฉ์ถœํ•ฉ๋‹ˆ๋‹ค. + /// + /// - Parameters: + /// - account: Keychain์—์„œ ์กฐํšŒํ•  ๋ฐ์ดํ„ฐ์— ๋Œ€์‘๋˜๋Š” Key (`String`) + /// - Returns: Keychain์— ์ €์žฅ๋œ ๋ฐ์ดํ„ฐ + public func get(_ type: T.Type, account: String) throws -> T? { + if try self.exists(account: account) { + guard let value = try self.fetch(account: account) else { + return nil + } + return try self.decoder.decode(T.self, from: value) + } else { + throw KeychainError.transactionError + } + } + + /// Keychain์— ์ €์žฅ๋˜์–ด ์žˆ๋Š” account Key์— ๋Œ€์‘๋˜๋Š” ๋ฐ์ดํ„ฐ๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. + /// + /// - Parameters: + /// - account: Keychain์—์„œ ์‚ญ์ œํ•  ๊ฐ’์— ๋Œ€์‘๋˜๋Š” Key (`String`) + public func delete(account: String) throws { + if try self.exists(account: account) { + return try self.remove(account: account) + } else { + throw KeychainError.transactionError + } + } + + /// Keychain์— ์ €์žฅ๋œ ๋ชจ๋“  ๋ฐ์ดํ„ฐ๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. + public func deleteAll() throws { + for account in Accounts.allCases where try self.exists(account: account.rawValue) { + try self.delete(account: account.rawValue) + } + } + +} diff --git a/iOS/MSCoreKit/Sources/MSNetworking/ErrorResponseDTO.swift b/iOS/MSCoreKit/Sources/MSNetworking/ErrorResponseDTO.swift new file mode 100644 index 0000000..9d69f11 --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSNetworking/ErrorResponseDTO.swift @@ -0,0 +1,46 @@ +// +// ErrorResponseDTO.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import Foundation + +public struct ErrorResponseDTO { + + public let method: String + public let path: String + public let timestamp: Date + public let message: String + public let statusCode: Int + +} + +extension ErrorResponseDTO: Decodable { + + enum CodingKeys: String, CodingKey { + case method + case path + case timestamp + case message + case statusCode + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.method = try container.decode(String.self, forKey: .method) + self.path = try container.decode(String.self, forKey: .path) + self.timestamp = try container.decode(Date.self, forKey: .timestamp) + if let message = try? container.decode(String.self, forKey: .message) { + self.message = message + } else if let decodedMessage = try? container.decode([String].self, forKey: .message), + let message = decodedMessage.first { + self.message = message + } else { + self.message = "" + } + self.statusCode = try container.decode(Int.self, forKey: .statusCode) + } + +} diff --git a/iOS/MSCoreKit/Sources/MSNetworking/HTTPBody.swift b/iOS/MSCoreKit/Sources/MSNetworking/HTTPBody.swift new file mode 100644 index 0000000..e3f121f --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSNetworking/HTTPBody.swift @@ -0,0 +1,72 @@ +// +// HTTPBody.swift +// MSCoreKit +// +// Created by ์ „๋ฏผ๊ฑด on 11/16/23. +// + +import Foundation + +import MSLogger +import UIKit + +public struct HTTPBody { + + // MARK: - Status + + public enum BodyType { + case normal + case multipart + } + + // MARK: - Properties + + public let type: BodyType + private let boundary: String? + var content: Encodable? + private var allOfMultipartData: [MultipartData]? + + // MARK: - Initializer + + public init(type: BodyType = .normal, + boundary: String? = nil, + content: Encodable? = nil, + multipartData: [MultipartData]? = nil) { + self.type = type + self.boundary = boundary + self.content = content + self.allOfMultipartData = multipartData + } + + // MARK: - Functions + + func data(encoder: JSONEncoder) -> Data? { + switch self.type { + case .normal: + guard let content, + let data = try? encoder.encode(content) else { + MSLogger.make(category: .network).error("HTTP Body ๋ฐ์ดํ„ฐ ์ธ์ฝ”๋”ฉ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") + return nil + } + return data + + case .multipart: + guard let boundary, + let delimiter: Data = "\r\n--\(boundary)\r\n".data(using: .utf8), + let finalDelimiter: Data = "\r\n--\(boundary)--\r\n".data(using: .utf8) else { + MSLogger.make(category: .network).debug("delimiter ์ƒ์„ฑ์— ์‹คํŒจํ•˜์˜€์Šต๋‹ˆ๋‹ค.") + return nil + } + var data = Data() + self.allOfMultipartData?.forEach { multipartData in + data.append(delimiter) + multipartData.contentInformation(using: encoder).forEach { + data.append($0) + } + } + data.append(finalDelimiter) + return data + } + } + +} diff --git a/iOS/MSCoreKit/Sources/MSNetworking/HTTPHeader.swift b/iOS/MSCoreKit/Sources/MSNetworking/HTTPHeader.swift new file mode 100644 index 0000000..67d4fe0 --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSNetworking/HTTPHeader.swift @@ -0,0 +1,11 @@ +// +// HTTPHeader.swift +// MSCoreKit +// +// Created by ์ด์ฐฝ์ค€ on 11/26/23. +// + +public typealias HTTPHeaderKey = String +public typealias HTTPHeaderValue = String +public typealias HTTPHeader = (key: HTTPHeaderKey, value: HTTPHeaderValue) +public typealias HTTPHeaders = [HTTPHeader] diff --git a/iOS/MSCoreKit/Sources/MSNetworking/HTTPMethod.swift b/iOS/MSCoreKit/Sources/MSNetworking/HTTPMethod.swift new file mode 100644 index 0000000..5afe787 --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSNetworking/HTTPMethod.swift @@ -0,0 +1,16 @@ +// +// HTTPMethod.swift +// MSCoreKit +// +// Created by ์ „๋ฏผ๊ฑด on 11/16/23. +// + +public enum HTTPMethod: String { + + case get = "GET" + case post = "POST" + case patch = "PATCH" + case put = "PUT" + case delete = "DELETE" + +} diff --git a/iOS/MSCoreKit/Sources/MSNetworking/MSNetworkError.swift b/iOS/MSCoreKit/Sources/MSNetworking/MSNetworkError.swift new file mode 100644 index 0000000..968e443 --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSNetworking/MSNetworkError.swift @@ -0,0 +1,39 @@ +// +// MSNetworkError.swift +// MSCoreKit +// +// Created by ์ „๋ฏผ๊ฑด on 11/16/23. +// + +import Foundation + +public enum MSNetworkError: Error { + + case invalidRouter + case unknownResponse + case invalidStatusCode(statusCode: Int, description: String) + case timeout + case unknownChildTask + +} + +extension MSNetworkError: Equatable { + + public static func == (lhs: MSNetworkError, rhs: MSNetworkError) -> Bool { + switch (lhs, rhs) { + case (.invalidRouter, .invalidRouter): + return true + case (.unknownResponse, .unknownResponse): + return true + case let (.invalidStatusCode(lhsStatusCode, _), .invalidStatusCode(rhsStatusCode, _)): + return lhsStatusCode == rhsStatusCode + case (.timeout, .timeout): + return true + case (.unknownChildTask, .unknownChildTask): + return true + default: + return false + } + } + +} diff --git a/iOS/MSCoreKit/Sources/MSNetworking/MSNetworking.swift b/iOS/MSCoreKit/Sources/MSNetworking/MSNetworking.swift new file mode 100644 index 0000000..c683d58 --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSNetworking/MSNetworking.swift @@ -0,0 +1,153 @@ +// +// MSNetworking.swift +// MSCoreKit +// +// Created by ์ „๋ฏผ๊ฑด on 11/16/23. +// + +import Foundation +import Combine + +import MSLogger + +public struct MSNetworking { + + public typealias TimeoutInterval = DispatchQueue.SchedulerTimeType.Stride + + // MARK: - Constants + + public static let dispatchQueueLabel = "com.MSNetworking.MSCoreKit.MusicSpot" + + // MARK: - Properties + + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions.insert(.withFractionalSeconds) + encoder.dateEncodingStrategy = .custom { date, encoder in + var container = encoder.singleValueContainer() + let dateString = dateFormatter.string(from: date) + try container.encode(dateString) + } + return encoder + }() + + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions.insert(.withFractionalSeconds) + decoder.dateDecodingStrategy = .custom({ decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + if let date = dateFormatter.date(from: dateString) { + return date + } + throw DecodingError.dataCorruptedError(in: container, + debugDescription: "Date ๋””์ฝ”๋”ฉ ์‹คํŒจ: \(dateString)") + }) + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + }() + + private let session: Session + public let queue: DispatchQueue + + // MARK: - Initializer + + public init(session: Session) { + self.session = session + self.queue = DispatchQueue(label: MSNetworking.dispatchQueueLabel, qos: .background) + } + + // MARK: - Functions + + /// ``Router``๋ฅผ ์‚ฌ์šฉํ•ด HTTP ๋„คํŠธ์›Œํ‚น์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. Combine์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + /// - Parameters: + /// - type: ๊ฒฐ๊ณผ๋กœ ๋ฐ›์„ ๊ฐ’์˜ ํƒ€์ž…. `String.self`์˜ ํ˜•ํƒœ๋กœ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. + /// - router: ๋„คํŠธ์›Œํ‚น์— ๋Œ€ํ•œ ์ •๋ณด๋ฅผ ๋‹ด์€ ``Router`` + /// - timeoutInterval: ๋„คํŠธ์›Œํ‚น์— ๋Œ€ํ•œ ํƒ€์ž„์•„์›ƒ ์‹œ๊ฐ„ + /// - Returns: ๊ฒฐ๊ณผ๊ฐ’๊ณผ ์—๋Ÿฌ๋ฅผ ๋‹ด์€ AnyPublisher + public func request(_ type: T.Type, + router: Router, + timeoutInterval: TimeoutInterval = .seconds(3)) -> AnyPublisher { + guard let request = router.makeRequest(encoder: self.encoder) else { + return Fail(error: MSNetworkError.invalidRouter).eraseToAnyPublisher() + } + + return self.session + .dataTaskPublisher(for: request) + .timeout(timeoutInterval, scheduler: self.queue) + .tryMap { data, response -> Data in + guard let response = response as? HTTPURLResponse else { + throw MSNetworkError.unknownResponse + } + guard 200..<300 ~= response.statusCode else { + throw MSNetworkError.invalidStatusCode(statusCode: response.statusCode, + description: response.description) + } + return data + } + .decode(type: T.self, decoder: self.decoder) + .eraseToAnyPublisher() + } + + /// ``Router``๋ฅผ ์‚ฌ์šฉํ•ด HTTP ๋„คํŠธ์›Œํ‚น์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. Swift Concurrency์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + /// - Parameters: + /// - type: ๊ฒฐ๊ณผ๋กœ ๋ฐ›์„ ๊ฐ’์˜ ํƒ€์ž…. `String.self`์˜ ํ˜•ํƒœ๋กœ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. + /// - router: ๋„คํŠธ์›Œํ‚น์— ๋Œ€ํ•œ ์ •๋ณด๋ฅผ ๋‹ด์€ ``Router`` + /// - timeoutInterval: ๋„คํŠธ์›Œํ‚น์— ๋Œ€ํ•œ ํƒ€์ž„์•„์›ƒ ์‹œ๊ฐ„ + /// - Returns: ๊ฒฐ๊ณผ๊ฐ’๊ณผ ์—๋Ÿฌ๋ฅผ ๋‹ด์€ Result + public func request(_ type: T.Type, + router: Router, + timeoutInterval: TimeoutInterval = .seconds(3)) async -> Result { + guard let request = router.makeRequest(encoder: self.encoder) else { + return .failure(MSNetworkError.invalidRouter) + } + + do { + return try await withThrowingTaskGroup(of: Result.self) { group in + defer { group.cancelAll() } + + group.addTask { + let (data, response) = try await self.session.data(for: request, delegate: nil) + try Task.checkCancellation() + + guard let response = response as? HTTPURLResponse else { + throw MSNetworkError.unknownResponse + } + + guard 200..<300 ~= response.statusCode else { + let errorResponse = try self.decoder.decode(ErrorResponseDTO.self, from: data) + throw MSNetworkError.invalidStatusCode(statusCode: errorResponse.statusCode, + description: errorResponse.message) + } + + do { + let decodedResult = try self.decoder.decode(T.self, from: data) + return .success(decodedResult) + } catch { + throw error + } + } + + group.addTask { + try await Task.sleep(nanoseconds: UInt64(timeoutInterval.magnitude)) + try Task.checkCancellation() + #if DEBUG + MSLogger.make(category: .network).warning("๋„คํŠธ์›Œํ‚น ํƒ€์ž„์•„์›ƒ: \(timeoutInterval.magnitude)ns") + #endif + throw MSNetworkError.timeout + } + + guard let result = try await group.next() else { + throw MSNetworkError.unknownChildTask + } + return result + } + } catch { + return .failure(error) + } + } + +} diff --git a/iOS/MSCoreKit/Sources/MSNetworking/MultipartData.swift b/iOS/MSCoreKit/Sources/MSNetworking/MultipartData.swift new file mode 100644 index 0000000..568dc2a --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSNetworking/MultipartData.swift @@ -0,0 +1,87 @@ +// +// MultipartData.swift +// MSCoreKit +// +// Created by ์ „๋ฏผ๊ฑด on 12/7/23. +// + +import Foundation + +import MSLogger + +public struct MultipartData { + + public enum ContentType { + case string + case image + } + + // MARK: - Properties + + private let type: ContentType + public let name: String + public let content: Encodable + private let imageType: String = "jpeg" + + // MARK: - Initializer + + public init(type: ContentType = .string, name: String, content: Encodable) { + self.type = type + self.name = name + self.content = content + } + + // MARK: - Functions + + public func contentInformation(using encoder: JSONEncoder) -> [Data] { + var dataStorage: [Data] = [] + switch self.type { + case .string: + let dispositionDescript = "Content-Disposition: form-data; name=\"\(self.name)\"\r\n\r\n" + if let disposition = dispositionDescript.data(using: .utf8), + let contentString = self.convertToString(from: self.content), + let contentData = contentString.data(using: .utf8) { + dataStorage.append(disposition) + dataStorage.append(contentData) + MSLogger.make(category: .network).debug("\(contentData): multipart๋กœ ๋ณด๋‚ผ ํ•ญ๋ชฉ๋“ค์ด ์„ฑ๊ณต์ ์œผ๋กœ ๋ณ€ํ™˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") + } else { + MSLogger.make(category: .network).debug("multipart๋กœ ๋ณด๋‚ผ ํ•ญ๋ชฉ๋“ค์˜ data ๋ฐ˜ํ™˜์— ์‹คํŒจํ•˜์˜€์Šต๋‹ˆ๋‹ค.") + } + + case .image: + let dispositionDescript = "Content-Disposition: form-data; name=\"image\"; filename=\"test.png\"\r\n" + let typeDescript = "Content-Type: image/\(self.imageType)\r\n\r\n" + if let disposition = dispositionDescript.data(using: .utf8), + let type = typeDescript.data(using: .utf8), + let contentData = self.content as? Data { + dataStorage.append(disposition) + dataStorage.append(type) + dataStorage.append(contentData) + MSLogger.make(category: .network).debug("\(contentData): multipart๋กœ ๋ณด๋‚ผ ์ด๋ฏธ์ง€๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ๋ณ€ํ™˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") + } else { + MSLogger.make(category: .network).debug("multipart๋กœ ๋ณด๋‚ผ ํ•ญ๋ชฉ๋“ค์˜ data ๋ฐ˜ํ™˜์— ์‹คํŒจํ•˜์˜€์Šต๋‹ˆ๋‹ค.") + } + } + return dataStorage + } + + // MARK: - Data Convert + + private func convertToString(from content: Encodable) -> String? { + switch content { + case is String: + return content as? String + case is Date: + guard let contentDate = content as? Date else { return nil } + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions.insert(.withFractionalSeconds) + let dateString = dateFormatter.string(from: contentDate) + return dateString + default: + guard let contentData = try? JSONEncoder().encode(content), + let contentString = String(data: contentData, encoding: .utf8) else { return nil } + return contentString + } + } + +} diff --git a/iOS/MSCoreKit/Sources/MSNetworking/Protocol/Router.swift b/iOS/MSCoreKit/Sources/MSNetworking/Protocol/Router.swift new file mode 100644 index 0000000..b3ea30f --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSNetworking/Protocol/Router.swift @@ -0,0 +1,80 @@ +// +// Router.swift +// MSCoreKit +// +// Created by ์ „๋ฏผ๊ฑด on 11/16/23. +// + +import Foundation +import UIKit + +import MSLogger + +public protocol Router { + + /// ๊ธฐ๋ณธ URL + /// > Tip: `https://www.naver.com` + var baseURL: String { get } + /// ๊ฒฝ๋กœ URL + /// > Tip: `/api` + var pathURL: String? { get } + /// HTTP Method + /// > Tip: `.get` + var method: HTTPMethod { get } + /// HTTP Body + /// > Tip: `StartJourneyRequestDTO()` + var body: HTTPBody? { get } + /// HTTP Header + /// > Tip: `applicaton/json` + var headers: HTTPHeaders? { get } + /// HTTP Queries + /// > Tip: `?userId=655efda2fdc81cae36d20650` + var queries: [URLQueryItem]? { get } + + /// ์ตœ์ข…์ ์œผ๋กœ ์‚ฌ์šฉ๋˜๋Š” `URLRequest` + func makeRequest(encoder: JSONEncoder) -> URLRequest? + +} + +extension Router { + + public func makeRequest(encoder: JSONEncoder) -> URLRequest? { + var urlString = self.baseURL + + if let path = self.pathURL { + urlString += "/\(path)" + } + + guard var baseURLComponents = URLComponents(string: urlString) else { return nil } + + if let queries = self.queries, !queries.isEmpty { + baseURLComponents.queryItems = self.queries + } + #if DEBUG + MSLogger.make(category: .network).log("\(baseURLComponents)") + #endif + guard let url = baseURLComponents.url else { return nil } + var request = URLRequest(url: url) + request.httpMethod = self.method.rawValue + if let headers = self.headers { + headers.forEach { key, value in + request.addValue(value, forHTTPHeaderField: key) + } + } + if let body = self.body { + request.httpBody = body.data(encoder: encoder) + } + return request + } + + public func fetchBaseURLFromPlist(from bundle: Bundle) -> String? { + guard let url = bundle.url(forResource: "APIInfo", withExtension: "plist"), + let data = try? Data(contentsOf: url), + let dict = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { + return nil + } + let urlString = dict["BaseURL"] as? String + return urlString + } + +} diff --git a/iOS/MSCoreKit/Sources/MSNetworking/Session.swift b/iOS/MSCoreKit/Sources/MSNetworking/Session.swift new file mode 100644 index 0000000..552d785 --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSNetworking/Session.swift @@ -0,0 +1,17 @@ +// +// Session.swift +// MSCoreKit +// +// Created by ์ „๋ฏผ๊ฑด on 11/16/23. +// + +import Foundation + +public protocol Session { + + func dataTaskPublisher(for request: URLRequest) -> URLSession.DataTaskPublisher + func data(for request: URLRequest, delegate: URLSessionTaskDelegate?) async throws -> (Data, URLResponse) + +} + +extension URLSession: Session { } diff --git a/iOS/MSCoreKit/Sources/MSPersistentStorage/CoreData/README.md b/iOS/MSCoreKit/Sources/MSPersistentStorage/CoreData/README.md new file mode 100644 index 0000000..6acce2f --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSPersistentStorage/CoreData/README.md @@ -0,0 +1,6 @@ +# CoreData + +- `MSPersistentStorage` ํ”„๋กœํ† ์ฝœ์„ ์ฑ„ํƒํ•˜๋Š” CoreDataStorage + +์–ธ์  ๊ฐ€ ๊ตฌํ˜„ํ•ด์ฃผ์‹ค๊ฑฐ๋ผ ๋ฏฟ์Šต๋‹ˆ๋‹ค... +๋ฏฟ์Œ์ด ๊ฐ•ํ•˜์‹œ๊ตฐ์š”.. diff --git a/iOS/MSCoreKit/Sources/MSPersistentStorage/FileManager/FileManagerStorage+Error.swift b/iOS/MSCoreKit/Sources/MSPersistentStorage/FileManager/FileManagerStorage+Error.swift new file mode 100644 index 0000000..ddca1a1 --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSPersistentStorage/FileManager/FileManagerStorage+Error.swift @@ -0,0 +1,16 @@ +// +// FileManagerStorage+Error.swift +// MSCoreKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.07. +// + +import Foundation + +extension FileManagerStorage { + + public enum StorageError: Error { + case invalidStorageURL + } + +} diff --git a/iOS/MSCoreKit/Sources/MSPersistentStorage/FileManager/FileManagerStorage.swift b/iOS/MSCoreKit/Sources/MSPersistentStorage/FileManager/FileManagerStorage.swift new file mode 100644 index 0000000..4c54136 --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSPersistentStorage/FileManager/FileManagerStorage.swift @@ -0,0 +1,220 @@ +// +// UserDefaultsStorage.swift +// MSCoreKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.07. +// + +import Foundation + +import MSConstants +import MSLogger + +public final class FileManagerStorage: NSObject, MSPersistentStorage { + + // MARK: - Properties + + private let fileManager: FileManager + + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withFractionalSeconds, .withTimeZone, .withInternetDateTime] + encoder.dateEncodingStrategy = .custom({ date, encoder in + var container = encoder.singleValueContainer() + let dateString = dateFormatter.string(from: date) + try container.encode(dateString) + }) + return encoder + }() + + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions.insert(.withFractionalSeconds) + decoder.dateDecodingStrategy = .custom({ decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + if let date = dateFormatter.date(from: dateString) { + return date + } + throw DecodingError.dataCorruptedError(in: container, + debugDescription: "Date ๋””์ฝ”๋”ฉ ์‹คํŒจ: \(dateString)") + }) + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + }() + + // MARK: - Initializer + + public init(fileManager: FileManager = FileManager()) { + self.fileManager = fileManager + } + + // MARK: - Functions + + /// `FileManager`๋ฅผ ์‚ฌ์šฉํ•œ PersistentStorage์—์„œ Key ๊ฐ’์— ํ•ด๋‹น๋˜๋Š” ๊ฐ’์„ ๋ถˆ๋Ÿฌ์˜ต๋‹ˆ๋‹ค. + /// - Parameters: + /// - type: ๋ถˆ๋Ÿฌ์˜ฌ ๊ฐ’์˜ ํƒ€์ž… + /// - key: ๋ถˆ๋Ÿฌ์˜ฌ ๊ฐ’์— ๋Œ€์‘๋˜๋Š” Key ๊ฐ’ + /// - Returns: `FileManager`์—์„œ ๋ถˆ๋Ÿฌ์˜จ ๋’ค ์ง€์ •๋œ ํƒ€์ž…์œผ๋กœ ๋””์ฝ”๋”ฉ ๋œ ๊ฐ’ + public func get(_ type: T.Type, forKey key: String) -> T? { + guard let fileURL = self.fileURL(forKey: key), + let data = try? Data(contentsOf: fileURL) else { + return nil + } + + guard let decodedData = try? self.decoder.decode(T.self, from: data) else { + return nil + } + + return decodedData + } + + public func getAllOf(_ type: T.Type) -> [T]? { + if let path = self.storageURL()?.path, + let contents = try? self.fileManager.contentsOfDirectory(atPath: path) { + let allDecodedData: [T] = contents.compactMap { content in + let key = String(content.dropLast(".json".count)) + guard let dataPath = + fileURL(forKey: key) else { + MSLogger.make(category: .fileManager).error("๊ฒฝ๋กœ์˜ Data๋ฅผ ๊ฐ€์ ธ์˜ค์ง€ ๋ชปํ•˜์˜€์Šต๋‹ˆ๋‹ค.") + return nil + } + MSLogger.make(category: .fileManager).error("๊ฒฝ๋กœ์˜ Data๋ฅผ ์„ฑ๊ณต์ ์œผ๋กœ ๊ฐ€์ ธ์™”์Šต๋‹ˆ๋‹ค.") + + guard let data = try? Data(contentsOf: dataPath), + let decodedData = try? self.decoder.decode(T.self, from: data) else { + MSLogger.make(category: .fileManager).error("decode์— ์‹คํŒจํ•˜์˜€์Šต๋‹ˆ๋‹ค.") + return nil + } + return decodedData + } + return allDecodedData + } + return nil + } + + /// `FileManager`๋ฅผ ์‚ฌ์šฉํ•œ PersistentStorage์— ์ฃผ์–ด์ง„ Key ๊ฐ’์œผ๋กœ ์ฃผ์–ด์ง„ ๋ฐ์ดํ„ฐ `value`๋ฅผ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. + /// - Parameters: + /// - value: ์ €์žฅํ•  ๋ฐ์ดํ„ฐ + /// - key: ์ €์žฅํ•  ๊ฐ’์— ๋Œ€์‘๋˜๋Š” Key ๊ฐ’ + /// - Returns: + /// `FileManager`๋ฅผ ์‚ฌ์šฉํ•œ ์ €์žฅ์— ์„ฑ๊ณตํ–ˆ์„ ๊ฒฝ์šฐ ์š”์ฒญํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. \ + /// ์ €์žฅ์— ์‹คํŒจํ–ˆ๊ฑฐ๋‚˜ ์ด๋ฏธ ์กด์žฌํ•œ๋‹ค๋ฉด `nil`์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + @discardableResult + public func set(value: T, forKey key: String) -> T? { + guard let fileURL = self.fileURL(forKey: key) else { + return nil + } + + guard let encodedData = try? self.encoder.encode(value) else { + return nil + } + + do { + try encodedData.write(to: fileURL) + return value + } catch { + #if DEBUG + MSLogger.make(category: .fileManager).error("์ธ์ฝ”๋”ฉ ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•˜๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: \(fileURL.absoluteString)") + #endif + return nil + } + } + + public func deleteAll() throws { + if let path = self.storageURL()?.path, + self.fileManager.fileExists(atPath: path) { + try self.fileManager.removeItem(atPath: path) + } + } + +} + +// MARK: - URL + +extension FileManagerStorage { + + /// Storage๊ฐ€ ์‚ฌ์šฉํ•˜๋Š” ๋””๋ ‰ํ† ๋ฆฌ URL์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + /// - Parameters: + /// - create: ๋””๋ ‰ํ† ๋ฆฌ URL์— ๋””๋ ‰ํ† ๋ฆฌ๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ ์ƒ์„ฑํ•  ์ง€ ์—ฌ๋ถ€๋ฅผ ๊ฒฐ์ •ํ•˜๋Š” flag + /// - Returns: + /// Storage๊ฐ€ ์‚ฌ์šฉํ•˜๋Š” ๋””๋ ‰ํ† ๋ฆฌ URL. \ + /// ํœ™๋“์— ์‹คํŒจํ–ˆ๊ฑฐ๋‚˜, `create` flag๊ฐ€ `true`์ผ ๋•Œ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์„ ๊ฒฝ์šฐ `nil`์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + func storageURL(create: Bool = false) -> URL? { + let directoryURL: URL? + if #available(iOS 16.0, *) { + let storageDirectoryURL = try? self.fileManager.url(for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: .cachesDirectory, + create: false) + directoryURL = storageDirectoryURL? + .appending(path: Constants.appBundleIdentifier, directoryHint: .isDirectory) + } else { + let cacheDirectoryURL = self.fileManager + .urls(for: .cachesDirectory, in: .userDomainMask) + .first + directoryURL = cacheDirectoryURL? + .appendingPathComponent(Constants.appBundleIdentifier, isDirectory: true) + } + + if create { + switch self.createDirectory(at: directoryURL) { + case .success(let url): + return url + case .failure(let error): + #if DEBUG + MSLogger.make(category: .fileManager).log("\(error)") + #endif + return nil + } + } + + return directoryURL + } + + /// ์ง€์ •๋œ Key ๊ฐ’์„ ์‚ฌ์šฉํ•ด ์ ‘๊ทผํ•  ํŒŒ์ผ์˜ URL์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + /// - Parameters: + /// - key: ์ ‘๊ทผํ•  ํŒŒ์ผ์˜ Key ๊ฐ’ + /// - Returns: + /// ์ง€์ •๋œ Key ๊ฐ’์— ๋Œ€์‘๋˜๋Š” ํŒŒ์ผ์˜ URL. \ + /// ํœ™๋“์— ์‹คํŒจํ–ˆ์„ ๊ฒฝ์šฐ `nil`์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + func fileURL(forKey key: String) -> URL? { + let fileURL: URL? + if #available(iOS 16.0, *) { + fileURL = self.storageURL(create: true)? + .appending(component: key, directoryHint: .notDirectory) + .appendingPathExtension("json") + } else { + fileURL = self.storageURL(create: true)? + .appendingPathComponent(key, isDirectory: false) + .appendingPathExtension("json") + } + return fileURL + } + + /// ์ฃผ์–ด์ง„ URL์— ๋””๋ ‰ํ† ๋ฆฌ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. + /// - Parameters: + /// - directoryURL: ๋””๋ ‰ํ† ๋ฆฌ๋ฅผ ์ƒ์„ฑํ•  ๊ฒฝ๋กœ URL + /// - Returns: + /// ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ ์—ฌ๋ถ€์— ๋”ฐ๋ผ `Result` ํƒ€์ž…์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. \ + /// ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ์— ์„ฑ๊ณตํ–ˆ์„ ๊ฒฝ์šฐ ๋””๋ ‰ํ† ๋ฆฌ URL์„ ๋ฐ˜ํ™˜ํ•˜๊ณ , ์‹คํŒจํ–ˆ์„ ๊ฒฝ์šฐ ์—๋Ÿฌ๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + @discardableResult + func createDirectory(at directoryURL: URL?) -> Result { + guard let directoryURL else { return .failure(StorageError.invalidStorageURL) } + do { + try self.fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + } catch { + return .failure(error) + } + return .success(directoryURL) + } + + public func fileExists(atPath path: URL, isDirectory: Bool = false) -> Bool { + var isDirectory: ObjCBool = ObjCBool(isDirectory) + return self.fileManager.fileExists(atPath: path.absoluteString, isDirectory: &isDirectory) + } + +} diff --git a/iOS/MSCoreKit/Sources/MSPersistentStorage/Protocol/MSPersistentStorage.swift b/iOS/MSCoreKit/Sources/MSPersistentStorage/Protocol/MSPersistentStorage.swift new file mode 100644 index 0000000..4d1a8ea --- /dev/null +++ b/iOS/MSCoreKit/Sources/MSPersistentStorage/Protocol/MSPersistentStorage.swift @@ -0,0 +1,14 @@ +// +// MSPersistentStorage.swift +// MSCoreKit +// +// Created by ์ด์ฐฝ์ค€ on 11/14/23. +// + +public protocol MSPersistentStorage { + + func get(_ type: T.Type, forKey key: String) -> T? + @discardableResult + func set(value: T, forKey key: String) -> T? + +} diff --git a/iOS/MSCoreKit/Tests/MSCacheStorageTests/MSCacheStorageTests.swift b/iOS/MSCoreKit/Tests/MSCacheStorageTests/MSCacheStorageTests.swift new file mode 100644 index 0000000..1c40dbe --- /dev/null +++ b/iOS/MSCoreKit/Tests/MSCacheStorageTests/MSCacheStorageTests.swift @@ -0,0 +1,76 @@ +// +// MSCacheStorageTests.swift +// MSCoreKit +// +// Created by ์ด์ฐฝ์ค€ on 11/14/23. +// + +import XCTest + +@testable import MSCacheStorage + +final class MSCacheStorageTests: XCTestCase { + + // MARK: - Properties + + private var cacheStorage: MSCacheStorage! + private var mockData = "TESTVALUE" + private let key = "cacheKey" + + // MARK: - Setup + + override func setUp() async throws { + let memory = CacheStorage.Cache() + let disk = FileManager.default + self.cacheStorage = MSCacheStorage(cache: memory, fileManager: disk) + } + + override func tearDown() async throws { + try self.cacheStorage.clean(.all) + self.cacheStorage = nil + } + + // MARK: - Tests + + func test_์ƒˆ๋กœ์šดKey๊ฐ’์œผ๋กœ_์บ์‹ฑ_์„ฑ๊ณต() { + let sut = Data(self.mockData.utf8) + + let result = self.cacheStorage.cache(sut, forKey: self.key) + XCTAssertEqual(result, .success(sut), + "์ƒˆ๋กœ์šด Key ๊ฐ’์œผ๋กœ ์ €์žฅํ•œ ๊ฐ’์€ .success ๊ฒฐ๊ณผ๋ฅผ ๋ฐ˜ํ™˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + } + + func test_์ค‘๋ณต๋œKey๊ฐ’์œผ๋กœ_์บ์‹ฑ_์„ฑ๊ณต() { + let sut = Data(self.mockData.utf8) + let sut2 = Data("AnoterTest".utf8) + + self.cacheStorage.cache(sut, forKey: self.key) + let result = self.cacheStorage.cache(sut2, forKey: self.key) + XCTAssertEqual(result, .success(sut2), + "์ค‘๋ณต๋œ Key ๊ฐ’์œผ๋กœ ์ €์žฅํ•  ๊ฒฝ์šฐ, ์ด์ „ ์บ์‹ฑ๋œ ๊ฐ’์„ ๋Œ€์ฒดํ•˜๋ฉฐ ์„ฑ๊ณตํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + } + + func test_์กด์žฌํ•˜๋Š”Key๊ฐ’์œผ๋กœ_์บ์‹œ๋ฐ์ดํ„ฐ์กฐํšŒ_๋ฐ์ดํ„ฐ๋ฐ˜ํ™˜_์„ฑ๊ณต() throws { + let sut = Data(self.mockData.utf8) + + let result = self.cacheStorage.cache(sut, forKey: self.key) + guard let cachedValue = self.cacheStorage.data(forKey: self.key) else { + XCTFail("์บ์‹ฑ๋œ ๋ฐ์ดํ„ฐ๋กœ๋ถ€ํ„ฐ nil ๊ฐ’์ด ๋ฐ˜ํ™˜ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") + return + } + + let decodedData = String(data: cachedValue, encoding: .utf8) + XCTAssertEqual(decodedData, self.mockData, + "์บ์‹ฑํ•œ ๊ฐ’๊ณผ ๋ถˆ๋Ÿฌ์˜จ ๊ฐ’์ด ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.") + } + + func test_์กด์žฌํ•˜์ง€์•Š๋Š”Key๊ฐ’์œผ๋กœ_์บ์‹œ๋ฐ์ดํ„ฐ์กฐํšŒ_nil๋ฐ˜ํ™˜_์„ฑ๊ณต() { + let sut = Data(self.mockData.utf8) + + self.cacheStorage.cache(sut, forKey: self.key) + let nilValue = self.cacheStorage.data(forKey: "undefined key") + + XCTAssertNil(nilValue, "์กด์žฌํ•˜์ง€ ์•Š๋Š” key ๊ฐ’์€ nil ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + } + +} diff --git a/iOS/MSCoreKit/Tests/MSFetcherTests/MSFetcherTests.swift b/iOS/MSCoreKit/Tests/MSFetcherTests/MSFetcherTests.swift new file mode 100644 index 0000000..d33b917 --- /dev/null +++ b/iOS/MSCoreKit/Tests/MSFetcherTests/MSFetcherTests.swift @@ -0,0 +1,12 @@ +// +// MSFetcherTests.swift +// MSCoreKit +// +// Created by ์ด์ฐฝ์ค€ on 11/14/23. +// + +import XCTest + +final class MSFetcherTests: XCTestCase { + +} diff --git a/iOS/MSCoreKit/Tests/MSNetworkingTests/MSCombineNetworkingTests.swift b/iOS/MSCoreKit/Tests/MSNetworkingTests/MSCombineNetworkingTests.swift new file mode 100644 index 0000000..9e15b99 --- /dev/null +++ b/iOS/MSCoreKit/Tests/MSNetworkingTests/MSCombineNetworkingTests.swift @@ -0,0 +1,99 @@ +// +// MSCombineNetworkingTests.swift +// MSCoreKit +// +// Created by ์ด์ฐฝ์ค€ on 11/14/23. +// + +import Combine +import XCTest + +@testable import MSNetworking + +final class MSCombineNetworkingTests: XCTestCase { + + // MARK: - Properties + + private var networking: MSNetworking! + + private var cancellables: Set = [] + + // MARK: - Setup + + override func setUp() { + URLProtocol.registerClass(MockURLProtocol.self) + let configuration: URLSessionConfiguration = .ephemeral + configuration.protocolClasses?.insert(MockURLProtocol.self, at: .zero) + let session = URLSession(configuration: configuration) + self.networking = MSNetworking(session: session) + } + + // MARK: - Tests + + func test_MSNetworking_์‘๋‹ต์ฝ”๋“œ๊ฐ€_200๋ฒˆ๋Œ€์ผ๊ฒฝ์šฐ_์ •์ƒ() throws { + // Arrange + let router = MockRouter() + let response = "Success" + let data = try JSONEncoder().encode(response) + MockURLProtocol.requestHandler = { _ in + let response = HTTPURLResponse(url: URL(string: "https://api.codesquad.kr/api")!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])! + return (response, data) + } + + let expectation = XCTestExpectation() + + // Act + self.networking.request(String.self, router: router) + .receive(on: self.networking.queue) + .sink { completion in + if case .failure = completion { + XCTFail("200 ๋ฒˆ๋Œ€ status code๋ฅผ ํฌํ•จํ•œ ์‘๋‹ต์€ ์„ฑ๊ณตํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + } + } receiveValue: { value in + XCTAssertEqual("Success", value, "์‘๋‹ต ๊ฐ’์ด ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.") + expectation.fulfill() + } + .store(in: &self.cancellables) + + // Assert + wait(for: [expectation], timeout: 5.0) + } + + func test_MSNetworking_์‘๋‹ต์ฝ”๋“œ๊ฐ€_200๋ฒˆ๋Œ€๊ฐ€์•„๋‹๊ฒฝ์šฐ_์—๋Ÿฌ() { + // Arrange + let router = MockRouter() + MockURLProtocol.requestHandler = { _ in + let response = HTTPURLResponse(url: URL(string: "https://api.codesquad.kr/api")!, + statusCode: 404, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])! + return (response, Data()) + } + + let expectation = XCTestExpectation() + + // Act + self.networking.request(String.self, router: router) + .receive(on: self.networking.queue) + .sink { completion in + if case .failure(let error) = completion { + // swiftlint: disable force_cast + XCTAssertEqual(error as! MSNetworkError, + MSNetworkError.invalidStatusCode(statusCode: 404, description: ""), + "404 status code ์‘๋‹ต์€ invalidStatusCode ์—๋Ÿฌ๋ฅผ ๋ฐœ์ƒ์‹œ์ผœ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + // swiftlint: enable force_cast + expectation.fulfill() + } + } receiveValue: { _ in + XCTFail("200 ~ 299 ์™ธ์˜ status code๋ฅผ ํฌํ•จํ•œ ์‘๋‹ต์€ ์—๋Ÿฌ๋ฅผ ๋ฐœ์ƒ์‹œ์ผœ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + } + .store(in: &self.cancellables) + + // Assert + wait(for: [expectation], timeout: 5.0) + } + +} diff --git a/iOS/MSCoreKit/Tests/MSNetworkingTests/MSConcurrencyNetworkingTests.swift b/iOS/MSCoreKit/Tests/MSNetworkingTests/MSConcurrencyNetworkingTests.swift new file mode 100644 index 0000000..18c9ce6 --- /dev/null +++ b/iOS/MSCoreKit/Tests/MSNetworkingTests/MSConcurrencyNetworkingTests.swift @@ -0,0 +1,86 @@ +// +// MSConcurrencyNetworkingTests.swift +// MSCoreKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.05. +// + +import XCTest +@testable import MSNetworking + +final class MSConcurrencyNetworkingTests: XCTestCase { + + // MARK: - Properties + + private var networking: MSNetworking! + + // MARK: - Setup + + override func setUp() { + URLProtocol.registerClass(MockURLProtocol.self) + let configuration: URLSessionConfiguration = .ephemeral + configuration.protocolClasses?.insert(MockURLProtocol.self, at: .zero) + let session = URLSession(configuration: configuration) + self.networking = MSNetworking(session: session) + } + + // MARK: - Tests + + func test_MSNetworking_์‘๋‹ต์ฝ”๋“œ๊ฐ€_200๋ฒˆ๋Œ€์ผ๊ฒฝ์šฐ_์ •์ƒ() async throws { + // Arrange + let router = MockRouter() + let response = "Success" + let data = try JSONEncoder().encode(response) + MockURLProtocol.requestHandler = { _ in + let response = HTTPURLResponse(url: URL(string: "https://api.codesquad.kr")!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])! + return (response, data) + } + + let expectation = XCTestExpectation() + + // Act + let result = await self.networking.request(String.self, router: router) + + switch result { + case .success(let value): + XCTAssertEqual("Success", value, "์‘๋‹ต ๊ฐ’์ด ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.") + expectation.fulfill() + case .failure: + XCTFail("200 ๋ฒˆ๋Œ€ status code๋ฅผ ํฌํ•จํ•œ ์‘๋‹ต์€ ์„ฑ๊ณตํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + } + + // Assert + await fulfillment(of: [expectation], timeout: 3) + } + + func test_MSNetworking_์‘๋‹ต์ฝ”๋“œ๊ฐ€_200๋ฒˆ๋Œ€๊ฐ€์•„๋‹๊ฒฝ์šฐ_์—๋Ÿฌ() async { + // Arrange + let router = MockRouter() + MockURLProtocol.requestHandler = { _ in + let response = HTTPURLResponse(url: URL(string: "https://api.codesquad.kr")!, + statusCode: 404, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])! + return (response, Data()) + } + + let expectation = XCTestExpectation() + + // Act + let result = await self.networking.request(String.self, router: router) + + switch result { + case .success: + XCTFail("200 ~ 299 ์™ธ์˜ status code๋ฅผ ํฌํ•จํ•œ ์‘๋‹ต์€ ์—๋Ÿฌ๋ฅผ ๋ฐœ์ƒ์‹œ์ผœ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + case .failure: + expectation.fulfill() + } + + // Assert + await fulfillment(of: [expectation], timeout: 5.0) + } + +} diff --git a/iOS/MSCoreKit/Tests/MSNetworkingTests/Mock/MockRouter.swift b/iOS/MSCoreKit/Tests/MSNetworkingTests/Mock/MockRouter.swift new file mode 100644 index 0000000..3e32a40 --- /dev/null +++ b/iOS/MSCoreKit/Tests/MSNetworkingTests/Mock/MockRouter.swift @@ -0,0 +1,29 @@ +// +// MockRouter.swift +// MSCoreKit +// +// Created by ์ „๋ฏผ๊ฑด on 11/16/23. +// + +import Foundation +@testable import MSNetworking + +struct MockRouter: Router { + + var baseURL: String { + return "https://www.naver.com" + } + + var pathURL: String? + + var method: HTTPMethod { + return .get + } + + var body: HTTPBody? + + var headers: HTTPHeaders? + + var queries: [URLQueryItem]? + +} diff --git a/iOS/MSCoreKit/Tests/MSNetworkingTests/Mock/MockURLProtocol.swift b/iOS/MSCoreKit/Tests/MSNetworkingTests/Mock/MockURLProtocol.swift new file mode 100644 index 0000000..8fe7fb3 --- /dev/null +++ b/iOS/MSCoreKit/Tests/MSNetworkingTests/Mock/MockURLProtocol.swift @@ -0,0 +1,53 @@ +// +// MockURLProtocol.swift +// MSCoreKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.11.27. +// + +import Foundation + +final class MockURLProtocol: URLProtocol { + + static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + static var delaySimulation: TimeInterval = 0 + static var requestObserver: ((URLRequest) -> Void)? + + override class func canInit(with request: URLRequest) -> Bool { + MockURLProtocol.requestObserver?(request) + return true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override func startLoading() { + DispatchQueue.global().asyncAfter(deadline: .now() + MockURLProtocol.delaySimulation) { + guard let handler = MockURLProtocol.requestHandler else { + assertionFailure("Received unexpected request with no handler set") + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + } + + override func stopLoading() { } + + static func simulateDelay(_ delay: TimeInterval) { + self.delaySimulation = delay + } + + static func observeRequests(_ observer: @escaping (URLRequest) -> Void) { + self.requestObserver = observer + } + +} diff --git a/iOS/MSCoreKit/Tests/MSNetworkingTests/RouterTests.swift b/iOS/MSCoreKit/Tests/MSNetworkingTests/RouterTests.swift new file mode 100644 index 0000000..4592c85 --- /dev/null +++ b/iOS/MSCoreKit/Tests/MSNetworkingTests/RouterTests.swift @@ -0,0 +1,156 @@ +// +// RouterTests.swift +// MSCoreKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.05. +// + +import XCTest +@testable import MSNetworking + +final class RouterTests: XCTestCase { + + // MARK: - Properties + + private let encoder = JSONEncoder() + + // MARK: - Tests + + func test_BaseURL๋งŒํฌํ•จํ•˜๋Š”_Router_์ƒ์„ฑ_์„ฑ๊ณต() { + struct SUTRouter: Router { + var baseURL: String { return "https://www.naver.com" } + var pathURL: String? + var method: HTTPMethod { return .get } + var body: HTTPBody? + var headers: HTTPHeaders? + var queries: [URLQueryItem]? + } + + let sut = SUTRouter() + + guard let request = sut.makeRequest(encoder: encoder) else { + XCTFail("URLRequest๋ฅผ ์ƒ์„ฑํ•˜๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") + return + } + + guard let url = URL(string: "https://www.naver.com") else { + return + } + XCTAssertEqual(request, URLRequest(url: url), + "BaseURL๋งŒ ํฌํ•จ๋œ Router๋กœ ์ž˜๋ชป๋œ URLRequest๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") + } + + func test_PathURL์„ํฌํ•จํ•˜๋Š”_Router_์ƒ์„ฑ_์„ฑ๊ณต() { + struct SUTRouter: Router { + var baseURL: String { return "https://www.naver.com" } + var pathURL: String? { return "api" } + var method: HTTPMethod { return .get } + var body: HTTPBody? + var headers: HTTPHeaders? + var queries: [URLQueryItem]? + } + + let sut = SUTRouter() + + guard let request = sut.makeRequest(encoder: encoder) else { + XCTFail("URLRequest๋ฅผ ์ƒ์„ฑํ•˜๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") + return + } + + guard let url = URL(string: "https://www.naver.com/api") else { + return + } + let message = """ + BaseURL๋งŒ ํฌํ•จ๋œ Router๋กœ ์ž˜๋ชป๋œ URLRequest๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. \ + ์ƒ์„ฑ๋œ URL: \(url) + """ + XCTAssertEqual(request, URLRequest(url: url), message) + } + + func test_Body๋ฅผํฌํ•จํ•˜๋Š”_Router_์ƒ์„ฑ_์„ฑ๊ณต() throws { + struct SUTRouter: Router { + var baseURL: String { return "https://www.naver.com" } + var pathURL: String? { return "api" } + var method: HTTPMethod { return .get } + var body: HTTPBody? { return HTTPBody(content: "Data") } + var headers: HTTPHeaders? + var queries: [URLQueryItem]? + } + + let sut = SUTRouter() + + guard let request = sut.makeRequest(encoder: encoder) else { + XCTFail("URLRequest๋ฅผ ์ƒ์„ฑํ•˜๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") + return + } + + guard let url = URL(string: "https://www.naver.com/api") else { + return + } + var urlRequest = URLRequest(url: url) + let data = try JSONEncoder().encode("Data") + urlRequest.httpBody = data + let message = """ + BaseURL๋งŒ ํฌํ•จ๋œ Router๋กœ ์ž˜๋ชป๋œ URLRequest๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. \ + ์ƒ์„ฑ๋œ URL: \(url) + """ + XCTAssertEqual(request.httpBody, urlRequest.httpBody, message) + } + + func test_Header๋ฅผํฌํ•จํ•˜๋Š”_Router_์ƒ์„ฑ_์„ฑ๊ณต() { + struct SUTRouter: Router { + var baseURL: String { return "https://www.naver.com" } + var pathURL: String? { return "api" } + var method: HTTPMethod { return .get } + var body: HTTPBody? + var headers: HTTPHeaders? { return [(key: "boostcamp", value: "WM8")] } + var queries: [URLQueryItem]? + } + + let sut = SUTRouter() + + guard let request = sut.makeRequest(encoder: encoder) else { + XCTFail("URLRequest๋ฅผ ์ƒ์„ฑํ•˜๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") + return + } + + guard let url = URL(string: "https://www.naver.com/api") else { + return + } + var urlRequest = URLRequest(url: url) + urlRequest.addValue("WM8", forHTTPHeaderField: "boostcamp") + let message = """ + BaseURL๋งŒ ํฌํ•จ๋œ Router๋กœ ์ž˜๋ชป๋œ URLRequest๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. \ + ์ƒ์„ฑ๋œ URL: \(url) + """ + XCTAssertEqual(request.allHTTPHeaderFields, urlRequest.allHTTPHeaderFields, message) + } + + func test_Query๋ฅผํฌํ•จํ•˜๋Š”_Router_์ƒ์„ฑ_์„ฑ๊ณต() { + struct SUTRouter: Router { + var baseURL: String { return "https://www.naver.com" } + var pathURL: String? { return "api" } + var method: HTTPMethod { return .get } + var body: HTTPBody? + var headers: HTTPHeaders? + var queries: [URLQueryItem]? { return [URLQueryItem(name: "boostcamp", value: "WM8")] } + } + + let sut = SUTRouter() + + guard let request = sut.makeRequest(encoder: encoder) else { + XCTFail("URLRequest๋ฅผ ์ƒ์„ฑํ•˜๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") + return + } + + guard let url = URL(string: "https://www.naver.com/api?boostcamp=WM8") else { + return + } + let message = """ + BaseURL๋งŒ ํฌํ•จ๋œ Router๋กœ ์ž˜๋ชป๋œ URLRequest๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. \ + ์ƒ์„ฑ๋œ URL: \(url) + """ + XCTAssertEqual(request, URLRequest(url: url), message) + } + +} diff --git a/iOS/MSCoreKit/Tests/MSPersistentStorageTests/FileManagerStorageTests.swift b/iOS/MSCoreKit/Tests/MSPersistentStorageTests/FileManagerStorageTests.swift new file mode 100644 index 0000000..b66d53d --- /dev/null +++ b/iOS/MSCoreKit/Tests/MSPersistentStorageTests/FileManagerStorageTests.swift @@ -0,0 +1,154 @@ +// +// MSPersistentStorageTests.swift +// MSCoreKit +// +// Created by ์ด์ฐฝ์ค€ on 11/14/23. +// + +import OSLog +import XCTest +@testable import MSPersistentStorage + +final class MSPersistentStorageTests: XCTestCase { + + // MARK: - Properties + + private let fileStorage = FileManagerStorage() + + // MARK: - Setup + + // MARK: - Test + + func test_StorageURL์ƒ์„ฑ์ถœ๋ ฅ_ํ•ญ์ƒ์„ฑ๊ณต() { + guard let storageURL = self.fileStorage.storageURL() else { + XCTFail("FileManagerStorage ๋””๋ ‰ํ† ๋ฆฌ URL ํœ™๋“์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") + return + } + os_log("\(storageURL)") + } + + func test_FileURL์ƒ์„ฑ์ถœ๋ ฅ_ํ•ญ์ƒ์„ฑ๊ณต() { + let key = UUID().uuidString + guard let fileURL = self.fileStorage.fileURL(forKey: key) else { + XCTFail("ํŒŒ์ผ ๋””๋ ‰ํ† ๋ฆฌ URL ํœ™๋“์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") + return + } + os_log("\(fileURL)") + } + + func test_StorageURL๋””๋ ‰ํ† ๋ฆฌ_์ƒ์„ฑ_์„ฑ๊ณต() { + guard let url = self.fileStorage.storageURL() else { + XCTFail("FileManagerStorage ๋””๋ ‰ํ† ๋ฆฌ URL ํœ™๋“์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") + return + } + + let result = self.fileStorage.createDirectory(at: url) + switch result { + case .success(let url): + let parentURL = url + let fileExists = self.fileStorage.fileExists(atPath: parentURL, isDirectory: true) + XCTAssertFalse(fileExists, "๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: \(parentURL)") + case .failure(let error): + XCTFail("๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: \(error)") + } + } + + func test_StorageURL๋””๋ ‰ํ† ๋ฆฌ_์ „์ฒด์‚ญ์ œ_์„ฑ๊ณต() throws { + guard let url = self.fileStorage.storageURL() else { + XCTFail("FileManagerStorage ๋””๋ ‰ํ† ๋ฆฌ URL ํœ™๋“์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") + return + } + self.fileStorage.createDirectory(at: url) + + try self.fileStorage.deleteAll() + + let fileExists = self.fileStorage.fileExists(atPath: url, isDirectory: true) + XCTAssertFalse(fileExists, "๋””๋ ‰ํ† ๋ฆฌ ์ „์ฒด ์‚ญ์ œ ํ›„์—๋Š” ๋””๋ ‰ํ† ๋ฆฌ๊ฐ€ ์กด์žฌํ•˜๋ฉด ์•ˆ๋ฉ๋‹ˆ๋‹ค.") + } + + func test_StorageURL์—_๋””๋ ‰ํ† ๋ฆฌ๊ฐ€์—†์„๊ฒฝ์šฐ_์ƒ์„ฑ_์„ฑ๊ณต() throws { + try self.fileStorage.deleteAll() + + guard let storageURL = self.fileStorage.storageURL(create: true) else { + XCTFail("FileManagerStorage ๋””๋ ‰ํ† ๋ฆฌ URL ํœ™๋“์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") + return + } + + let fileExists = self.fileStorage.fileExists(atPath: storageURL, isDirectory: true) + XCTAssertFalse(fileExists, "storageURL(create: true)๋Š” ๋””๋ ‰ํ† ๋ฆฌ๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ ์ƒˆ๋กœ ์ƒ์„ฑํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + } + + func test_FileManagerStorage์—_๋ฐ์ดํ„ฐ์ €์žฅ_์„ฑ๊ณต() { + let sut = MockCodableData(title: "boostcamp", content: "wm8") + let key = "S045" + + let storedData = self.fileStorage.set(value: sut, forKey: key) + XCTAssertNotNil(storedData, "๋ฐ์ดํ„ฐ๊ฐ€ ์ €์žฅ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.") + } + + func test_FileManagerStorage์—์„œ_๋ฐ์ดํ„ฐ๋กœ๋“œ_์„ฑ๊ณต() { + let sut = MockCodableData(title: "boostcamp", content: "wm8") + let key = "S045" + self.fileStorage.set(value: sut, forKey: key) + + guard let storedData = self.fileStorage.get(MockCodableData.self, forKey: key) else { + XCTFail("๋ฐ์ดํ„ฐ ์ฝ๊ธฐ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") + return + } + + XCTAssertEqual(sut, storedData, + "๋ชฉํ‘œ ๋ฐ์ดํ„ฐ์™€ ๋ถˆ๋Ÿฌ์˜จ ๊ฐ’์ด ๋‹ค๋ฆ…๋‹ˆ๋‹ค.") + } + + func test_FileManagerStorage์—์„œ_๋ชจ๋“ ๋ฐ์ดํ„ฐ์ €์žฅ๋ถˆ๋Ÿฌ์˜ค๊ธฐ_์„ฑ๊ณต() { + let sut1 = MockCodableData(title: "boostcamp", content: "wm8") + let sut2 = MockCodableData(title: "boostcamp", content: "wm8") + let key1 = "S045" + let key2 = "S034" + + self.fileStorage.set(value: sut1, forKey: key1) + self.fileStorage.set(value: sut2, forKey: key2) + + guard let allStoredData = self.fileStorage.getAllOf(MockCodableData.self) else { + XCTFail("๋ฐ์ดํ„ฐ ์ฝ๊ธฐ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") + return + } + + XCTAssertEqual(allStoredData.count, 2, "๋ฐ์ดํ„ฐ ์ €์žฅ์— ์‹คํŒจํ•˜์˜€์Šต๋‹ˆ๋‹ค.") + XCTAssertTrue(allStoredData.allSatisfy { $0 == sut1 || $0 == sut2 }) + } + + func test_FileManagerStorage์—์„œ_๋ชจ๋“ ๋ฐ์ดํ„ฐ์ €์žฅ๋ถˆ๋Ÿฌ์˜ฌ๋•Œ_ํด๋”ํ•˜์œ„ํ•ญ๋ชฉ๊นŒ์ง€_์ฝ์„์ˆ˜์žˆ๋Š”์ง€_์‹คํŒจ() { + let sut1 = MockCodableData(title: "boostcamp", content: "wm8") + let sut2 = MockCodableData(title: "boostcamp", content: "wm8") + let key1 = "S045" + let key2 = "/handsome/jeonmingun/S034" + + self.fileStorage.set(value: sut1, forKey: key1) + self.fileStorage.set(value: sut2, forKey: key2) + + guard let allStoredData = self.fileStorage.getAllOf(MockCodableData.self) else { + XCTFail("๋ฐ์ดํ„ฐ ์ฝ๊ธฐ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") + return + } + + XCTAssertEqual(allStoredData.count, 2, "๋ฐ์ดํ„ฐ ์ €์žฅ์— ์‹คํŒจํ•˜์˜€์Šต๋‹ˆ๋‹ค.") + XCTAssertFalse(allStoredData.allSatisfy { $0 == sut1 || $0 == sut2 }) + } + + func test_Dateํ˜•์‹_์ €์žฅํ• _์ˆ˜_์žˆ๋Š”์ง€_์„ฑ๊ณต() { + let sut = Date.now + let key = "S034" + + self.fileStorage.set(value: sut, forKey: key) + + guard let storedData = self.fileStorage.get(Date.self, forKey: key) else { + XCTFail("๋ฐ์ดํ„ฐ ์ฝ๊ธฐ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") + return + } + + XCTAssertEqual(sut.description, storedData.description, + "๋ชฉํ‘œ ๋ฐ์ดํ„ฐ์™€ ๋ถˆ๋Ÿฌ์˜จ ๊ฐ’์ด ๋‹ค๋ฆ…๋‹ˆ๋‹ค.") + } + +} diff --git a/iOS/MSCoreKit/Tests/MSPersistentStorageTests/Mock/MockCodableData.swift b/iOS/MSCoreKit/Tests/MSPersistentStorageTests/Mock/MockCodableData.swift new file mode 100644 index 0000000..1cc3630 --- /dev/null +++ b/iOS/MSCoreKit/Tests/MSPersistentStorageTests/Mock/MockCodableData.swift @@ -0,0 +1,24 @@ +// +// MockCodableData.swift +// MSCoreKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.07. +// + +import Foundation + +struct MockCodableData: Codable, Equatable { + + let id: UUID + let title: String + let content: String + + init(id: UUID = UUID(), + title: String, + content: String) { + self.id = id + self.title = title + self.content = content + } + +} diff --git a/iOS/MSData/.gitignore b/iOS/MSData/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/iOS/MSData/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/iOS/MSData/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/iOS/MSData/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/iOS/MSData/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/iOS/MSData/.swiftpm/xcode/xcshareddata/xcschemes/MSData.xcscheme b/iOS/MSData/.swiftpm/xcode/xcshareddata/xcschemes/MSData.xcscheme new file mode 100644 index 0000000..d164747 --- /dev/null +++ b/iOS/MSData/.swiftpm/xcode/xcshareddata/xcschemes/MSData.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/MSData/.swiftpm/xcode/xcshareddata/xcschemes/MSDataTests.xcscheme b/iOS/MSData/.swiftpm/xcode/xcshareddata/xcschemes/MSDataTests.xcscheme new file mode 100644 index 0000000..c0fb639 --- /dev/null +++ b/iOS/MSData/.swiftpm/xcode/xcshareddata/xcschemes/MSDataTests.xcscheme @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/MSData/.swiftpm/xcode/xcshareddata/xcschemes/RepositoryTests.xcscheme b/iOS/MSData/.swiftpm/xcode/xcshareddata/xcschemes/RepositoryTests.xcscheme new file mode 100644 index 0000000..869bbd9 --- /dev/null +++ b/iOS/MSData/.swiftpm/xcode/xcshareddata/xcschemes/RepositoryTests.xcscheme @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/MSData/Package.swift b/iOS/MSData/Package.swift new file mode 100644 index 0000000..29b7251 --- /dev/null +++ b/iOS/MSData/Package.swift @@ -0,0 +1,89 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +// MARK: - Constants + +extension String { + + static let package = "MSData" + + var testTarget: String { + return self + "Tests" + } + + var fromRootPath: String { + return "../" + self + } + +} + +private enum Target { + + static let msData = "MSData" + static let repository = "Repository" + +} + +private enum Dependency { + + static let msDomain = "MSDomain" + static let msNetworking = "MSNetworking" + static let msKeychainStorage = "MSKeychainStorage" + static let msPersistentStorage = "MSPersistentStorage" + static let msCoreKit = "MSCoreKit" + static let msLogger = "MSLogger" + static let msUserDefaults = "MSUserDefaults" + static let msFoundation = "MSFoundation" + +} + +// MARK: - Package + +let package = Package( + name: .package, + platforms: [ + .iOS(.v15) + ], + products: [ + .library(name: Target.msData, + targets: [Target.msData]) + ], + dependencies: [ + .package(name: Dependency.msDomain, + path: Dependency.msDomain.fromRootPath), + .package(name: Dependency.msCoreKit, + path: Dependency.msCoreKit.fromRootPath), + .package(name: Dependency.msFoundation, + path: Dependency.msFoundation.fromRootPath) + ], + targets: [ + .target(name: Target.msData, + dependencies: [ + .product(name: Dependency.msDomain, + package: Dependency.msDomain), + .product(name: Dependency.msNetworking, + package: Dependency.msCoreKit), + .product(name: Dependency.msKeychainStorage, + package: Dependency.msCoreKit), + .product(name: Dependency.msPersistentStorage, + package: Dependency.msCoreKit), + .product(name: Dependency.msLogger, + package: Dependency.msFoundation), + .product(name: Dependency.msUserDefaults, + package: Dependency.msFoundation) + ], + resources: [ + .process("Resources") + ]), + .testTarget(name: Target.msData.testTarget, + dependencies: [ + .target(name: Target.msData) + ]), + .testTarget(name: Target.repository.testTarget, + dependencies: [ + .target(name: Target.msData) + ]) + ] +) diff --git a/iOS/MSData/Sources/MSData/DTO/Fragment/AlbumCoverDTO.swift b/iOS/MSData/Sources/MSData/DTO/Fragment/AlbumCoverDTO.swift new file mode 100644 index 0000000..25000ee --- /dev/null +++ b/iOS/MSData/Sources/MSData/DTO/Fragment/AlbumCoverDTO.swift @@ -0,0 +1,68 @@ +// +// AlbumCoverDTO.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import Foundation + +public struct AlbumCoverDTO { + + // MARK: - Properties + + public let width: Int + public let height: Int + public let url: URL? + public let backgroundColor: String? + + // MARK: - Initializer + + public init(width: Int, + height: Int, + url: URL?, + backgroundColor: String?) { + self.width = width + self.height = height + self.url = url + self.backgroundColor = backgroundColor + } + +} + +// MARK: - Codable + +extension AlbumCoverDTO: Codable { + + enum CodingKeys: String, CodingKey { + case width + case height + case url + case backgroundColor = "bgColor" + } + +} + +// MARK: - Domain Mapping + +import MSDomain + +extension AlbumCoverDTO { + + public init?(_ domain: AlbumCover?) { + guard let domain else { return nil } + + self.width = domain.width + self.height = domain.height + self.url = domain.url + self.backgroundColor = domain.backgroundColor + } + + public func toDomain() -> AlbumCover { + return AlbumCover(width: self.width, + height: self.height, + url: self.url, + backgroundColor: self.backgroundColor) + } + +} diff --git a/iOS/MSData/Sources/MSData/DTO/Fragment/CoordinateDTO.swift b/iOS/MSData/Sources/MSData/DTO/Fragment/CoordinateDTO.swift new file mode 100644 index 0000000..b90fc15 --- /dev/null +++ b/iOS/MSData/Sources/MSData/DTO/Fragment/CoordinateDTO.swift @@ -0,0 +1,68 @@ +// +// CoordinateDTO.swift +// MSCoreKit +// +// Created by ์ „๋ฏผ๊ฑด on 11/16/23. +// + +// Incoming Data: +// [37.555946, 126.972384], +// [37.555946, 126.972384] + +public struct CoordinateDTO { + + // MARK: - Properties + + public let latitude: Double + public let longitude: Double + + // MARK: - Initializer + + public init(latitude: Double, + longitude: Double) { + self.latitude = latitude + self.longitude = longitude + } + +} + +// MARK: - Codable + +extension CoordinateDTO: Codable { + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode([self.latitude, self.longitude]) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let coordinates = try container.decode([Double].self) + + guard coordinates.count == 2 else { + throw DecodingError.dataCorruptedError(in: container, + debugDescription: "Coordinate ๊ฐ’์€ 2๊ฐœ ๊ฐ’์œผ๋กœ ์ด๋ฃจ์–ด์ง„ ๋ฐฐ์—ด์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + } + self.latitude = coordinates[0] + self.longitude = coordinates[1] + } + +} + +// MARK: - Domain Mapping + +import MSDomain + +extension CoordinateDTO { + + public init(_ domain: Coordinate) { + self.latitude = domain.latitude + self.longitude = domain.longitude + } + + public func toDomain() -> Coordinate { + return Coordinate(latitude: self.latitude, + longitude: self.longitude) + } + +} diff --git a/iOS/MSData/Sources/MSData/DTO/Fragment/JourneyDTO.swift b/iOS/MSData/Sources/MSData/DTO/Fragment/JourneyDTO.swift new file mode 100644 index 0000000..cddc824 --- /dev/null +++ b/iOS/MSData/Sources/MSData/DTO/Fragment/JourneyDTO.swift @@ -0,0 +1,79 @@ +// +// JourneyDTO.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import Foundation + +public struct JourneyDTO: Identifiable { + + // MARK: - Properties + + public let id: String + public let metadata: JourneyMetadataDTO + public let title: String + public let spots: [SpotDTO] + public let coordinates: [CoordinateDTO] + public let song: SongDTO + + // MARK: - Initializer + + public init(id: String, + metadata: JourneyMetadataDTO, + title: String, + spots: [SpotDTO], + coordinates: [CoordinateDTO], + song: SongDTO) { + self.id = id + self.metadata = metadata + self.title = title + self.spots = spots + self.coordinates = coordinates + self.song = song + } + +} + +// MARK: - Codable + +extension JourneyDTO: Codable { + + enum CodingKeys: String, CodingKey { + case id = "_id" + case metadata = "journeyMetadata" + case title + case spots + case coordinates + case song + } + +} + +// MARK: - Domain Mapping + +import MSDomain + +extension JourneyDTO { + + public init(_ domain: Journey) { + self.id = domain.id + self.metadata = JourneyMetadataDTO(startTimestamp: domain.date.start, + endTimestamp: domain.date.end) + self.title = domain.title + self.spots = domain.spots.map { SpotDTO($0) } + self.coordinates = domain.coordinates.map { CoordinateDTO($0) } + self.song = SongDTO(domain.music) + } + + public func toDomain() -> Journey { + return Journey(id: self.id, + title: self.title, + date: (start: self.metadata.startTimestamp, end: self.metadata.endTimestamp), + spots: self.spots.map { $0.toDomain() }, + coordinates: self.coordinates.map { $0.toDomain() }, + music: self.song.toDomain()) + } + +} diff --git a/iOS/MSData/Sources/MSData/DTO/Fragment/JourneyMetadataDTO.swift b/iOS/MSData/Sources/MSData/DTO/Fragment/JourneyMetadataDTO.swift new file mode 100644 index 0000000..a2d95e9 --- /dev/null +++ b/iOS/MSData/Sources/MSData/DTO/Fragment/JourneyMetadataDTO.swift @@ -0,0 +1,37 @@ +// +// JourneyMetadataDTO.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.07. +// + +import Foundation + +public struct JourneyMetadataDTO { + + public let startTimestamp: Date + public let endTimestamp: Date + +} + +// MARK: - Codable + +extension JourneyMetadataDTO: Codable { + + enum CodingKeys: String, CodingKey { + case startTimestamp + case endTimestamp + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.startTimestamp = try container.decode(Date.self, forKey: .startTimestamp) + if let endTimestamp = try? container.decode(String.self, forKey: .endTimestamp), + endTimestamp.isEmpty { + self.endTimestamp = .now + return + } + self.endTimestamp = try container.decode(Date.self, forKey: .endTimestamp) + } + +} diff --git a/iOS/MSData/Sources/MSData/DTO/Fragment/RecordingJourneyDTO.swift b/iOS/MSData/Sources/MSData/DTO/Fragment/RecordingJourneyDTO.swift new file mode 100644 index 0000000..0569ef0 --- /dev/null +++ b/iOS/MSData/Sources/MSData/DTO/Fragment/RecordingJourneyDTO.swift @@ -0,0 +1,39 @@ +// +// RecordingJourneyDTO.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.10. +// + +import Foundation + +public struct RecordingJourneyDTO: Codable { + + public let id: String + public let startTimestamp: Date + public let spots: [SpotDTO] + public let coordinates: [CoordinateDTO] + +} + +// MARK: - Domain Mapping + +import MSDomain + +extension RecordingJourneyDTO { + + public init(_ domain: RecordingJourney) { + self.id = domain.id + self.startTimestamp = domain.startTimestamp + self.spots = domain.spots.map { SpotDTO($0) } + self.coordinates = domain.coordinates.map { CoordinateDTO($0) } + } + + public func toDomain() -> RecordingJourney { + return RecordingJourney(id: self.id, + startTimestamp: self.startTimestamp, + spots: self.spots.map { $0.toDomain() }, + coordinates: self.coordinates.map { $0.toDomain() }) + } + +} diff --git a/iOS/MSData/Sources/MSData/DTO/Fragment/SongDTO.swift b/iOS/MSData/Sources/MSData/DTO/Fragment/SongDTO.swift new file mode 100644 index 0000000..4377d94 --- /dev/null +++ b/iOS/MSData/Sources/MSData/DTO/Fragment/SongDTO.swift @@ -0,0 +1,66 @@ +// +// SongDTO.swift +// MSCoreKit +// +// Created by ์ „๋ฏผ๊ฑด on 11/16/23. +// + +import Foundation + +public struct SongDTO: Identifiable { + + // MARK: - Properties + + public let id: String + public let title: String + public let artist: String + public let albumCover: AlbumCoverDTO? + + // MARK: - Initializer + + public init(id: String, + title: String, + artist: String, + artwork: AlbumCoverDTO?) { + self.id = id + self.title = title + self.artist = artist + self.albumCover = artwork + } + +} + +// MARK: - Codable + +extension SongDTO: Codable { + + enum CodingKeys: String, CodingKey { + case id + case title = "name" + case artist = "artistName" + case albumCover = "artwork" + } + +} + +// MARK: - Domain Mapping + +import MSDomain + +extension SongDTO { + + public init(_ domain: Music) { + self.id = domain.id + self.title = domain.title + self.artist = domain.artist + self.albumCover = AlbumCoverDTO(domain.albumCover) + } + + public func toDomain() -> Music { + return Music(id: self.id, + title: self.title, + artist: self.artist, + albumCover: self.albumCover?.toDomain()) + } + +} diff --git a/iOS/MSData/Sources/MSData/DTO/Fragment/SpotDTO.swift b/iOS/MSData/Sources/MSData/DTO/Fragment/SpotDTO.swift new file mode 100644 index 0000000..d1bdf0c --- /dev/null +++ b/iOS/MSData/Sources/MSData/DTO/Fragment/SpotDTO.swift @@ -0,0 +1,60 @@ +// +// SpotDTO.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import Foundation + +public struct SpotDTO { + + // MARK: - Properties + + public let coordinate: CoordinateDTO + public let timestamp: Date + public let photoURL: URL + + // MARK: - Initializer + + public init(coordinate: CoordinateDTO, + timestamp: Date, + photoURL: URL) { + self.coordinate = coordinate + self.timestamp = timestamp + self.photoURL = photoURL + } + +} + +// MARK: - Codable + +extension SpotDTO: Codable { + + enum CodingKeys: String, CodingKey { + case coordinate + case timestamp + case photoURL = "photoUrl" + } + +} + +// MARK: - Domain Mapping + +import MSDomain + +extension SpotDTO { + + public init(_ domain: Spot) { + self.coordinate = CoordinateDTO(domain.coordinate) + self.timestamp = domain.timestamp + self.photoURL = domain.photoURL + } + + public func toDomain() -> Spot { + return Spot(coordinate: self.coordinate.toDomain(), + timestamp: self.timestamp, + photoURL: self.photoURL) + } + +} diff --git a/iOS/MSData/Sources/MSData/DTO/Request/Journey/DeleteJourneyRequestDTO.swift b/iOS/MSData/Sources/MSData/DTO/Request/Journey/DeleteJourneyRequestDTO.swift new file mode 100644 index 0000000..eb500fb --- /dev/null +++ b/iOS/MSData/Sources/MSData/DTO/Request/Journey/DeleteJourneyRequestDTO.swift @@ -0,0 +1,24 @@ +// +// DeleteJourneyRequestDTO.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.10. +// + +import Foundation + +public struct DeleteJourneyRequestDTO: Encodable { + + // MARK: - Properties + + public let userID: UUID + public let journeyID: String + + // MARK: - Encodable + + enum CodingKeys: String, CodingKey { + case userID = "userId" + case journeyID = "journeyId" + } + +} diff --git a/iOS/MSData/Sources/MSData/DTO/Request/Journey/EndJourneyRequestDTO.swift b/iOS/MSData/Sources/MSData/DTO/Request/Journey/EndJourneyRequestDTO.swift new file mode 100644 index 0000000..171b42a --- /dev/null +++ b/iOS/MSData/Sources/MSData/DTO/Request/Journey/EndJourneyRequestDTO.swift @@ -0,0 +1,50 @@ +// +// EndJourneyRequestDTO.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.05. +// + +import Foundation + +public struct EndJourneyRequestDTO { + + // MARK: - Properties + + public let journeyID: String + /// ์™„๋ฃŒ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅธ ์ตœ์ข… ์ขŒํ‘œ + /// > Tip: ์„œ๋ฒ„ ์ „์†ก ์‹คํŒจ ์‹œ ์ด์ „ ๋ฐ์ดํ„ฐ๋“ค๋„ ํ•จ๊ป˜ ๋ณด๋‚ด๊ธฐ ์œ„ํ•ด ๋ฐฐ์—ด์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + public let coordinates: [CoordinateDTO] + public let endTimestamp: Date + public let title: String + public let song: SongDTO + + // MARK: - Initializer + + public init(journeyID: String, + coordinates: [CoordinateDTO], + endTimestamp: Date, + title: String, + song: SongDTO) { + self.journeyID = journeyID + self.coordinates = coordinates + self.endTimestamp = endTimestamp + self.title = title + self.song = song + } + +} + +// MARK: - Encodable + +extension EndJourneyRequestDTO: Encodable { + + enum CodingKeys: String, CodingKey { + case journeyID = "journeyId" + case coordinates + case endTimestamp + case title + case song + } + +} diff --git a/iOS/MSData/Sources/MSData/DTO/Request/Journey/RecordCoordinateRequestDTO.swift b/iOS/MSData/Sources/MSData/DTO/Request/Journey/RecordCoordinateRequestDTO.swift new file mode 100644 index 0000000..d43173b --- /dev/null +++ b/iOS/MSData/Sources/MSData/DTO/Request/Journey/RecordCoordinateRequestDTO.swift @@ -0,0 +1,38 @@ +// +// RecordSpotRequestDTO.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.05. +// + +import Foundation + +public struct RecordCoordinateRequestDTO { + + // MARK: - Properties + + public let journeyID: String + /// ์ €์žฅํ•  ์ขŒํ‘œ ๊ฐ’ + /// > Tip: ์„œ๋ฒ„ ์ „์†ก ์‹คํŒจ ์‹œ ์ด์ „ ๋ฐ์ดํ„ฐ๋“ค๋„ ํ•จ๊ป˜ ๋ณด๋‚ด๊ธฐ ์œ„ํ•ด ๋ฐฐ์—ด์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + public let coordinates: [CoordinateDTO] + + // MARK: - Initializer + + public init(journeyID: String, + coordinates: [CoordinateDTO]) { + self.journeyID = journeyID + self.coordinates = coordinates + } + +} + +// MARK: - Encodable + +extension RecordCoordinateRequestDTO: Codable { + + enum CodingKeys: String, CodingKey { + case journeyID = "journeyId" + case coordinates + } + +} diff --git a/iOS/MSData/Sources/MSData/DTO/Request/Journey/StartJourneyRequestDTO.swift b/iOS/MSData/Sources/MSData/DTO/Request/Journey/StartJourneyRequestDTO.swift new file mode 100644 index 0000000..0f69265 --- /dev/null +++ b/iOS/MSData/Sources/MSData/DTO/Request/Journey/StartJourneyRequestDTO.swift @@ -0,0 +1,40 @@ +// +// StartJourneyRequestDTO.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.05. +// + +import Foundation + +public struct StartJourneyRequestDTO { + + // MARK: - Properties + + public let coordinate: CoordinateDTO + public let startTimestamp: Date + public let userID: UUID + + // MARK: - Initializer + + public init(coordinate: CoordinateDTO, + startTimestamp: Date, + userID: UUID) { + self.coordinate = coordinate + self.startTimestamp = startTimestamp + self.userID = userID + } + +} + +// MARK: - Encodable + +extension StartJourneyRequestDTO: Encodable { + + enum CodingKeys: String, CodingKey { + case coordinate + case startTimestamp + case userID = "userId" + } + +} diff --git a/iOS/MSData/Sources/MSData/DTO/Request/Spot/CreateSpotRequestDTO.swift b/iOS/MSData/Sources/MSData/DTO/Request/Spot/CreateSpotRequestDTO.swift new file mode 100644 index 0000000..c2ec387 --- /dev/null +++ b/iOS/MSData/Sources/MSData/DTO/Request/Spot/CreateSpotRequestDTO.swift @@ -0,0 +1,43 @@ +// +// CreateSpotRequestDTO.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.05. +// + +import Foundation + +public struct CreateSpotRequestDTO { + + // MARK: - Properties + + public let journeyID: String + public let coordinate: CoordinateDTO + public let timestamp: Date + public let photoData: Data + + // MARK: - Initializer + + public init(journeyId: String, + coordinate: CoordinateDTO, + timestamp: Date, + photoData: Data) { + self.journeyID = journeyId + self.coordinate = coordinate + self.timestamp = timestamp + self.photoData = photoData + } + +} + +// MARK: - Encodable + +extension CreateSpotRequestDTO: Encodable { + + enum CodingKeys: String, CodingKey { + case journeyID = "journeyId" + case coordinate + case timestamp + } + +} diff --git a/iOS/MSData/Sources/MSData/DTO/Request/User/UserRequestDTO.swift b/iOS/MSData/Sources/MSData/DTO/Request/User/UserRequestDTO.swift new file mode 100644 index 0000000..30c7a1a --- /dev/null +++ b/iOS/MSData/Sources/MSData/DTO/Request/User/UserRequestDTO.swift @@ -0,0 +1,32 @@ +// +// UserRequestDTO.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import Foundation + +public struct UserRequestDTO { + + // MARK: - Properties + + public let userID: UUID + + // MARK: - Initializer + + public init(userID: UUID) { + self.userID = userID + } + +} + +// MARK: - Encodable + +extension UserRequestDTO: Encodable { + + enum CodingKeys: String, CodingKey { + case userID = "userId" + } + +} diff --git a/iOS/MSData/Sources/MSData/DTO/Response/Journey/CheckJourneyResponseDTO.swift b/iOS/MSData/Sources/MSData/DTO/Response/Journey/CheckJourneyResponseDTO.swift new file mode 100644 index 0000000..febaf57 --- /dev/null +++ b/iOS/MSData/Sources/MSData/DTO/Response/Journey/CheckJourneyResponseDTO.swift @@ -0,0 +1,10 @@ +// +// CheckJourneyResponseDTO.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import Foundation + +public typealias CheckJourneyResponseDTO = [JourneyDTO] diff --git a/iOS/MSData/Sources/MSData/DTO/Response/Journey/DeleteJourneyResponseDTO.swift b/iOS/MSData/Sources/MSData/DTO/Response/Journey/DeleteJourneyResponseDTO.swift new file mode 100644 index 0000000..52d942f --- /dev/null +++ b/iOS/MSData/Sources/MSData/DTO/Response/Journey/DeleteJourneyResponseDTO.swift @@ -0,0 +1,44 @@ +// +// DeleteJourneyResponseDTO.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.10. +// + +import Foundation + +public struct DeleteJourneyResponseDTO: Identifiable { + + // MARK: - Properties + + public let id: String + public let metadata: JourneyMetadataDTO + public let spots: [SpotDTO] + public let coordinates: [CoordinateDTO] + + // MARK: - Initializer + + public init(id: String, + metadata: JourneyMetadataDTO, + spots: [SpotDTO], + coordinates: [CoordinateDTO]) { + self.id = id + self.metadata = metadata + self.spots = spots + self.coordinates = coordinates + } + +} + +// MARK: - Decodable + +extension DeleteJourneyResponseDTO: Decodable { + + enum CodingKeys: String, CodingKey { + case id = "_id" + case metadata = "journeyMetadata" + case spots + case coordinates + } + +} diff --git a/iOS/MSData/Sources/MSData/DTO/Response/Journey/EndJourneyResponseDTO.swift b/iOS/MSData/Sources/MSData/DTO/Response/Journey/EndJourneyResponseDTO.swift new file mode 100644 index 0000000..165a7cc --- /dev/null +++ b/iOS/MSData/Sources/MSData/DTO/Response/Journey/EndJourneyResponseDTO.swift @@ -0,0 +1,35 @@ +// +// EndJourneyResponseDTO.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.05. +// + +import Foundation + +public struct EndJourneyResponseDTO: Decodable { + + // MARK: - Properties + + public let id: String + /// > Tip: ์„œ๋ฒ„ ์ „์†ก ์‹คํŒจ ์‹œ ์ด์ „ ๋ฐ์ดํ„ฐ๋“ค๋„ ํ•จ๊ป˜ ๋ณด๋‚ด๊ธฐ ์œ„ํ•ด ๋ฐฐ์—ด์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + public let coordinates: [CoordinateDTO] + public let numberOfCoordinates: Int + public let endTimestamp: Date + public let song: SongDTO + + // MARK: - Initializer + + public init(id: String, + coordinates: [CoordinateDTO], + numberOfCoordinates: Int, + endTimestamp: Date, + song: SongDTO) { + self.id = id + self.coordinates = coordinates + self.numberOfCoordinates = numberOfCoordinates + self.endTimestamp = endTimestamp + self.song = song + } + +} diff --git a/iOS/MSData/Sources/MSData/DTO/Response/Journey/RecordJourneyResponseDTO.swift b/iOS/MSData/Sources/MSData/DTO/Response/Journey/RecordJourneyResponseDTO.swift new file mode 100644 index 0000000..00de500 --- /dev/null +++ b/iOS/MSData/Sources/MSData/DTO/Response/Journey/RecordJourneyResponseDTO.swift @@ -0,0 +1,22 @@ +// +// RecordJourneyResponseDTO.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import Foundation + +public struct RecordJourneyResponseDTO: Decodable { + + // MARK: - Properties + + public let coordinates: [CoordinateDTO] + + // MARK: - Initializer + + public init(coordinates: [CoordinateDTO]) { + self.coordinates = coordinates + } + +} diff --git a/iOS/MSData/Sources/MSData/DTO/Response/Journey/StartJourneyResponseDTO.swift b/iOS/MSData/Sources/MSData/DTO/Response/Journey/StartJourneyResponseDTO.swift new file mode 100644 index 0000000..8908868 --- /dev/null +++ b/iOS/MSData/Sources/MSData/DTO/Response/Journey/StartJourneyResponseDTO.swift @@ -0,0 +1,41 @@ +// +// StartJourneyResponseDTO.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.05. +// + +import Foundation + +public struct StartJourneyResponseDTO { + + // MARK: - Properties + + public let coordinate: CoordinateDTO + public let startTimestamp: Date + /// ์‹œ์ž‘๋œ ์—ฌ์ •์— ๋Œ€ํ•œ ID ๊ฐ’ + public let journeyID: String + + // MARK: - Initializer + + public init(coordinate: CoordinateDTO, + startTimestamp: Date, + journeyID: String) { + self.coordinate = coordinate + self.startTimestamp = startTimestamp + self.journeyID = journeyID + } + +} + +// MARK: - Decodable + +extension StartJourneyResponseDTO: Decodable { + + enum CodingKeys: String, CodingKey { + case coordinate + case startTimestamp + case journeyID = "journeyId" + } + +} diff --git a/iOS/MSData/Sources/MSData/DTO/Response/Spot/Note b/iOS/MSData/Sources/MSData/DTO/Response/Spot/Note new file mode 100644 index 0000000..d59c68a --- /dev/null +++ b/iOS/MSData/Sources/MSData/DTO/Response/Spot/Note @@ -0,0 +1,3 @@ + +SpotResponseDTO๋Š” SpotDTO์™€ ๊ฐ™๋„ค์š”. +SpotDTO๋ฅผ ์šฐ์„  ์‚ฌ์šฉํ•ด์ฃผ์„ธ์š”! diff --git a/iOS/MSData/Sources/MSData/DTO/Response/User/UserResponseDTO.swift b/iOS/MSData/Sources/MSData/DTO/Response/User/UserResponseDTO.swift new file mode 100644 index 0000000..60f33ec --- /dev/null +++ b/iOS/MSData/Sources/MSData/DTO/Response/User/UserResponseDTO.swift @@ -0,0 +1,26 @@ +// +// UserResponseDTO.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import Foundation + +public struct UserResponseDTO { + + // MARK: - Properties + + public let userID: UUID + + // MARK: - Initializer + +} + +extension UserResponseDTO: Decodable { + + enum CodingKeys: String, CodingKey { + case userID = "userId" + } + +} diff --git a/iOS/MSData/Sources/MSData/Repository/Error/Repository+Error.swift b/iOS/MSData/Sources/MSData/Repository/Error/Repository+Error.swift new file mode 100644 index 0000000..918ed1b --- /dev/null +++ b/iOS/MSData/Sources/MSData/Repository/Error/Repository+Error.swift @@ -0,0 +1,14 @@ +// +// Repository+Error.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.12. +// + +import Foundation + +public enum RepositoryError: Error { + + case emptyResponse + +} diff --git a/iOS/MSData/Sources/MSData/Repository/JourneyRepository.swift b/iOS/MSData/Sources/MSData/Repository/JourneyRepository.swift new file mode 100644 index 0000000..2a5c26e --- /dev/null +++ b/iOS/MSData/Sources/MSData/Repository/JourneyRepository.swift @@ -0,0 +1,210 @@ +// +// JourneyRepository.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 11/26/23. +// + +import Combine +import Foundation + +import MSConstants +import MSDomain +import MSLogger +import MSNetworking +import MSPersistentStorage +import MSUserDefaults + +public protocol JourneyRepository: Persistable { + + func fetchIsRecording() -> Bool + mutating func updateIsRecording(_ isRecording: Bool) -> Bool + func fetchRecordingJourneyID() -> String? + func fetchRecordingJourney(forID id: String) -> RecordingJourney? + func fetchJourneyList(userID: UUID, + minCoordinate: Coordinate, + maxCoordinate: Coordinate) async -> Result<[Journey], Error> + mutating func startJourney(at coordinate: Coordinate, userID: UUID) async -> Result + mutating func endJourney(_ journey: Journey) async -> Result + func recordJourney(journeyID: String, at coordinates: [Coordinate]) async -> Result + mutating func deleteJourney(_ journey: RecordingJourney, userID: UUID) async -> Result + +} + +public struct JourneyRepositoryImplementation: JourneyRepository { + + // MARK: - Properties + + private let networking: MSNetworking + public let storage: MSPersistentStorage + + @UserDefaultsWrapped(UserDefaultsKey.isRecording, defaultValue: false) + private var isRecording: Bool + + @UserDefaultsWrapped(UserDefaultsKey.recordingJourneyID, defaultValue: nil) + private var recordingJourneyID: String? + + // MARK: - Initializer + + public init(session: URLSession = URLSession(configuration: .default), + persistentStorage: MSPersistentStorage = FileManagerStorage()) { + self.networking = MSNetworking(session: session) + self.storage = persistentStorage + } + + // MARK: - Functions + + public func fetchIsRecording() -> Bool { + return self.isRecording + } + + @discardableResult + public mutating func updateIsRecording(_ isRecording: Bool) -> Bool { + self.isRecording = isRecording + return self.isRecording + } + + public func fetchRecordingJourneyID() -> String? { + guard let recordingJourneyID = self.recordingJourneyID else { + MSLogger.make(category: .userDefaults).error("๊ธฐ๋ก ์ค‘์ธ ์—ฌ์ • ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") + return nil + } + return recordingJourneyID + } + + public func fetchRecordingJourney(forID id: String) -> RecordingJourney? { + return self.storage.get(RecordingJourneyDTO.self, forKey: id)?.toDomain() + } + + public func fetchJourneyList(userID: UUID, + minCoordinate: Coordinate, + maxCoordinate: Coordinate) async -> Result<[Journey], Error> { + #if MOCK + guard let jsonURL = Bundle.module.url(forResource: "MockJourney", withExtension: "json") else { + return .failure((MSNetworkError.invalidRouter)) + } + do { + let jsonData = try Data(contentsOf: jsonURL) + let decoder = JSONDecoder() + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + decoder.dateDecodingStrategy = .formatted(dateFormatter) + let responseDTO = try decoder.decode(CheckJourneyResponseDTO.self, from: jsonData) + return .success(responseDTO.map { $0.toDomain() }) + } catch { + return .failure(error) + } + #else + let router = JourneyRouter.checkJourney(userID: userID, + minCoordinate: CoordinateDTO(minCoordinate), + maxCoordinate: CoordinateDTO(maxCoordinate)) + let result = await self.networking.request(CheckJourneyResponseDTO.self, router: router) + switch result { + case .success(let response): + return .success(response.map { $0.toDomain() }) + case .failure(let error): + return .failure(error) + } + #endif + } + + public mutating func startJourney(at coordinate: Coordinate, + userID: UUID) async -> Result { + #if MOCK + let recordingJourneyID = "657537c178b6463b9f810371" + let recordingJourney = RecordingJourney(id: recordingJourneyID, + startTimestamp: .now, + spots: [], + coordinates: []) + self.recordingJourneyID = recordingJourneyID + return .success(recordingJourney) + #else + let requestDTO = StartJourneyRequestDTO(coordinate: CoordinateDTO(coordinate), + startTimestamp: .now, + userID: userID) + let router = JourneyRouter.startJourney(dto: requestDTO) + let result = await self.networking.request(StartJourneyResponseDTO.self, router: router) + switch result { + case .success(let responseDTO): + let recordingJourney = RecordingJourney(id: responseDTO.journeyID, + startTimestamp: responseDTO.startTimestamp, + spots: [], + coordinates: [responseDTO.coordinate.toDomain()]) + + self.saveToLocal(value: recordingJourney.id) + self.saveToLocal(value: recordingJourney.startTimestamp) + + self.recordingJourneyID = recordingJourney.id + self.isRecording = true + + #if DEBUG + if let recordingJourneyID = self.recordingJourneyID { + MSLogger.make(category: .userDefaults).debug("๊ธฐ๋ก์ค‘์ธ ์—ฌ์ • ์ •๋ณด๊ฐ€ ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค: \(recordingJourneyID)") + } else { + MSLogger.make(category: .userDefaults).error("๊ธฐ๋ก์ค‘์ธ ์—ฌ์ • ์ •๋ณด ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") + } + #endif + + return .success(recordingJourney) + case .failure(let error): + return .failure(error) + } + #endif + } + + public func recordJourney(journeyID: String, + at coordinates: [Coordinate]) async -> Result { + let coordinatesDTO = coordinates.map { CoordinateDTO($0) } + let requestDTO = RecordCoordinateRequestDTO(journeyID: journeyID, coordinates: coordinatesDTO) + let router = JourneyRouter.recordCoordinate(dto: requestDTO) + let result = await self.networking.request(RecordJourneyResponseDTO.self, router: router) + switch result { + case .success(let responseDTO): + let coordinates = responseDTO.coordinates.map { $0.toDomain() } + let recordingJourney = RecordingJourney(id: journeyID, + startTimestamp: Date(), + spots: [], + coordinates: coordinates) + + responseDTO.coordinates.forEach { self.saveToLocal(value: $0) } + + return .success(recordingJourney) + case .failure(let error): + return .failure(error) + } + } + + public mutating func endJourney(_ journey: Journey) async -> Result { + let requestDTO = EndJourneyRequestDTO(journeyID: journey.id, + coordinates: journey.coordinates.map { CoordinateDTO($0) }, + endTimestamp: journey.date.end, + title: journey.title, + song: SongDTO(journey.music)) + let router = JourneyRouter.endJourney(dto: requestDTO) + let result = await self.networking.request(EndJourneyResponseDTO.self, router: router) + switch result { + case .success(let responseDTO): + self.recordingJourneyID = nil + self.isRecording = false + return .success(responseDTO.id) + case .failure(let error): + return .failure(error) + } + } + + public mutating func deleteJourney(_ recordingJourney: RecordingJourney, + userID: UUID) async -> Result { + let requestDTO = DeleteJourneyRequestDTO(userID: userID, journeyID: recordingJourney.id) + let router = JourneyRouter.deleteJourney(dto: requestDTO) + let result = await self.networking.request(DeleteJourneyResponseDTO.self, router: router) + switch result { + case .success(let responseDTO): + self.recordingJourneyID = nil + self.isRecording = false + return .success(responseDTO.id) + case .failure(let error): + return .failure(error) + } + } + +} diff --git a/iOS/MSData/Sources/MSData/Repository/Persistable.swift b/iOS/MSData/Sources/MSData/Repository/Persistable.swift new file mode 100644 index 0000000..f3d53d5 --- /dev/null +++ b/iOS/MSData/Sources/MSData/Repository/Persistable.swift @@ -0,0 +1,114 @@ +// +// Persistable.swift +// MSData +// +// Created by ์ „๋ฏผ๊ฑด on 2023.12.10. +// + +import Foundation + +import MSDomain +import MSLogger +import MSPersistentStorage + +public protocol Persistable { + + var storage: MSPersistentStorage { get } + + @discardableResult + func saveToLocal(value: Codable) -> Bool + func loadJourneyFromLocal() -> RecordingJourney? + +} + +// MARK: - KeyStorage + +private struct KeyStorage { + + static var id: String? + static var startTimestamp: String? + static var spots = [String]() + static var coordinates = [String]() + +} + +// MARK: - Default Implementations + +extension Persistable { + + @discardableResult + public func saveToLocal(value: Codable) -> Bool { + let key = UUID().uuidString + self.storage.set(value: value, forKey: key) + + switch value { + case is String: + if KeyStorage.id == nil { + KeyStorage.id = key + } else { + MSLogger.make(category: .persistable).debug("journey ID๋Š” ํ•˜๋‚˜์˜ ๊ฐ’๋งŒ ์ €์žฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + return false + } + case is Date: + if KeyStorage.startTimestamp == nil { + KeyStorage.startTimestamp = key + } else { + MSLogger.make(category: .persistable).debug("start tamp๋Š” ํ•˜๋‚˜์˜ ๊ฐ’๋งŒ ์ €์žฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + return false + } + case is SpotDTO: + KeyStorage.spots.append(key) + case is CoordinateDTO: + KeyStorage.coordinates.append(key) + default: + MSLogger.make(category: .persistable).debug("RecordingJourney ํƒ€์ž…์˜ ์š”์†Œ๋“ค๋งŒ ๋„ฃ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + return false + } + return true + } + + public func loadJourneyFromLocal() -> RecordingJourney? { + guard let id = self.loadID(), + let startTimestamp = self.loadStartTimeStamp() else { + return nil + } + return RecordingJourney(id: id, + startTimestamp: startTimestamp, + spots: self.loadSpots(), + coordinates: self.loadCoordinates()) + } + + func loadStartTimeStamp() -> Date? { + guard let startTimestampKey = KeyStorage.startTimestamp, + let startTimestamp = self.storage.get(Date.self, forKey: startTimestampKey) + else { + MSLogger.make(category: .persistable).debug("id ๋˜๋Š” startTimestamp๊ฐ€ ์ €์žฅ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.") + return nil + } + return startTimestamp + } + + func loadID() -> String? { + guard let idKey = KeyStorage.id, + let id = self.storage.get(String.self, forKey: idKey) else { + MSLogger.make(category: .persistable).debug("id ๋˜๋Š” startTimestamp๊ฐ€ ์ €์žฅ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.") + return nil + } + return id + } + + func loadSpots() -> [Spot] { + return KeyStorage.spots.compactMap { spotKey in + let spotDTO = self.storage.get(SpotDTO.self, forKey: spotKey) + return spotDTO?.toDomain() + } + } + + func loadCoordinates() -> [Coordinate] { + return KeyStorage.coordinates.compactMap { coordinateKey in + let coordinateDTO = self.storage.get(CoordinateDTO.self, forKey: coordinateKey) + return coordinateDTO?.toDomain() + } + } + +} diff --git a/iOS/MSData/Sources/MSData/Repository/SongRepository.swift b/iOS/MSData/Sources/MSData/Repository/SongRepository.swift new file mode 100644 index 0000000..843687e --- /dev/null +++ b/iOS/MSData/Sources/MSData/Repository/SongRepository.swift @@ -0,0 +1,90 @@ +// +// SongRepository.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.03. +// + +import Foundation +import MusicKit + +import MSLogger +import MSNetworking + +public protocol SongRepository { + + func fetchSong(withID id: String) async -> Result + func fetchSongList(with term: String) async -> Result, Error> + @available(iOS 16.0, *) + func fetchSongListByRank() async -> Result, Error> + +} + +public struct SongRepositoryImplementation: SongRepository { + + // MARK: - Properties + + // MARK: - Initializer + + public init() { } + + // MARK: - Functions + + public func fetchSong(withID id: String) async -> Result { + let musicItemID = MusicItemID(id) + let request = MusicCatalogResourceRequest(matching: \.id, equalTo: musicItemID) + + do { + let response = try await request.response() + guard let song = response.items.first else { + MSLogger.make(category: .music).error("์ฃผ์–ด์ง„ ID์— ๋งž๋Š” ์Œ์•…๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + return .failure(RepositoryError.emptyResponse) + } + return .success(song) + } catch { + return .failure(error) + } + } + + public func fetchSongList(with term: String) async -> Result, Error> { + #if MOCK + guard let jsonURL = Bundle.module.url(forResource: "MockSong", withExtension: "json") else { + return .failure(MSNetworkError.invalidRouter) + } + + do { + let jsonData = try Data(contentsOf: jsonURL) + let decoder = JSONDecoder() + if let result = try? decoder.decode(MusicCatalogSearchResponse.self, from: jsonData) { + return .success(result.songs) + } + } catch { + return .failure(error) + } + #else + var searchRequest = MusicCatalogSearchRequest(term: term, types: [Song.self]) + searchRequest.limit = 10 + if #available(iOS 16.0, *) { + searchRequest.includeTopResults = true + } + do { + let searchResponse = try await searchRequest.response() + return .success(searchResponse.songs) + } catch { + return .failure(error) + } + #endif + } + + @available(iOS 16.0, *) + public func fetchSongListByRank() async -> Result, Error> { + let request = MusicCatalogChartsRequest(kinds: [.cityTop], types: [Song.self]) + do { + let searchResponse = try await request.response() + return .success(searchResponse.songCharts.first!.items) + } catch { + return .failure(error) + } + } + +} diff --git a/iOS/MSData/Sources/MSData/Repository/SpotRepository.swift b/iOS/MSData/Sources/MSData/Repository/SpotRepository.swift new file mode 100644 index 0000000..336cb94 --- /dev/null +++ b/iOS/MSData/Sources/MSData/Repository/SpotRepository.swift @@ -0,0 +1,85 @@ +// +// SpotRepository.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.04. +// + +import Foundation + +import MSDomain +import MSNetworking +import MSLogger +import MSPersistentStorage + +public protocol SpotRepository: Persistable { + + func fetchRecordingSpots() async -> Result<[Spot], Error> + func upload(spot: CreateSpotRequestDTO) async -> Result + +} + +public struct SpotRepositoryImplementation: SpotRepository { + + // MARK: - Properties + + private let networking: MSNetworking + public let storage: MSPersistentStorage + + // MARK: - Initializer + + public init(session: URLSession = URLSession(configuration: .default), + persistentStorage: MSPersistentStorage = FileManagerStorage()) { + self.networking = MSNetworking(session: session) + self.storage = persistentStorage + } + + // MARK: - Functions + + public func fetchRecordingSpots() async -> Result<[Spot], Error> { + #if MOCK + guard let jsonURL = Bundle.module.url(forResource: "MockSpot", withExtension: "json") else { + return .failure((MSNetworkError.invalidRouter)) + } + do { + let jsonData = try Data(contentsOf: jsonURL) + let decoder = JSONDecoder() + let spots = try decoder.decode([SpotDTO].self, from: jsonData) + return .success(spots.map { $0.toDomain() }) + } catch { + MSLogger.make(category: .network).error("/(error)") + } + return .failure(MSNetworkError.unknownResponse) + #else + let router = SpotRouter.downloadSpot + let result = await self.networking.request([SpotDTO].self, router: router) + switch result { + case .success(let spot): + #if DEBUG + MSLogger.make(category: .network).debug("์„ฑ๊ณต์ ์œผ๋กœ ๋‹ค์šด๋กœ๋“œํ•˜์˜€์Šต๋‹ˆ๋‹ค.") + #endif + return .success(spot.map { $0.toDomain() }) + case .failure(let error): + MSLogger.make(category: .network).error("\(error): ๋‹ค์šด๋กœ๋“œ์— ์‹คํŒจํ•˜์˜€์Šต๋‹ˆ๋‹ค.") + return .failure(error) + } + #endif + } + + public func upload(spot: CreateSpotRequestDTO) async -> Result { + let router = SpotRouter.upload(spot: spot, id: UUID()) + let result = await self.networking.request(SpotDTO.self, router: router) + switch result { + case .success(let spot): + #if DEBUG + MSLogger.make(category: .network).debug("์„ฑ๊ณต์ ์œผ๋กœ ์—…๋กœ๋“œํ•˜์˜€์Šต๋‹ˆ๋‹ค.") + #endif + self.saveToLocal(value: spot) + return .success(spot.toDomain()) + case .failure(let error): + MSLogger.make(category: .network).error("\(error): ์—…๋กœ๋“œ์— ์‹คํŒจํ•˜์˜€์Šต๋‹ˆ๋‹ค.") + return .failure(error) + } + } + +} diff --git a/iOS/MSData/Sources/MSData/Repository/UserRepository.swift b/iOS/MSData/Sources/MSData/Repository/UserRepository.swift new file mode 100644 index 0000000..24954a7 --- /dev/null +++ b/iOS/MSData/Sources/MSData/Repository/UserRepository.swift @@ -0,0 +1,96 @@ +// +// UserRepository.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import Foundation + +import MSKeychainStorage +import MSLogger +import MSNetworking + +public protocol UserRepository { + + func createUser() async -> Result + func storeUUID(_ userID: UUID) throws -> UUID + func fetchUUID() -> UUID? + +} + +public struct UserRepositoryImplementation: UserRepository { + + // MARK: - Properties + + private let networking: MSNetworking + private let keychain: MSKeychainStorage + + // MARK: - Initializer + + public init(session: URLSession = URLSession(configuration: .default), + keychain: MSKeychainStorage = MSKeychainStorage()) { + self.networking = MSNetworking(session: session) + self.keychain = keychain + } + + // MARK: - Functions + + public func createUser() async -> Result { + // Keychain์— UserID๊ฐ€ ์ €์žฅ๋˜์–ด ์žˆ๋Š” ์ง€ ํ™•์ธํ•˜๊ณ  ์•„๋‹ˆ๋ผ๋ฉด ์ƒˆ๋กœ ์ƒ์„ฑ + let userID: UUID + if let existingUserID = self.fetchUUID() { + userID = existingUserID + #if DEBUG + MSLogger.make(category: .keychain).debug("Keychain์— ์œ ์ €๊ฐ€ ์กด์žฌํ•ด ํ•ด๋‹น ์œ ์ € ID๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค: \(userID)") + #endif + } else { + userID = UUID() + #if DEBUG + MSLogger.make(category: .keychain).debug("Keychain์— ์œ ์ €๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์•„ ์ƒˆ๋กœ์šด ์œ ์ € ID๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค: \(userID)") + #endif + } + + let requestDTO = UserRequestDTO(userID: userID) + let router = UserRouter.newUser(dto: requestDTO) + + let result = await self.networking.request(UserResponseDTO.self, router: router) + switch result { + case .success(let userResponse): + if let storedUserID = try? self.storeUUID(userID) { + return .success(storedUserID) + } + MSLogger.make(category: .keychain).warning("์„œ๋ฒ„์— ์ƒˆ๋กœ์šด ์œ ์ €๋ฅผ ์ƒ์„ฑํ–ˆ์ง€๋งŒ, Keychain์— ์ €์žฅํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.") + return .success(userResponse.userID) + case .failure(let error): + return .failure(error) + } + } + + @discardableResult + public func storeUUID(_ userID: UUID) throws -> UUID { + let account = MSKeychainStorage.Accounts.userID.rawValue + + do { + try self.keychain.set(value: userID, account: account) + return userID + } catch { + throw MSKeychainStorage.KeychainError.creationError + } + } + + /// UUID๊ฐ€ ์ด๋ฏธ ํ‚ค์ฒด์ธ์— ๋“ฑ๋ก๋˜์–ด ์žˆ๋‹ค๋ฉด ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค. + public func fetchUUID() -> UUID? { + let account = MSKeychainStorage.Accounts.userID.rawValue + guard let userID = try? self.keychain.get(UUID.self, account: account) else { + MSLogger.make(category: .keychain).error("Keychain์—์„œ UserID๋ฅผ ์กฐํšŒํ•˜๋Š” ๊ฒƒ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") + return nil + } + + #if DEBUG + MSLogger.make(category: .keychain).debug("Keychain์—์„œ UserID๋ฅผ ์กฐํšŒํ–ˆ์Šต๋‹ˆ๋‹ค: \(userID)") + #endif + return userID + } + +} diff --git a/iOS/MSData/Sources/MSData/Resources/MockJourney.json b/iOS/MSData/Sources/MSData/Resources/MockJourney.json new file mode 100644 index 0000000..61c8d81 --- /dev/null +++ b/iOS/MSData/Sources/MSData/Resources/MockJourney.json @@ -0,0 +1,546 @@ +[ + { + "id": "20008aad-362f-4e43-b4e4-4adfee08a30d", + "location": "์„œ์šธ์‹œ ๊ฐ•๋‚จ๊ตฌ", + "metaData": { + "date": "2023-10-28T00:00:00" + }, + "spots": [ + { + "journeyId": "389354bc-7c14-402b-90ce-ff9b1e6aec13", + "coordinate": [ + 74.34012768689522, + -20.0591261579182 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "ede775e3-7ec4-4b92-a314-a8682d00c3f6", + "coordinate": [ + 39.79236622936409, + -20.288943745440292 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "82fff66b-538b-4c4d-9f0c-cf0b10098374", + "coordinate": [ + -61.2994089681573, + 147.81682997676376 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + } + ], + "coordinates": [ + { + "latitude": 57.465627107728835, + "longitude": 21.259425432982624 + }, + { + "latitude": -53.09543651781735, + "longitude": -127.7430923166473 + }, + { + "latitude": -77.73569106610123, + "longitude": 175.41162341151937 + }, + { + "latitude": 5.183073888694608, + "longitude": -97.39973919319664 + } + ], + "song": { + "id": "421a73d5-64b5-4907-9726-fc263da4e34b", + "title": "Super Shy", + "artwork": "Artwork URL", + "artist": "NewJeans" + }, + "lineColor": "Color 0" + }, + { + "id": "8f98238a-3dec-458d-9a03-984e8cae332d", + "location": "๋ถ€์ฒœ์‹œ ์›๋ฏธ๊ตฌ", + "metaData": { + "date": "2022-04-04T00:00:00" + }, + "spots": [ + { + "journeyId": "8e4fd5a5-d5bc-4fe7-aaf5-e0de9da96685", + "coordinate": [ + 31.52421333174138, + -43.89463635754254 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "272fdb4c-6aca-45dc-b00c-528e6204e294", + "coordinate": [ + -68.4588807563729, + 43.959766770510186 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "d210ee40-8895-46d5-8f5b-85c31b441d8a", + "coordinate": [ + -34.28043419715898, + -164.73303980456268 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "e330d5f1-45e4-4dfa-a2a6-291e6be56f35", + "coordinate": [ + -66.19095010133054, + -8.569810942887017 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "68140671-237f-48d1-9888-4eb2d0d02b2c", + "coordinate": [ + -55.72402145238744, + -18.60154681858242 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + } + ], + "coordinates": [ + { + "latitude": 43.82013766554985, + "longitude": 173.00171490769134 + }, + { + "latitude": -29.320190959438754, + "longitude": -28.21501987990979 + }, + { + "latitude": 87.99211291043426, + "longitude": 50.670604577768586 + }, + { + "latitude": 28.003576208636588, + "longitude": -164.59912441639696 + } + ], + "song": { + "id": "f4ec694c-c577-4e1b-a7f8-c5d984537881", + "title": "OMG", + "artwork": "Artwork URL", + "artist": "NewJeans" + }, + "lineColor": "Color 1" + }, + { + "id": "2367bcf6-972f-432d-837a-0cc4d434eb9a", + "location": "์ˆ˜์›์‹œ ํŒ”๋‹ฌ๊ตฌ", + "metaData": { + "date": "2023-11-07T00:00:00" + }, + "spots": [ + { + "journeyId": "d76c335b-e410-4fea-9270-e417c4f57c0f", + "coordinate": [ + -64.81468160689631, + -76.60199610190303 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "dc7436fc-58d2-422e-85ea-f7542198c0d5", + "coordinate": [ + 73.79998313510268, + 68.35171492227144 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "6732642e-1972-4c85-8697-3d3ddc2eb1c0", + "coordinate": [ + 8.909582214448818, + -151.7541955219229 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + } + ], + "coordinates": [ + { + "latitude": 33.314649021558964, + "longitude": -144.60966101329134 + }, + { + "latitude": -17.28334467846844, + "longitude": 96.71348559491207 + } + ], + "song": { + "id": "c7ffc956-fd68-45fa-9523-0a365b379d90", + "title": "Either Way", + "artwork": "Artwork URL", + "artist": "IVE" + }, + "lineColor": "Color 2" + }, + { + "id": "d2765c97-f216-4ab2-80e2-0f947145aaf0", + "location": "์„œ์šธ์‹œ ๋งˆํฌ๊ตฌ", + "metaData": { + "date": "2023-11-03T00:00:00" + }, + "spots": [ + { + "journeyId": "6d3ee7b2-32af-4d64-8704-a8b0fd2254cb", + "coordinate": [ + 37.496598, + 126.958120 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + } + ], + "coordinates": [ + { + "latitude": 37.496598, + "longitude": 126.958120 + }, + { + "latitude": -65.0654246205996, + "longitude": 82.56458972940237 + } + ], + "song": { + "id": "0fe57f3a-4623-4064-88ac-a1fe09ecf7e9", + "title": "์ด๋ธŒ, ํ”„์‹œ์ผ€ ๊ทธ๋ฆฌ๊ณ  ํ‘ธ๋ฅธ ์ˆ˜์—ผ์˜ ์•„๋‚ด", + "artwork": "Artwork URL", + "artist": "LE SSERAFIM" + }, + "lineColor": "Color 3" + }, + { + "id": "53c0e005-c721-44ca-9765-ee4b742cd675", + "location": "๊ณ ์–‘์‹œ ๋•์–‘๊ตฌ", + "metaData": { + "date": "2023-02-13T00:00:00" + }, + "spots": [ + { + "journeyId": "1f25a310-5e5d-42c7-918f-a61a0187db68", + "coordinate": [ + 37.495976, + 126.956837 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "833719bd-7b54-489b-b3e7-9bc6b667dc5e", + "coordinate": [ + 36.495973, + 125.956837 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + } + ], + "coordinates": [ + { + "latitude": -67.51343427177022, + "longitude": 17.431624937453194 + }, + { + "latitude": 17.539925914331036, + "longitude": 127.209259846463 + }, + { + "latitude": -19.192151423397974, + "longitude": -94.047267104666 + } + ], + "song": { + "id": "35c87451-46ea-4bce-96ac-57604530994d", + "title": "Attention", + "artwork": "Artwork URL", + "artist": "NewJeans" + }, + "lineColor": "Color 4" + }, + { + "id": "a4a00895-0afc-43fd-9ad9-b215dddbe816", + "location": "์„œ์šธ์‹œ ์˜๋“ฑํฌ๊ตฌ", + "metaData": { + "date": "2023-01-19T00:00:00" + }, + "spots": [ + { + "journeyId": "24ca84aa-6a80-47c0-8302-6a0924791c80", + "coordinate": [ + 37.16742284659284, + 126.225978927134577 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "2f812435-5d79-4e49-bee1-51a541b7e2bc", + "coordinate": [ + 37.537466139282714, + 126.92574821724574 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "b52aa54e-8ca2-4581-aa03-b3b50fc9e031", + "coordinate": [ + 37.68755334180694, + 126.4740708009648 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "0a416cf3-f957-4372-9cae-5a1997161c3b", + "coordinate": [ + 37.38993174770837, + 126.94575572765291 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "3a3869bf-a149-4ae2-8936-61ef4025921f", + "coordinate": [ + 26.00204566583929, + -38.88354925355324 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + } + ], + "coordinates": [ + { + "latitude": 37.42509, + "longitude": 124.9565 + }, + { + "latitude": 37.39509, + "longitude": 125.9565 + }, + { + "latitude": 37.49539, + "longitude": 126.9565 + }, + { + "latitude": 37.49509, + "longitude": 127.9565 + } + ], + "song": { + "id": "d1488750-4120-4a9a-999b-a87a45953ca5", + "title": "Drama", + "artwork": "Artwork URL", + "artist": "aespa" + }, + "lineColor": "Color 5" + }, + { + "id": "93260b82-153a-4f5a-a5d8-ec58f1a7ad71", + "location": "์ธ์ฒœ์‹œ ์—ฐ์ˆ˜๊ตฌ", + "metaData": { + "date": "2020-12-07T00:00:00" + }, + "spots": [ + { + "journeyId": "5ee04c1e-d914-4fe0-bafc-6410415323a0", + "coordinate": [ + -72.15636930638713, + -136.0116257911593 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "e4445b1a-d4b8-4215-a50e-c3088d18d7d8", + "coordinate": [ + 86.14073588308193, + 71.3073252728758 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "d8af22f2-e004-4d37-a6ca-6ad99c6c7d89", + "coordinate": [ + 77.87222808724792, + -3.792694490590094 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + } + ], + "coordinates": [ + { + "latitude": 8.80229997752501, + "longitude": 47.63625800444882 + }, + { + "latitude": -35.63401796013899, + "longitude": 118.02930151637929 + }, + { + "latitude": 4.774969630280225, + "longitude": -38.19033863439523 + }, + { + "latitude": 17.40994536703667, + "longitude": -123.27816118997819 + } + ], + "song": { + "id": "87e57ea4-ea53-405b-9631-c4d10f6dc4fc", + "title": "Chill Kill", + "artwork": "Artwork URL", + "artist": "Red Velvet" + }, + "lineColor": "Color 6" + }, + { + "id": "d26426b1-aec5-4f51-8a23-6f1401fffb52", + "location": "์ธ์ฒœ์‹œ ์ค‘๊ตฌ", + "metaData": { + "date": "2022-10-14T00:00:00" + }, + "spots": [ + { + "journeyId": "70a868dc-52d4-4a6c-aefa-8f4d2db72bbd", + "coordinate": [ + -67.2052661143819, + -103.79187685194488 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + } + ], + "coordinates": [ + { + "latitude": -17.049476729920784, + "longitude": -95.60934011188559 + }, + { + "latitude": -86.08561003130464, + "longitude": -3.220089719655789 + }, + { + "latitude": -4.114875686387336, + "longitude": 89.03707513766813 + } + ], + "song": { + "id": "99955ba8-a619-468a-8d82-5f49b7494c56", + "title": "DIE 4 YOU", + "artwork": "Artwork URL", + "artist": "DEAN" + }, + "lineColor": "Color 7" + }, + { + "id": "7311e698-f313-4acc-bf18-a080233b78ae", + "location": "์ฒœ์•ˆ์‹œ ์„œ๋ถ๊ตฌ", + "metaData": { + "date": "2023-12-25T00:00:00" + }, + "spots": [ + { + "journeyId": "ce8fb5a9-cf2e-44bb-a695-07ef19df8684", + "coordinate": [ + 26.754371020802807, + -136.87790951732427 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "2a5bcf5d-ff5c-458f-ad3d-1f3560a01f05", + "coordinate": [ + -31.05255299691919, + 109.12110052412726 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "b8736196-293e-4674-9b49-882419739bc2", + "coordinate": [ + 44.11921868914504, + -1.3713120422063696 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "4fac5c54-a3d3-4a2e-b7bc-b9a24f6f716f", + "coordinate": [ + -9.650099854349605, + 63.87852947086256 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "24f129a0-fdef-41e3-8590-a70de1568f09", + "coordinate": [ + -54.74979632677334, + 165.518236994507 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + } + ], + "coordinates": [ + { + "latitude": 14.132893106998878, + "longitude": -22.231429270383558 + }, + { + "latitude": 1.7423769434293916, + "longitude": -35.145660538473976 + }, + { + "latitude": 55.22007564767648, + "longitude": 44.828484292139535 + } + ], + "song": { + "id": "d9698b7c-20ad-4af5-97d9-42f83a1654c5", + "title": "All I Want for Christmas Is You", + "artwork": "Artwork URL", + "artist": "Mariah Carey" + }, + "lineColor": "Color 8" + }, + { + "id": "bec2b75d-7782-47b6-a67e-0067e49bbca8", + "location": "์ „์ฃผ์‹œ ์™„์‚ฐ๊ตฌ", + "metaData": { + "date": "2023-07-14T00:00:00" + }, + "spots": [ + { + "journeyId": "25ac53b9-86d2-4935-99a7-1cf2f362f6e1", + "coordinate": [ + -12.855147544861794, + -44.31218853533764 + ], + "photoUrl": "https://source.unsplash.com/random/200x300" + } + ], + "coordinates": [ + { + "latitude": 23.29047112271624, + "longitude": -120.86503806882197 + }, + { + "latitude": 6.1062416592369715, + "longitude": -116.13348365253415 + }, + { + "latitude": -4.550860523478789, + "longitude": -168.32578052239512 + }, + { + "latitude": -61.40086403396403, + "longitude": 101.95422471280716 + } + ], + "song": { + "id": "d21f4f50-6fee-4584-ab23-3a5a73879156", + "title": "ETA", + "artwork": "Artwork URL", + "artist": "NewJeans" + }, + "lineColor": "Color 9" + } +] diff --git a/iOS/MSData/Sources/MSData/Resources/MockSong.json b/iOS/MSData/Sources/MSData/Resources/MockSong.json new file mode 100644 index 0000000..810d4a1 --- /dev/null +++ b/iOS/MSData/Sources/MSData/Resources/MockSong.json @@ -0,0 +1,227 @@ +{ + "results": { + "songs": { + "href": "/v1/catalog/us/search?limit=5&term=beach+bunny&types=songs", + "next": "/v1/catalog/us/search?offset=5&term=beach+bunny&types=songs", + "data": [ + { + "id": "1482041830", + "type": "songs", + "href": "/v1/catalog/us/songs/1482041830", + "attributes": { + "albumName": "Honeymoon", + "genreNames": [ + "Alternative", + "Music" + ], + "trackNumber": 9, + "releaseDate": "2020-02-14", + "durationInMillis": 147351, + "isrc": "USQE91600054", + "artwork": { + "width": 3000, + "height": 3000, + "url": "https://is3-ssl.mzstatic.com/image/thumb/Music125/v4/0b/b2/52/0bb2524d-ecfc-1bae-9c1e-218c978d7072/Honeymoon_3K.jpg/{w}x{h}bb.jpg", + "bgColor": "fffaa9", + "textColor1": "030005", + "textColor2": "363240", + "textColor3": "353226", + "textColor4": "5e5a55" + }, + "url": "https://music.apple.com/us/album/cloud-9/1482041821?i=1482041830", + "playParams": { + "id": "1482041830", + "kind": "song" + }, + "discNumber": 1, + "isAppleDigitalMaster": false, + "hasLyrics": true, + "name": "Cloud 9", + "previews": [ + { + "url": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview115/v4/5b/98/32/5b9832bb-5337-cd4f-5a7e-063709c465cc/mzaf_8852412892959276575.plus.aac.p.m4a" + } + ], + "artistName": "Beach Bunny" + } + }, + { + "id": "1476463574", + "type": "songs", + "href": "/v1/catalog/us/songs/1476463574", + "attributes": { + "albumName": "Prom Queen - EP", + "genreNames": [ + "Alternative", + "Music" + ], + "trackNumber": 1, + "durationInMillis": 136562, + "releaseDate": "2018-08-10", + "isrc": "QZDA71885746", + "artwork": { + "width": 3000, + "height": 3000, + "url": "https://is3-ssl.mzstatic.com/image/thumb/Music114/v4/d0/1b/9f/d01b9f8a-fd47-4428-ef15-e5f23860e988/Prom_Queen_Art.jpg/{w}x{h}bb.jpg", + "bgColor": "af7db8", + "textColor1": "080008", + "textColor2": "221424", + "textColor3": "29192b", + "textColor4": "3e2942" + }, + "url": "https://music.apple.com/us/album/prom-queen/1476463573?i=1476463574", + "playParams": { + "id": "1476463574", + "kind": "song" + }, + "discNumber": 1, + "isAppleDigitalMaster": false, + "hasLyrics": true, + "name": "Prom Queen", + "previews": [ + { + "url": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview125/v4/bd/d6/02/bdd60248-cad4-34a8-bb0d-d2e462dd8b12/mzaf_8931382300295146023.plus.aac.p.m4a" + } + ], + "artistName": "Beach Bunny" + } + }, + { + "id": "1476463542", + "type": "songs", + "href": "/v1/catalog/us/songs/1476463542", + "attributes": { + "albumName": "Sports - Single", + "genreNames": [ + "Alternative", + "Music" + ], + "trackNumber": 1, + "releaseDate": "2018-01-01", + "durationInMillis": 164165, + "isrc": "QZAPG1780549", + "artwork": { + "width": 3000, + "height": 3000, + "url": "https://is3-ssl.mzstatic.com/image/thumb/Music114/v4/c6/82/63/c682635e-f2b6-6183-e4ce-c950f88dcf5a/Sports_Art.jpg/{w}x{h}bb.jpg", + "bgColor": "59c2c6", + "textColor1": "000808", + "textColor2": "33242f", + "textColor3": "122d2e", + "textColor4": "3a434d" + }, + "url": "https://music.apple.com/us/album/sports/1476463541?i=1476463542", + "playParams": { + "id": "1476463542", + "kind": "song" + }, + "discNumber": 1, + "hasLyrics": true, + "isAppleDigitalMaster": false, + "name": "Sports", + "previews": [ + { + "url": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview125/v4/b7/19/60/b71960e4-ef93-9d20-55f6-18111c3edaf0/mzaf_10496805436073509743.plus.aac.p.m4a" + } + ], + "artistName": "Beach Bunny" + } + }, + { + "id": "1613600384", + "type": "songs", + "href": "/v1/catalog/us/songs/1613600384", + "attributes": { + "albumName": "Emotional Creature", + "genreNames": [ + "Alternative", + "Music" + ], + "trackNumber": 12, + "releaseDate": "2022-07-22", + "durationInMillis": 342693, + "isrc": "USQE92100268", + "artwork": { + "width": 3000, + "height": 3000, + "url": "https://is1-ssl.mzstatic.com/image/thumb/Music112/v4/df/4e/68/df4e6833-9828-51d7-cdeb-71ecf6d3a23d/810090090962.png/{w}x{h}bb.jpg", + "bgColor": "202020", + "textColor1": "aea6f6", + "textColor2": "b68ef6", + "textColor3": "918bcb", + "textColor4": "9878cb" + }, + "composerName": "Anthony Vaccaro, Jon Alvarado, Lili Trifilio & Matt Henkels", + "playParams": { + "id": "1613600384", + "kind": "song" + }, + "url": "https://music.apple.com/us/album/love-song/1613600183?i=1613600384", + "discNumber": 1, + "hasLyrics": true, + "isAppleDigitalMaster": true, + "name": "Love Song", + "previews": [ + { + "url": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview112/v4/56/1a/58/561a584f-7d1f-27c6-8cc3-cf02e5000fce/mzaf_3967919156577514157.plus.aac.p.m4a" + } + ], + "artistName": "Beach Bunny" + } + }, + { + "id": "1613600377", + "type": "songs", + "href": "/v1/catalog/us/songs/1613600377", + "attributes": { + "albumName": "Emotional Creature", + "genreNames": [ + "Alternative", + "Music" + ], + "trackNumber": 8, + "releaseDate": "2022-07-22", + "durationInMillis": 95147, + "isrc": "USQE92100264", + "artwork": { + "width": 3000, + "height": 3000, + "url": "https://is1-ssl.mzstatic.com/image/thumb/Music112/v4/df/4e/68/df4e6833-9828-51d7-cdeb-71ecf6d3a23d/810090090962.png/{w}x{h}bb.jpg", + "bgColor": "202020", + "textColor1": "aea6f6", + "textColor2": "b68ef6", + "textColor3": "918bcb", + "textColor4": "9878cb" + }, + "composerName": "Anthony Vaccaro, Jon Alvarado, Lili Trifilio & Matt Henkels", + "url": "https://music.apple.com/us/album/gravity/1613600183?i=1613600377", + "playParams": { + "id": "1613600377", + "kind": "song" + }, + "discNumber": 1, + "hasLyrics": false, + "isAppleDigitalMaster": true, + "name": "Gravity", + "previews": [ + { + "url": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview122/v4/ad/3c/ff/ad3cff93-17aa-8ff2-5e1e-e0035dd9c074/mzaf_13538224288044086289.plus.aac.p.m4a" + } + ], + "artistName": "Beach Bunny" + } + } + ] + } + }, + "meta": { + "results": { + "order": [ + "songs" + ], + "rawOrder": [ + "songs" + ] + } + } +} diff --git a/iOS/MSData/Sources/MSData/Resources/MockSpot.json b/iOS/MSData/Sources/MSData/Resources/MockSpot.json new file mode 100644 index 0000000..991112c --- /dev/null +++ b/iOS/MSData/Sources/MSData/Resources/MockSpot.json @@ -0,0 +1,52 @@ +[ + { + "journeyId": "ab4068ef-95ed-40c3-be6d-3db35df866b9", + "coordinate": [37.46569, 126.44748], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "2c5dc172-724f-4781-9669-2657fad175c8", + "coordinate": [37.37501, 126.63229], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "bb058be2-c5d0-400c-8b18-140cc971c126", + "coordinate": [37.52593, 126.91611], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "f263a338-5fff-4349-95af-feb5abacbf29", + "coordinate": [37.55676, 126.91247], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "02a90f9d-ca78-49f5-893a-3f0e79ccb8e6", + "coordinate": [37.54440, 127.04031], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "8b10e0bb-4387-4997-adaf-9a4a6369f91a", + "coordinate": [37.57774, 126.97258], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "d6397ea4-582e-4228-a98d-4dc541f854bb", + "coordinate": [37.27979, 127.01506], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "3cd9a630-edbd-4c45-bb94-5f8b549f7586", + "coordinate": [36.82055, 127.14046], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "00eefb58-bc78-4058-a898-f54d07e4c467", + "coordinate": [33.27913, 126.30613], + "photoUrl": "https://source.unsplash.com/random/200x300" + }, + { + "journeyId": "26b68488-60fc-4d8f-ac88-ff54c3edd3a5", + "coordinate": [35.14139, 129.10911], + "photoUrl": "https://source.unsplash.com/random/200x300" + } +] diff --git a/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+Body.swift b/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+Body.swift new file mode 100644 index 0000000..4e4d796 --- /dev/null +++ b/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+Body.swift @@ -0,0 +1,26 @@ +// +// JourneyRouter+Body.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 11/26/23. +// + +import MSNetworking + +extension JourneyRouter { + + public var body: HTTPBody? { + switch self { + case let .startJourney(dto): + return HTTPBody(content: dto) + case let .endJourney(dto): + return HTTPBody(content: dto) + case let .recordCoordinate(dto): + return HTTPBody(content: dto) + case let .deleteJourney(dto): + return HTTPBody(content: dto) + default: return nil + } + } + +} diff --git a/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+Header.swift b/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+Header.swift new file mode 100644 index 0000000..664e018 --- /dev/null +++ b/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+Header.swift @@ -0,0 +1,21 @@ +// +// JourneyRouter+Header.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 11/26/23. +// + +import MSNetworking + +extension JourneyRouter { + + public var headers: HTTPHeaders? { + switch self { + default: + return [ + (key: "Content-Type", value: "application/json") + ] + } + } + +} diff --git a/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+Method.swift b/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+Method.swift new file mode 100644 index 0000000..a717151 --- /dev/null +++ b/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+Method.swift @@ -0,0 +1,23 @@ +// +// JourneyRouter+Method.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 11/26/23. +// + +import MSNetworking + +extension JourneyRouter { + + public var method: HTTPMethod { + switch self { + case .startJourney: return .post + case .endJourney: return .post + case .recordCoordinate: return .post + case .checkJourney: return .get + case .loadLastJourney: return .get + case .deleteJourney: return .delete + } + } + +} diff --git a/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+Query.swift b/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+Query.swift new file mode 100644 index 0000000..8c67752 --- /dev/null +++ b/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+Query.swift @@ -0,0 +1,32 @@ +// +// JourneyRouter+Query.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import Foundation + +import MSNetworking + +extension JourneyRouter { + + public var queries: [URLQueryItem]? { + switch self { + case .checkJourney(let userID, let minCoordinate, let maxCoordinate): + return [ + URLQueryItem(name: "userId", value: "\(userID)"), + URLQueryItem(name: "minCoordinate", value: "\(minCoordinate.latitude)"), + URLQueryItem(name: "minCoordinate", value: "\(minCoordinate.longitude)"), + URLQueryItem(name: "maxCoordinate", value: "\(maxCoordinate.latitude)"), + URLQueryItem(name: "maxCoordinate", value: "\(maxCoordinate.longitude)") + ] + case let .loadLastJourney(userID): + return [ + URLQueryItem(name: "userId", value: "\(userID)") + ] + default: return nil + } + } + +} diff --git a/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+URL.swift b/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+URL.swift new file mode 100644 index 0000000..e0e18a2 --- /dev/null +++ b/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+URL.swift @@ -0,0 +1,33 @@ +// +// JourneyRouter+BaseURL.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 11/26/23. +// + +import Foundation + +import MSNetworking + +extension JourneyRouter { + + public var baseURL: String { + guard let urlString = self.fetchBaseURLFromPlist(from: Bundle.module) else { + fatalError("APIInfo.plist ํŒŒ์ผ์„ ์ฝ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + } + + return urlString + "/journey" + } + + public var pathURL: String? { + switch self { + case .startJourney: return "start" + case .endJourney: return "end" + case .recordCoordinate: return "record" + case .checkJourney: return "check" + case .loadLastJourney: return "loadLastData" + case .deleteJourney: return nil + } + } + +} diff --git a/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter.swift b/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter.swift new file mode 100644 index 0000000..683ec90 --- /dev/null +++ b/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter.swift @@ -0,0 +1,27 @@ +// +// JourneyRouter.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 11/26/23. +// + +import Foundation + +import MSNetworking + +public enum JourneyRouter: Router { + + /// ์—ฌ์ • ๊ธฐ๋ก์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค. + case startJourney(dto: StartJourneyRequestDTO) + /// ์—ฌ์ •์˜ ๊ฐ ์ขŒํ‘œ๋ฅผ ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค. + case recordCoordinate(dto: RecordCoordinateRequestDTO) + /// ์—ฌ์ • ๊ธฐ๋ก์„ ์ข…๋ฃŒํ•ฉ๋‹ˆ๋‹ค. + case endJourney(dto: EndJourneyRequestDTO) + /// ํ•ด๋‹น ๋ฒ”์œ„ ๋‚ด ์—ฌ์ •๋“ค์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + case checkJourney(userID: UUID, minCoordinate: CoordinateDTO, maxCoordinate: CoordinateDTO) + /// ์ง„ํ–‰ ์ค‘์ธ ์—ฌ์ •์ด ์žˆ๋Š” ์ง€ ํ™•์ธํ•˜๊ณ , ์žˆ๋‹ค๋ฉด ์—ฌ์ • ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + case loadLastJourney(userID: UUID) + /// ์—ฌ์ • ID์— ๋”ฐ๋ฅธ ์—ฌ์ • ์‚ญ์ œ + case deleteJourney(dto: DeleteJourneyRequestDTO) + +} diff --git a/iOS/MSData/Sources/MSData/Router/Spot/SpotRouter+Body.swift b/iOS/MSData/Sources/MSData/Router/Spot/SpotRouter+Body.swift new file mode 100644 index 0000000..fbb6eb9 --- /dev/null +++ b/iOS/MSData/Sources/MSData/Router/Spot/SpotRouter+Body.swift @@ -0,0 +1,29 @@ +// +// SpotRouter+Body.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.05. +// + +import Foundation +import MSNetworking + +extension SpotRouter { + + public var body: HTTPBody? { + switch self { + case let .upload(dto, id): + let multipartData = + [MultipartData(type: .image, name: "image", content: dto.photoData), + MultipartData(name: "journeyId", content: dto.journeyID ), + MultipartData(name: "timestamp", content: dto.timestamp), + MultipartData(name: "coordinate", content: dto.coordinate)] + return HTTPBody(type: .multipart, + boundary: "Boundary-\(id.uuidString)", + multipartData: multipartData) + default: + return nil + } + } + +} diff --git a/iOS/MSData/Sources/MSData/Router/Spot/SpotRouter+Header.swift b/iOS/MSData/Sources/MSData/Router/Spot/SpotRouter+Header.swift new file mode 100644 index 0000000..bafaa64 --- /dev/null +++ b/iOS/MSData/Sources/MSData/Router/Spot/SpotRouter+Header.swift @@ -0,0 +1,28 @@ +// +// SpotRouter+Header.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.05. +// + +import Foundation + +import MSNetworking + +extension SpotRouter { + + public var headers: HTTPHeaders? { + switch self { + case .upload(_, let id): + return [ + (key: "Content-Type", + value: "multipart/form-data; boundary=Boundary-\(id.uuidString)") + ] + default: + return [ + (key: "Content-Type", value: "application/json") + ] + } + } + +} diff --git a/iOS/MSData/Sources/MSData/Router/Spot/SpotRouter+Method.swift b/iOS/MSData/Sources/MSData/Router/Spot/SpotRouter+Method.swift new file mode 100644 index 0000000..bf7b2b0 --- /dev/null +++ b/iOS/MSData/Sources/MSData/Router/Spot/SpotRouter+Method.swift @@ -0,0 +1,19 @@ +// +// SpotRouter+Method.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.05. +// + +import MSNetworking + +extension SpotRouter { + + public var method: HTTPMethod { + switch self { + case .upload: return .post + case .downloadSpot: return .get + } + } + +} diff --git a/iOS/MSData/Sources/MSData/Router/Spot/SpotRouter+Query.swift b/iOS/MSData/Sources/MSData/Router/Spot/SpotRouter+Query.swift new file mode 100644 index 0000000..48a9c0a --- /dev/null +++ b/iOS/MSData/Sources/MSData/Router/Spot/SpotRouter+Query.swift @@ -0,0 +1,20 @@ +// +// SpotRouter+Query.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import Foundation + +import MSNetworking + +extension SpotRouter { + + public var queries: [URLQueryItem]? { + switch self { + default: return nil + } + } + +} diff --git a/iOS/MSData/Sources/MSData/Router/Spot/SpotRouter+URL.swift b/iOS/MSData/Sources/MSData/Router/Spot/SpotRouter+URL.swift new file mode 100644 index 0000000..42b0e0f --- /dev/null +++ b/iOS/MSData/Sources/MSData/Router/Spot/SpotRouter+URL.swift @@ -0,0 +1,29 @@ +// +// SpotRouter+URL.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.05. +// + +import Foundation + +import MSNetworking + +extension SpotRouter { + + public var baseURL: String { + guard let urlString = self.fetchBaseURLFromPlist(from: Bundle.module) else { + fatalError("APIInfo.plist ํŒŒ์ผ์„ ์ฝ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + } + + return urlString.appending("/spot") + } + + public var pathURL: String? { + switch self { + case .upload: return nil + case .downloadSpot: return "find" + } + } + +} diff --git a/iOS/MSData/Sources/MSData/Router/Spot/SpotRouter.swift b/iOS/MSData/Sources/MSData/Router/Spot/SpotRouter.swift new file mode 100644 index 0000000..54af336 --- /dev/null +++ b/iOS/MSData/Sources/MSData/Router/Spot/SpotRouter.swift @@ -0,0 +1,19 @@ +// +// SpotRouter.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.05. +// + +import Foundation + +import MSNetworking + +public enum SpotRouter: Router { + + /// Spot์„ ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค. + case upload(spot: CreateSpotRequestDTO, id: UUID) + /// Spot์„ ๋ฐ›์•„์˜ต๋‹ˆ๋‹ค. + case downloadSpot + +} diff --git a/iOS/MSData/Sources/MSData/Router/User/UserRouter+Body.swift b/iOS/MSData/Sources/MSData/Router/User/UserRouter+Body.swift new file mode 100644 index 0000000..8d7ca90 --- /dev/null +++ b/iOS/MSData/Sources/MSData/Router/User/UserRouter+Body.swift @@ -0,0 +1,19 @@ +// +// UserRouter+Body.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import MSNetworking + +extension UserRouter { + + public var body: HTTPBody? { + switch self { + case .newUser(let dto): + return HTTPBody(content: dto) + } + } + +} diff --git a/iOS/MSData/Sources/MSData/Router/User/UserRouter+Header.swift b/iOS/MSData/Sources/MSData/Router/User/UserRouter+Header.swift new file mode 100644 index 0000000..d92ee0d --- /dev/null +++ b/iOS/MSData/Sources/MSData/Router/User/UserRouter+Header.swift @@ -0,0 +1,21 @@ +// +// UserRouter+Header.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import MSNetworking + +extension UserRouter { + + public var headers: HTTPHeaders? { + switch self { + default: + return [ + (key: "Content-Type", value: "application/json") + ] + } + } + +} diff --git a/iOS/MSData/Sources/MSData/Router/User/UserRouter+Method.swift b/iOS/MSData/Sources/MSData/Router/User/UserRouter+Method.swift new file mode 100644 index 0000000..cf7c968 --- /dev/null +++ b/iOS/MSData/Sources/MSData/Router/User/UserRouter+Method.swift @@ -0,0 +1,18 @@ +// +// UserRouter+Method.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import MSNetworking + +extension UserRouter { + + public var method: HTTPMethod { + switch self { + case .newUser: return .post + } + } + +} diff --git a/iOS/MSData/Sources/MSData/Router/User/UserRouter+Query.swift b/iOS/MSData/Sources/MSData/Router/User/UserRouter+Query.swift new file mode 100644 index 0000000..0c3bbd3 --- /dev/null +++ b/iOS/MSData/Sources/MSData/Router/User/UserRouter+Query.swift @@ -0,0 +1,20 @@ +// +// UserRouter+Query.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import Foundation + +import MSNetworking + +extension UserRouter { + + public var queries: [URLQueryItem]? { + switch self { + default: return nil + } + } + +} diff --git a/iOS/MSData/Sources/MSData/Router/User/UserRouter+URL.swift b/iOS/MSData/Sources/MSData/Router/User/UserRouter+URL.swift new file mode 100644 index 0000000..f8f4d8b --- /dev/null +++ b/iOS/MSData/Sources/MSData/Router/User/UserRouter+URL.swift @@ -0,0 +1,28 @@ +// +// UserRouter+URL.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import Foundation + +import MSNetworking + +extension UserRouter { + + public var baseURL: String { + guard let urlString = self.fetchBaseURLFromPlist(from: Bundle.module) else { + return "" + } + + return urlString + "/user" + } + + public var pathURL: String? { + switch self { + case .newUser: return nil + } + } + +} diff --git a/iOS/MSData/Sources/MSData/Router/User/UserRouter.swift b/iOS/MSData/Sources/MSData/Router/User/UserRouter.swift new file mode 100644 index 0000000..2276492 --- /dev/null +++ b/iOS/MSData/Sources/MSData/Router/User/UserRouter.swift @@ -0,0 +1,15 @@ +// +// UserRouter.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import MSNetworking + +public enum UserRouter: Router { + + /// ์ฒซ ์‹œ์ž‘ ์‹œ ์œ ์ €๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. + case newUser(dto: UserRequestDTO) + +} diff --git a/iOS/MSData/Tests/MSDataTests/MSDataTests.swift b/iOS/MSData/Tests/MSDataTests/MSDataTests.swift new file mode 100644 index 0000000..18cf9e1 --- /dev/null +++ b/iOS/MSData/Tests/MSDataTests/MSDataTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import MSData + +final class MSDataTests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest + + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + } +} diff --git a/iOS/MSData/Tests/RepositoryTests/PersistableRepositoryTests.swift b/iOS/MSData/Tests/RepositoryTests/PersistableRepositoryTests.swift new file mode 100644 index 0000000..4cdcf5c --- /dev/null +++ b/iOS/MSData/Tests/RepositoryTests/PersistableRepositoryTests.swift @@ -0,0 +1,57 @@ +// +// PersistableRepositoryTests.swift +// MSData +// +// Created by ์ „๋ฏผ๊ฑด on 12/11/23. +// + +import XCTest +@testable import MSData +@testable import MSDomain + +final class PersistableRepositoryTests: XCTestCase { + + // MARK: - Properties + + private let journeyRepository = JourneyRepositoryImplementation() + + // MARK: - Tests + + func test_Spot์ €์žฅ_์„ฑ๊ณต() { + let coordinate = Coordinate(latitude: 10, longitude: 10) + let url = URL(string: "/../")! + + let spot = Spot(coordinate: coordinate, timestamp: .now, photoURL: url) + + XCTAssertTrue(self.journeyRepository.saveToLocal(value: SpotDTO(spot))) + } + + func test_RecordingJourney_ํ•˜์œ„์š”์†Œ๊ฐ€_์•„๋‹Œ_๊ฒƒ๋“ค_์ €์žฅ_์‹คํŒจ() { + XCTAssertFalse(self.journeyRepository.saveToLocal(value: Int())) + } + + func test_RecordingJourney_๋ฐ˜ํ™˜_์„ฑ๊ณต() { + let url = URL(string: "/../")! + + let id = "id" + let startTimestamp = Date.now + let coordinate = Coordinate(latitude: 5, longitude: 5) + let spot = Spot(coordinate: coordinate, timestamp: .now, photoURL: url) + + self.journeyRepository.saveToLocal(value: id) + self.journeyRepository.saveToLocal(value: Date.now) + self.journeyRepository.saveToLocal(value: SpotDTO(spot)) + self.journeyRepository.saveToLocal(value: CoordinateDTO(coordinate)) + + guard let loadedJourney = self.journeyRepository.loadJourneyFromLocal() else { + XCTFail("load ์‹คํŒจ") + return + } + + XCTAssertEqual(loadedJourney.id, id) + XCTAssertEqual(loadedJourney.startTimestamp.description, startTimestamp.description) + XCTAssertEqual(loadedJourney.spots.description, [spot].description) + XCTAssertEqual(loadedJourney.coordinates, [coordinate]) + } + +} diff --git a/iOS/MSData/Tests/RepositoryTests/SongRepositoryTests.swift b/iOS/MSData/Tests/RepositoryTests/SongRepositoryTests.swift new file mode 100644 index 0000000..ca35167 --- /dev/null +++ b/iOS/MSData/Tests/RepositoryTests/SongRepositoryTests.swift @@ -0,0 +1,19 @@ +// +// SongRepositoryTests.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.03. +// + +import XCTest +@testable import MSData + +final class SongRepositoryTests: XCTestCase { + + // MARK: - Properties + + private let songRepository = SongRepositoryImplementation() + + // MARK: - Tests + +} diff --git a/iOS/MSData/Tests/RepositoryTests/SpotRepositoryTests.swift b/iOS/MSData/Tests/RepositoryTests/SpotRepositoryTests.swift new file mode 100644 index 0000000..e37df60 --- /dev/null +++ b/iOS/MSData/Tests/RepositoryTests/SpotRepositoryTests.swift @@ -0,0 +1,19 @@ +// +// SpotRepositoryTests.swift +// MSData +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.04. +// + +import XCTest +@testable import MSData + +final class SpotRepositoryTests: XCTestCase { + + // MARK: - Properties + + private let sut = SpotRepositoryImplementation() + + // MARK: - Tests + +} diff --git a/iOS/MSDomain/.gitignore b/iOS/MSDomain/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/iOS/MSDomain/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/iOS/MSDomain/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/iOS/MSDomain/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/iOS/MSDomain/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/iOS/MSDomain/Package.swift b/iOS/MSDomain/Package.swift new file mode 100644 index 0000000..fe7a1bd --- /dev/null +++ b/iOS/MSDomain/Package.swift @@ -0,0 +1,39 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +// MARK: - Constants + +extension String { + + static let package = "MSDomain" + + var fromRootPath: String { + return "../" + self + } + +} + +private enum Target { + + static let msDomain = "MSDomain" + +} + +// MARK: - Package + +let package = Package( + name: .package, + platforms: [ + .iOS(.v15) + ], + products: [ + .library(name: Target.msDomain, + targets: [Target.msDomain]) + ], + targets: [ + .target(name: Target.msDomain) + ], + swiftLanguageVersions: [.v5] +) diff --git a/iOS/MSDomain/Sources/MSDomain/Model/AlbumCover.swift b/iOS/MSDomain/Sources/MSDomain/Model/AlbumCover.swift new file mode 100644 index 0000000..3a56c64 --- /dev/null +++ b/iOS/MSDomain/Sources/MSDomain/Model/AlbumCover.swift @@ -0,0 +1,35 @@ +// +// Artwork.swift +// MSDomain +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.07. +// + +import Foundation + +public struct AlbumCover { + + // MARK: - Properties + + public let width: Int + public let height: Int + public let url: URL? + public let backgroundColor: String? + + // MARK: - Initializer + + public init(width: Int, + height: Int, + url: URL?, + backgroundColor: String?) { + self.width = width + self.height = height + self.url = url + self.backgroundColor = backgroundColor + } + +} + +// MARK: - Hashable + +extension AlbumCover: Hashable { } diff --git a/iOS/MSDomain/Sources/MSDomain/Model/Coordinate.swift b/iOS/MSDomain/Sources/MSDomain/Model/Coordinate.swift new file mode 100644 index 0000000..be96150 --- /dev/null +++ b/iOS/MSDomain/Sources/MSDomain/Model/Coordinate.swift @@ -0,0 +1,43 @@ +// +// Coordinate.swift +// MSDomain +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.06. +// + +import Foundation + +public struct Coordinate { + + // MARK: - Properties + + public let latitude: Double + public let longitude: Double + + // MARK: - Initializer + + public init(latitude: Double, + longitude: Double) { + self.latitude = latitude + self.longitude = longitude + } + +} + +// MARK: - Hashable + +extension Coordinate: Hashable { } + +// MARK: - String Convertible + +extension Coordinate: CustomStringConvertible { + + public var description: String { + return """ + Coordinate + - latitude: \(self.latitude) + - longitude: \(self.longitude) + """ + } + +} diff --git a/iOS/MSDomain/Sources/MSDomain/Model/Journey.swift b/iOS/MSDomain/Sources/MSDomain/Model/Journey.swift new file mode 100644 index 0000000..06027e4 --- /dev/null +++ b/iOS/MSDomain/Sources/MSDomain/Model/Journey.swift @@ -0,0 +1,67 @@ +// +// Journey.swift +// MSDomain +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.07. +// + +import Foundation + +public struct Journey: Identifiable { + + // MARK: - Properties + + public let id: String + public let title: String + public let date: (start: Date, end: Date) + public let spots: [Spot] + public let coordinates: [Coordinate] + public let music: Music + + // MARK: - Initializer + + public init(id: String, + title: String, + date: (start: Date, end: Date), + spots: [Spot], + coordinates: [Coordinate], + music: Music) { + self.id = id + self.title = title + self.date = date + self.spots = spots + self.coordinates = coordinates + self.music = music + } + +} + +// MARK: - Hashable + +extension Journey: Hashable { + + public static func == (lhs: Journey, rhs: Journey) -> Bool { + return lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } + +} + +// MARK: - String Convertible + +extension Journey: CustomStringConvertible { + + public var description: String { + return """ + Journey + - title: \(self.title) + - date: + - start: \(self.date.start) + - end: \(self.date.end) + """ + } + +} diff --git a/iOS/MSDomain/Sources/MSDomain/Model/Music+MusicKit.swift b/iOS/MSDomain/Sources/MSDomain/Model/Music+MusicKit.swift new file mode 100644 index 0000000..25a3bf1 --- /dev/null +++ b/iOS/MSDomain/Sources/MSDomain/Model/Music+MusicKit.swift @@ -0,0 +1,52 @@ +// +// Music+MusicKit.swift +// SelectSong +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.07. +// + +import Foundation +import MusicKit + +extension Music { + + public init(_ song: Song) { + self.init(id: song.id.rawValue, + title: song.title, + artist: song.artistName, + albumCover: AlbumCover(song.artwork)) + } + +} + +extension AlbumCover { + + public init?(_ artwork: Artwork?, + width: Int = 500, + height: Int = 500) { + guard let artwork else { return nil } + self.init(width: width, + height: height, + url: artwork.url(width: width, height: height), + backgroundColor: artwork.backgroundColor?.hexValue) + } + +} + +import CoreGraphics + +fileprivate extension CGColor { + + var hexValue: String? { + guard let components = self.components else { + return nil + } + + let red = Int(components[0] * 255.0) + let green = Int(components[1] * 255.0) + let blue = Int(components[2] * 255.0) + + return String(format: "#%02X%02X%02X", red, green, blue) + } + +} diff --git a/iOS/MSDomain/Sources/MSDomain/Model/Music.swift b/iOS/MSDomain/Sources/MSDomain/Model/Music.swift new file mode 100644 index 0000000..63d1f76 --- /dev/null +++ b/iOS/MSDomain/Sources/MSDomain/Model/Music.swift @@ -0,0 +1,35 @@ +// +// Song.swift +// MSDomain +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.07. +// + +import Foundation + +public struct Music: Identifiable { + + // MARK: - Properties + + public let id: String + public let title: String + public let artist: String + public let albumCover: AlbumCover? + + // MARK: - Initializer + + public init(id: String, + title: String, + artist: String, + albumCover: AlbumCover?) { + self.id = id + self.title = title + self.artist = artist + self.albumCover = albumCover + } + +} + +// MARK: - Hashable + +extension Music: Hashable { } diff --git a/iOS/MSDomain/Sources/MSDomain/Model/RecordingJourney.swift b/iOS/MSDomain/Sources/MSDomain/Model/RecordingJourney.swift new file mode 100644 index 0000000..167b55e --- /dev/null +++ b/iOS/MSDomain/Sources/MSDomain/Model/RecordingJourney.swift @@ -0,0 +1,45 @@ +// +// RecordingJourney.swift +// MSDomain +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.07. +// + +import Foundation + +public struct RecordingJourney: Identifiable { + + // MARK: - Properties + + public let id: String + public let startTimestamp: Date + public let spots: [Spot] + public let coordinates: [Coordinate] + + // MARK: - Initializer + + public init(id: String, + startTimestamp: Date, + spots: [Spot], + coordinates: [Coordinate]) { + self.id = id + self.startTimestamp = startTimestamp + self.spots = spots + self.coordinates = coordinates + } + +} + +// MARK: - Hashable + +extension RecordingJourney: Hashable { + + public static func == (lhs: RecordingJourney, rhs: RecordingJourney) -> Bool { + return lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } + +} diff --git a/iOS/MSDomain/Sources/MSDomain/Model/Spot.swift b/iOS/MSDomain/Sources/MSDomain/Model/Spot.swift new file mode 100644 index 0000000..93cbb5b --- /dev/null +++ b/iOS/MSDomain/Sources/MSDomain/Model/Spot.swift @@ -0,0 +1,50 @@ +// +// Spot.swift +// MSDomain +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.07. +// + +import Foundation + +public struct Spot { + + // MARK: - Properties + + public let coordinate: Coordinate + public let timestamp: Date + public let photoURL: URL + + // MARK: - Initializer + + public init(coordinate: Coordinate, + timestamp: Date, + photoURL: URL) { + self.coordinate = coordinate + self.timestamp = timestamp + self.photoURL = photoURL + } + +} + +// MARK: - Hashable + +extension Spot: Hashable { } + +// MARK: - String Convertible + +extension Spot: CustomStringConvertible { + + public var description: String { + return """ + Coordinate: + - latitude: \(self.coordinate.latitude) + - longitude: \(self.coordinate.longitude) + PhotoURL + - \(self.photoURL.absoluteString) + Timestamp + - \(self.timestamp) + """ + } + +} diff --git a/iOS/MSFoundation/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/iOS/MSFoundation/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/iOS/MSFoundation/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/iOS/MSFoundation/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/iOS/MSFoundation/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/iOS/MSFoundation/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/iOS/MSFoundation/.swiftpm/xcode/xcshareddata/xcschemes/MSLoggerTests.xcscheme b/iOS/MSFoundation/.swiftpm/xcode/xcshareddata/xcschemes/MSLoggerTests.xcscheme new file mode 100644 index 0000000..652c200 --- /dev/null +++ b/iOS/MSFoundation/.swiftpm/xcode/xcshareddata/xcschemes/MSLoggerTests.xcscheme @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/MSFoundation/.swiftpm/xcode/xcshareddata/xcschemes/MSUserDefaultsTests.xcscheme b/iOS/MSFoundation/.swiftpm/xcode/xcshareddata/xcschemes/MSUserDefaultsTests.xcscheme new file mode 100644 index 0000000..5d159c4 --- /dev/null +++ b/iOS/MSFoundation/.swiftpm/xcode/xcshareddata/xcschemes/MSUserDefaultsTests.xcscheme @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/MSFoundation/Package.swift b/iOS/MSFoundation/Package.swift new file mode 100644 index 0000000..79e8504 --- /dev/null +++ b/iOS/MSFoundation/Package.swift @@ -0,0 +1,62 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +// MARK: - Constants + +private extension String { + + static let package = "MSFoundation" + + var testTarget: String { + return self + "Tests" + } + +} + +private enum Target { + + static let msConstants = "MSConstants" + static let msExtension = "MSExtension" + static let msLogger = "MSLogger" + static let msUserDefaults = "MSUserDefaults" + +} + +// MARK: - Package + +let package = Package( + name: .package, + platforms: [ + .iOS(.v15) + ], + products: [ + .library(name: Target.msConstants, + targets: [Target.msConstants]), + .library(name: Target.msExtension, + targets: [Target.msExtension]), + .library(name: Target.msLogger, + targets: [Target.msLogger]), + .library(name: Target.msUserDefaults, + targets: [Target.msUserDefaults]) + ], + targets: [ + // Codes + .target(name: Target.msConstants), + .target(name: Target.msExtension), + .target(name: Target.msLogger), + .target(name: Target.msUserDefaults), + + // Tests + .testTarget(name: Target.msLogger.testTarget, + dependencies: [ + .target(name: Target.msLogger) + ]), + .testTarget(name: Target.msUserDefaults.testTarget, + dependencies: [ + .target(name: Target.msUserDefaults) + ]) + ], + swiftLanguageVersions: [.v5] +) diff --git a/iOS/MSFoundation/Sources/MSConstants/Constants.swift b/iOS/MSFoundation/Sources/MSConstants/Constants.swift new file mode 100644 index 0000000..3e7a041 --- /dev/null +++ b/iOS/MSFoundation/Sources/MSConstants/Constants.swift @@ -0,0 +1,15 @@ +// +// Constants.swift +// MSFoundation +// +// Created by ์ด์ฐฝ์ค€ on 2023.11.29. +// + +import Foundation + +public enum Constants { + + public static let appName = "MusicSpot" + public static let appBundleIdentifier = "kr.codesquad.boostcamp8.MusicSpot" + +} diff --git a/iOS/MSFoundation/Sources/MSConstants/UserDefaultsKey.swift b/iOS/MSFoundation/Sources/MSConstants/UserDefaultsKey.swift new file mode 100644 index 0000000..2eacaca --- /dev/null +++ b/iOS/MSFoundation/Sources/MSConstants/UserDefaultsKey.swift @@ -0,0 +1,16 @@ +// +// UserDefaultsKey.swift +// MSFoundation +// +// Created by ์œค๋™์ฃผ on 12/4/23. +// + +import Foundation + +public enum UserDefaultsKey { + + public static let isFirstLaunch = "isFirstLaunch" + public static let isRecording = "isRecording" + public static let recordingJourneyID = "recordingJourneyID" + +} diff --git a/iOS/MSFoundation/Sources/MSExtension/String+.swift b/iOS/MSFoundation/Sources/MSExtension/String+.swift new file mode 100644 index 0000000..50e046c --- /dev/null +++ b/iOS/MSFoundation/Sources/MSExtension/String+.swift @@ -0,0 +1,6 @@ +// +// String+.swift +// MSFoundation +// +// Created by ์ด์ฐฝ์ค€ on 11/14/23. +// diff --git a/iOS/MSFoundation/Sources/MSExtension/URL+.swift b/iOS/MSFoundation/Sources/MSExtension/URL+.swift new file mode 100644 index 0000000..9dca2b8 --- /dev/null +++ b/iOS/MSFoundation/Sources/MSExtension/URL+.swift @@ -0,0 +1,21 @@ +// +// URL+.swift +// MSFoundation +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.08. +// + +import Foundation + +public extension URL { + + /// `if #available` ๊ทธ๋งŒ ์“ฐ์ž ํŒจ์• ์• ์“ฐ + func paath(percentEncoded: Bool = true) -> String { + if #available(iOS 16.0, *) { + self.path(percentEncoded: percentEncoded) + } else { + self.path + } + } + +} diff --git a/iOS/MSFoundation/Sources/MSLogger/MSLogCategory.swift b/iOS/MSFoundation/Sources/MSLogger/MSLogCategory.swift new file mode 100644 index 0000000..5caef6c --- /dev/null +++ b/iOS/MSFoundation/Sources/MSLogger/MSLogCategory.swift @@ -0,0 +1,30 @@ +// +// MSLogCategory.swift +// MSFoundation +// +// Created by ์ „๋ฏผ๊ฑด on 11/16/23. +// + +import Foundation + +public enum MSLogCategory: String { + + case uiKit = "UI" + case network + case imageFetcher = "ImageFetcher" + case userDefaults + case keychain = "Keychain" + case fileManager = "FileManager" + case persistable + case music = "MusicKit" + + case home + case navigateMap + case camera + case spot + case selectSong = "SelectSong" + case saveJourney + case journeyList + case rewindJourney + +} diff --git a/iOS/MSFoundation/Sources/MSLogger/MSLogger.swift b/iOS/MSFoundation/Sources/MSLogger/MSLogger.swift new file mode 100644 index 0000000..882939c --- /dev/null +++ b/iOS/MSFoundation/Sources/MSLogger/MSLogger.swift @@ -0,0 +1,20 @@ +// +// MSLogger.swift +// MSFoundation +// +// Created by ์ด์ฐฝ์ค€ on 11/14/23. +// + +import OSLog + +public enum MSLogger { + + public static func make(category: MSLogCategory) -> Logger { + if let subsystem = Bundle.main.bundleIdentifier { + return Logger(subsystem: subsystem, category: category.rawValue) + } else { + return Logger(subsystem: "", category: category.rawValue) + } + } + +} diff --git a/iOS/MSFoundation/Sources/MSUserDefaults/UserDefaultsWrapped.swift b/iOS/MSFoundation/Sources/MSUserDefaults/UserDefaultsWrapped.swift new file mode 100644 index 0000000..2b24f14 --- /dev/null +++ b/iOS/MSFoundation/Sources/MSUserDefaults/UserDefaultsWrapped.swift @@ -0,0 +1,46 @@ +// +// UserDefaultsWrapped.swift +// MSFoundation +// +// Created by ์ด์ฐฝ์ค€ on 11/15/23. +// + +import Foundation + +@propertyWrapper +public struct UserDefaultsWrapped { + + private let key: String + private var defaultValue: T + private let userDefaults: UserDefaults + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + public init(_ key: String, + defaultValue: T, + userDefaults: UserDefaults = .standard) { + self.key = key + self.defaultValue = defaultValue + self.userDefaults = userDefaults + } + + public var wrappedValue: T { + get { self.load(forKey: self.key) ?? self.defaultValue } + set { self.save(newValue) } + } + + private func save(_ newValue: T) { + if let encoded = try? self.encoder.encode(newValue) { + self.userDefaults.setValue(encoded, forKey: self.key) + } + } + + private func load(forKey key: String) -> T? { + guard let savedData = self.userDefaults.object(forKey: key) as? Data, + let loadedObject = try? self.decoder.decode(T.self, from: savedData) else { + return nil + } + return loadedObject + } + +} diff --git a/iOS/MSFoundation/Tests/MSLoggerTests/MSLoggerTests.swift b/iOS/MSFoundation/Tests/MSLoggerTests/MSLoggerTests.swift new file mode 100644 index 0000000..741fcb1 --- /dev/null +++ b/iOS/MSFoundation/Tests/MSLoggerTests/MSLoggerTests.swift @@ -0,0 +1,20 @@ +// +// MSLoggerTests.swift +// MSFoundation +// +// Created by ์ด์ฐฝ์ค€ on 11/14/23. +// + +import XCTest +import MSLogger + +final class MSLoggerTests: XCTestCase { + + func test_Logger๊ฐ์ฒด_์ž˜_์ƒ์„ฑ๋˜๋Š”์ง€_์„ฑ๊ณต() { + // arrange, act + let logger = MSLogger.make(category: .network) + + // assert + XCTAssertNotNil(logger) + } +} diff --git a/iOS/MSFoundation/Tests/MSUserDefaultsTests/FakeCodableData.swift b/iOS/MSFoundation/Tests/MSUserDefaultsTests/FakeCodableData.swift new file mode 100644 index 0000000..de64d54 --- /dev/null +++ b/iOS/MSFoundation/Tests/MSUserDefaultsTests/FakeCodableData.swift @@ -0,0 +1,24 @@ +// +// FakeCodableData.swift +// MSFoundation +// +// Created by ์ด์ฐฝ์ค€ on 11/15/23. +// + +import Foundation + +struct FakeCodableData: Codable, Equatable { + let id: UUID + var name: String + var number: Int + + init(id: UUID = UUID(), name: String, number: Int) { + self.id = id + self.name = name + self.number = number + } + + func isEqual(to data: FakeCodableData) -> Bool { + return name == data.name && number == data.number + } +} diff --git a/iOS/MSFoundation/Tests/MSUserDefaultsTests/MSUserDefaultsTests.swift b/iOS/MSFoundation/Tests/MSUserDefaultsTests/MSUserDefaultsTests.swift new file mode 100644 index 0000000..9931c70 --- /dev/null +++ b/iOS/MSFoundation/Tests/MSUserDefaultsTests/MSUserDefaultsTests.swift @@ -0,0 +1,28 @@ +// +// MSUserDefaultsTests.swift +// MSFoundation +// +// Created by ์ด์ฐฝ์ค€ on 11/14/23. +// + +import XCTest +import MSUserDefaults + +final class MSUserDefaultsTests: XCTestCase { + + @UserDefaultsWrapped("sut", defaultValue: FakeCodableData(name: "Fake", number: 0)) + private var sut: FakeCodableData + + override func setUp() { + UserDefaults.standard.removeObject(forKey: "sut") + } + + func testUserDefaults_Wrapper๋ฅผ์‚ฌ์šฉํ•ด_์ €์žฅ๊ณผ๋กœ๋“œ_์„ฑ๊ณต() { + let fake = FakeCodableData(name: "MusicSpot", number: 231215) + sut = fake + + XCTAssertTrue(sut.isEqual(to: fake), + "UserDefaults๋กœ ์ €์žฅํ•œ ๊ฐ’๊ณผ ๋กœ๋“œํ•œ ๊ฐ’์ด ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.") + } + +} diff --git a/iOS/MSUIKit/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/iOS/MSUIKit/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/iOS/MSUIKit/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/iOS/MSUIKit/Package.swift b/iOS/MSUIKit/Package.swift new file mode 100644 index 0000000..0df0c20 --- /dev/null +++ b/iOS/MSUIKit/Package.swift @@ -0,0 +1,75 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +// MARK: - Constants + +extension String { + + static let package = "MSUIKit" + + var fromRootPath: String { + return "../" + self + } + +} + +private enum Target { + + static let msDesignSystem = "MSDesignSystem" + static let msUIKit = "MSUIKit" + static let combineCocoa = "CombineCocoa" + +} + +private enum Dependency { + + static let msImageFetcher = "MSImageFetcher" + static let msCoreKit = "MSCoreKit" + static let msExtension = "MSExtension" + static let msLogger = "MSLogger" + static let msFoundation = "MSFoundation" + +} + +// MARK: - Package + +let package = Package( + name: .package, + platforms: [ + .iOS(.v15) + ], + products: [ + .library(name: Target.msDesignSystem, + targets: [Target.msDesignSystem]), + .library(name: Target.msUIKit, + targets: [Target.msUIKit]), + .library(name: Target.combineCocoa, + targets: [Target.combineCocoa]) + ], + dependencies: [ + .package(name: Dependency.msCoreKit, + path: Dependency.msCoreKit.fromRootPath), + .package(name: Dependency.msFoundation, + path: Dependency.msFoundation.fromRootPath) + ], + targets: [ + .target(name: Target.msDesignSystem, + resources: [ + .process("../\(Target.msDesignSystem)/Resources") + ]), + .target(name: Target.combineCocoa), + .target(name: Target.msUIKit, + dependencies: [ + .target(name: Target.msDesignSystem), + .product(name: Dependency.msImageFetcher, + package: Dependency.msCoreKit), + .product(name: Dependency.msExtension, + package: Dependency.msFoundation), + .product(name: Dependency.msLogger, + package: Dependency.msFoundation) + ]) + ], + swiftLanguageVersions: [.v5] +) diff --git a/iOS/MSUIKit/Sources/CombineCocoa/UITextField+Combine.swift b/iOS/MSUIKit/Sources/CombineCocoa/UITextField+Combine.swift new file mode 100644 index 0000000..8ab0f78 --- /dev/null +++ b/iOS/MSUIKit/Sources/CombineCocoa/UITextField+Combine.swift @@ -0,0 +1,23 @@ +// +// UITextField+Combine.swift +// MSUIKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.03. +// + +import Combine +import UIKit + +public extension UITextField { + + var textPublisher: AnyPublisher { + let publisher = NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, + object: self) + + return publisher + .compactMap { $0.object as? UITextField } + .map { $0.text ?? "" } + .eraseToAnyPublisher() + } + +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/MSColor.swift b/iOS/MSUIKit/Sources/MSDesignSystem/MSColor.swift new file mode 100644 index 0000000..10d8dfb --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/MSColor.swift @@ -0,0 +1,37 @@ +// +// MSColor.swift +// MSUIKit +// +// Created by ์ด์ฐฝ์ค€ on 11/19/23. +// + +import UIKit + +public enum MSColor: String { + case primaryBackground = "Background Primary" + case secondaryBackground = "Background Secondary" + case primaryButtonBackground = "Button Background Primary" + case secondaryButtonBackground = "Button Background Secondary" + case modalBackground = "Background Modal" + + case primaryTypo = "Typo Primary" + case primaryButtonTypo = "Button Typo Primary" + case secondaryTypo = "Typo Secondary" + case secondaryButtonTypo = "Button Typo Secondary" + + case componentBackground = "Component Background" + case componentTypo = "Component Typo" + + case textFieldBackground = "TextField Background" + case textFieldTypo = "TextField Typo" + + case musicSpot = "MusicSpot" +} + +extension UIColor { + + public static func msColor(_ color: MSColor) -> UIColor { + return UIColor(named: color.rawValue, in: .module, compatibleWith: .current)! + } + +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/MSFont.swift b/iOS/MSUIKit/Sources/MSDesignSystem/MSFont.swift new file mode 100644 index 0000000..3bc0e36 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/MSFont.swift @@ -0,0 +1,73 @@ +// +// MSFont.swift +// MSUIKit +// +// Created by ์ด์ฐฝ์ค€ on 11/19/23. +// + +import UIKit + +public enum MSFont { + case superTitle + case duperTitle + case headerTitle + case subtitle + case buttonTitle + case paragraph + case boldCaption + case caption + + // MARK: - Functions + + private var fontDetails: (fontName: String, size: CGFloat) { + switch self { + case .superTitle: return ("Pretendard-Bold", 34.0) + case .duperTitle: return ("Pretendard-Bold", 28.0) + case .headerTitle: return ("Pretendard-Bold", 22.0) + case .subtitle: return ("Pretendard-Bold", 20.0) + case .buttonTitle: return ("Pretendard-SemiBold", 20.0) + case .paragraph: return ("Pretendard-Regular", 17.0) + case .boldCaption: return ("Pretendard-SemiBold", 13.0) + case .caption: return ("Pretendard-Regular", 13.0) + } + } + + internal func font() -> UIFont? { + let details = self.fontDetails + return UIFont(name: details.fontName, size: details.size) + } + + fileprivate static func registerFont(bundle: Bundle, fontName: String, fontExtension: String) { + guard let fontURL = bundle.url(forResource: fontName, withExtension: fontExtension), + let fontDataProvider = CGDataProvider(url: fontURL as CFURL), + let font = CGFont(fontDataProvider) else { + return + } + + var error: Unmanaged? + CTFontManagerRegisterGraphicsFont(font, &error) + } + + public static func registerFonts() { + [ + "Pretendard-Regular", + "Pretendard-SemiBold", + "Pretendard-Bold" + ].forEach { + registerFont(bundle: .module, fontName: $0, fontExtension: "otf") + } + } + +} + +public extension UIFont { + + static func msFont(_ font: MSFont) -> UIFont? { + if let font = font.font() { + return font + } else { + return nil + } + } + +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/MSIcon.swift b/iOS/MSUIKit/Sources/MSDesignSystem/MSIcon.swift new file mode 100644 index 0000000..2fc0a16 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/MSIcon.swift @@ -0,0 +1,42 @@ +// +// MSIcon.swift +// MSUIKit +// +// Created by ์ด์ฐฝ์ค€ on 11/19/23. +// + +import UIKit + +public enum MSIcon: String { + case check = "Check" + case close = "Close" + + case arrowUp = "Up" + case arrowLeft = "Left" + case arrowDown = "Down" + case arrowRight = "Right" + + case calendar = "Calendar" + case image = "Image" + case location = "Location" + case addLocation = "Location Add" + case lock = "Lock" + case map = "Map" + case message = "Message" + case setting = "Setting" + case userTag = "User Tag" + + case pause = "Pause" + case play = "Play" + case voice = "Voice" + case volumeHigh = "Volume High" + case volumeOff = "Volume Off" +} + +extension UIImage { + + public static func msIcon(_ icon: MSIcon) -> UIImage? { + return UIImage(named: icon.rawValue, in: .module, compatibleWith: .current)?.withRenderingMode(.alwaysTemplate) + } + +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/Fonts/Pretendard-Bold.otf b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/Fonts/Pretendard-Bold.otf new file mode 100644 index 0000000..e6d6ce8 Binary files /dev/null and b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/Fonts/Pretendard-Bold.otf differ diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/Fonts/Pretendard-Regular.otf b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/Fonts/Pretendard-Regular.otf new file mode 100644 index 0000000..858cdd3 Binary files /dev/null and b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/Fonts/Pretendard-Regular.otf differ diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/Fonts/Pretendard-SemiBold.otf b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/Fonts/Pretendard-SemiBold.otf new file mode 100644 index 0000000..fe81db7 Binary files /dev/null and b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/Fonts/Pretendard-SemiBold.otf differ diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Background Modal.colorset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Background Modal.colorset/Contents.json new file mode 100644 index 0000000..fc0088a --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Background Modal.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFE" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.980", + "blue" : "0xFA", + "green" : "0xFA", + "red" : "0xFA" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.980", + "blue" : "0x23", + "green" : "0x23", + "red" : "0x23" + } + }, + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Background Primary.colorset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Background Primary.colorset/Contents.json new file mode 100644 index 0000000..37005c7 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Background Primary.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF6", + "green" : "0xF6", + "red" : "0xF6" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF6", + "green" : "0xF6", + "red" : "0xF6" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x15", + "green" : "0x15", + "red" : "0x15" + } + }, + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Background Secondary.colorset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Background Secondary.colorset/Contents.json new file mode 100644 index 0000000..ca86825 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Background Secondary.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF6", + "green" : "0xF6", + "red" : "0xF6" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.980", + "blue" : "0xFA", + "green" : "0xFA", + "red" : "0xFA" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.980", + "blue" : "0x23", + "green" : "0x23", + "red" : "0x23" + } + }, + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Button Background Primary.colorset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Button Background Primary.colorset/Contents.json new file mode 100644 index 0000000..6b4fcde --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Button Background Primary.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF6", + "green" : "0xF6", + "red" : "0xF6" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x23", + "green" : "0x23", + "red" : "0x23" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF6", + "green" : "0xF6", + "red" : "0xF6" + } + }, + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Button Background Secondary.colorset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Button Background Secondary.colorset/Contents.json new file mode 100644 index 0000000..9da726d --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Button Background Secondary.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF6", + "green" : "0xF6", + "red" : "0xF6" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xEC", + "green" : "0xEC", + "red" : "0xEC" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x35", + "green" : "0x35", + "red" : "0x35" + } + }, + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Button Typo Primary.colorset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Button Typo Primary.colorset/Contents.json new file mode 100644 index 0000000..37005c7 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Button Typo Primary.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF6", + "green" : "0xF6", + "red" : "0xF6" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF6", + "green" : "0xF6", + "red" : "0xF6" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x15", + "green" : "0x15", + "red" : "0x15" + } + }, + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Button Typo Secondary.colorset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Button Typo Secondary.colorset/Contents.json new file mode 100644 index 0000000..048e7a5 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Button Typo Secondary.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF6", + "green" : "0xF6", + "red" : "0xF6" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x15", + "green" : "0x15", + "red" : "0x15" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF7", + "green" : "0xF7", + "red" : "0xF7" + } + }, + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Component Background.colorset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Component Background.colorset/Contents.json new file mode 100644 index 0000000..8daabe6 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Component Background.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFE" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.700", + "blue" : "0xF1", + "green" : "0xED", + "red" : "0xF0" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.700", + "blue" : "0x3C", + "green" : "0x38", + "red" : "0x3E" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Component Typo.colorset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Component Typo.colorset/Contents.json new file mode 100644 index 0000000..03bca55 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Component Typo.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFE" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.600", + "blue" : "0x43", + "green" : "0x3C", + "red" : "0x3C" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.600", + "blue" : "0xF5", + "green" : "0xEB", + "red" : "0xEB" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/MusicSpot.colorset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/MusicSpot.colorset/Contents.json new file mode 100644 index 0000000..a2baa68 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/MusicSpot.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF6", + "green" : "0xF6", + "red" : "0xF6" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xAC", + "green" : "0xD2", + "red" : "0x1C" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x9B", + "green" : "0xBE", + "red" : "0x19" + } + }, + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/TextField Background.colorset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/TextField Background.colorset/Contents.json new file mode 100644 index 0000000..86ed0b0 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/TextField Background.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFE" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.700", + "blue" : "0xF1", + "green" : "0xED", + "red" : "0xF0" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.700", + "blue" : "0x3C", + "green" : "0x38", + "red" : "0x3E" + } + }, + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/TextField Typo.colorset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/TextField Typo.colorset/Contents.json new file mode 100644 index 0000000..269a60c --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/TextField Typo.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFE" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.600", + "blue" : "0x43", + "green" : "0x3C", + "red" : "0x3C" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.600", + "blue" : "0xF5", + "green" : "0xEB", + "red" : "0xEB" + } + }, + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Typo Primary.colorset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Typo Primary.colorset/Contents.json new file mode 100644 index 0000000..aac7649 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Typo Primary.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF6", + "green" : "0xF6", + "red" : "0xF6" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x15", + "green" : "0x15", + "red" : "0x15" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFB", + "green" : "0xF8", + "red" : "0xFA" + } + }, + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Typo Secondary.colorset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Typo Secondary.colorset/Contents.json new file mode 100644 index 0000000..58656a2 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSColor.xcassets/Typo Secondary.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF6", + "green" : "0xF6", + "red" : "0xF6" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x9E", + "green" : "0x9E", + "red" : "0x9E" + } + }, + "idiom" : "iphone" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x9E", + "green" : "0x9E", + "red" : "0x9E" + } + }, + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Calendar.imageset/Calendar.pdf b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Calendar.imageset/Calendar.pdf new file mode 100644 index 0000000..8830486 Binary files /dev/null and b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Calendar.imageset/Calendar.pdf differ diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Calendar.imageset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Calendar.imageset/Contents.json new file mode 100644 index 0000000..b70f44b --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Calendar.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Calendar.pdf", + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Check.imageset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Check.imageset/Contents.json new file mode 100644 index 0000000..8e467fb --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Check.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Tick.pdf", + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Check.imageset/Tick.pdf b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Check.imageset/Tick.pdf new file mode 100644 index 0000000..737c935 Binary files /dev/null and b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Check.imageset/Tick.pdf differ diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Close.imageset/Close.pdf b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Close.imageset/Close.pdf new file mode 100644 index 0000000..9a272fd Binary files /dev/null and b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Close.imageset/Close.pdf differ diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Close.imageset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Close.imageset/Contents.json new file mode 100644 index 0000000..f37efb1 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Close.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Close.pdf", + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Down.imageset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Down.imageset/Contents.json new file mode 100644 index 0000000..af5a0b0 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Down.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Down 2.pdf", + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Down.imageset/Down 2.pdf b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Down.imageset/Down 2.pdf new file mode 100644 index 0000000..49cd5c9 Binary files /dev/null and b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Down.imageset/Down 2.pdf differ diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Image.imageset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Image.imageset/Contents.json new file mode 100644 index 0000000..49f937a --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Image.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Image 2.pdf", + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Image.imageset/Image 2.pdf b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Image.imageset/Image 2.pdf new file mode 100644 index 0000000..1849543 Binary files /dev/null and b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Image.imageset/Image 2.pdf differ diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Left.imageset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Left.imageset/Contents.json new file mode 100644 index 0000000..27a55a1 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Left.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Left 2.pdf", + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Left.imageset/Left 2.pdf b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Left.imageset/Left 2.pdf new file mode 100644 index 0000000..76f3470 Binary files /dev/null and b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Left.imageset/Left 2.pdf differ diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Location Add.imageset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Location Add.imageset/Contents.json new file mode 100644 index 0000000..63ce9c3 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Location Add.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Location Add.pdf", + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Location Add.imageset/Location Add.pdf b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Location Add.imageset/Location Add.pdf new file mode 100644 index 0000000..379d9d9 Binary files /dev/null and b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Location Add.imageset/Location Add.pdf differ diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Location.imageset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Location.imageset/Contents.json new file mode 100644 index 0000000..f66a3bf --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Location.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Location.pdf", + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Location.imageset/Location.pdf b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Location.imageset/Location.pdf new file mode 100644 index 0000000..40621ce Binary files /dev/null and b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Location.imageset/Location.pdf differ diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Lock.imageset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Lock.imageset/Contents.json new file mode 100644 index 0000000..b6835c4 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Lock.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Lock 2.pdf", + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Lock.imageset/Lock 2.pdf b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Lock.imageset/Lock 2.pdf new file mode 100644 index 0000000..33b860a Binary files /dev/null and b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Lock.imageset/Lock 2.pdf differ diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Map.imageset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Map.imageset/Contents.json new file mode 100644 index 0000000..eeb84de --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Map.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Map.pdf", + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Map.imageset/Map.pdf b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Map.imageset/Map.pdf new file mode 100644 index 0000000..2456c29 Binary files /dev/null and b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Map.imageset/Map.pdf differ diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Message.imageset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Message.imageset/Contents.json new file mode 100644 index 0000000..d72df9e --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Message.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Message 15.pdf", + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Message.imageset/Message 15.pdf b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Message.imageset/Message 15.pdf new file mode 100644 index 0000000..e42fd45 Binary files /dev/null and b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Message.imageset/Message 15.pdf differ diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Pause.imageset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Pause.imageset/Contents.json new file mode 100644 index 0000000..bc8c287 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Pause.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Pause.pdf", + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Pause.imageset/Pause.pdf b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Pause.imageset/Pause.pdf new file mode 100644 index 0000000..0fc0dd0 Binary files /dev/null and b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Pause.imageset/Pause.pdf differ diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Play.imageset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Play.imageset/Contents.json new file mode 100644 index 0000000..994eac5 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Play.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Play.pdf", + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Play.imageset/Play.pdf b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Play.imageset/Play.pdf new file mode 100644 index 0000000..0a2c8a2 Binary files /dev/null and b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Play.imageset/Play.pdf differ diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Right.imageset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Right.imageset/Contents.json new file mode 100644 index 0000000..a96cd3c --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Right.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Right 2.pdf", + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Right.imageset/Right 2.pdf b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Right.imageset/Right 2.pdf new file mode 100644 index 0000000..9eaf133 Binary files /dev/null and b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Right.imageset/Right 2.pdf differ diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Setting.imageset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Setting.imageset/Contents.json new file mode 100644 index 0000000..855d6fb --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Setting.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Setting.pdf", + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Setting.imageset/Setting.pdf b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Setting.imageset/Setting.pdf new file mode 100644 index 0000000..122a73a Binary files /dev/null and b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Setting.imageset/Setting.pdf differ diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Up.imageset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Up.imageset/Contents.json new file mode 100644 index 0000000..45d843a --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Up.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Up 3.pdf", + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Up.imageset/Up 3.pdf b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Up.imageset/Up 3.pdf new file mode 100644 index 0000000..65d89dc Binary files /dev/null and b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Up.imageset/Up 3.pdf differ diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/User Tag.imageset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/User Tag.imageset/Contents.json new file mode 100644 index 0000000..edf9901 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/User Tag.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "User Tag 2.pdf", + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/User Tag.imageset/User Tag 2.pdf b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/User Tag.imageset/User Tag 2.pdf new file mode 100644 index 0000000..b5c3491 Binary files /dev/null and b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/User Tag.imageset/User Tag 2.pdf differ diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Voice.imageset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Voice.imageset/Contents.json new file mode 100644 index 0000000..95f462f --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Voice.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Voice.pdf", + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Voice.imageset/Voice.pdf b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Voice.imageset/Voice.pdf new file mode 100644 index 0000000..b7f434f Binary files /dev/null and b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Voice.imageset/Voice.pdf differ diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Volume High.imageset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Volume High.imageset/Contents.json new file mode 100644 index 0000000..8eb05ae --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Volume High.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Volume High.pdf", + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Volume High.imageset/Volume High.pdf b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Volume High.imageset/Volume High.pdf new file mode 100644 index 0000000..696c6fa Binary files /dev/null and b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Volume High.imageset/Volume High.pdf differ diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Volume Off.imageset/Contents.json b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Volume Off.imageset/Contents.json new file mode 100644 index 0000000..1ed65fa --- /dev/null +++ b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Volume Off.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Volume Off.pdf", + "idiom" : "iphone" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Volume Off.imageset/Volume Off.pdf b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Volume Off.imageset/Volume Off.pdf new file mode 100644 index 0000000..b3aee0b Binary files /dev/null and b/iOS/MSUIKit/Sources/MSDesignSystem/Resources/MSIcon.xcassets/Volume Off.imageset/Volume Off.pdf differ diff --git a/iOS/MSUIKit/Sources/MSUIKit/BaseViewController.swift b/iOS/MSUIKit/Sources/MSUIKit/BaseViewController.swift new file mode 100644 index 0000000..aabfba3 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSUIKit/BaseViewController.swift @@ -0,0 +1,37 @@ +// +// BaseViewController.swift +// MSUIKit +// +// Created by ์ด์ฐฝ์ค€ on 11/23/23. +// + +import UIKit + +import MSDesignSystem + +open class BaseViewController: UIViewController { + + open override func viewDidLoad() { + super.viewDidLoad() + self.configureSafeArea() + self.configureStyle() + self.configureLayout() + } + + /// CornerRadius, ์ƒ‰์ƒ ๋“ฑ์˜ ๋ณ€๊ฒฝ์„ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค. + open func configureStyle() { + self.view.backgroundColor = .msColor(.primaryBackground) + } + + /// addSubView, Auto Layout ๋“ฑ์˜ ๋ ˆ์ด์•„์›ƒ ์„ค์ •์„ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค. + open func configureLayout() { } + + private func configureSafeArea() { + let safeAreaInsets = UIEdgeInsets(top: 24.0, + left: 16.0, + bottom: 24.0, + right: 16.0) + self.additionalSafeAreaInsets = safeAreaInsets + } + +} diff --git a/iOS/MSUIKit/Sources/MSUIKit/Cells/EmptyCell.swift b/iOS/MSUIKit/Sources/MSUIKit/Cells/EmptyCell.swift new file mode 100644 index 0000000..d15b504 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSUIKit/Cells/EmptyCell.swift @@ -0,0 +1,29 @@ +// +// EmptyCell.swift +// MSUIKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.09. +// + +import UIKit + +public final class EmptyCell: UICollectionViewCell { + + // MARK: - Initializer + + public override init(frame: CGRect) { + super.init(frame: frame) + self.configureStyles() + } + + public required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - UI Configuration + + private func configureStyles() { + self.backgroundColor = .clear + } + +} diff --git a/iOS/MSUIKit/Sources/MSUIKit/Cells/JourneyCell/JourneyCell.swift b/iOS/MSUIKit/Sources/MSUIKit/Cells/JourneyCell/JourneyCell.swift new file mode 100644 index 0000000..73d2cd3 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSUIKit/Cells/JourneyCell/JourneyCell.swift @@ -0,0 +1,172 @@ +// +// JourneyCell.swift +// MSUIKit +// +// Created by ์ด์ฐฝ์ค€ on 11/24/23. +// + +import UIKit + +import MSDesignSystem +import MSExtension +import MSImageFetcher + +public final class JourneyCell: UICollectionViewCell { + + // MARK: - Constants + + public static let estimatedHeight: CGFloat = 268.0 + + private enum Metric { + static let cornerRadius: CGFloat = 12.0 + static let spacing: CGFloat = 5.0 + static let verticalInset: CGFloat = 20.0 + static let horizontalInset: CGFloat = 16.0 + } + + // MARK: - UI Components + + private let infoView = JourneyInfoView() + + private let scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.showsVerticalScrollIndicator = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.alwaysBounceHorizontal = true + return scrollView + }() + + let spotImageStack: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = Metric.spacing + return stackView + }() + + // MARK: - Initializer + + public override init(frame: CGRect) { + super.init(frame: frame) + self.configureStyles() + self.configureLayout() + } + + public required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + public override func prepareForReuse() { + self.spotImageStack.arrangedSubviews.forEach { + $0.removeFromSuperview() + } + } + + // MARK: - Functions + + public func update(with model: JourneyCellModel) { + self.infoView.update(location: model.location, + date: model.date, + title: model.song?.title, + artist: model.song?.artist) + } + + public func updateImages(with photoURLs: [URL], for indexPath: IndexPath) { + self.addImageView(count: photoURLs.count) + + photoURLs.enumerated().forEach { index, photoURL in + let photoIndexPath = IndexPath(item: index, section: indexPath.item) + self.updateImage(with: photoURL, at: photoIndexPath) + } + } + + @MainActor + public func addImageView(count: Int) { + guard count != .zero else { return } + + (1...count).forEach { _ in + let imageView = SpotPhotoImageView() + self.spotImageStack.addArrangedSubview(imageView) + } + } + + public func updateImage(with imageURL: URL, at indexPath: IndexPath) { + guard self.spotImageStack.arrangedSubviews.count > indexPath.item else { + return + } + guard let photoView = self.spotImageStack.arrangedSubviews[indexPath.item] as? SpotPhotoImageView else { + return + } + + photoView.imageView.ms.setImage(with: imageURL, forKey: imageURL.paath()) + } + +} + +// MARK: - UI Configuration + +private extension JourneyCell { + + func configureStyles() { + self.backgroundColor = .msColor(.componentBackground) + self.layer.cornerRadius = Metric.cornerRadius + self.clipsToBounds = true + } + + func configureLayout() { + self.addSubview(self.infoView) + self.infoView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.infoView.topAnchor.constraint(equalTo: self.topAnchor, + constant: Metric.verticalInset), + self.infoView.leadingAnchor.constraint(equalTo: self.leadingAnchor, + constant: Metric.horizontalInset), + self.infoView.trailingAnchor.constraint(equalTo: self.trailingAnchor, + constant: -Metric.horizontalInset) + ]) + + self.addSubview(self.scrollView) + self.scrollView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.scrollView.topAnchor.constraint(equalTo: self.infoView.bottomAnchor, + constant: Metric.spacing), + self.scrollView.leadingAnchor.constraint(equalTo: self.leadingAnchor, + constant: Metric.horizontalInset), + self.scrollView.trailingAnchor.constraint(equalTo: self.trailingAnchor, + constant: -Metric.horizontalInset), + self.scrollView.bottomAnchor.constraint(equalTo: self.bottomAnchor, + constant: -Metric.verticalInset) + ]) + + self.scrollView.addSubview(self.spotImageStack) + self.spotImageStack.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.spotImageStack.topAnchor.constraint(equalTo: self.scrollView.topAnchor), + self.spotImageStack.leadingAnchor.constraint(equalTo: self.scrollView.leadingAnchor), + self.spotImageStack.bottomAnchor.constraint(equalTo: self.scrollView.bottomAnchor), + self.spotImageStack.trailingAnchor.constraint(equalTo: self.scrollView.trailingAnchor), + self.spotImageStack.heightAnchor.constraint(equalTo: self.scrollView.heightAnchor) + ]) + } + +} + +// MARK: - Preview + +@available(iOS 17, *) +#Preview(traits: .fixedLayout(width: 373.0, height: 268.0)) { + MSFont.registerFonts() + + let cell = JourneyCell() + NSLayoutConstraint.activate([ + cell.widthAnchor.constraint(equalToConstant: 373.0), + cell.heightAnchor.constraint(equalToConstant: 268.0) + ]) + + (1...10).forEach { _ in + let imageView = SpotPhotoImageView() + imageView.backgroundColor = .systemBlue + cell.spotImageStack.addArrangedSubview(imageView) + } + + return cell +} diff --git a/iOS/MSUIKit/Sources/MSUIKit/Cells/JourneyCell/JourneyCellModel.swift b/iOS/MSUIKit/Sources/MSUIKit/Cells/JourneyCell/JourneyCellModel.swift new file mode 100644 index 0000000..0bf86d5 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSUIKit/Cells/JourneyCell/JourneyCellModel.swift @@ -0,0 +1,55 @@ +// +// JourneyCellModel.swift +// JourneyList +// +// Created by ์ด์ฐฝ์ค€ on 11/23/23. +// + +import Foundation + +public struct JourneyCellModel: Hashable { + + public struct Song { + let artist: String + let title: String + } + + // MARK: - Properties + + let id: UUID + let location: String + let date: Date + let song: Song? + + // MARK: - Initializer + + public init(id: UUID = UUID(), + location: String, + date: Date, + songTitle: String?, + songArtist: String?) { + self.id = id + self.location = location + self.date = date + if let songTitle, let songArtist { + self.song = Song(artist: songArtist, title: songTitle) + } else { + self.song = nil + } + } + +} + +// MARK: - Hashable + +extension JourneyCellModel { + + public static func == (lhs: JourneyCellModel, rhs: JourneyCellModel) -> Bool { + return lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } + +} diff --git a/iOS/MSUIKit/Sources/MSUIKit/Cells/JourneyCell/JourneyInfoView.swift b/iOS/MSUIKit/Sources/MSUIKit/Cells/JourneyCell/JourneyInfoView.swift new file mode 100644 index 0000000..5b38918 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSUIKit/Cells/JourneyCell/JourneyInfoView.swift @@ -0,0 +1,138 @@ +// +// JourneyInfoView.swift +// MSUIKit +// +// Created by ์ด์ฐฝ์ค€ on 11/24/23. +// + +import UIKit + +import MSDesignSystem + +final class JourneyInfoView: UIView { + + // MARK: - Constants + + private enum Metric { + static let contentSpacing: CGFloat = 5.0 + static let labelStackSpacing: CGFloat = 4.0 + static let subLabelStackSpacing: CGFloat = 8.0 + } + + // MARK: - UI Components + + private let contentStack: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = Metric.contentSpacing + return stackView + }() + + private let titleLabelStack: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = Metric.labelStackSpacing + stackView.alignment = .leading + return stackView + }() + + private let subLabelStack: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = Metric.subLabelStackSpacing + return stackView + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.font = .msFont(.subtitle) + label.textColor = .msColor(.primaryTypo) + label.setContentHuggingPriority(.defaultHigh, for: .vertical) + label.text = "์—ฌ์ • ์œ„์น˜" + return label + }() + + private let dateLabel: UILabel = { + let label = UILabel() + label.font = .msFont(.caption) + label.textColor = .msColor(.secondaryTypo) + label.setContentHuggingPriority(.defaultHigh, for: .vertical) + label.text = "2023. 01. 01" + return label + }() + + private let w3wLabel: UILabel = { + let label = UILabel() + label.font = .msFont(.caption) + label.textColor = .msColor(.secondaryTypo) + label.text = "ํ•˜๋Š˜.์‚ฌ๋‹ค.๋น„์‹ผ" + return label + }() + + private let musicInfoView = MusicInfoView() + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: frame) + self.configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - Functions + + func update(location: String, + date: Date, + w3w: String = "", + title: String?, + artist: String?) { + self.titleLabel.text = location + self.dateLabel.text = date.formatted(date: .abbreviated, time: .omitted) + self.w3wLabel.text = w3w + if let title, let artist { + self.musicInfoView.update(artist: artist, title: title) + self.musicInfoView.isHidden = false + } else { + self.musicInfoView.isHidden = true + } + } + +} + +// MARK: - UI Configuration + +private extension JourneyInfoView { + + func configureLayout() { + self.addSubview(self.contentStack) + self.contentStack.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.contentStack.topAnchor.constraint(equalTo: self.topAnchor), + self.contentStack.leadingAnchor.constraint(equalTo: self.leadingAnchor), + self.contentStack.trailingAnchor.constraint(equalTo: self.trailingAnchor), + self.contentStack.bottomAnchor.constraint(equalTo: self.bottomAnchor) + ]) + + self.contentStack.addArrangedSubview(self.titleLabelStack) + self.contentStack.addArrangedSubview(self.musicInfoView) + + self.titleLabelStack.addArrangedSubview(self.titleLabel) + self.titleLabelStack.addArrangedSubview(self.subLabelStack) + + self.subLabelStack.addArrangedSubview(self.dateLabel) + self.subLabelStack.addArrangedSubview(self.w3wLabel) + } + +} + +// MARK: - Preview + +@available(iOS 17.0, *) +#Preview(traits: .fixedLayout(width: 341.0, height: 73.0)) { + MSFont.registerFonts() + let header = JourneyInfoView() + return header +} diff --git a/iOS/MSUIKit/Sources/MSUIKit/Cells/JourneyCell/MusicInfoView.swift b/iOS/MSUIKit/Sources/MSUIKit/Cells/JourneyCell/MusicInfoView.swift new file mode 100644 index 0000000..eddccc9 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSUIKit/Cells/JourneyCell/MusicInfoView.swift @@ -0,0 +1,131 @@ +// +// MusicInfoView.swift +// JourneyList +// +// Created by ์ด์ฐฝ์ค€ on 11/23/23. +// + +import UIKit + +final class MusicInfoView: UIView { + + // MARK: - Constants + + private enum Metric { + static let spacing: CGFloat = 8.0 + static let iconSize: CGFloat = 24.0 + } + + // MARK: - UI Components + + private let stackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = Metric.spacing + return stackView + }() + + private let musicInfoStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + return stackView + }() + + private let iconImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = .msIcon(.voice) + imageView.tintColor = .msColor(.primaryTypo) + return imageView + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.font = .msFont(.boldCaption) + label.textColor = .msColor(.primaryTypo) + label.text = "Title" + return label + }() + + private let dividerLabel: UILabel = { + let label = UILabel() + label.font = .msFont(.caption) + label.textColor = .msColor(.primaryTypo) + label.text = "ใƒป" + return label + }() + + private let artistLabel: UILabel = { + let label = UILabel() + label.font = .msFont(.caption) + label.textColor = .msColor(.primaryTypo) + label.text = "Artist" + return label + }() + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: frame) + self.configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - Functions + + func update(artist: String?, title: String?) { + self.artistLabel.text = artist + self.titleLabel.text = title + } + +} + +// MARK: - UI Configuration + +private extension MusicInfoView { + + func configureLayout() { + self.addSubview(self.stackView) + self.stackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.stackView.topAnchor.constraint(equalTo: self.topAnchor), + self.stackView.trailingAnchor.constraint(equalTo: self.trailingAnchor), + self.stackView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + self.stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor) + ]) + + [ + Spacer(.horizontal), + self.iconImageView, + self.musicInfoStackView + ].forEach { + self.stackView.addArrangedSubview($0) + } + self.iconImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.iconImageView.widthAnchor.constraint(equalToConstant: Metric.iconSize), + self.iconImageView.heightAnchor.constraint(equalToConstant: Metric.iconSize) + ]) + + [ + self.titleLabel, + self.dividerLabel, + self.artistLabel + ].forEach { + self.musicInfoStackView.addArrangedSubview($0) + } + } + +} + +// MARK: - Preview + +import MSDesignSystem +@available(iOS 17.0, *) +#Preview(traits: .fixedLayout(width: 341.0, height: 24.0)) { + MSFont.registerFonts() + let musicInfoView = MusicInfoView() + return musicInfoView +} diff --git a/iOS/MSUIKit/Sources/MSUIKit/Cells/JourneyCell/SpotPhotoImageView.swift b/iOS/MSUIKit/Sources/MSUIKit/Cells/JourneyCell/SpotPhotoImageView.swift new file mode 100644 index 0000000..c14d39e --- /dev/null +++ b/iOS/MSUIKit/Sources/MSUIKit/Cells/JourneyCell/SpotPhotoImageView.swift @@ -0,0 +1,86 @@ +// +// SpotPhotoImageView.swift +// JourneyList +// +// Created by ์ด์ฐฝ์ค€ on 11/23/23. +// + +import UIKit + +import MSDesignSystem + +final class SpotPhotoImageView: UIView { + + // MARK: - Constants + + private enum Metric { + static let width: CGFloat = 120.0 + static let height: CGFloat = 150.0 + static let cornerRadius: CGFloat = 5.0 + } + + // MARK: - UI Components + + private(set) var imageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + return imageView + }() + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: frame) + self.configureStyle() + self.configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - Functions + + func update(with imageData: Data) { + self.imageView.image = UIImage(data: imageData) + } + +} + +// MARK: - UI Configuration + +private extension SpotPhotoImageView { + + func configureStyle() { + self.backgroundColor = .msColor(.secondaryBackground) + self.layer.cornerRadius = Metric.cornerRadius + self.clipsToBounds = true + self.isUserInteractionEnabled = true + } + + func configureLayout() { + self.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.widthAnchor.constraint(equalToConstant: Metric.width), + self.heightAnchor.constraint(equalToConstant: Metric.height) + ]) + + self.addSubview(self.imageView) + self.imageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.imageView.topAnchor.constraint(equalTo: self.topAnchor), + self.imageView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + self.imageView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + self.imageView.trailingAnchor.constraint(equalTo: self.trailingAnchor) + ]) + } + +} + +// MARK: - Preview + +@available(iOS 17.0, *) +#Preview { + let cell = SpotPhotoImageView() + return cell +} diff --git a/iOS/MSUIKit/Sources/MSUIKit/Cells/SongListCell/SongListCell.swift b/iOS/MSUIKit/Sources/MSUIKit/Cells/SongListCell/SongListCell.swift new file mode 100644 index 0000000..0b9e266 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSUIKit/Cells/SongListCell/SongListCell.swift @@ -0,0 +1,170 @@ +// +// SongListCell.swift +// MSUIKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.03. +// + +import UIKit + +import MSDesignSystem +import MSExtension +import MSImageFetcher + +public final class SongListCell: UICollectionViewCell { + + // MARK: - Constants + + public static let estimatedHeight: CGFloat = 68.0 + + private enum Metric { + + static let horizontalInset: CGFloat = 4.0 + static let horizontalSpacing: CGFloat = 12.0 + static let albumArtImageViewSize: CGFloat = 52.0 + static let albumArtImageViewCornerRadius: CGFloat = 5.0 + static let songInfoStackSpacing: CGFloat = 4.0 + static let rightIconImageViewSize: CGFloat = 24.0 + + } + + // MARK: - UI Components + + private let albumArtImageView: UIImageView = { + let imageView = UIImageView() + imageView.layer.cornerRadius = Metric.albumArtImageViewCornerRadius + imageView.clipsToBounds = true + imageView.backgroundColor = .msColor(.musicSpot) + return imageView + }() + + private let songInfoStack: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = Metric.songInfoStackSpacing + return stackView + }() + + private let songTitleLabel: UILabel = { + let label = UILabel() + label.font = .msFont(.paragraph) + label.textColor = .msColor(.primaryTypo) + label.text = "Title" + return label + }() + + private let artistLabel: UILabel = { + let label = UILabel() + label.font = .msFont(.caption) + label.textColor = .msColor(.secondaryTypo) + label.text = "Artist" + return label + }() + + private let rightIconImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = .msIcon(.arrowRight) + imageView.tintColor = .msColor(.primaryTypo) + return imageView + }() + + // MARK: - Initializer + + public override init(frame: CGRect) { + super.init(frame: frame) + self.configureStyles() + self.configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - Life Cycle + + public override func prepareForReuse() { + self.albumArtImageView.image = nil + } + + // MARK: - Functions + + public func update(with cellModel: SongListCellModel) { + self.songTitleLabel.text = cellModel.title + self.artistLabel.text = cellModel.artist + + guard let albumArtURL = cellModel.albumArtURL else { return } + self.albumArtImageView.ms.setImage(with: albumArtURL, forKey: albumArtURL.paath()) + } + +} + +// MARK: - UI Configuration + +private extension SongListCell { + + func configureStyles() { + self.backgroundColor = .msColor(.primaryBackground) + } + + func configureLayout() { + [ + self.albumArtImageView, + self.songInfoStack, + self.rightIconImageView + ].forEach { + self.addSubview($0) + } + + self.albumArtImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.albumArtImageView.widthAnchor.constraint(equalToConstant: Metric.albumArtImageViewSize), + self.albumArtImageView.heightAnchor.constraint(equalToConstant: Metric.albumArtImageViewSize), + self.albumArtImageView.centerYAnchor.constraint(equalTo: self.centerYAnchor), + self.albumArtImageView.leadingAnchor.constraint(equalTo: self.leadingAnchor, + constant: Metric.horizontalInset) + ]) + + self.songInfoStack.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.songInfoStack.leadingAnchor.constraint(equalTo: self.albumArtImageView.trailingAnchor, + constant: Metric.horizontalSpacing), + self.songInfoStack.centerYAnchor.constraint(equalTo: self.centerYAnchor) + ]) + + self.rightIconImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.rightIconImageView.widthAnchor.constraint(equalToConstant: Metric.rightIconImageViewSize), + self.rightIconImageView.heightAnchor.constraint(equalToConstant: Metric.rightIconImageViewSize), + self.rightIconImageView.leadingAnchor.constraint(equalTo: self.songInfoStack.trailingAnchor, + constant: Metric.horizontalSpacing), + self.rightIconImageView.trailingAnchor.constraint(equalTo: self.trailingAnchor, + constant: Metric.horizontalInset), + self.rightIconImageView.centerYAnchor.constraint(equalTo: self.centerYAnchor) + ]) + + [ + self.songTitleLabel, + self.artistLabel + ].forEach { + self.songInfoStack.addArrangedSubview($0) + } + } + +} + +// MARK: - Preview + +#if DEBUG +@available(iOS 17, *) +#Preview(traits: .fixedLayout(width: 345.0, height: 68.0)) { + MSFont.registerFonts() + + let cell = SongListCell() + NSLayoutConstraint.activate([ + cell.widthAnchor.constraint(equalToConstant: 345.0), + cell.heightAnchor.constraint(equalToConstant: 68.0) + ]) + + return cell +} +#endif diff --git a/iOS/MSUIKit/Sources/MSUIKit/Cells/SongListCell/SongListCellModel.swift b/iOS/MSUIKit/Sources/MSUIKit/Cells/SongListCell/SongListCellModel.swift new file mode 100644 index 0000000..509b4ab --- /dev/null +++ b/iOS/MSUIKit/Sources/MSUIKit/Cells/SongListCell/SongListCellModel.swift @@ -0,0 +1,27 @@ +// +// SongListCellModel.swift +// MSUIKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.03. +// + +import Foundation + +public struct SongListCellModel: Identifiable { + + public let id: String + let title: String + let artist: String + let albumArtURL: URL? + + public init(id: String, + title: String, + artist: String, + albumArtURL: URL?) { + self.id = id + self.title = title + self.artist = artist + self.albumArtURL = albumArtURL + } + +} diff --git a/iOS/MSUIKit/Sources/MSUIKit/Extension/NSCollectionLayoutGroup+.swift b/iOS/MSUIKit/Sources/MSUIKit/Extension/NSCollectionLayoutGroup+.swift new file mode 100644 index 0000000..b8bab82 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSUIKit/Extension/NSCollectionLayoutGroup+.swift @@ -0,0 +1,26 @@ +// +// NSCollectionLayoutGroup+.swift +// MSUIKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.08. +// + +import UIKit + +extension NSCollectionLayoutGroup { + + public static func horizontal(layoutSize: NSCollectionLayoutSize, + item: NSCollectionLayoutItem, + count: Int) -> NSCollectionLayoutGroup { + if #available(iOS 16.0, *) { + return NSCollectionLayoutGroup.horizontal(layoutSize: layoutSize, + repeatingSubitem: item, + count: count) + } else { + return NSCollectionLayoutGroup.horizontal(layoutSize: layoutSize, + subitem: item, + count: count) + } + } + +} diff --git a/iOS/MSUIKit/Sources/MSUIKit/Extension/UIColor+HEX.swift b/iOS/MSUIKit/Sources/MSUIKit/Extension/UIColor+HEX.swift new file mode 100644 index 0000000..f7aee1b --- /dev/null +++ b/iOS/MSUIKit/Sources/MSUIKit/Extension/UIColor+HEX.swift @@ -0,0 +1,32 @@ +// +// UIColor+HEX.swift +// MSUIKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.12. +// + +import UIKit + +extension UIColor { + + public convenience init(hexCode: String, alpha: CGFloat = 1.0) { + var hexFormatted: String = hexCode + .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + .uppercased() + + if hexFormatted.hasPrefix("#") { + hexFormatted = String(hexFormatted.dropFirst()) + } + + assert(hexFormatted.count == 6, "Invalid hex code used.") + + var rgbValue: UInt64 = 0 + Scanner(string: hexFormatted).scanHexInt64(&rgbValue) + + self.init(red: CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0, + green: CGFloat((rgbValue & 0x00FF00) >> 8) / 255.0, + blue: CGFloat(rgbValue & 0x0000FF) / 255.0, + alpha: alpha) + } + +} diff --git a/iOS/MSUIKit/Sources/MSUIKit/MSAlertViewController.swift b/iOS/MSUIKit/Sources/MSUIKit/MSAlertViewController.swift new file mode 100644 index 0000000..d41862f --- /dev/null +++ b/iOS/MSUIKit/Sources/MSUIKit/MSAlertViewController.swift @@ -0,0 +1,322 @@ +// +// MSAlertViewController.swift +// MSUIKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.04. +// + +import UIKit + +import MSDesignSystem + +open class MSAlertViewController: UIViewController { + + // MARK: - Constants + + private enum Typo { + + static let cancelButtonTitle = "์ทจ์†Œ" + static let doneButtonTitle = "์—ฌ์ • ์™„๋ฃŒ" + + } + + private enum Metric { + + static let bottomSheetHeight: CGFloat = 294.0 + static let dismissingHeightRatio: CGFloat = 0.6 + static let dimmedViewMaximumAlpha: CGFloat = 0.6 + + static let horizontalInset: CGFloat = 12.0 + static let verticalInset: CGFloat = 24.0 + static let containerViewCornerRadius: CGFloat = 12.0 + static let stackSpacing: CGFloat = 4.0 + + static let cancelButtonVerticalInset: CGFloat = 10.0 + static let cancelButtonHorizontalInset: CGFloat = 28.0 + + enum ResizeIndicator { + static let width: CGFloat = 36.0 + static let height: CGFloat = 5.0 + static let topSpacing: CGFloat = 5.0 + } + + static let gestureVelocity: CGFloat = 750.0 + + } + + // MARK: - UI Components + + // Base + public let containerView: UIView = { + let view = UIView() + view.backgroundColor = .msColor(.modalBackground) + view.layer.cornerRadius = Metric.containerViewCornerRadius + view.clipsToBounds = true + return view + }() + + private let dimmedView: UIView = { + let view = UIView() + view.backgroundColor = .black + view.alpha = .zero + return view + }() + + private let resizeIndicator: UIView = { + let view = UIView() + view.backgroundColor = .darkGray.withAlphaComponent(0.5) + view.layer.cornerRadius = 2.5 + view.clipsToBounds = true + return view + }() + + // Title + private let titleStack: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = Metric.stackSpacing + return stackView + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.font = .msFont(.headerTitle) + label.textColor = .msColor(.primaryTypo) + return label + }() + + private let subtitleLabel: UILabel = { + let label = UILabel() + label.font = .msFont(.caption) + label.textColor = .msColor(.secondaryTypo) + return label + }() + + // Button + private let buttonStack: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = Metric.stackSpacing + stackView.distribution = .fillProportionally + return stackView + }() + + private let cancelButton: MSButton = { + let button = MSButton.secondary() + button.cornerStyle = .squared + button.title = Typo.cancelButtonTitle + button.configuration?.contentInsets = NSDirectionalEdgeInsets(top: Metric.cancelButtonVerticalInset, + leading: Metric.cancelButtonHorizontalInset, + bottom: Metric.cancelButtonVerticalInset, + trailing: Metric.cancelButtonHorizontalInset) + return button + }() + + private let doneButton: MSButton = { + let button = MSButton.primary() + button.cornerStyle = .squared + button.title = Typo.doneButtonTitle + button.isEnabled = false + return button + }() + + // Gesture + private lazy var tapGesture: UITapGestureRecognizer = { + let tagGesture = UITapGestureRecognizer(target: self, + action: #selector(self.dismissBottomSheet)) + return tagGesture + }() + + private lazy var panGesture: UIPanGestureRecognizer = { + let panGesture = UIPanGestureRecognizer(target: self, + action: #selector(self.handlePanGesture(_:))) + panGesture.delaysTouchesBegan = false + panGesture.delaysTouchesEnded = false + return panGesture + }() + + private var containerViewHeight: NSLayoutConstraint? + private var containerViewBottomInset: NSLayoutConstraint? + + private var keyboardLayoutHeight: CGFloat { + self.view.keyboardLayoutGuide.layoutFrame.height + } + + // MARK: - Properties + + open var cancelButtonAction: UIAction? { + didSet { + guard let action = self.cancelButtonAction else { return } + self.cancelButton.addAction(action, for: .touchUpInside) + } + } + + open var doneButtonAction: UIAction? { + didSet { + guard let action = self.doneButtonAction else { return } + self.doneButton.addAction(action, for: .touchUpInside) + } + } + + // MARK: - Life Cycle + + open override func viewDidLoad() { + super.viewDidLoad() + self.configureStyles() + self.configureLayout() + } + + open override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + self.animatePresentView() + } + + // MARK: - Helpers + + @objc + open func dismissBottomSheet() { + self.animateDismissView() + } + + @objc + private func handlePanGesture(_ sender: UIPanGestureRecognizer) { + let translation = sender.translation(in: self.containerView) + let velocity = sender.velocity(in: self.containerView) + let updatedBottomInset = Metric.verticalInset + translation.y + + switch sender.state { + case .changed where updatedBottomInset > Metric.verticalInset: + self.containerViewBottomInset?.constant = updatedBottomInset - self.keyboardLayoutHeight + self.view.layoutIfNeeded() + case .ended: + if translation.y > Metric.bottomSheetHeight * (1 - Metric.dismissingHeightRatio) + || velocity.y > Metric.gestureVelocity { + self.animateDismissView() + } else { + UIView.animate(withDuration: 0.4) { + self.containerViewBottomInset?.constant = Metric.verticalInset - self.keyboardLayoutHeight + self.view.layoutIfNeeded() + } + } + default: + break + } + } + + private func animatePresentView() { + UIView.animate(withDuration: 0.3, delay: .zero, options: .curveEaseInOut) { + self.containerViewBottomInset?.constant = -Metric.verticalInset / 2 + self.view.layoutIfNeeded() + } + + self.dimmedView.alpha = .zero + UIView.animate(withDuration: 0.4) { + self.dimmedView.alpha = Metric.dimmedViewMaximumAlpha + } + } + + private func animateDismissView() { + UIView.animate(withDuration: 0.3) { + self.containerViewBottomInset?.constant = Metric.bottomSheetHeight + self.keyboardLayoutHeight + self.view.layoutIfNeeded() + } + + self.dimmedView.alpha = Metric.dimmedViewMaximumAlpha + UIView.animate(withDuration: 0.4) { + self.dimmedView.alpha = .zero + } completion: { _ in + self.dismiss(animated: false) + } + } + + // MARK: - UI Configuration + + open func configureStyles() { + self.view.backgroundColor = .clear + self.dimmedView.addGestureRecognizer(self.tapGesture) + self.containerView.addGestureRecognizer(self.panGesture) + } + + open func configureLayout() { + self.configureSubviews() + self.configureConstraints() + } + + private func configureSubviews() { + [self.dimmedView, self.containerView].forEach { + self.view.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + } + + [self.resizeIndicator, self.titleStack, self.buttonStack].forEach { + self.containerView.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + } + + [self.titleLabel, self.subtitleLabel].forEach { + self.titleStack.addArrangedSubview($0) + } + + [self.cancelButton, self.doneButton].forEach { + self.buttonStack.addArrangedSubview($0) + } + } + + private func configureConstraints() { + let bottomInset = self.containerView.bottomAnchor.constraint(equalTo: self.view.keyboardLayoutGuide.topAnchor, + constant: Metric.bottomSheetHeight) + let heightConstraint = self.containerView.heightAnchor.constraint(equalToConstant: Metric.bottomSheetHeight) + NSLayoutConstraint.activate([ + self.dimmedView.topAnchor.constraint(equalTo: self.view.topAnchor), + self.dimmedView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.dimmedView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + self.dimmedView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + + self.containerView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, + constant: Metric.horizontalInset), + bottomInset, + self.containerView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, + constant: -Metric.horizontalInset), + heightConstraint + ]) + self.containerViewBottomInset = bottomInset + self.containerViewHeight = heightConstraint + + NSLayoutConstraint.activate([ + self.resizeIndicator.widthAnchor.constraint(equalToConstant: Metric.ResizeIndicator.width), + self.resizeIndicator.heightAnchor.constraint(equalToConstant: Metric.ResizeIndicator.height), + self.resizeIndicator.centerXAnchor.constraint(equalTo: self.containerView.centerXAnchor), + self.resizeIndicator.topAnchor.constraint(equalTo: self.containerView.topAnchor, + constant: Metric.ResizeIndicator.topSpacing), + + self.titleStack.topAnchor.constraint(equalTo: self.containerView.topAnchor, + constant: Metric.verticalInset), + self.titleStack.leadingAnchor.constraint(equalTo: self.containerView.leadingAnchor, + constant: Metric.horizontalInset), + self.titleStack.trailingAnchor.constraint(equalTo: self.containerView.trailingAnchor, + constant: -Metric.horizontalInset), + + self.buttonStack.leadingAnchor.constraint(equalTo: self.containerView.leadingAnchor, + constant: Metric.horizontalInset), + self.buttonStack.bottomAnchor.constraint(equalTo: self.containerView.bottomAnchor, + constant: -Metric.verticalInset / 2), + self.buttonStack.trailingAnchor.constraint(equalTo: self.containerView.trailingAnchor, + constant: -Metric.horizontalInset) + ]) + } + + // MARK: - Functions + + public func updateTitle(_ title: String) { + self.titleLabel.text = title + } + + public func updateSubtitle(_ subtitle: String) { + self.subtitleLabel.text = subtitle + } + + public func updateDoneButton(isEnabled: Bool) { + self.doneButton.isEnabled = isEnabled + } + +} diff --git a/iOS/MSUIKit/Sources/MSUIKit/MSBottomSheetViewController/BottomSheetConfiguration.swift b/iOS/MSUIKit/Sources/MSUIKit/MSBottomSheetViewController/BottomSheetConfiguration.swift new file mode 100644 index 0000000..47f9ef2 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSUIKit/MSBottomSheetViewController/BottomSheetConfiguration.swift @@ -0,0 +1,73 @@ +// +// BottomSheetConfiguration.swift +// MSUIKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.01. +// + +import CoreGraphics +import Foundation + +extension MSBottomSheetViewController { + + public struct Configuration { + + public enum Dimension { + case fractional(CGFloat) + case absolute(CGFloat) + } + + // MARK: - Properties + + var standardMetric: CGFloat? + + let fullDimension: Dimension + let detentDimension: Dimension + let minimizedDimension: Dimension + + var fullHeight: CGFloat { + return self.calculateHeight(for: self.fullDimension) + } + + var detentHeight: CGFloat { + return self.calculateHeight(for: self.detentDimension) + } + + var minimizedHeight: CGFloat { + return self.calculateHeight(for: self.minimizedDimension) + } + + var fullDetentDiff: CGFloat { + return self.fullHeight - self.detentHeight + } + + var detentMinimizedDiff: CGFloat { + return self.detentHeight - self.minimizedHeight + } + + // MARK: - Initializer + + public init(fullDimension: Dimension, + detentDimension: Dimension, + minimizedDimension: Dimension) { + self.fullDimension = fullDimension + self.detentDimension = detentDimension + self.minimizedDimension = minimizedDimension + } + + // MARK: - Helpers + + private func calculateHeight(for dimension: Dimension) -> CGFloat { + guard let standardMetric = standardMetric else { return .zero } + + switch dimension { + case .fractional(let fractionalDimension): + return standardMetric * fractionalDimension + case .absolute(let absoluteDimension): + return absoluteDimension + } + } + + } + +} diff --git a/iOS/MSUIKit/Sources/MSUIKit/MSBottomSheetViewController/MSBottomSheetViewController.swift b/iOS/MSUIKit/Sources/MSUIKit/MSBottomSheetViewController/MSBottomSheetViewController.swift new file mode 100644 index 0000000..af8efcd --- /dev/null +++ b/iOS/MSUIKit/Sources/MSUIKit/MSBottomSheetViewController/MSBottomSheetViewController.swift @@ -0,0 +1,322 @@ +// +// MSUIComponents.swift +// MSUIKit +// +// Created by ์ด์ฐฝ์ค€ on 11/14/23. +// + +import UIKit + +import MSDesignSystem +import MSLogger + +open class MSBottomSheetViewController +: UIViewController, UIGestureRecognizerDelegate { + + // MARK: - State + + public enum State: String { + case full + case detented + case minimized + } + + // MARK: - UI Components + + public let contentViewController: Content + public let bottomSheetViewController: BottomSheet + + private let resizeIndicator: UIView = { + let view = UIView() + view.backgroundColor = .darkGray.withAlphaComponent(0.5) + view.layer.cornerRadius = 2.5 + view.clipsToBounds = true + return view + }() + + private var topConstraints: NSLayoutConstraint? + + private lazy var panGesture: UIPanGestureRecognizer = { + let panGesture = UIPanGestureRecognizer() + panGesture.delegate = self + panGesture.addTarget(self, action: #selector(self.handlePanGesture(_:))) + return panGesture + }() + + // MARK: - Properties + + public var configuration: MSBottomSheetViewController.Configuration? + private let gestureVelocity: CGFloat = 750.0 + + public var state: State = .minimized { + willSet { self.stateDidChanged(newValue) } + } + + // MARK: - Initializer + + public init(contentViewController: Content, + bottomSheetViewController: BottomSheet) { + self.contentViewController = contentViewController + self.bottomSheetViewController = bottomSheetViewController + super.init(nibName: nil, bundle: nil) + } + + public required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - Life Cycle + + open override func viewDidLoad() { + super.viewDidLoad() + self.configureStyle() + self.configureLayout() + self.configureGesture() + } + + // MARK: - Functions + + open func stateDidChanged(_ state: State) { + MSLogger.make(category: .uiKit).log("Bottom Sheet ์ƒํƒœ๊ฐ€ \(state.rawValue)๋กœ ์—…๋ฐ์ดํŠธ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") + + if case .full = state { + self.resizeIndicator.isHidden = true + } + } + + private func presentFullBottomSheet(animated: Bool = true) { + guard let configuration = self.configuration else { return } + self.topConstraints?.constant = -configuration.fullHeight + + if animated { + UIView.animate(withDuration: 0.3) { + self.view.layoutIfNeeded() + } completion: { _ in + self.state = .full + } + } else { + self.view.layoutIfNeeded() + self.state = .full + } + } + + private func presentDetentedBottomSheet(animated: Bool = true) { + guard let configuration = self.configuration else { return } + self.topConstraints?.constant = -configuration.detentHeight + + if animated { + UIView.animate(withDuration: 0.5, + delay: .zero, + usingSpringWithDamping: 0.8, + initialSpringVelocity: 0.5, + options: [.curveEaseInOut]) { + self.view.layoutIfNeeded() + } completion: { _ in + self.state = .detented + } + } else { + self.view.layoutIfNeeded() + self.state = .detented + } + } + + private func dismissBottomSheet(animated: Bool = true) { + guard let configuration = self.configuration else { return } + self.topConstraints?.constant = -configuration.minimizedHeight + + if animated { + UIView.animate(withDuration: 0.5, + delay: .zero, + usingSpringWithDamping: 0.8, + initialSpringVelocity: 0.5, + options: [.curveEaseInOut]) { + self.view.layoutIfNeeded() + } completion: { _ in + self.state = .minimized + } + } else { + self.view.layoutIfNeeded() + self.state = .minimized + } + } + + public func hideBottomSheet(animated: Bool = true) { + self.topConstraints?.constant = .zero + + if animated { + UIView.animate(withDuration: 0.5, + delay: .zero, + usingSpringWithDamping: 0.8, + initialSpringVelocity: 0.5, + options: [.curveEaseInOut]) { + self.view.layoutIfNeeded() + } + } else { + self.view.layoutIfNeeded() + } + } + + public func showBottomSheet(animated: Bool = true) { + guard let configuration = self.configuration else { return } + self.topConstraints?.constant = -configuration.minimizedHeight + + if animated { + UIView.animate(withDuration: 0.5, + delay: .zero, + usingSpringWithDamping: 0.8, + initialSpringVelocity: 0.5, + options: [.curveEaseInOut]) { + self.view.layoutIfNeeded() + self.state = .minimized + } + } else { + self.view.layoutIfNeeded() + self.state = .minimized + } + } + + // MARK: - Pan Gesture + + @objc + private func handlePanGesture(_ sender: UIPanGestureRecognizer) { + let translation = sender.translation(in: self.bottomSheetViewController.view) + let velocity = sender.velocity(in: self.bottomSheetViewController.view) + + switch sender.state { + case .began, .changed: + self.handlePanChanged(translation: translation) + case .ended: + self.handlePanEnded(translation: translation, velocity: velocity) + case .failed: + self.handlePanFailed() + default: break + } + } + + private func handlePanChanged(translation: CGPoint) { + guard let configuration = self.configuration else { return } + + self.resizeIndicator.isHidden = false + + switch self.state { + case .full: + guard translation.y > 0 else { return } + + self.topConstraints?.constant = -(configuration.fullHeight - translation.y.magnitude) + self.view.layoutIfNeeded() + case .detented: + if translation.y >= 0 { // ์•„๋ž˜๋กœ Pan + self.topConstraints?.constant = -(configuration.detentHeight - translation.y.magnitude) + } else if translation.y < 0 { // ์œ„๋กœ Pan + self.topConstraints?.constant = -(configuration.detentHeight + translation.y.magnitude) + } + self.view.layoutIfNeeded() + case .minimized: + guard translation.y < 0 else { return } + + let newConstant = -(configuration.minimizedHeight + translation.y.magnitude) + self.topConstraints?.constant = newConstant + self.view.layoutIfNeeded() + } + } + + private func handlePanEnded(translation: CGPoint, velocity: CGPoint) { + guard let configuration = self.configuration else { return } + + let yTransMagnitude = translation.y.magnitude + switch self.state { + case .full: + if velocity.y < 0 { + self.presentFullBottomSheet() + } else if yTransMagnitude >= configuration.fullDetentDiff / 2 || velocity.y > self.gestureVelocity { + self.presentDetentedBottomSheet() + } else { + self.presentFullBottomSheet() + } + case .detented: + if translation.y <= -configuration.fullDetentDiff / 2 || velocity.y < -self.gestureVelocity { + self.presentFullBottomSheet() + } else if translation.y >= configuration.detentMinimizedDiff / 2 || velocity.y > self.gestureVelocity { + self.dismissBottomSheet() + } else { + self.presentDetentedBottomSheet() + } + case .minimized: + if yTransMagnitude >= configuration.detentHeight / 2 || velocity.y < -self.gestureVelocity { + self.presentDetentedBottomSheet() + } else { + self.dismissBottomSheet() + } + } + } + + private func handlePanFailed() { + switch self.state { + case .full: + self.presentFullBottomSheet() + case .detented: + self.presentDetentedBottomSheet() + case .minimized: + self.dismissBottomSheet() + } + } + +} + +// MARK: - UI Configuration + +private extension MSBottomSheetViewController { + + func configureStyle() { + self.bottomSheetViewController.view.layer.cornerRadius = 12.0 + self.bottomSheetViewController.view.clipsToBounds = true + + self.configuration?.standardMetric = self.view.frame.height + } + + func configureLayout() { + guard let configuration = self.configuration else { return } + + self.addChild(self.contentViewController) + self.addChild(self.bottomSheetViewController) + + self.view.addSubview(self.contentViewController.view) + self.view.addSubview(self.bottomSheetViewController.view) + + self.contentViewController.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.contentViewController.view.topAnchor.constraint(equalTo: self.view.topAnchor), + self.contentViewController.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.contentViewController.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + self.contentViewController.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor) + ]) + self.contentViewController.didMove(toParent: self) + + self.bottomSheetViewController.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.bottomSheetViewController.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.bottomSheetViewController.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + self.bottomSheetViewController.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor) + ]) + self.topConstraints = self.bottomSheetViewController.view.topAnchor + .constraint(equalTo: self.view.bottomAnchor, + constant: -configuration.minimizedHeight) + self.topConstraints?.isActive = true + self.bottomSheetViewController.didMove(toParent: self) + + self.bottomSheetViewController.view.addSubview(self.resizeIndicator) + self.resizeIndicator.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.resizeIndicator.widthAnchor.constraint(equalToConstant: 36.0), + self.resizeIndicator.heightAnchor.constraint(equalToConstant: 5.0), + self.resizeIndicator.centerXAnchor.constraint(equalTo: self.bottomSheetViewController.view.centerXAnchor), + self.resizeIndicator.topAnchor.constraint(equalTo: self.bottomSheetViewController.view.topAnchor, + constant: 6.0) + ]) + } + + func configureGesture() { + self.bottomSheetViewController.view.addGestureRecognizer(self.panGesture) + } + +} diff --git a/iOS/MSUIKit/Sources/MSUIKit/MSButton/MSButton+Primary.swift b/iOS/MSUIKit/Sources/MSUIKit/MSButton/MSButton+Primary.swift new file mode 100644 index 0000000..e05a182 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSUIKit/MSButton/MSButton+Primary.swift @@ -0,0 +1,37 @@ +// +// MSButton+Primary.swift +// MSUIKit +// +// Created by ์ด์ฐฝ์ค€ on 11/19/23. +// + +import UIKit + +import MSDesignSystem + +extension MSButton { + + public static func primary(isBrandColored: Bool = true) -> MSButton { + let button = MSButton() + let haptic = UINotificationFeedbackGenerator() + defer { button.haptic = haptic } + + button.configuration?.baseForegroundColor = .msColor(.primaryButtonTypo) + button.configuration?.baseBackgroundColor = isBrandColored + ? .msColor(.musicSpot) + : .msColor(.primaryButtonBackground) + + haptic.prepare() + button.configurationUpdateHandler = { primaryButton in + switch primaryButton.state { + case .highlighted: + haptic.notificationOccurred(.warning) + default: + break + } + } + + return button + } + +} diff --git a/iOS/MSUIKit/Sources/MSUIKit/MSButton/MSButton+Secondary.swift b/iOS/MSUIKit/Sources/MSUIKit/MSButton/MSButton+Secondary.swift new file mode 100644 index 0000000..3ed6eb0 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSUIKit/MSButton/MSButton+Secondary.swift @@ -0,0 +1,21 @@ +// +// MSButton+Secondary.swift +// MSUIKit +// +// Created by ์ด์ฐฝ์ค€ on 11/19/23. +// + +import UIKit + +import MSDesignSystem + +extension MSButton { + + public static func secondary() -> MSButton { + let button = MSButton() + button.configuration?.baseForegroundColor = .msColor(.secondaryButtonTypo) + button.configuration?.baseBackgroundColor = .msColor(.secondaryButtonBackground) + return button + } + +} diff --git a/iOS/MSUIKit/Sources/MSUIKit/MSButton/MSButton.swift b/iOS/MSUIKit/Sources/MSUIKit/MSButton/MSButton.swift new file mode 100644 index 0000000..2d0d448 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSUIKit/MSButton/MSButton.swift @@ -0,0 +1,109 @@ +// +// MSButton.swift +// MSUIKit +// +// Created by ์ด์ฐฝ์ค€ on 11/19/23. +// + +import UIKit + +import MSDesignSystem + +public class MSButton: UIButton { + + public enum CornerStyle { + case squared + case rounded + case custom(CGFloat) + + var cornerRadius: CGFloat { + switch self { + case .squared: return 8.0 + case .rounded: return 25.0 + case .custom(let cornerRadius): return cornerRadius + } + } + + } + + // MARK: - Constants + + private enum Metric { + static let height: CGFloat = 60.0 + static let horizontalEdgeInsets: CGFloat = 58.0 + static let verticalEdgeInsets: CGFloat = 10.0 + static let imagePadding: CGFloat = 8.0 + } + + // MARK: - Properties + + public var title: String? { + didSet { self.configureTitle(self.title) } + } + + public var image: UIImage? { + didSet { self.configureIcon(self.image) } + } + + public var cornerStyle: CornerStyle = .squared { + didSet { self.configureCornerStyle(self.cornerStyle) } + } + + var haptic: UIFeedbackGenerator? + + // MARK: - Initializer + + private override init(frame: CGRect) { + super.init(frame: frame) + self.configureStyles() + self.configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - UI Configuration + + private func configureStyles() { + var configuration = UIButton.Configuration.filled() + configuration.contentInsets = NSDirectionalEdgeInsets(top: Metric.verticalEdgeInsets, + leading: Metric.horizontalEdgeInsets, + bottom: Metric.verticalEdgeInsets, + trailing: Metric.horizontalEdgeInsets) + configuration.imagePlacement = .leading + configuration.imagePadding = Metric.imagePadding + configuration.titleAlignment = .center + configuration.titleLineBreakMode = .byTruncatingMiddle + self.configuration = configuration + } + + private func configureLayout() { + self.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.heightAnchor.constraint(equalToConstant: Metric.height) + ]) + } + +} + +// MARK: - Private Configuration Functions + +private extension MSButton { + + private func configureTitle(_ title: String?) { + var container = AttributeContainer() + container.font = .msFont(.buttonTitle) + self.configuration?.attributedTitle = AttributedString(title ?? "", attributes: container) + } + + private func configureIcon(_ icon: UIImage?) { + self.configuration?.image = icon + } + + private func configureCornerStyle(_ cornerStyle: CornerStyle) { + self.layer.cornerRadius = cornerStyle.cornerRadius + self.clipsToBounds = true + } + +} diff --git a/iOS/MSUIKit/Sources/MSUIKit/MSButton/MSRectButton+Large.swift b/iOS/MSUIKit/Sources/MSUIKit/MSButton/MSRectButton+Large.swift new file mode 100644 index 0000000..29ff40b --- /dev/null +++ b/iOS/MSUIKit/Sources/MSUIKit/MSButton/MSRectButton+Large.swift @@ -0,0 +1,21 @@ +// +// MSRectButton+Large.swift +// MSUIKit +// +// Created by ์ด์ฐฝ์ค€ on 11/20/23. +// + +import MSDesignSystem + +extension MSRectButton { + + public static func large(isBrandColored: Bool = true) -> MSRectButton { + let button = MSRectButton() + button.style = .large + button.configuration?.baseBackgroundColor = isBrandColored + ? .msColor(.musicSpot) + : .msColor(.secondaryButtonBackground).withAlphaComponent(0.8) + return button + } + +} diff --git a/iOS/MSUIKit/Sources/MSUIKit/MSButton/MSRectButton+Small.swift b/iOS/MSUIKit/Sources/MSUIKit/MSButton/MSRectButton+Small.swift new file mode 100644 index 0000000..9147a38 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSUIKit/MSButton/MSRectButton+Small.swift @@ -0,0 +1,21 @@ +// +// MSRectButton+Small.swift +// MSUIKit +// +// Created by ์ด์ฐฝ์ค€ on 11/20/23. +// + +import MSDesignSystem + +extension MSRectButton { + + public static func small(isBrandColored: Bool = false) -> MSRectButton { + let button = MSRectButton() + button.style = .small + button.configuration?.baseBackgroundColor = isBrandColored + ? .msColor(.musicSpot) + : .msColor(.secondaryButtonBackground).withAlphaComponent(0.8) + return button + } + +} diff --git a/iOS/MSUIKit/Sources/MSUIKit/MSButton/MSRectButton.swift b/iOS/MSUIKit/Sources/MSUIKit/MSButton/MSRectButton.swift new file mode 100644 index 0000000..dbb3adc --- /dev/null +++ b/iOS/MSUIKit/Sources/MSUIKit/MSButton/MSRectButton.swift @@ -0,0 +1,129 @@ +// +// MSRectButton.swift +// MSUIKit +// +// Created by ์ด์ฐฝ์ค€ on 11/19/23. +// + +import UIKit + +import MSDesignSystem + +public final class MSRectButton: UIButton { + + internal enum Style { + case small + case large + + var size: CGSize { + switch self { + case .small: return CGSize(width: 52.0, height: 56.0) + case .large: return CGSize(width: 120.0, height: 120.0) + } + } + + var cornerRadius: CGFloat { + switch self { + case .small: return 8.0 + case .large: return 30.0 + } + } + + } + + // MARK: - Constants + + private enum Metric { + static let edgeInsets: CGFloat = 10.0 + } + + // MARK: - Properties + + public var title: String? { + didSet { self.configureTitle(self.title) } + } + + public var image: UIImage? { + didSet { self.configureIcon(self.image) } + } + + internal var style: Style = .small { + didSet { + self.configureSize(by: self.style) + self.configureCornerStyle(by: self.style) + } + } + + private let haptic = UIImpactFeedbackGenerator(style: .medium) + + // MARK: - Initializer + + private override init(frame: CGRect) { + super.init(frame: frame) + self.configureStyles() + self.configureLayout() + self.haptic.prepare() + } + + required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - UI Configuration + + private func configureStyles() { + var configuration = UIButton.Configuration.filled() + configuration.baseForegroundColor = .msColor(.primaryTypo) + configuration.contentInsets = NSDirectionalEdgeInsets(top: Metric.edgeInsets, + leading: Metric.edgeInsets, + bottom: Metric.edgeInsets, + trailing: Metric.edgeInsets) + configuration.imagePlacement = .leading + configuration.titleAlignment = .center + self.configuration = configuration + + self.configurationUpdateHandler = { button in + switch button.state { + case .highlighted: + self.haptic.impactOccurred() + default: + break + } + } + } + + private func configureLayout() { + self.translatesAutoresizingMaskIntoConstraints = false + } + +} + +// MARK: - Private Configuration Functions + +private extension MSRectButton { + + private func configureTitle(_ title: String?) { + self.configuration?.image = nil + var container = AttributeContainer() + container.font = self.style == .large ? .msFont(.duperTitle) : .msFont(.buttonTitle) + self.configuration?.attributedTitle = AttributedString(title ?? "", attributes: container) + } + + private func configureIcon(_ icon: UIImage?) { + self.configuration?.attributedTitle = nil + self.configuration?.image = icon + } + + private func configureSize(by style: Style) { + NSLayoutConstraint.activate([ + self.widthAnchor.constraint(equalToConstant: style.size.width), + self.heightAnchor.constraint(equalToConstant: style.size.height) + ]) + } + + private func configureCornerStyle(by style: Style) { + self.layer.cornerRadius = style.cornerRadius + self.clipsToBounds = true + } + +} diff --git a/iOS/MSUIKit/Sources/MSUIKit/MSImageFetcher/MSImageFetcher+UIImageView.swift b/iOS/MSUIKit/Sources/MSUIKit/MSImageFetcher/MSImageFetcher+UIImageView.swift new file mode 100644 index 0000000..c7d87f4 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSUIKit/MSImageFetcher/MSImageFetcher+UIImageView.swift @@ -0,0 +1,44 @@ +// +// MSImageFetcher+UIImageView.swift +// MSUIKit +// +// Created by ์ด์ฐฝ์ค€ on 2023.12.02. +// + +import UIKit + +import MSCacheStorage +import MSImageFetcher + +extension UIImageView: MSImageFetcherCompatible { } + +extension MSImageFetcherWrapper where Base: UIImageView { + + /// URL๋กœ๋ถ€ํ„ฐ ์ด๋ฏธ์ง€๋ฅผ ๊ฐ€์ ธ์™€ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. + /// - Parameters: + /// - imageURL: `UIImageView`์— ์„ค์ •ํ•  ์ด๋ฏธ์ง€์˜ URL + /// - key: ์ด๋ฏธ์ง€๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” Task๋ฅผ ๊ตฌ๋ถ„ํ•˜๊ธฐ ์œ„ํ•œ Key + public func setImage(with imageURL: URL, forKey key: String) { + Task { + guard let imageData = await self.fetchImage(with: imageURL, forKey: key) else { + return + } + + await self.updateImage(imageData) + } + } + + // MARK: - Helpers + + private func fetchImage(with imageURL: URL, + forKey key: String) async -> Data? { + let imageData = await MSImageFetcher.shared.fetchImage(from: imageURL, forKey: key) + return imageData + } + + @MainActor + private func updateImage(_ imageData: Data) { + self.base.image = UIImage(data: imageData) + } + +} diff --git a/iOS/MSUIKit/Sources/MSUIKit/MSTextField/MSTextField.swift b/iOS/MSUIKit/Sources/MSUIKit/MSTextField/MSTextField.swift new file mode 100644 index 0000000..5006481 --- /dev/null +++ b/iOS/MSUIKit/Sources/MSUIKit/MSTextField/MSTextField.swift @@ -0,0 +1,215 @@ +// +// MSTextField.swift +// MSUIKit +// +// Created by ์ „๋ฏผ๊ฑด on 11/14/23. +// + +import UIKit + +import MSDesignSystem + +public class MSTextField: UITextField { + + // MARK: - Constants + + private enum Metric { + + static let height: CGFloat = 50.0 + static let leftInset: CGFloat = 22.0 + static let rightInset: CGFloat = 12.0 + static let verticalEdgeInsets: CGFloat = 10.0 + static let imageWidth: CGFloat = 20.0 + static let imageHeight: CGFloat = 20.0 + static let imageInset: CGFloat = 12.0 + static let cornerRadius: CGFloat = 8.0 + + } + + private enum ImageBox { + + static let magnifyingglass = UIImage(systemName: "magnifyingglass") + static let close = UIImage(systemName: "multiply.circle.fill") + + } + + public enum ImageStyle { + + case none + case search + case pin + case calender + case lock + + var leftImage: UIImage? { + switch self { + case .none: + return nil + case .search: + return ImageBox.magnifyingglass + case .pin: + return .msIcon(.location) + case .calender: + return .msIcon(.calendar) + case .lock: + return .msIcon(.lock) + } + } + var rightImage: UIImage? { + switch self { + case .none, .search: + return nil + default: + return .msIcon(.arrowRight) + } + } + + } + + // MARK: - Properties + + private var leftImage = UIImageView() + private var rightImage = UIImageView() + + public var imageStyle: ImageStyle = .none { + didSet { + self.configureImageStyle() + self.configureLayout() + } + } + + public override var text: String? { + didSet { self.convertMode() } + } + + public override var placeholder: String? { + didSet { self.configurePlaceholder() } + } + + // MARK: - Initializer + + private override init(frame: CGRect) { + super.init(frame: frame) + self.configureStyles() + self.configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("MusicSpot์€ code-based๋กœ๋งŒ ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.") + } + + // MARK: - UI Configuration + + private func configureStyles() { + self.font = .msFont(.caption) + + self.layer.cornerRadius = Metric.cornerRadius + self.backgroundColor = .msColor(.textFieldBackground) + + self.configureImageStyle() + } + + private func configureLayout() { + self.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.heightAnchor.constraint(equalToConstant: Metric.height) + ]) + self.configurePlaceholderLayout() + + self.addSubview(self.leftImage) + self.addSubview(self.rightImage) + + self.configureLeftImageLayout() + self.configureRightImageLayout() + } + + private func configurePlaceholderLayout() { + self.configureLeftPlaceholderLayout() + self.configureRightPlaceholderLayout() + } + + private func configureLeftPlaceholderLayout() { + let extraInset: CGFloat = self.imageStyle == .none ? 0.0 : Metric.imageWidth + Metric.imageInset + self.leftView = UIView(frame: CGRect(x: 0.0, y: 0.0, width: Metric.leftInset + extraInset, height: 0.0)) + self.leftViewMode = .always + } + + private func configureRightPlaceholderLayout() { + let extraInset: CGFloat = self.hasText ? Metric.imageWidth + Metric.imageInset : 0.0 + self.rightView = UIView(frame: CGRect(x: 0.0, y: 0.0, width: Metric.rightInset + extraInset, height: 0.0)) + self.rightViewMode = .always + } + + private func configureLeftImageLayout() { + self.leftImage.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.leftImage.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: Metric.leftInset), + self.leftImage.heightAnchor.constraint(equalToConstant: Metric.imageHeight), + self.leftImage.widthAnchor.constraint(equalToConstant: Metric.imageWidth), + self.leftImage.centerYAnchor.constraint(equalTo: self.centerYAnchor) + ]) + } + + private func configureRightImageLayout() { + self.rightImage.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.rightImage.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -Metric.imageInset), + self.rightImage.heightAnchor.constraint(equalToConstant: Metric.imageHeight), + self.rightImage.widthAnchor.constraint(equalToConstant: Metric.imageWidth), + self.rightImage.centerYAnchor.constraint(equalTo: self.centerYAnchor) + ]) + } + + private func configurePlaceholder() { + var container = AttributeContainer() + container.font = .msFont(.caption) + let attributedString = AttributedString(self.placeholder ?? "", attributes: container) + self.attributedPlaceholder = NSAttributedString(attributedString) + } + +} + +// MARK: - Edit/Non-Edit Mode Functions + +public extension MSTextField { + + func convertMode() { + self.configureRightImageStyle() + self.configureRightImageLayout() + } + +} + +// MARK: - Private Configuration Functions + +private extension MSTextField { + + private func configureImageStyle() { + self.configureLeftImageStyle() + self.configureRightImageStyle() + } + + private func configureLeftImageStyle() { + if let leftImage = self.imageStyle.leftImage { + self.leftImage.image = leftImage + } + self.leftImage.tintColor = .msColor(.textFieldTypo) + } + + private func configureRightImageStyle() { + if let rightImage = self.imageStyle.rightImage { + self.rightImage.image = rightImage + } + + if self.hasText { + self.rightImage.image = ImageBox.close + } + self.rightImage.tintColor = .msColor(.textFieldTypo) + } + + private func configureImages(color: UIColor) { + self.leftImage.tintColor = color + self.rightImage.tintColor = color + } + +} diff --git a/iOS/MSUIKit/Sources/MSUIKit/Spacer.swift b/iOS/MSUIKit/Sources/MSUIKit/Spacer.swift new file mode 100644 index 0000000..aef78ca --- /dev/null +++ b/iOS/MSUIKit/Sources/MSUIKit/Spacer.swift @@ -0,0 +1,26 @@ +// +// Spacer.swift +// MSUIKit +// +// Created by ์ด์ฐฝ์ค€ on 11/23/23. +// + +import UIKit + +public final class Spacer: UIView { + + private override init(frame: CGRect = .zero) { + super.init(frame: frame) + } + + public convenience init(_ axis: NSLayoutConstraint.Axis) { + self.init() + setContentHuggingPriority(.defaultLow, for: axis) + backgroundColor = .clear + } + + public required init?(coder: NSCoder) { + super.init(coder: coder) + } + +} diff --git a/iOS/MusicSpot.xcworkspace/contents.xcworkspacedata b/iOS/MusicSpot.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..2c03be0 --- /dev/null +++ b/iOS/MusicSpot.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/MusicSpot.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/iOS/MusicSpot.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/iOS/MusicSpot.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/iOS/MusicSpot.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist~HEAD b/iOS/MusicSpot.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist~HEAD new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/iOS/MusicSpot.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist~HEAD @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/iOS/MusicSpot.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist~release b/iOS/MusicSpot.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist~release new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/iOS/MusicSpot.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist~release @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/iOS/MusicSpot/.swift-version b/iOS/MusicSpot/.swift-version new file mode 100644 index 0000000..95ee81a --- /dev/null +++ b/iOS/MusicSpot/.swift-version @@ -0,0 +1 @@ +5.9 diff --git a/iOS/MusicSpot/.swiftformat b/iOS/MusicSpot/.swiftformat new file mode 100644 index 0000000..d92e7aa --- /dev/null +++ b/iOS/MusicSpot/.swiftformat @@ -0,0 +1,11 @@ +# ์ฝ”๋“œ ๋“ค์—ฌ์“ฐ๊ธฐ +--indent 4 + +# ๊ณต๋ฐฑ ๋ฌธ์ž ์‚ญ์ œ +--trimwhitespace always + +# ์ค„ ๋ฐ”๊ฟˆ ๋ฌธ์ž ๊ด€๋ฆฌ +--linebreaks lf #cr, lf, crlf + +# wrapMultilineStatementBraces ๋น„ํ™œ์„ฑํ™” +--disable wrapMultilineStatementBraces diff --git a/iOS/MusicSpot/MusicSpot.xcodeproj/project.pbxproj b/iOS/MusicSpot/MusicSpot.xcodeproj/project.pbxproj new file mode 100644 index 0000000..7df5787 --- /dev/null +++ b/iOS/MusicSpot/MusicSpot.xcodeproj/project.pbxproj @@ -0,0 +1,622 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + 08CBF8782B18468E007D3797 /* SaveJourneyCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08CBF8702B18468E007D3797 /* SaveJourneyCoordinator.swift */; }; + 08CBF8792B18468E007D3797 /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08CBF8712B18468E007D3797 /* AppCoordinator.swift */; }; + 08CBF87A2B18468E007D3797 /* RewindJourneyCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08CBF8722B18468E007D3797 /* RewindJourneyCoordinator.swift */; }; + 08CBF87B2B18468E007D3797 /* HomeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08CBF8732B18468E007D3797 /* HomeCoordinator.swift */; }; + 08CBF87D2B18468E007D3797 /* SpotCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08CBF8752B18468E007D3797 /* SpotCoordinator.swift */; }; + 08CBF87E2B18468E007D3797 /* SelectSongCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08CBF8762B18468E007D3797 /* SelectSongCoordinator.swift */; }; + 08CBF87F2B18468E007D3797 /* Coordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08CBF8772B18468E007D3797 /* Coordinator.swift */; }; + DD0194E92B25C42E007CE082 /* Home in Frameworks */ = {isa = PBXBuildFile; productRef = DD0194E82B25C42E007CE082 /* Home */; }; + DD0194EB2B25C42E007CE082 /* NavigateMap in Frameworks */ = {isa = PBXBuildFile; productRef = DD0194EA2B25C42E007CE082 /* NavigateMap */; }; + DD0194EE2B25C434007CE082 /* JourneyList in Frameworks */ = {isa = PBXBuildFile; productRef = DD0194ED2B25C434007CE082 /* JourneyList */; }; + DD0194F12B25C43B007CE082 /* RewindJourney in Frameworks */ = {isa = PBXBuildFile; productRef = DD0194F02B25C43B007CE082 /* RewindJourney */; }; + DD0194F42B25C441007CE082 /* SaveJourney in Frameworks */ = {isa = PBXBuildFile; productRef = DD0194F32B25C441007CE082 /* SaveJourney */; }; + DD0194F72B25C448007CE082 /* SelectSong in Frameworks */ = {isa = PBXBuildFile; productRef = DD0194F62B25C448007CE082 /* SelectSong */; }; + DD0194FA2B25C44E007CE082 /* Spot in Frameworks */ = {isa = PBXBuildFile; productRef = DD0194F92B25C44E007CE082 /* Spot */; }; + DD73F8592B024C4900EE9BF2 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD73F8582B024C4900EE9BF2 /* AppDelegate.swift */; }; + DD73F85B2B024C4900EE9BF2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD73F85A2B024C4900EE9BF2 /* SceneDelegate.swift */; }; + DD73F8622B024C4B00EE9BF2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DD73F8612B024C4B00EE9BF2 /* Assets.xcassets */; }; + DD73F8652B024C4B00EE9BF2 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DD73F8632B024C4B00EE9BF2 /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 08CBF8702B18468E007D3797 /* SaveJourneyCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SaveJourneyCoordinator.swift; sourceTree = ""; }; + 08CBF8712B18468E007D3797 /* AppCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; + 08CBF8722B18468E007D3797 /* RewindJourneyCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RewindJourneyCoordinator.swift; sourceTree = ""; }; + 08CBF8732B18468E007D3797 /* HomeCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeCoordinator.swift; sourceTree = ""; }; + 08CBF8752B18468E007D3797 /* SpotCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SpotCoordinator.swift; sourceTree = ""; }; + 08CBF8762B18468E007D3797 /* SelectSongCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectSongCoordinator.swift; sourceTree = ""; }; + 08CBF8772B18468E007D3797 /* Coordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Coordinator.swift; sourceTree = ""; }; + DD73F8552B024C4900EE9BF2 /* MusicSpot.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MusicSpot.app; sourceTree = BUILT_PRODUCTS_DIR; }; + DD73F8582B024C4900EE9BF2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + DD73F85A2B024C4900EE9BF2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + DD73F8612B024C4B00EE9BF2 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + DD73F8642B024C4B00EE9BF2 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + DD73F8662B024C4B00EE9BF2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DD73F86D2B024CA000EE9BF2 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/LaunchScreen.strings; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + DD73F8522B024C4900EE9BF2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DD0194EB2B25C42E007CE082 /* NavigateMap in Frameworks */, + DD0194E92B25C42E007CE082 /* Home in Frameworks */, + DD0194F12B25C43B007CE082 /* RewindJourney in Frameworks */, + DD0194EE2B25C434007CE082 /* JourneyList in Frameworks */, + DD0194FA2B25C44E007CE082 /* Spot in Frameworks */, + DD0194F42B25C441007CE082 /* SaveJourney in Frameworks */, + DD0194F72B25C448007CE082 /* SelectSong in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 08CBF86F2B18468E007D3797 /* MSCoordinator */ = { + isa = PBXGroup; + children = ( + DD2856C32B187BAB002C994D /* Protocol */, + 08CBF8712B18468E007D3797 /* AppCoordinator.swift */, + 08CBF8732B18468E007D3797 /* HomeCoordinator.swift */, + 08CBF8752B18468E007D3797 /* SpotCoordinator.swift */, + 08CBF8762B18468E007D3797 /* SelectSongCoordinator.swift */, + 08CBF8702B18468E007D3797 /* SaveJourneyCoordinator.swift */, + 08CBF8722B18468E007D3797 /* RewindJourneyCoordinator.swift */, + ); + path = MSCoordinator; + sourceTree = ""; + }; + DD2856C32B187BAB002C994D /* Protocol */ = { + isa = PBXGroup; + children = ( + 08CBF8772B18468E007D3797 /* Coordinator.swift */, + ); + path = Protocol; + sourceTree = ""; + }; + DD5EA23F2B16EC690080AEC1 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + DD73F84C2B024C4900EE9BF2 = { + isa = PBXGroup; + children = ( + DD73F8572B024C4900EE9BF2 /* MusicSpot */, + DD73F8562B024C4900EE9BF2 /* Products */, + DD5EA23F2B16EC690080AEC1 /* Frameworks */, + ); + sourceTree = ""; + }; + DD73F8562B024C4900EE9BF2 /* Products */ = { + isa = PBXGroup; + children = ( + DD73F8552B024C4900EE9BF2 /* MusicSpot.app */, + ); + name = Products; + sourceTree = ""; + }; + DD73F8572B024C4900EE9BF2 /* MusicSpot */ = { + isa = PBXGroup; + children = ( + 08CBF86F2B18468E007D3797 /* MSCoordinator */, + DD73F8582B024C4900EE9BF2 /* AppDelegate.swift */, + DD73F85A2B024C4900EE9BF2 /* SceneDelegate.swift */, + DD73F8612B024C4B00EE9BF2 /* Assets.xcassets */, + DD73F8632B024C4B00EE9BF2 /* LaunchScreen.storyboard */, + DD73F8662B024C4B00EE9BF2 /* Info.plist */, + ); + path = MusicSpot; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + DD73F8542B024C4900EE9BF2 /* MusicSpot */ = { + isa = PBXNativeTarget; + buildConfigurationList = DD73F8692B024C4B00EE9BF2 /* Build configuration list for PBXNativeTarget "MusicSpot" */; + buildPhases = ( + DD73F8512B024C4900EE9BF2 /* Sources */, + DD73F8522B024C4900EE9BF2 /* Frameworks */, + DD73F8532B024C4900EE9BF2 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MusicSpot; + packageProductDependencies = ( + DD0194E82B25C42E007CE082 /* Home */, + DD0194EA2B25C42E007CE082 /* NavigateMap */, + DD0194ED2B25C434007CE082 /* JourneyList */, + DD0194F02B25C43B007CE082 /* RewindJourney */, + DD0194F32B25C441007CE082 /* SaveJourney */, + DD0194F62B25C448007CE082 /* SelectSong */, + DD0194F92B25C44E007CE082 /* Spot */, + ); + productName = MusicSpot; + productReference = DD73F8552B024C4900EE9BF2 /* MusicSpot.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + DD73F84D2B024C4900EE9BF2 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + TargetAttributes = { + DD73F8542B024C4900EE9BF2 = { + CreatedOnToolsVersion = 15.0.1; + }; + }; + }; + buildConfigurationList = DD73F8502B024C4900EE9BF2 /* Build configuration list for PBXProject "MusicSpot" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = ko; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + ko, + ); + mainGroup = DD73F84C2B024C4900EE9BF2; + packageReferences = ( + DD0194E72B25C42E007CE082 /* XCLocalSwiftPackageReference "../Features/Home" */, + DD0194EC2B25C434007CE082 /* XCLocalSwiftPackageReference "../Features/JourneyList" */, + DD0194EF2B25C43B007CE082 /* XCLocalSwiftPackageReference "../Features/RewindJourney" */, + DD0194F22B25C441007CE082 /* XCLocalSwiftPackageReference "../Features/SaveJourney" */, + DD0194F52B25C448007CE082 /* XCLocalSwiftPackageReference "../Features/SelectSong" */, + DD0194F82B25C44E007CE082 /* XCLocalSwiftPackageReference "../Features/Spot" */, + ); + productRefGroup = DD73F8562B024C4900EE9BF2 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + DD73F8542B024C4900EE9BF2 /* MusicSpot */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + DD73F8532B024C4900EE9BF2 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DD73F8652B024C4B00EE9BF2 /* LaunchScreen.storyboard in Resources */, + DD73F8622B024C4B00EE9BF2 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + DD73F8512B024C4900EE9BF2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 08CBF8792B18468E007D3797 /* AppCoordinator.swift in Sources */, + 08CBF8782B18468E007D3797 /* SaveJourneyCoordinator.swift in Sources */, + 08CBF87F2B18468E007D3797 /* Coordinator.swift in Sources */, + 08CBF87D2B18468E007D3797 /* SpotCoordinator.swift in Sources */, + 08CBF87E2B18468E007D3797 /* SelectSongCoordinator.swift in Sources */, + 08CBF87A2B18468E007D3797 /* RewindJourneyCoordinator.swift in Sources */, + DD73F8592B024C4900EE9BF2 /* AppDelegate.swift in Sources */, + 08CBF87B2B18468E007D3797 /* HomeCoordinator.swift in Sources */, + DD73F85B2B024C4900EE9BF2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + DD73F8632B024C4B00EE9BF2 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + DD73F8642B024C4B00EE9BF2 /* Base */, + DD73F86D2B024CA000EE9BF2 /* ko */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + DD73F8672B024C4B00EE9BF2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + 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; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + 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 = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + DD73F8682B024C4B00EE9BF2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + 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; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + 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 = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + DD73F86A2B024C4B00EE9BF2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = B3PWYBKFUK; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MusicSpot/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = MusicSpot; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.travel"; + INFOPLIST_KEY_NSAppleMusicUsageDescription = "์Œ์•… ๊ธฐ๋Šฅ์„ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ๋ฏธ๋””์–ด ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์‚ฌ์šฉ ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."; + INFOPLIST_KEY_NSCameraUsageDescription = "์ŠคํŒŸ์„ ๋‚จ๊ธฐ๊ธฐ ์œ„ํ•œ ์นด๋ฉ”๋ผ ์ ‘๊ทผ ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."; + INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "์‚ฌ์šฉ์ž์˜ ์œ„์น˜ ์ •๋ณด์— ๋Œ€ํ•œ ์—‘์„ธ์Šค๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."; + INFOPLIST_KEY_NSLocationUsageDescription = "์‚ฌ์šฉ์ž์˜ ์œ„์น˜ ์ •๋ณด๋ฅผ ํ†ตํ•ด ์‹ค์‹œ๊ฐ„ ์œ„์น˜ ํ‘œ์‹œ ๋ฐ ๋ฐ์ดํ„ฐ๋ฅผ ์ œ๊ณตํ•ด๋“œ๋ฆฝ๋‹ˆ๋‹ค."; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "MusicSPot์€ ์œ„์น˜ ๊ธฐ๋ฐ˜ ์—ฌ์ • ์ €์žฅ ์•ฑ์ž…๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž์˜ ์œ„์น˜ ๊ถŒํ•œ์„ ํ—ˆ์šฉํ•ด์ฃผ์„ธ์š”."; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.5.0; + PRODUCT_BUNDLE_IDENTIFIER = kr.codesquad.boostcamp8.MusicSpot; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + DD73F86B2B024C4B00EE9BF2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = B3PWYBKFUK; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MusicSpot/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = MusicSpot; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.travel"; + INFOPLIST_KEY_NSAppleMusicUsageDescription = "์Œ์•… ๊ธฐ๋Šฅ์„ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ๋ฏธ๋””์–ด ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์‚ฌ์šฉ ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."; + INFOPLIST_KEY_NSCameraUsageDescription = "์ŠคํŒŸ์„ ๋‚จ๊ธฐ๊ธฐ ์œ„ํ•œ ์นด๋ฉ”๋ผ ์ ‘๊ทผ ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."; + INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "์‚ฌ์šฉ์ž์˜ ์œ„์น˜ ์ •๋ณด์— ๋Œ€ํ•œ ์—‘์„ธ์Šค๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."; + INFOPLIST_KEY_NSLocationUsageDescription = "์‚ฌ์šฉ์ž์˜ ์œ„์น˜ ์ •๋ณด๋ฅผ ํ†ตํ•ด ์‹ค์‹œ๊ฐ„ ์œ„์น˜ ํ‘œ์‹œ ๋ฐ ๋ฐ์ดํ„ฐ๋ฅผ ์ œ๊ณตํ•ด๋“œ๋ฆฝ๋‹ˆ๋‹ค."; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "MusicSPot์€ ์œ„์น˜ ๊ธฐ๋ฐ˜ ์—ฌ์ • ์ €์žฅ ์•ฑ์ž…๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž์˜ ์œ„์น˜ ๊ถŒํ•œ์„ ํ—ˆ์šฉํ•ด์ฃผ์„ธ์š”."; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.5.0; + PRODUCT_BUNDLE_IDENTIFIER = kr.codesquad.boostcamp8.MusicSpot; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + DDEADCBB2B22BEE0008D59E9 /* Mock */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + 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; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + 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 = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = MOCK; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Mock; + }; + DDEADCBC2B22BEE0008D59E9 /* Mock */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = B3PWYBKFUK; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MusicSpot/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = MusicSpot; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.travel"; + INFOPLIST_KEY_NSAppleMusicUsageDescription = "์Œ์•… ๊ธฐ๋Šฅ์„ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ๋ฏธ๋””์–ด ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์‚ฌ์šฉ ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."; + INFOPLIST_KEY_NSCameraUsageDescription = "์ŠคํŒŸ์„ ๋‚จ๊ธฐ๊ธฐ ์œ„ํ•œ ์นด๋ฉ”๋ผ ์ ‘๊ทผ ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."; + INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "์‚ฌ์šฉ์ž์˜ ์œ„์น˜ ์ •๋ณด์— ๋Œ€ํ•œ ์—‘์„ธ์Šค๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."; + INFOPLIST_KEY_NSLocationUsageDescription = "์‚ฌ์šฉ์ž์˜ ์œ„์น˜ ์ •๋ณด๋ฅผ ํ†ตํ•ด ์‹ค์‹œ๊ฐ„ ์œ„์น˜ ํ‘œ์‹œ ๋ฐ ๋ฐ์ดํ„ฐ๋ฅผ ์ œ๊ณตํ•ด๋“œ๋ฆฝ๋‹ˆ๋‹ค."; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "MusicSPot์€ ์œ„์น˜ ๊ธฐ๋ฐ˜ ์—ฌ์ • ์ €์žฅ ์•ฑ์ž…๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž์˜ ์œ„์น˜ ๊ถŒํ•œ์„ ํ—ˆ์šฉํ•ด์ฃผ์„ธ์š”."; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.5.0; + PRODUCT_BUNDLE_IDENTIFIER = kr.codesquad.boostcamp8.MusicSpot; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = MOCK; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Mock; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + DD73F8502B024C4900EE9BF2 /* Build configuration list for PBXProject "MusicSpot" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DD73F8672B024C4B00EE9BF2 /* Debug */, + DDEADCBB2B22BEE0008D59E9 /* Mock */, + DD73F8682B024C4B00EE9BF2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DD73F8692B024C4B00EE9BF2 /* Build configuration list for PBXNativeTarget "MusicSpot" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DD73F86A2B024C4B00EE9BF2 /* Debug */, + DDEADCBC2B22BEE0008D59E9 /* Mock */, + DD73F86B2B024C4B00EE9BF2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + DD0194E72B25C42E007CE082 /* XCLocalSwiftPackageReference "../Features/Home" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../Features/Home; + }; + DD0194EC2B25C434007CE082 /* XCLocalSwiftPackageReference "../Features/JourneyList" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../Features/JourneyList; + }; + DD0194EF2B25C43B007CE082 /* XCLocalSwiftPackageReference "../Features/RewindJourney" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../Features/RewindJourney; + }; + DD0194F22B25C441007CE082 /* XCLocalSwiftPackageReference "../Features/SaveJourney" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../Features/SaveJourney; + }; + DD0194F52B25C448007CE082 /* XCLocalSwiftPackageReference "../Features/SelectSong" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../Features/SelectSong; + }; + DD0194F82B25C44E007CE082 /* XCLocalSwiftPackageReference "../Features/Spot" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../Features/Spot; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + DD0194E82B25C42E007CE082 /* Home */ = { + isa = XCSwiftPackageProductDependency; + productName = Home; + }; + DD0194EA2B25C42E007CE082 /* NavigateMap */ = { + isa = XCSwiftPackageProductDependency; + productName = NavigateMap; + }; + DD0194ED2B25C434007CE082 /* JourneyList */ = { + isa = XCSwiftPackageProductDependency; + productName = JourneyList; + }; + DD0194F02B25C43B007CE082 /* RewindJourney */ = { + isa = XCSwiftPackageProductDependency; + productName = RewindJourney; + }; + DD0194F32B25C441007CE082 /* SaveJourney */ = { + isa = XCSwiftPackageProductDependency; + productName = SaveJourney; + }; + DD0194F62B25C448007CE082 /* SelectSong */ = { + isa = XCSwiftPackageProductDependency; + productName = SelectSong; + }; + DD0194F92B25C44E007CE082 /* Spot */ = { + isa = XCSwiftPackageProductDependency; + productName = Spot; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = DD73F84D2B024C4900EE9BF2 /* Project object */; +} diff --git a/iOS/MusicSpot/MusicSpot.xcodeproj/xcshareddata/xcschemes/MusicSpot-Debug.xcscheme b/iOS/MusicSpot/MusicSpot.xcodeproj/xcshareddata/xcschemes/MusicSpot-Debug.xcscheme new file mode 100644 index 0000000..7eb04dc --- /dev/null +++ b/iOS/MusicSpot/MusicSpot.xcodeproj/xcshareddata/xcschemes/MusicSpot-Debug.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/MusicSpot/MusicSpot.xcodeproj/xcshareddata/xcschemes/MusicSpot-Mock.xcscheme b/iOS/MusicSpot/MusicSpot.xcodeproj/xcshareddata/xcschemes/MusicSpot-Mock.xcscheme new file mode 100644 index 0000000..4ac78ad --- /dev/null +++ b/iOS/MusicSpot/MusicSpot.xcodeproj/xcshareddata/xcschemes/MusicSpot-Mock.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/MusicSpot/MusicSpot/AppDelegate.swift b/iOS/MusicSpot/MusicSpot/AppDelegate.swift new file mode 100644 index 0000000..3c99dde --- /dev/null +++ b/iOS/MusicSpot/MusicSpot/AppDelegate.swift @@ -0,0 +1,32 @@ +// +// AppDelegate.swift +// MusicSpot +// +// Created by ์ด์ฐฝ์ค€ on 11/13/23. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_: UIApplication, + didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + true + } + + // MARK: UISceneSession Lifecycle + + func application(_: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options _: UIScene.ConnectionOptions) -> UISceneConfiguration { + UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + return true + } + +} diff --git a/iOS/MusicSpot/MusicSpot/Assets.xcassets/AccentColor.colorset/Contents.json b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..274babb --- /dev/null +++ b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/1024.png b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 0000000..b78c3f2 Binary files /dev/null and b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/114.png b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/114.png new file mode 100644 index 0000000..39bba14 Binary files /dev/null and b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/114.png differ diff --git a/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/120.png b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 0000000..c455a15 Binary files /dev/null and b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/120.png differ diff --git a/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/180.png b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 0000000..a7a18cc Binary files /dev/null and b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/180.png differ diff --git a/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/29.png b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/29.png new file mode 100644 index 0000000..20dab9a Binary files /dev/null and b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/29.png differ diff --git a/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/40.png b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 0000000..17bd08e Binary files /dev/null and b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/40.png differ diff --git a/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/57.png b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/57.png new file mode 100644 index 0000000..548f497 Binary files /dev/null and b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/57.png differ diff --git a/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/58.png b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/58.png new file mode 100644 index 0000000..4f6512c Binary files /dev/null and b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/58.png differ diff --git a/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/60.png b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/60.png new file mode 100644 index 0000000..5a9ebc9 Binary files /dev/null and b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/60.png differ diff --git a/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/80.png b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 0000000..b407ed9 Binary files /dev/null and b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/80.png differ diff --git a/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/87.png b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/87.png new file mode 100644 index 0000000..ae43c83 Binary files /dev/null and b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/87.png differ diff --git a/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/Contents.json b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..73d3b7f --- /dev/null +++ b/iOS/MusicSpot/MusicSpot/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"}]} \ No newline at end of file diff --git a/iOS/MusicSpot/MusicSpot/Assets.xcassets/Contents.json b/iOS/MusicSpot/MusicSpot/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iOS/MusicSpot/MusicSpot/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/MusicSpot/MusicSpot/Assets.xcassets/logo.imageset/Contents.json b/iOS/MusicSpot/MusicSpot/Assets.xcassets/logo.imageset/Contents.json new file mode 100644 index 0000000..4f547d0 --- /dev/null +++ b/iOS/MusicSpot/MusicSpot/Assets.xcassets/logo.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "logo.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iOS/MusicSpot/MusicSpot/Assets.xcassets/logo.imageset/logo.pdf b/iOS/MusicSpot/MusicSpot/Assets.xcassets/logo.imageset/logo.pdf new file mode 100644 index 0000000..77885b4 Binary files /dev/null and b/iOS/MusicSpot/MusicSpot/Assets.xcassets/logo.imageset/logo.pdf differ diff --git a/iOS/MusicSpot/MusicSpot/Base.lproj/LaunchScreen.storyboard b/iOS/MusicSpot/MusicSpot/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..9eaed41 --- /dev/null +++ b/iOS/MusicSpot/MusicSpot/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/MusicSpot/MusicSpot/Info.plist b/iOS/MusicSpot/MusicSpot/Info.plist new file mode 100644 index 0000000..158716f --- /dev/null +++ b/iOS/MusicSpot/MusicSpot/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleURLTypes + + + CFBundleURLName + musicSpotSettings + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/iOS/MusicSpot/MusicSpot/MSCoordinator/AppCoordinator.swift b/iOS/MusicSpot/MusicSpot/MSCoordinator/AppCoordinator.swift new file mode 100644 index 0000000..2834201 --- /dev/null +++ b/iOS/MusicSpot/MusicSpot/MSCoordinator/AppCoordinator.swift @@ -0,0 +1,39 @@ +// +// AppCoordinator.swift +// MusicSpot +// +// Created by ์œค๋™์ฃผ on 11/28/23. +// + +import UIKit + +protocol AppCoordinatorDelegate: AnyObject { + + func popToHomeMap(from coordinator: Coordinator) + func popToSearchMusic(from coordinator: Coordinator) + +} + +final class AppCoordinator: Coordinator { + + // MARK: - Properties + + let navigationController: UINavigationController + + var childCoordinators: [Coordinator] = [] + + // MARK: - Initializer + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + // MARK: - From: HomeMap + + func start() { + let homeMapCoordinator = HomeCoordinator(navigationController: self.navigationController) + self.childCoordinators.append(homeMapCoordinator) + homeMapCoordinator.start() + } + +} diff --git a/iOS/MusicSpot/MusicSpot/MSCoordinator/HomeCoordinator.swift b/iOS/MusicSpot/MusicSpot/MSCoordinator/HomeCoordinator.swift new file mode 100644 index 0000000..846e3fb --- /dev/null +++ b/iOS/MusicSpot/MusicSpot/MSCoordinator/HomeCoordinator.swift @@ -0,0 +1,145 @@ +// +// HomeCoordinator.swift +// MusicSpot +// +// Created by ์œค๋™์ฃผ on 11/28/23. +// + +import UIKit + +import Home +import JourneyList +import MSData +import MSDomain +import MSUIKit +import NavigateMap + +protocol HomeCoordinatorDelegate: AnyObject { + + func popToHome(from coordinator: Coordinator) + func popToHomeWithSpot(from coordinator: Coordinator, spot: Spot) + func popToHome(from coordinator: Coordinator, with endedJourney: Journey) + +} + +final class HomeCoordinator: Coordinator { + + // MARK: - Properties + + var navigationController: UINavigationController + + var childCoordinators: [Coordinator] = [] + + weak var delegate: AppCoordinatorDelegate? + + // MARK: - Initializer + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + // MARK: - Functions + + func start() { + + let journeyRepository = JourneyRepositoryImplementation() + + // NavigateMap + let navigateMapViewmodel = NavigateMapViewModel(repository: journeyRepository) + let navigateMapViewController = MapViewController(viewModel: navigateMapViewmodel) + + // Journey List + let journeyListViewModel = JourneyListViewModel(repository: journeyRepository) + let journeyListViewController = JourneyListViewController(viewModel: journeyListViewModel) + journeyListViewController.navigationDelegate = self + + // Bottom Sheet + let userRepository = UserRepositoryImplementation() + let homeViewModel = HomeViewModel(journeyRepository: journeyRepository, userRepository: userRepository) + let homeViewController = HomeViewController(viewModel: homeViewModel, + contentViewController: navigateMapViewController, + bottomSheetViewController: journeyListViewController) + let configuration = HomeViewController.Configuration(fullDimension: .fractional(1.0), + detentDimension: .fractional(0.4), + minimizedDimension: .absolute(100.0)) + homeViewController.configuration = configuration + homeViewController.navigationDelegate = self + navigateMapViewController.delegate = homeViewController + self.navigationController.pushViewController(homeViewController, animated: true) + } + +} + +// MARK: - Home Navigation + +extension HomeCoordinator: HomeNavigationDelegate { + + func navigateToSpot(spotCoordinate coordinate: Coordinate) { + let spotCoordinator = SpotCoordinator(navigationController: self.navigationController) + spotCoordinator.delegate = self + self.childCoordinators.append(spotCoordinator) + spotCoordinator.start(spotCoordinate: coordinate) + } + + func navigateToSelectSong(lastCoordinate: Coordinate) { + let selectSongCoordinator = SelectSongCoordinator(navigationController: self.navigationController) + selectSongCoordinator.delegate = self + self.childCoordinators.append(selectSongCoordinator) + selectSongCoordinator.start(lastCoordinate: lastCoordinate) + } + +} + +// MARK: - JourneyList Navigation + +extension HomeCoordinator: JourneyListNavigationDelegate { + + func navigateToRewindJourney(with urls: [URL], music: Music) { + let rewindJourneyCoordinator = RewindJourneyCoordinator(navigationController: self.navigationController) + rewindJourneyCoordinator.delegate = self + self.childCoordinators.append(rewindJourneyCoordinator) + rewindJourneyCoordinator.start(with: urls, music: music) + } + +} + +// MARK: - HomeMap Coordinator + +extension HomeCoordinator: HomeCoordinatorDelegate { + + func popToHome(from coordinator: Coordinator) { + guard let homeViewController = self.navigationController.viewControllers.first(where: { viewController in + viewController is HomeViewController + }) else { + return + } + self.navigationController.dismiss(animated: true) { [weak self] in + self?.navigationController.popToViewController(homeViewController, animated: true) + self?.childCoordinators.removeAll() + } + } + + func popToHomeWithSpot(from coordinator: Coordinator, spot: Spot) { + guard let homeViewController = self.navigationController.viewControllers.first(where: { viewController in + viewController is HomeViewController + }) as? HomeBottomSheetViewController else { + return + } + homeViewController.contentViewController.addSpotInRecording(spot: spot) + self.navigationController.dismiss(animated: true) { [weak self] in + self?.navigationController.popToViewController(homeViewController, animated: true) + self?.childCoordinators.removeAll() + } + } + + func popToHome(from coordinator: Coordinator, with endedJourney: Journey) { + let viewControllers = self.navigationController.viewControllers + guard let viewController = viewControllers.first(where: { $0 is HomeViewController }), + let homeViewController = viewController as? HomeViewController else { + return + } + + self.navigationController.popToViewController(homeViewController, animated: true) + } + +} diff --git a/iOS/MusicSpot/MusicSpot/MSCoordinator/Protocol/Coordinator.swift b/iOS/MusicSpot/MusicSpot/MSCoordinator/Protocol/Coordinator.swift new file mode 100644 index 0000000..940294a --- /dev/null +++ b/iOS/MusicSpot/MusicSpot/MSCoordinator/Protocol/Coordinator.swift @@ -0,0 +1,15 @@ +// +// Coordinator.swift +// MusicSpot +// +// Created by ์œค๋™์ฃผ on 11/29/23. +// + +import UIKit + +protocol Coordinator: AnyObject { + + var navigationController: UINavigationController { get } + var childCoordinators: [Coordinator] { get set } + +} diff --git a/iOS/MusicSpot/MusicSpot/MSCoordinator/RewindJourneyCoordinator.swift b/iOS/MusicSpot/MusicSpot/MSCoordinator/RewindJourneyCoordinator.swift new file mode 100644 index 0000000..31a043f --- /dev/null +++ b/iOS/MusicSpot/MusicSpot/MSCoordinator/RewindJourneyCoordinator.swift @@ -0,0 +1,73 @@ +// +// RewindJourneyCoordinator.swift +// MusicSpot +// +// Created by ์œค๋™์ฃผ on 11/28/23. +// + +import UIKit + +import MSData +import MSDomain +import RewindJourney + +final class RewindJourneyCoordinator: Coordinator { + + // MARK: - Properties + + var navigationController: UINavigationController + + var childCoordinators: [Coordinator] = [] + + weak var delegate: HomeCoordinatorDelegate? + + // MARK: - Initializer + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + // MARK: - Functions + + func start(with urls: [URL], music: Music) { + let spotRepository = SpotRepositoryImplementation() + let songRepository = SongRepositoryImplementation() + let viewModel = RewindJourneyViewModel(photoURLs: urls, + music: music, + spotRepository: spotRepository, + songRepository: songRepository) + let rewindJourneyViewController = RewindJourneyViewController(viewModel: viewModel) + rewindJourneyViewController.navigationDelegate = self + self.navigationController.pushViewController(rewindJourneyViewController, animated: true) + } + +} + +// MARK: - HomeMap Coordinator + +extension RewindJourneyCoordinator: HomeCoordinatorDelegate { + func popToHomeWithSpot(from coordinator: Coordinator, spot: Spot) { + // ๋ฏธ์‚ฌ์šฉ ํ•จ์ˆ˜ + } + + func popToHome(from coordinator: Coordinator) { + self.childCoordinators.removeAll() + self.navigationController.popViewController(animated: true) + self.delegate?.popToHome(from: self) + } + + func popToHome(from coordinator: Coordinator, with endedJourney: Journey) { + + } + +} + +// MARK: - RewindJourney Navigation + +extension RewindJourneyCoordinator: RewindJourneyNavigationDelegate { + + func popToHomeMap() { + self.popToHome(from: self) + } + +} diff --git a/iOS/MusicSpot/MusicSpot/MSCoordinator/SaveJourneyCoordinator.swift b/iOS/MusicSpot/MusicSpot/MSCoordinator/SaveJourneyCoordinator.swift new file mode 100644 index 0000000..4a82033 --- /dev/null +++ b/iOS/MusicSpot/MusicSpot/MSCoordinator/SaveJourneyCoordinator.swift @@ -0,0 +1,54 @@ +// +// SearchMusicCoordinator.swift +// MusicSpot +// +// Created by ์œค๋™์ฃผ on 11/29/23. +// + +import UIKit +import MusicKit + +import MSData +import MSDomain +import SaveJourney + +final class SaveJourneyCoordinator: Coordinator { + + // MARK: - Properties + + var navigationController: UINavigationController + + var childCoordinators: [Coordinator] = [] + + weak var delegate: SelectSongCoordinatorDelegate? + + // MARK: - Initializer + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + // MARK: - Functions + + func start(lastCoordinate: Coordinate, + selectedSong: Song) { + let journeyRepository = JourneyRepositoryImplementation() + let saveJourneyViewModel = SaveJourneyViewModel(lastCoordinate: lastCoordinate, + selectedSong: selectedSong, + journeyRepository: journeyRepository) + let saveJourneyViewController = SaveJourneyViewController(viewModel: saveJourneyViewModel) + saveJourneyViewController.navigationDelegate = self + self.navigationController.pushViewController(saveJourneyViewController, animated: true) + } + +} + +// MARK: - SaveJourney Navigation + +extension SaveJourneyCoordinator: SaveJourneyNavigationDelegate { + + func popToHome(with endedJourney: Journey) { + self.delegate?.popToHome(from: self, with: endedJourney) + } + +} diff --git a/iOS/MusicSpot/MusicSpot/MSCoordinator/SelectSongCoordinator.swift b/iOS/MusicSpot/MusicSpot/MSCoordinator/SelectSongCoordinator.swift new file mode 100644 index 0000000..6350606 --- /dev/null +++ b/iOS/MusicSpot/MusicSpot/MSCoordinator/SelectSongCoordinator.swift @@ -0,0 +1,82 @@ +// +// SelectSongCoordinator.swift +// MusicSpot +// +// Created by ์œค๋™์ฃผ on 11/29/23. +// + +import UIKit +import MusicKit + +import MSData +import MSDomain +import SaveJourney +import SelectSong + +protocol SelectSongCoordinatorDelegate: AnyObject { + + func popToHome(from coordinator: Coordinator, with endedJourney: Journey) + func popToSelectSong(from coordinator: Coordinator) + +} + +final class SelectSongCoordinator: Coordinator { + + // MARK: - Properties + + var navigationController: UINavigationController + + var childCoordinators: [Coordinator] = [] + + weak var delegate: HomeCoordinatorDelegate? + + // MARK: - Initializer + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + // MARK: - Functions + + func start(lastCoordinate: Coordinate) { + let songRepository = SongRepositoryImplementation() + let selectSongViewModel = SelectSongViewModel(lastCoordinate: lastCoordinate, + repository: songRepository) + let searchMusicViewController = SelectSongViewController(viewModel: selectSongViewModel) + searchMusicViewController.navigationDelegate = self + self.navigationController.pushViewController(searchMusicViewController, animated: true) + } + +} + +// MARK: - SelectSong Navigation + +extension SelectSongCoordinator: SelectSongNavigationDelegate { + + func navigateToHomeMap() { + self.delegate?.popToHome(from: self) + } + + func navigateToSaveJourney(lastCoordinate: Coordinate, selectedSong: Song) { + let saveJourneyCoordinator = SaveJourneyCoordinator(navigationController: self.navigationController) + saveJourneyCoordinator.delegate = self + self.childCoordinators.append(saveJourneyCoordinator) + saveJourneyCoordinator.start(lastCoordinate: lastCoordinate, selectedSong: selectedSong) + } + +} + +// MARK: - SelectSong Coordinator + +extension SelectSongCoordinator: SelectSongCoordinatorDelegate { + + func popToHome(from coordinator: Coordinator, with endedJourney: Journey) { + self.childCoordinators.removeAll() + self.delegate?.popToHome(from: coordinator) + } + + func popToSelectSong(from coordinator: Coordinator) { + self.childCoordinators.removeAll() + } + +} diff --git a/iOS/MusicSpot/MusicSpot/MSCoordinator/SpotCoordinator.swift b/iOS/MusicSpot/MusicSpot/MSCoordinator/SpotCoordinator.swift new file mode 100644 index 0000000..4d251e9 --- /dev/null +++ b/iOS/MusicSpot/MusicSpot/MSCoordinator/SpotCoordinator.swift @@ -0,0 +1,109 @@ +// +// SpotCoordinator.swift +// MusicSpot +// +// Created by ์œค๋™์ฃผ on 11/28/23. +// + +import UIKit + +import MSData +import MSDomain +import Spot + +final class SpotCoordinator: Coordinator { + + // MARK: - Properties + + var navigationController: UINavigationController + var childCoordinators: [Coordinator] = [] + + weak var delegate: HomeCoordinatorDelegate? + + // MARK: - Functions + + func start(spotCoordinate coordinate: Coordinate) { + let viewModel = SpotViewModel(coordinate: coordinate) + let spotViewController = SpotViewController(viewModel: viewModel) + self.navigationController.modalTransitionStyle = .coverVertical + self.navigationController.pushViewController(spotViewController, animated: true) + spotViewController.navigationDelegate = self + } + + // MARK: - Initializer + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + +} + +// MARK: - Spot Navigation + +extension SpotCoordinator: SpotNavigationDelegate { + + func presentPhotos(from viewController: UIViewController) { + guard let spotViewController = viewController as? SpotViewController else { + return + } + + let picker = UIImagePickerController() + picker.sourceType = .photoLibrary + picker.allowsEditing = true + picker.delegate = spotViewController + spotViewController.present(picker, animated: true) + } + + func presentSpotSave(using image: UIImage, coordinate: Coordinate) { + let journeyRepository = JourneyRepositoryImplementation() + let spotRepository = SpotRepositoryImplementation() + let viewModel = SpotSaveViewModel(journeyRepository: journeyRepository, + spotRepository: spotRepository, + coordinate: coordinate) + let spotSaveViewController = SpotSaveViewController(image: image, viewModel: viewModel) + spotSaveViewController.modalPresentationStyle = .fullScreen + spotSaveViewController.navigationDelegate = self + self.navigationController.presentedViewController?.dismiss(animated: true) + self.navigationController.present(spotSaveViewController, animated: true) + } + + func dismissToSpot() { + guard let presentedViewController = self.navigationController.presentedViewController, + let spotSaveViewController = presentedViewController as? SpotSaveViewController else { + return + } + + spotSaveViewController.dismiss(animated: true) + } + + func popToHome() { + self.popToHome(from: self) + } + + func popToHomeWithSpot(spot: Spot) { + self.popToHomeWithSpot(from: self, spot: spot) + } + +} + +// MARK: - HomeMap Coordinator + +extension SpotCoordinator: HomeCoordinatorDelegate { + + func popToHome(from coordinator: Coordinator) { + self.childCoordinators.removeAll() + self.navigationController.popViewController(animated: true) + self.delegate?.popToHome(from: self) + } + + func popToHomeWithSpot(from coordinator: Coordinator, spot: Spot) { + self.childCoordinators.removeAll() + self.navigationController.popViewController(animated: true) + self.delegate?.popToHomeWithSpot(from: self, spot: spot) + } + + func popToHome(from coordinator: Coordinator, with endedJourney: Journey) { + // + } + +} diff --git a/iOS/MusicSpot/MusicSpot/SceneDelegate.swift b/iOS/MusicSpot/MusicSpot/SceneDelegate.swift new file mode 100644 index 0000000..da7a457 --- /dev/null +++ b/iOS/MusicSpot/MusicSpot/SceneDelegate.swift @@ -0,0 +1,96 @@ +// +// SceneDelegate.swift +// MusicSpot +// +// Created by ์ด์ฐฝ์ค€ on 11/13/23. +// + +import UIKit + +import JourneyList +import MSConstants +import MSData +import MSDesignSystem +import MSLogger +import MSUserDefaults + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + // MARK: - Properties + + var window: UIWindow? + private var appCoordinator: Coordinator! + + @UserDefaultsWrapped(UserDefaultsKey.recordingJourneyID, defaultValue: nil) + var recordingJourneyID: String? + @UserDefaultsWrapped(UserDefaultsKey.isFirstLaunch, defaultValue: false) + var isFirstLaunch: Bool + @UserDefaultsWrapped(UserDefaultsKey.isRecording, defaultValue: false) + var isRecording: Bool + + #if DEBUG + var keychain = MSKeychainStorage() + #endif + + // MARK: - Functions + + func scene(_ scene: UIScene, + willConnectTo _: UISceneSession, + options _: UIScene.ConnectionOptions) { + guard let windowScene = (scene as? UIWindowScene) else { return } + let window = UIWindow(windowScene: windowScene) + defer { self.window = window } + + MSLogger.make(category: .userDefaults).info("isFirstLaunch: \(self.isFirstLaunch.description)") + MSLogger.make(category: .userDefaults).info("isRecording: \(self.isRecording.description)") + MSLogger.make(category: .userDefaults).info("recordingJourneyID: \(self.recordingJourneyID ?? "No Recording")") + + #if DEBUG + self.prepareToDebug() + #endif + MSFont.registerFonts() + + let musicSpotNavigationController = self.makeNavigationController() + let appCoordinator = AppCoordinator(navigationController: musicSpotNavigationController) + self.appCoordinator = appCoordinator + appCoordinator.start() + + window.rootViewController = musicSpotNavigationController + window.makeKeyAndVisible() + } + +} + +// MARK: - NavigationController + +private extension SceneDelegate { + + func makeNavigationController() -> UINavigationController { + let navigationController = UINavigationController() + navigationController.navigationBar.tintColor = .msColor(.primaryTypo) + return navigationController + } + +} + +// MARK: - Debug + +#if DEBUG +import MSKeychainStorage + +private extension SceneDelegate { + + func prepareToDebug() { + self.isFirstLaunch = true + self.recordingJourneyID = nil + self.isRecording = false + + do { + try self.keychain.deleteAll() + } catch { + MSLogger.make(category: .keychain).error("ํ‚ค์ฒด์ธ ์ดˆ๊ธฐํ™” ์‹คํŒจ") + } + } + +} +#endif diff --git a/iOS/MusicSpot/MusicSpot/ko.lproj/LaunchScreen.strings b/iOS/MusicSpot/MusicSpot/ko.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/iOS/MusicSpot/MusicSpot/ko.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/iOS/commit b/iOS/commit new file mode 100644 index 0000000..d3f7aca --- /dev/null +++ b/iOS/commit @@ -0,0 +1,32 @@ +#!/bin/sh + +LINTPATH='.swiftlint.yml' +declare -a PATHS=("MSCoreKit" "MSFoundation" "MSUIKit" "MSData" "MusicSpot" "Features") +failures="" + +for path in "${PATHS[@]}"; do + if [ -d "$path" ]; then + echo "๐Ÿ‘€ Running SwiftLint for $path" + result=$(swiftlint lint --progress --config $LINTPATH $path) + if [[ "$result" == *warning* ]]; then + echo "๐Ÿšง Lint Warning: \n$result" + fi + if [[ ! "$result" == *error* ]]; then + echo "โœ… Lint succeed for $path\n" + else + echo "โŒ Lint failed for $path\n" + failures+="\n\033[1mโœ”๏ธ $path\033[0m\n$result\n" + fi + else + echo "โ“ $path directory does not exist." + exit 1 + fi +done + +if [ ! -z "$failures" ]; then + echo "$failures" + exit 1 +else + echo "โœจ All linting checks passed. Ready to commit." + gitmoji -c +fi \ No newline at end of file