over 1 year ago

How to custom UITableViewCell with Swift

隨手筆記,詳細資訊可以參考網路上的教學素材,推薦 iTunesU 上 standford 的 iOS 開發課程,本篇是基於 chapter 10.tableview 的筆記。

Start custom table view cell

在 Storyboard 把 cell 變成 custom
新增 cocoatouch TweetTableViewCell 檔案繼承 UITableViewCell
把 cell 的 class 設為 TweetTableViewCell
把 cell 裡面的物件都跟 TweetTableViewCell 裡面變數連結

UITableViewCell class 裡面定義物件 ex:

TweetTableViewCell.swift
var tweet: Tweet {
     didSet {
          updateUI()
     }
}

func updateUI() {
    // Do some UI Stuff

}

在 UITableViewController 裡面 cell.tweet = tweet 即可

TweetTableViewController.swift
struct Storyboard {
    static let CellReusIdentifier = "TweetCell"
}

override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCellWithIdentifier(Storyboard.CellReusIdentifier) as! NoteTableViewCell
    
    cell.tweet = tweets[indexPath.row]
    
    return cell
}

Extra:

自動判斷 table cell 高度:

UITableViewController viewDidLoad

TweetTableViewController.swift
override func viewDidLoad() {
    super.viewDidLoad()
    tableView.estimatedRowHeight = tableView.rowHeight
    tableView.rowHeight = UITableViewAutomaticDimension
}

Table View Pull to refresh

TableViewController pull to refresh
選取 table view controller 然後把 refreshing 改成 Enabled
然後去 outline 找到 refresh control, ctr drag 到 controller 就可以了

TweetTableViewController.swift
@IBAction func refresh(sender: UIRefreshControl?) {
    // Do request

        
    sender?.endRefreshing()
}
 
almost 2 years ago

1. 加上 Table View

開啟新專案 TodoExample 選 Tabbed Application,把 View 都設定好

請照著影片上的流程做

影片一

教學影片1 - 設定 View

2. Install Cocoapods & RealmSwift

Cocoapods 是幫我們管理 App 套件的程式,安裝好後就可以很方便的幫 App 加上各種 Plugin,在我們的 Todo App 中因為需要有可以存資料的地方,所以我們要安裝 RealmSwift 這個套件,安裝方式就是在 Cocoapods 產生的 Podfile 裡面加上 pod 'RealmSwift' 然後在 Command Line 打上 pod install 就可以了。

安裝 Cocoapods:

待補
CocoaPods

安裝好 Cocoapods 後要讓 Cocoapods 管理這個專案,首先打開 Command Line 進到目前的資料夾:

wayne@Waynes-MacBook-Pro ~/Downloads/TodoExample $
$ pod init
Podfile
# Uncomment this line to define a global platform for your project

# platform :ios, '8.0'

# Uncomment this line if you're using Swift

# use_frameworks!


target 'TodoExample' do
  use_frameworks!
  pod 'RealmSwift'
end
$ pod install

把 Xcode 關掉,改成開 TodoExample.xcworkspace 而不要開 TodoExample.xcodeproj,因為只有 workspace 的檔案會幫你載入你想要的 framework, 在此例就是幫你載入 Realm 的 framework.

3. 定義 Model

建立一個新的檔案叫做 Task.swift

Task.swift
import RealmSwift

class Task: Object {

    dynamic var name = ""
    dynamic var isCompleted = false

}

教學影片2 - 建立Task

4. 讓 Table View 顯示東西,並且可以新增 Task

記得剛剛我們有兩個 Tabs 嗎? 現在我們要加上一些功能

  1. 點擊新增的按鈕可以跳出新增 Task 的 Popup 頁面,並可以新增 Task
  2. 讓 Table 可以顯示所有未完成的 Tasks

(1) 點擊新增的按鈕可以跳出新增 Task 的 Popup 頁面,並可以新增 Task

首先我們先看第一個 Tab,也就是 Todo List 的 Tab, 這個 Tab 對應的檔案 Xcode 已經幫我們產生了,打開 FirstViewController.swift 會看到現在長這樣:

FirstViewController.swift
import UIKit

class FirstViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.

    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.

    }


    @IBAction func addNewItem(sender: AnyObject) {
    }
}

