Skip to content

musicabbage/interaction-experiment

Folders and files

NameName
Last commit message
Last commit date

Latest commit

ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

Development documentation

Install instructions

Apple has strict rules for installing apps on real devices. Basically, everyone should download apps from the App Store so that they can get commissions from developers. A valid IPA(iOS App Store package) which can run on devices should contain a valid certification authenticated by Apple. The certification is generated from Apple and signed with an Apple ID.

To create a valid IPA, we need an Apple ID. By logging in to the account using Xcode, the source code can be compiled along with the certification downloaded from Apple. Once this is done, the API can be authorized by Apple and installed on devices.

overall_diagram

What you need?

Setup Xcode

The given instruction is for using Xcode v15.0.

Step 1. Log in with your Apple ID on Xcode

Open Xcode, press Command + , to open Xcode Settings. Setting > Accounts > โž•

1_setup_xcode_1_1

Choose Apple ID

1_setup_xcode_1_2

Then log in with your Apple ID.

Your account should appear in the list after successfully logging in with your Apple ID.

1_setup_xcode_1_3

Step 2. Download the source code at https://github.com/musicabbage/interaction-experiment. The latest version is v1.1

Step 3. Setup signing

Select the project file > Choose InteractExperiment in the TARGETS list, > Select Signing & Capabilities > Expand the Signing section

1_setup_xcode_2_2

Check Automatically manage signing โœ… and select the team related to the Apple ID you just logged in to.

1_setup_xcode_2_1

If everything is set, you should see the Signing setting like this:

1_setup_xcode_2_3

Trouble shooting:

1_setup_xcode_2_ts

If you see the error in the Signing Certificate field with the following message:

Failed to register bundle identifier

The app identifier "ac.sussex.InteractExperiment" cannot be registered to your development team because it is not available. Change your bundle identifier to a unique string to try again.

Which means others used this ID. (The Bundle Identifier must be unique across ALL app IDs worldwide.) Therefore, change the Bundle Identifier to another unique id, ex: ac.{your_sussex_id}.InteractExperiment.

Step 4. Ensure iOS SDK is downloaded.

1_setup_xcode_2_4

Setup the iPad

Step1. Enable Developer Mode. Open your iPad Settings.

Select Privacy & Security. In the SECURITY sections, tap Developer Mode.

2_setup_ipad_1_1

Switch on Developer Mode

2_setup_ipad_1_2

You will receive a warning that enabling Developer Mode will decrease your device's security and ask you to restart the iPad. Tap the Restart button to continue to open Developer Mode.

2_setup_ipad_1_3

You can find Apple's official documentation on enabling Developer Mode with the following link: https://developer.apple.com/documentation/xcode/enabling-developer-mode-on-a-device.

Step 2. Ensure the iPad is connected to the internet.

Step 3. Connect the iPad to the Mac. If it connects successfully, the device will show in the list of running target devices.

2_setup_ipad_3_1

Trouble shooting:

If you can't see the iPad on the device list, please check:

  1. Ensure the iPad is connected to the Mac by checking Finder's Sidebar. (The iPad would show in the Sidebar if it connect to the Mac correctly.) https://support.apple.com/en-gb/guide/mac-help/mchld88ac7da/mac
  2. Ensure the iOS SDK is Downloaded.
  3. Ensure Xcode supports the iOS version. https://developer.apple.com/support/xcode

Running the app on the iPad

Step 1. [Xcode] Select the running scheme InteractExperiment.

3_running_1_1

Step 2. [Xcode] Select the target device.

3_running_2_1

Step 3. [Xcode] Tap the run button in Xcode.

3_running_3_1

Step 4. [iPad] Trust the profile for the app on the iPad.

You need to trust the developer profile if you receive an Untrusted Developer alert on your iPad.

3_running_4_1

Open iPad Settings > General > VPN & Device Management.

3_running_4_2

In the DEVELOPER APP section, tap Apple Development: {your_Apple_ID}.

Trust Apple Development: {your_Apple_ID}. 3_running_4_3

3_running_4_4

Step 5. [Xcode] Run the app again. (as the same with Step 3)

That's it!!! ๐ŸŽ‰ I hope that the app runs smoothly on your iPad.โค๏ธ

References:

ViewModel

1. Define as protocol

2. Create from coordinator

3. Define ViewState to update view state.

Such as loading / load text or images / display error message

4. Using Publisher to notify views

To make the view model can be reuse between SwiftUI and UIKit

Coordinator

Coordinators are responsible for routing between different views, such as pushing a view to the navigation stack or presenting a modal view or a cover view. There are three things that coordinators have to do:

  1. Create views.
  2. Perform push/present behaviours.
  3. Maintain the state of the navigation stack.