記得我們在 Step1 新增 View 的時候有把新增按鈕拖動到這個檔案裡面嗎?就是下面這段:

FirstViewController.swift
@IBAction func addNewItem(sender: AnyObject) {
}

這段 Code 的意思就是每當我們按下新增按鈕的時候,執行這段 Code。
也就是說,裡面要執行這些動作:

  1. 跳出新增的視窗
  2. 可以輸入 Task 的 Name
  3. 可以新增 Task
  4. 可以 Cancel 取消新增

讓我們一行一行打上去變這樣:

首先因為我們要在這個 Controller 裡面使用到 Realm 的功能,所以要在這個檔案的開頭加上

import RealmSwift

然後在 FirstViewController class 裡面定義一個 Realm 的 Object 常數

let realm = try! Realm()

之後定義使用者按了新增的按鈕後應該會發生的動作:

@IBAction func addNewItem(sender: AnyObject) {
    // 定義 alertController, UIAlertController 就是之後會跳出來的 Popup View

    let alertController = UIAlertController(title: "Add Task", message: nil, preferredStyle: UIAlertControllerStyle.Alert)
    
    // 加上一個 Input 的 TextField 框框讓使用者輸入 Task Name

    alertController.addTextFieldWithConfigurationHandler({ (textField) -> Void in
        textField.placeholder = "type task name here.."
    })
    
    
    // 定義這個 Alert Popup 會有哪些動作,此例我們只需要 Cancel 和 Add

    let cancelAction = UIAlertAction(title: "Cancel", style: .Default, handler: { (action: UIAlertAction!) in
        // handler 裡面的 code 代表使用者按了這個 Action 以後要處理的事情。

        print("Canceled")
    })
    
    let addTaskAction = UIAlertAction(title: "Add", style: .Default, handler: { (action: UIAlertAction!) in
        let textField = alertController.textFields![0] as UITextField
        let taskName = textField.text! as String
        
        // 初始化 Task 的 Object

        let task = Task()
        task.name = taskName

        // 將 Task 寫進資料庫

        try! self.realm.write {
            self.realm.add(task)
        }
    })
    
    // 把剛剛定義好的 Action 塞到 Alert 的 Popup 裡面

    alertController.addAction(cancelAction)
    alertController.addAction(addTaskAction)
    
    // 將前面設定好的 Alert View 顯示出來

    presentViewController(alertController, animated: true, completion: nil)
}

整個檔案會變成這樣:

FirstViewController.swift
import UIKit
import RealmSwift

class FirstViewController: UIViewController {

    let realm = try! Realm()
    @IBOutlet weak var tableView: UITableView!
    
    

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.

    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.

    }


    @IBAction func addNewItem(sender: AnyObject) {
        // 定義 alertController, UIAlertController 就是之後會跳出來的 Popup View

        let alertController = UIAlertController(title: "Add Task", message: nil, preferredStyle: UIAlertControllerStyle.Alert)
        
        // 加上一個 Input 的 TextField 框框讓使用者輸入 Task Name

        alertController.addTextFieldWithConfigurationHandler({ (textField) -> Void in
            textField.placeholder = "type task name here.."
        })
        
        
        // 定義這個 Alert Popup 會有哪些動作,此例我們只需要 Cancel 和 Add

        let cancelAction = UIAlertAction(title: "Cancel", style: .Default, handler: { (action: UIAlertAction!) in
            // handler 裡面的 code 代表使用者按了這個 Action 以後要處理的事情。

            print("Canceled")
        })
        
        let addTaskAction = UIAlertAction(title: "Add", style: .Default, handler: { (action: UIAlertAction!) in
            let textField = alertController.textFields![0] as UITextField
            let taskName = textField.text! as String
            
            // 初始化 Task 的 Object

            let task = Task()
            task.name = taskName

            // 將 Task 寫進資料庫

            try! self.realm.write {
                self.realm.add(task)
            }
        })
        
        
        // 把剛剛定義好的 Action 塞到 Alert 的 Popup 裡面

        alertController.addAction(cancelAction)
        alertController.addAction(addTaskAction)
        
        // 將前面設定好的 Alert View 顯示出來

        presentViewController(alertController, animated: true, completion: nil)
    }
}

(2) 讓 Table 可以顯示所有未完成的 Tasks

請照著教學影片的做法將 Table 設定好