Create views

Each view should be clean and only present UI and data. The business logic is stored in the ViewModel, and the routing is performed by coordinators.

To separate the UI and the business logic, a view model is initialized by a coordinator and then injected into the created views. Additionally, the FlowState is used to bind user actions from the UI to the coordinator.

struct CreateConfigurationCoordinator: View {

    @StateObject var state: CreateConfigFlowState
    private let viewModel: CreateConfigurationViewModel = .init()

    var body: some View {
        CreateConfigurationView(flowState: state, viewModel: viewModel)
    }
}

To route to this coordinator, just create this coordinator like this:

    var body: some View {
        CreateConfigurationCoordinator()
    }

Perform push/present behaviours

Push and present behaviours are triggered by a FlowState, such as:

class CreateConfigFlowState: ObservableObject {
    // 1
    @Binding var path: NavigationPath 
    // 2
    @Published var presentedItem: CreateConfigFlowLink? 
    @Published var coverItem: CreateConfigFlowLink?
    
    init(path: Binding<NavigationPath>) {
        _path = .init(projectedValue: path)
    }
    
    // 3
    static var mock: CreateConfigFlowState {
        CreateConfigFlowState(path: .constant(.init()))
    }
}

// 4 & 5
enum CreateConfigFlowLink: Hashable & Identifiable {
    case FlowLinkA
    case FlowLinkB(String) //parameter

    var id: String { String(describing: self) }
}
  1. If this coordinator might be contained in a navigation stack, it should have a NavigationPath with the @Binding property wrapper. This NavigationPath needs to be injected from its parent coordinator so that the NavigationView which store in the parent can be notified when the path is changed.
// parent coordinator
NavigationStack(path: $state.path) {
    //views
}
  1. presentedItem and coverItem represent modal and full-screen views, respectively. These are represented as an enum so that when the coordinator is notified of presenting a view, it can identify which view will be shown based on the enum value.

  2. You can create a mock coordinator variable for testing and SwiftUI preview.

  3. Declare an enum for FlowLink, which defines different routes based on users' actions. In this case, there are two actions FlowLinkA and FlowLinkB. You can define paremeters by using associated values.

  4. FlowLink conforms to Hashable and Identifiable protocols to enable its usage in NavigationPath.

With a FlowState, a coordinator can perform a push/present action with the following code:

struct CreateConfigurationCoordinator: View {
    NavigationStack(path: $state.path) {
        CreateConfigurationView(flowState: state, viewModel: viewModel)
            .navigationDestination(for: CreateConfigFlowLink.self, destination: navigationDestination)
            .sheet(item: $state.presentedItem, content: presentContent)
            .fullScreenCover(item: $state.coverItem, content: coverContent)
    }
}

private extension CreateConfigurationCoordinator {
    @ViewBuilder
    private func navigationDestination(link: CreateConfigFlowLink) -> some View {
        switch link {
        case let .FlowLinkB(parameter):
            //some view
        default:
            Text("not implemented process")
        }
    }
    
    @ViewBuilder
    private func presentContent(item: CreateConfigFlowLink) -> some View {
        switch item {
        case .FlowLinkA:
           //some view
        default:
            Text("undefined present content")
        }
    }

    @ViewBuilder
    private func coverContent(item: CreateConfigFlowLink) -> some View {
        //some view
    }
}

Maintain the state of the navigation stack.

The NavigationPath instance is initialised by the coordinator who owns the NavigationStack. It passes the path into child coordinator to make children can push/pop views.

  • Parent
@ObservedObject var state: RootFlowState = .init()
    