教學影片3 - 設定 Table

影片解釋:

我們使用了 UITableViewDelegateUITableViewDataSource 兩個 Protocal 讓這個 FristViewController 可以有操作 Table 的能力,在 Storyboard 按住 Control 拖拉 Table View 是為了讓 Story Board 中 Table 的 Object 可以跟我們寫在 Controller 中的東西連結起來,建立 Reference,若是少了這個步驟那模擬器就不會顯示任何資訊在 Table 上面。

另外由於 UITableViewDataSource 需要有以下兩個 function, 所以我在影片中快速 Demo 了一下這兩個 Function 的作用,如果想要查在這兩個 protocal 裡面還有哪些可以用的 function 可以按住 Command 然後點擊它,就如同影片中做的一樣,有空可以多看看。

// 告訴 Table View 我們的 Table 有幾格 (幾個 Cells)

func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 1 // 這個 Table 只有 1 個 cell

}

// 告訴 Table View 每個 Cell 的內容

tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        // 建立 Cell 的物件並設個 Text 測試看看是否成功。

        let cell = UITableViewCell()
        
        cell.textLabel!.text = "Test Cell Here!!"
        
        return cell
}

Table 設定好之後,我們就要想辦法讓真正的資料顯示在 Cell 中

方便起見,我們先定義一個變數 items 來儲存所有新增的 Tasks

var tasks :Results<Task>!

然後在 viewDidLoad() 裡面把這些 Tasks 都拿出來:

override func viewDidLoad() {
    super.viewDidLoad()
    // Do any additional setup after loading the view, typically from a nib.

    
    loadTasks()
}

func loadTasks() {
    tasks = realm.objects(Task).filter("isCompleted == 0")
}

viewDidLoad() 這個 function 的意思就是讓手機在 load 完這個 View 但是還沒顯示前做的事情,所以我們要趁沒顯示前先把資料準備好也是很合理的。

接下來就是把剛剛寫過的兩個 table 相關的 functions 改成以下的樣子:

func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    // 有多少 Tasks 就有多少 Cell

    return tasks.count
}

func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
    let cell = UITableViewCell()
    
    // 因為 tasks 是一個 List,所以我們可以從 cell 的順序來取得每一個 Task,按照順序顯示出來

    cell.textLabel!.text = tasks[indexPath.row].name
    
    return cell
}

最後還有一件事情要做,就是在每次 Task 有變動的時候記得要重新 refresh table,目前我們會變動的時候只有新增 Task 的時候,所以要加兩行 Code 在 addNewItem() 的裡面,分別是重新讀取 Tasks 的資料以及 Refresh table

self.loadTasks()
self.tableView.reloadData()

addNewItem 的 function 變成這樣:

@IBAction func addNewItem(sender: AnyObject) {
    // 定義 alertController, UIAlertController 就是之後會跳出來的 Popup View

    let alertController = UIAlertController(title: "Add Task", message: nil, preferredStyle: UIAlertControllerStyle.Alert)
    
    // 加上一個 Input 的 TextField 框框讓使用者輸入 Task Name

    alertController.addTextFieldWithConfigurationHandler({ (textField) -> Void in
        textField.placeholder = "type task name here.."
    })
    
    
    // 定義這個 Alert Popup 會有哪些動作,此例我們只需要 Cancel 和 Add

    let cancelAction = UIAlertAction(title: "Cancel", style: .Default, handler: { (action: UIAlertAction!) in
        // handler 裡面的 code 代表使用者按了這個 Action 以後要處理的事情。

        print("Canceled")
    })
    
    let addTaskAction = UIAlertAction(title: "Add", style: .Default, handler: { (action: UIAlertAction!) in
        let textField = alertController.textFields![0] as UITextField
        let taskName = textField.text! as String
        
        // 初始化 Task 的 Object

        let task = Task()
        task.name = taskName

        // 將 Task 寫進資料庫

        try! self.realm.write {
            self.realm.add(task)
        }
        
        self.loadTasks()
        self.tableView.reloadData()
    })
    
    
    // 把剛剛定義好的 Action 塞到 Alert 的 Popup 裡面

    alertController.addAction(cancelAction)
    alertController.addAction(addTaskAction)
    
    // 將前面設定好的 Alert View 顯示出來

    presentViewController(alertController, animated: true, completion: nil)
}

教學影片4 - 讓 Table 顯示 Data

5. 可以將每個 Task Mark as completed 和 Delete

接下來要做滑動 Cell 就可以把 Task 變成完成和刪除的功能,在 UITableViewDataSource 裡面有提供 editActionsForRowAtIndexPath 的 API 給我們用,所以只要寫以下的 Code 就可以實作:

func tableView(tableView: UITableView, editActionsForRowAtIndexPath indexPath: NSIndexPath) -> [UITableViewRowAction]? {
    let doneAction = UITableViewRowAction(style: .Normal, title: "Mark as completed") { action, index in
        let task = self.tasks[indexPath.row]
        
        try! self.realm.write {
            task.isCompleted = true
        }
        
        self.loadTasks()
        self.tableView.reloadData()
    }
    doneAction.backgroundColor = UIColor.greenColor()
    
    let deleteAction = UITableViewRowAction(style: .Default, title: "Delete") { action, index in
        let task = self.tasks[indexPath.row]
        
        
        try! self.realm.write {
            self.realm.delete(task)
        }
        
        self.loadTasks()
        self.tableView.reloadData()
    }
    
    return [deleteAction, doneAction]
}

6. 完成 Completed 的 Tab

以上就大致完成了這個 Todo App, 剩下的就是把一樣的功能也寫進 SecondViewController 裡面,大致上就把 FirstViewController 的 Code 貼過去,大概會長這樣:

import UIKit
import RealmSwift

class SecondViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {

    
    let realm = try! Realm()
    
    var tasks :Results<Task>!
    
    @IBOutlet weak var tableView: UITableView!
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.

        
        loadTasks()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.

    }
    
    func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return tasks.count
    }
    
    func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        let cell = UITableViewCell()
        
        cell.textLabel!.text = tasks[indexPath.row].name
        
        return cell
    }



    @IBAction func addNewItem(sender: AnyObject) {
        let alertController = UIAlertController(title: "Add Task", message: nil, preferredStyle: UIAlertControllerStyle.Alert)
        
        alertController.addTextFieldWithConfigurationHandler({ (textField) -> Void in
            textField.placeholder = "type task name here.."
        })
        
        let cancelAction = UIAlertAction(title: "Cancel", style: .Default, handler: { (action: UIAlertAction!) in
            print("Canceled")
        })
        
        let addTaskAction = UIAlertAction(title: "Add", style: .Default, handler: { (action: UIAlertAction!) in
            let textField = alertController.textFields![0] as UITextField
            let taskName = textField.text! as String
            
            let task = Task()
            task.name = taskName
            

            try! self.realm.write {
                self.realm.add(task)
            }
        })
        
        
        alertController.addAction(cancelAction)
        alertController.addAction(addTaskAction)
        
        presentViewController(alertController, animated: true, completion: nil)
    }
    
    func tableView(tableView: UITableView, editActionsForRowAtIndexPath indexPath: NSIndexPath) -> [UITableViewRowAction]? {
        let doneAction = UITableViewRowAction(style: .Normal, title: "Mark as uncompleted") { action, index in
            let task = self.tasks[indexPath.row]
            
            try! self.realm.write {
                task.isCompleted = false
            }
            
            self.loadTasks()
            self.tableView.reloadData()
        }
        doneAction.backgroundColor = UIColor.greenColor()
        
        let deleteAction = UITableViewRowAction(style: .Default, title: "Delete") { action, index in
            let task = self.tasks[indexPath.row]
            
            
            try! self.realm.write {
                self.realm.delete(task)
            }
            
            self.loadTasks()
            self.tableView.reloadData()
        }
        
        return [deleteAction, doneAction]
    }
    
    
    func loadTasks() {
        tasks = realm.objects(Task).filter("isCompleted == 1")
    }
}

幾個注意的點

  1. 記得把 table view 跟 delegate 和 datasource reference 起來(storyboard)
  2. 把 mark as completed 改成 mark as uncompleted
  3. Load isCompleted == 1 的 tasks
  4. 新增完 task 不用 reload tasks 跟 table view