var body: some View {
NavigationStack(path: $state.path) {
    RootView(flowState: state)
        .navigationDestination(for: RootFlowLink.self, destination: navigationDestination)
}
  • Children
class ChildFlowState: ObservableObject {
    @Binding var path: NavigationPath
    
    init(path: Binding<NavigationPath>) {
        _path = .init(projectedValue: path)
    }
}

struct ChildCoordinator: View {
    @StateObject var state: ChildFlowState
    
    init(navigationPath: Binding<NavigationPath>) {
        _state = .init(wrappedValue: .init(path: navigationPath))
    }
}

To push a new view, child coordinator can append a new FlowLink into the NavigationPath:

state.path.append(ChildFlowLink.someLink)

Put them together, the architecture with coordinators can be described by the following diagram:

Coordinator

I used these articles as references to construct this coordinator structure. ๐Ÿ‘‡

Experiment Log Data

File name

{Name} (Trial {trial number}){experiment start date}

ex. Anonymous Participant (Trial 1) 2023-07-04 - 18-48-10.txt

outputFileString = InteractLog.filepath+" (Trial "+trial.getTrialnr()+") "+filedate.format(expStartDate)+".txt";

Experient Data

Name                 :Anonymous Participant
Experiment Start     :04/07/2023 - 17:29:30
Stroke Colour        :0,0,0,255
Background Colour    :255,255,255,255
Stroke Width         :2.0
Stimulus Files       :S1.png,S2.png,S3.png,S4.png,S5.png,S6.png,S7.png,S8.png
Familiarisation File :P1.png
Input Mask File      :
Drawing Pad Size     :1260,600
Trial Number         :1
Trial Start          :04/07/2023 - 17:29:34
Trial End            :04/07/2023 - 17:29:41
Field
Name
Experiment Start After inputting participant id (instruction)
Stroke Colour
Background Colour
Stroke Width
Stimulus Files
Familiarisation File
Input Mask File
Drawing Pad Size
Trial Number
Trial Start Experiment startNextTrial (InputPad init)
Trial End KeyEvent.VK_ESCAPE (trial ended, before end message)

Event Data

Code Callstack
("0;0","KeyPressed_"+kc+"("+InteractLog.data_separator_descr+":"+e.getKeyLocation()+")")
("0;0","KeyPressed_"+kc+"(RETURN"+e.getKeyLocation()+")")
("0;0","KeyPressed_"+kc+"("+InteractLog.repstim_keyname+":"+e.getKeyLocation()+")")
("0;0","KeyPressed_"+kc+"("+InteractLog.nextstim_keyname+":"+e.getKeyLocation()+")")
("0;0","KeyPressed_"+kc+"("+InteractLog.prevstim_keyname+":"+e.getKeyLocation()+")")
("0;0","KeyPressed_"+kc+"("+InteractLog.hidestim_keyname+":"+e.getKeyLocation()+")")
("0;0","KeyPressed_"+kc+"("+InteractLog.endexp_keyname+":"+e.getKeyLocation()+")")
("0;0","KeyPressed_"+kc+"("+e.getKeyChar()+":"+e.getKeyLocation()+")")
("0;0","KeyReleased_"+kc+"("+InteractLog.data_separator_descr+":"+e.getKeyLocation()+")")
("0;0","KeyReleased_"+kc+"(RETURN"+e.getKeyLocation()+")")
("0;0","KeyReleased_"+kc+"("+InteractLog.repstim_keyname+":"+e.getKeyLocation()+")")
("0;0","KeyReleased_"+kc+"("+InteractLog.nextstim_keyname+":"+e.getKeyLocation()+")")
("0;0","KeyReleased_"+kc+"("+InteractLog.prevstim_keyname+":"+e.getKeyLocation()+")")
("0;0","KeyReleased_"+kc+"("+InteractLog.hidestim_keyname+":"+e.getKeyLocation()+")")
("0;0","KeyReleased_"+kc+"("+InteractLog.endexp_keyname+":"+e.getKeyLocation()+")")
("0;0","KeyReleased_"+kc+"("+e.getKeyChar()+":"+e.getKeyLocation()+")")
eventCount+";"+System.nanoTime()+";"+x2+";"+y2+";ButtonReleased"+newline; mouseReleased
eventCount+";"+System.nanoTime()+";"+x1+";"+y1+";ButtonDown"+newline; mousePressed
("0;0","DrawingEnabled") InputPane init
("0;0","FamiliarisationOff_"+InteractLog.famfilename) InputPane removeStim (firststim)
("0;0","StimulusOff_"+InteractLog.stimfilename) InputPane removeStim (stimpresent)
("0;0","StimulusOn_"+InteractLog.stimfilename) InputPane showStim
("0;0","FamiliarisationOn_"+InteractLog.famfilename) StimPanel init
  • ButtonDown/ButtonReleased: strokesData
    • ButtonDown: get x1, y1
      • mousePressed
    • ButtonReleased: add to strokesData strokesData+= ";"+temp[1]+";"+x1+";"+y1+";"+temp[2]+";"+temp[3]+newline;
      • mouseReleased
temp = {String[5]@3809} ["6", "24912044833416", "373", "175", "ButtonDown"]
 0 = "6"
 1 = "24912044833416"
 2 = "373" //x1
 3 = "175" //y1
 4 = "ButtonDown"
 
["7", "24913597758916", "326", "366", "ButtonReleased"]
  • KeyReleased: keyData

InputPad line.170

if(kc == KeyEvent.VK_ESCAPE || (e.isControlDown() && kc == KeyEvent.VK_ENTER)) {
			if(writeFile()) ex.endTrial();
		}

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Packages

No packages published

Languages