做完後其實會有一個小 Bug,就是 mark as completed/uncompleted 後再切換到另外一個 Tab 發現剛剛 Mark 的 Task 並沒有出現在上面,原因是因為我們只有在 viewDidLoad 的時候更新 Tasks,但是切換 Tab 的時候 View 早就 Loaded 好了,所以並不會再度觸發 viewDidLoad 裡面的 loadTasks(),所以其實我們應該要在這兩個 Tab 的 Controller 裡面再加上一個 function:

override func viewDidAppear(animated: Bool) {
    loadTasks()
    tableView.reloadData()
}

這段就會讓我們在切換 Tab 但是 View 還沒顯示出來的時候運行,以上。

最後成品:Github

待續

  1. 整理 Code
  2. Edit Task
  3. 批次刪除 Tasks

參考資料

Realm
CocoaPods
http://www.appcoda.com/realm-database-swift/

 
almost 2 years ago

實作環境:

Xcode: Version 7.1.1
Language: Apple Swift version 2.1

教學影片:

https://www.youtube.com/watch?v=cpANieebE2M

遇到的問題和解法

建議1: FB App 的設定部份可以直接到 FB App 的 Dashboard 的 Get Started with the Facebook SDK 格子內選擇 Choose Platform,照裡面的步驟走,把 info.plist 檔案設定好,只要走到加 Bundle Identifier 的步驟就可以了。

雷1: Xcode 7 以上的版本 copy item if needed 要勾選,否則會出現 No such module FBSDKCoreKit 的 Error。 StackOverflow
雷2: 要記得把 Bundle Identifier 加到 FB App Settings 裡,如果還沒加 iOS Platform 可以在 FB App Dashboard按 Choose Platform 選 iOS Quick Start 或是直接到 Setting Tab 裡面加。

Source Code:

https://github.com/wayne5540/swift-fb-login-example

 
about 2 years ago

此篇主要筆記 attr_reader, attr_writer, 和 attr_accessor 的用法與差別

Differences

attr_reader

attr_reader 做的事情其實就是幫忙定義 instance method,如此我們就可以藉由這個 instance method 取得 instance variable 的值。舉例說明:

class User
  attr_reader :email
end

意思就是幫你定義一個叫做 email的 instance method 去取得 email 這個 instance variable 的值:

class User
  # 為避免混淆,這裡省略 initialize method

  def email
    @email
  end
end

所以我們就才可以對 User 的 Instance Object 問 email:

user = User.new(email: "wayne@example.com")
user.email
# => wayne@example.com

attr_writer

相較於 attr_reader 是讓我們取得 instance variable 的值, attr_writer 則是讓我們指派 instance variable 的值。

class User
  attr_writer :email
end

其實意思就是:

class User
  def email=(email)
    @email = email
  end
end

這樣我們就可以在 new 完 User 的 instance object 之後再 assign @email 的值給它:

user = User.new
user
# => <User:0x0000010ef74c48>

user.email = "wayne@example.com"
user
# => <User:0x0000010ef74c48 @email="wayne@example.com">

attr_accessor

attr_accessor 簡單來說就是 attr_readerattr_writer 的綜合體,舉例如下:

class User
  attr_accessor :email
end

其實意思就是:

class User
  def email
    @email
  end
  def email=(email)
    @email = email
  end
end

這樣寫有什麼好處呢?

其實這樣就是節省時間以及讓 code 變得更乾淨,想像一下今天有一個 class 有 5 個 attributes,那不就要定義 10 個長得很像的 method 來 指派/存取 instance variable 的值?身為懶人工程師是不可以讓這件事情發生的,所以這時如果可以一行寫完這些 method 不是很好嗎?於是有了以下的寫法:

class User
  attr_accessor :name, :email, :address, :age, :gender
end

通常我們會把 attr_accessor 這類的 method 放在 class 的最上面,這樣一進來就可以很清楚地知道有哪些 method 可以使用。

參考資料:

RubyDoc - attr_accessor
RubyDoc - attr_reader
RubyDoc - attr_writer

 
over 2 years ago

Stripe subscription 的實作筆記

最近用 Stripe 實作使用者訂閱的功能,基本上就是類似 Evernote, Dropbox 這種服務會在每個月的同一天 Charge 使用者一個固定金額的功能,第一次實作所以留下一些記錄。

Rails Version: rails4.0.
Ruby Version: 2.2.2

1. 基本的 Stripe API 和 Subscribe

在此省略 Stripe 的基本設定部分,直接跳到實作訂閱功能的地方

Stripe 的做法是先建立 Plan, Plan 會有一個 id 是自己設定的字串,之後我們要靠這個 id 來 subscribe。

基本上每個 Stripe 的 Customer (User) 都可以 Subscribe Plan, Subscribe 之後會有 Subscription 的 object 產生,想知道 subscription object 有哪些 attributes 可以去後台看看 subscription 的 object 或是參考 library doc 就可以略知一二。

進入正題

首先我們需要有方法讓我們可以很快速的從我們自己的 user object 拿到 stripe 的 customer object,假設 User Table 內有一個欄位叫做 stripe_customer_token 。

app/models/user.rb
# ...

def stripe_customer
  if stripe_customer_token.blank?
    customer = Stripe::Customer.create(:email => email)
    update(stripe_customer_token: customer.id)
  else
    customer = Stripe::Customer.retrieve(stripe_customer_token)
  end

  customer
end
# ...

藉由 User#stripe_customer 這個 method 我們可以很容易拿到 stripe customer object,接下來就是訂閱 plan, 這部分其實也很簡單,官方範例如下:

customer = Stripe::Customer.retrieve({CUSTOMER_ID})
customer.subscriptions.create({:plan => PLAN_ID})

但是這樣直接 call 很難處理 error, 所以可以把它包進 service object 裡面,在 app/services 路境內建立 stripe_subscribe_service.rb 的檔案。

app/services/stripe_subscribe_service.rb
class StripeSubscribeService
  def initialize(user)
    @user = user
  end

    # @param plan_id [String] Stripe Plan ID.

    # @param trial_end [Integer] UNIX Timestamp, set custom trial to plan.

    # @param tax_percent [Integer] 0~100

    # @param metadata [Hash] store any data with key-value

  def subscribe!(plan_id, trial_end: nil, tax_percent: nil, metadata: {})
    @user.stripe_customer.subscriptions.create(
      plan: plan_id,
      trial_end: trial_end,
      tax_percent: tax_percent,
      metadata: metadata
    )
  rescue Stripe::StripeError => e
    raise GatewayError.new(e)
  end

  class Error < StandardError; end
  class GatewayError < Error; end
end

如此一來以後使用者要訂閱的時候就只需要 call StripeSubscribeService.new(user).subscribe!("plan_id_here") 就可以了,也可以做基本的錯誤處理,rescue StripeSubscribeService::Error 來救援。

2. 建立 Stripe Webhook 用的 Endpoint

上一章節講到可以 StripeSubscribeService.new(user).subscribe!("plan_id_here") 訂閱 Plan, 這章則是要講如何做 Plan 成功 Subscribe 之後的處理。

根據 Stripe 的解釋,訂閱成功後會觸發一系列的 Event, 而我們也需要藉由這些 Event 了解這個使用者每次的付款狀況如何,舉例說明我們可能希望等 Stripe 端確認成功跟使用者收款後再真正 activate 使用者的 Plan,或是有些使用者的 credit card charge failed 了我們要 cancel 他的 plan 之類的情況,這些就要靠在 Stripe 設定 Webhook (我們的 API endpoint) 來得知。

簡單來說就是要開一個 api 給 Stripe 呼叫,這裏用 Grape 實作

當我們 subscribe 成功後, Stripe 會依序發送 Event:

  • customer.subscription.created
  • charge.succeeded
  • invoice.created
  • invoice.payment_succeeded or invoice.payment_failed

除了 subscribe 成功外,每個月付款成功也會依序觸發以下 events:

  • invoice.created
  • customer.subscription.updated
  • 約一小時後 > charge.succeeded
  • invoice.payment_succeeded or invoice.payment_failed
  • invoice.updated

我們只需要針對 invoice.payment_succeededinvoice.payment_failed 做處理即可,因為 invoice 代表使用者的付款狀態,是我們唯一有興趣的 object。因此我們可以在 api 端接收這兩個 events:

app/api/stripe_webhooks/invoice_api.rb
module StripeWebhookAPI
  class InvoiceAPI < Grape::API
    format :json
    post :memberships do
      case params[:type]
      when "invoice.payment_succeeded"
        # 付款成功,將使用者訂閱的資訊標記為 active, 並更新 end_time

      when "invoice.payment_failed"
        # 付款失敗,寄信給使用者告知錯誤訊息,並標記訂閱為 Inactive

      end

      status 200 # Stripe 只把 200~299 當作送達 event,所以回傳 200 (or 201) 給 Stripe

    end
  end
end

3. 設定 Stripe Webhook 讓 local 可以接收到資料

Stripe 在每個 Event 發生時都會藉由我們設定的 Webhook 送 event object 的 data 過來,首先我們要在 Stripe 設定 Webhook 打到 local, 可以藉由 ngrok 做到。

Download ngrok 後 cd 到 ngrok 所處的資料夾打 ./ngrok -help 可以知道有哪些指令可以用,基本上 ngrok 的功用就是讓我們的本機 server 有個網址可以接收外網來的資料,我們要做的就是打開 rails server 和 ngrok ./ngrok http 3000

本機設定好之後就是到 Stripe 的後台點選右上角的 Your Account > Account Setting > Webhooks > Add

之後隨便找個 Event Retry 一下確認 local server 有收到

如果沒有收到也可以試試 RequestBin 確認一下是 Stripe 還是 ngrok 沒設定好。

參考資料:

To be continue:

收到 Webhook 資料後續處理 (Webhook 來的資料如何包裝)
Rspec 測試 Webhook
其他 API 中難發現的小事 (例:Stripe Subscription 只能用使用者 default 的信用卡結賬)

 
over 3 years ago

routes的一些小技巧

routes.rb
resources :post, only: [:index]
resources :user, except: [:destroy]
routes.rb
resources :board, constraints: {subdomain: 'admin'}
constraints subdomain: 'api' do
  resources :post
  resources :board
end
# => http://api.example.com/posts 

用namespace分隔開也是一個做法

routes.rb
constraints subdomain: 'api' do
    namespace :api do
    resources :post
    resources :board
  end
end
# => http://api.example.com/api/posts 

更簡潔一點:

routes.rb
constraints subdomain: 'api' do
    namespace :api, path: '/' do
    resources :post
    resources :board
  end
end
# => http://api.example.com/posts 
 
over 3 years ago

在有些情況我們會需要做 Nested Form,這時候就需要將 Nested Form 的 Attributes 也加到 Strong Parameters 內。

假設每一個 Person Model 有都可以有一頂 Hat,在新增 Person 的時候用 Nested Form 同時新增 Hat 的顏色。我們應該這樣做:

1.在 Person Modle 中宣告我們可以接受 hat 的 attributes:

class Person < ActiveRecord::Base
  has_one :hat
  accepts_nested_attributes_for :hat
end
=begin
  Hat Database Information:
    id:integer
    color:string
    created_at:datetime
    updated_at:datetime
=end
class Hat < ActiveRecord::Base
    belongs_to :person
end

2.在 Controller 中指定接收的 Attributes

由於我們在 People Controller 內的 Strong Parameters 並不包含 color 這個 Attributes,所以在新增 Hat 的時候就無法把 color 寫進資料庫,這時候需要加上 hats_attributes: [] 這個 Attributes,並且將要傳的 Hat Attributes 寫進 Array 中:

class PeopleController < ActionController::Base
  def update
    person.update_attributes!(person_params)
    redirect_to :back
  end

  private
    def person_params
      params.require(:person).permit(:name, :age, hats_attributes: [:color])
    end
end

這樣就可以通過 People Controller 的驗證了。

 
over 3 years ago

Rails Delegate是ActiveSupport內的Delegate module
它可以讓一個class直接取用另外一個關聯class的attribute
例如Rails專案中有時會看到這樣的設定:

model.post.rb
=begin
Schema Information
Table name: users
  id                     :integer          not null, primary key
  title                   :string(255)
  created_at             :datetime
  updated_at             :datetime
=end
class Post
  belongs_to :user
  delegate :name, :to => :user, :allow_nil => true
end
model/user.rb
=begin
Schema Information
Table name: users
  id                     :integer          not null, primary key
  name                   :string(255)
  created_at             :datetime
  updated_at             :datetime
=end
class User
  has_many :posts
end

在這裏Post下了delegate指令的意思就是讓@post.name可以回傳@post.user.name的值,:allow_nil的目的是讓name的值為nil時不會噴錯。
另外delegate也有一些好用的選項可以使用,例如prefix可以讓剛剛的post輸出方式變成@post.user_name

class Post
  belongs_to :user
  delegate :name, :to => :user, :prefix => true, :allow_nil => true
end

或是可以自定prefix,例如下方例子就會變成@post.owner_name

class Post
  belongs_to :user
  delegate :name, :to => :user, :prefix => "owner", :allow_nil => true
end

參考文章

 
over 3 years ago

今天要介紹的是friendly_id這個GEM
friendly_id的主要功能就是可以幫我們客製化網址
正常情況下我們的product#show會是這樣子的:www.example.dev/products/:id
利用friendly_id就可以將它變成www.example.dev/products/:name或是其他你想要的組合
在這個rails cast中有教使用friendly_id和不使用friendly_id的做法
若不使用friendly_id,基本原理就是在product model下改寫to_params的method,細節可以看Rails Cast
今天要教學的是使用friendly_id顯示product的name attributes,並且支援中文網址
首先是gemfile設定:

# rails4以上版本要裝5.0.0以上的friendly_id

gem 'friendly_id', '~> 5.0.0'
# babosa這個gem是讓我們做字元轉換的,要支援中文網址時會用到

gem "babosa"

接下來建立必要的資料表:

rails generate friendly_id
rails generate migration add_slug_to_products slug:string:uniq
rake db:migrate

資料表建立完成後在Produc Model下再這樣設定:

class Product < ActiveRecord::Base
  extend FriendlyId
  friendly_id :name, use: :slugged
end

接著到config/initializers/friendly_id.rb下把自動查找的功能打開,將config.use :finders這段解除註解就可以了。
(不打開finders也可以,但就必須要把Product.find(params[:id])的地方改寫成Product.friendly.find(params[:id])

到目前這個步驟為止就可以使用www.example.dev/products/:name的網址了
接下來就是要支援中文並且客製化想要顯示的網址,首先要改寫normalize_friendly_id(input)的method讓中文字可以顯示,接下來就是改friendly_id讀取的symbol就可以了

class Product < ActiveRecord::Base
  extend FriendlyId
  # 把:name改成:slug_candidates
  friendly_id :slug_candidates, use: :slugged
  
  # 原本是input.to_s.parameterize,但是parameterize只支援英文跟數字,所以改用babosa的to_slug
  def normalize_friendly_id(input)
    input.to_s.to_slug.normalize.to_s
  end
  
  # 定義slug_candidates,預設會找第一個,如果有重複的name就會找第二個(name-price),最後才會生成亂序
  def slug_candidates
    [
      :name,
      [:name, :price]
    ]
  end
end

參考資料:

http://railscasts.com/episodes/314-pretty-urls-with-friendlyid
https://github.com/norman/friendly_id
http://blog.roachking.net/blog/2014/01/17/babosa-friendly-id-solve-chinese-problems/

 
over 3 years ago

有趣的關聯模式
在同一個model內就可以建立關聯
舉rails guide的例子
我們希望建立一個員工資料的model叫做Employee
但是employee之間應該會有上屬與下屬的關係,這代表什麼?
employee 1可能有很多下屬(employee2, employee3, employee4)
我希望可以在同一個model內就能找到employee 1的所有下屬
這時候就可以用self join
rails guide的範例就很清楚了,請看加上中文註解的版本:

In designing a data model, you will sometimes find a model that should have a relation to itself. For example, you may want to store all employees in a single database model, but be able to trace relationships such as between manager and subordinates. This situation can be modeled with self-joining associations:

class Employee < ActiveRecord::Base
  #一個employee有很多的下屬(subordinates),這些下屬是屬於Employee這個class的(也就是目前的class)

  #且這些下屬都歸一個manager管,這個manager可以從manager_id找到

  has_many :subordinates, class_name: "Employee",
                          foreign_key: "manager_id"
  #員工都被manager管,而Manager本身也是Employee這個class的(也就是目前的class)

  belongs_to :manager, class_name: "Employee"
end

With this setup, you can retrieve @employee.subordinates and @employee.manager.

In your migrations/schema, you will add a references column to the model itself.

class CreateEmployees < ActiveRecord::Migration
  def change
    create_table :employees do |t|
      t.references :manager
      t.timestamps
    end
  end
end

參考資料
http://guides.rubyonrails.org/association_basics.html#self-joins