Fitbit Ionic & Versa 向けアプリを作ってみた。

##### 【更新 2023.04.27】
Fitbit Studioは2023年4月20日をもって閉鎖となりました。今後のCLI環境での開発のみとなります。
##### 【更新 2021.09.16】
開発環境にCLI環境構築のリンクを貼りました。
##### 【更新 2020.12.8】
* [SDK 5.0のマイグレーション記事](https://qiita.com/cozzbox/items/1bf06976656c5393e89b)書きました
* 本記事にあるPanoramaViewコンポーネントはSDK5.0にて廃止されました。
##### 【更新 2018.3.19】
女性向けの新デバイスFitbit Versaの発表と共に待望のSimulatorがプレビュー版でリリースされ、
実機がなくてもビルドおよび動作確認ができるようになりました。

# 前書き
普段はAndroidアプリ開発をしている人間なのですが、ひょんなことから [fitbit ionic](https://www.fitbit.com/jp/ionic) 向けのアプリを
開発することになりましたので、その足跡を記録しておこうと思います。これから始めようって人の助走程度になれば。

# 開発環境

まずは[オフィシャルのガイド](https://dev.fitbit.com/build/guides/)を読めば、なんとなーく容易に開発スタートできます。

開発にはブラウザベースのオフィシャルIDE「 [fitbit studio](https://studio.fitbit.com/) 」上で行うか [CLI環境をローカルに作って進める](https://qiita.com/cozzbox/items/1bf06976656c5393e89b#cli%E7%92%B0%E5%A2%83%E6%A7%8B%E7%AF%89)事ができます。コードのGithub管理などを考えるとCLI環境がベターだと思います。現時点ではとてもシンプルな内容で、Android StudioやXcodeのように多くのことはできません。つまり使い方に迷うことはないでしょう。~~また、2018年2月現在では、 **エミュレーターも無いため、アプリの実行には実機(ionic)が必要** です。ビルドはできます。~~このあたりについは、「 [Getting Started](https://dev.fitbit.com/getting-started/)  」でWatch Faceサンプルを試してみると分かります。

# Folder Structure
ざーっくりで以下の感じです。

|フォルダ| （個人的）Description |
|:-:|:--|
|app|fitbitデバイス(ionic)側の実装を書く。<br>companionとMessageAPIを使ってやり取りする。|
|common|ガイドには"Shared Code"とあり、appとcompanionで共通利用できるコードを設置するのかな？<br>共通メソッドや定数をおいてたり？|
|companion|スマフォ(Android端末oriPhone端末)側の実装を書く。<br>appとMessageAPIを使ってやり取りしたり、インターネット通信もここでやります。<br>またsettingsとのやり取りはsettingsStorageの変更監視をトリガーに行われる。|
|resources|アプリで使う画像リソースを置くところ。<br>icon.pngはionic上でのアイコンなる。|
|settings|アプリ独自の設定実装を書く。<br>自社サービスとの接続機能(例えばログイン）はここに実装することになると思う。<br>設定データはsettingsStorageに自動保存され、その変更をcommanionが監視している。|

# 豆知識的な事
開発を始めるにあたり、たまたま参加した[developerカンファレンス](https://japan.cnet.com/article/35110465/2/)で以下程度の情報はありました。
・バッテリーが 25%以下になると実機へインストールできなくなる
・JavaScript, SVG, CSSはいつものが動かないことがある
・フリーズしたり挙動がおかしくなったら物理ボタン３つ長押しで強制再起動してみる
・以下のコードを埋め込むことで、ionic画面がスリープしなくなる

```javascript:app
 import { display } from "display";
 display.autoOff = false;
 display.on = true;
```
ionicは最長でも20秒で画面がスリープするので、この情報は凄く助かりました。また、ボタンイベントがループすることがあったり、ionicのフリーズは日常茶飯事で再起動に追い込まれること多々です。違った意味で開発に忍耐が必要かもしれません。

# 開発
特に難しいところはないのですが、少しだけ書いておきます。

### 画面遷移の仕方
例えば準備した２つのスクリーンを切り替えるには、displayの値を動的に変更してやればOK。

```html:gui
<!-- screen 1 -->
<svg id="screen-1" display="inline">
  <text class="defaultText" x="135" y="50%" text-anchor="start">Screen 1</text>
</svg>
  
<!-- screen 2 -->
<svg id="screen-2" display="none">
  <text class="defaultText" x="135" y="50%" text-anchor="start">Screen 2</text>
</svg>
```
inlineで表示、noneで非表示ってことです。

```javascript:app
let screen1 = document.getElementById("screen-1");
let screen2 = document.getElementById("screen-2");

// screen1からscreen2に切り替える
screen1.style.display = "none";
screen2.style.display = "inline";
```
### AppとCompanionのデータやり取り
例えば、APIサーバのレスポンス内容をIonicに表示する場合、Companion側でサーバ通信し、レスポンスをMessage APIを使ってAppへ渡す流れになる。基本的にはfetchを使うのでGET/POSTもカスタムヘッダー埋め込みとかもできる。

```javascript:companion
fetch(url, {
  headers: {
    "Accept":"application/json",
    "Content-Type":"application/json",
    "My-Custom-Header":xxxxxxx  //カスタムヘッダーの埋め込みも可能
  },
  method: 'POST',
  body: JSON.stringify({
      id: mail,
      password: pass
    })
})
.then(function(res) {
  return res.json(); 
})
.then(function(json) {
  let jsonStr = JSON.stringify(json);
  console.log("res: " + jsonStr);
  
  let message = JSON.parse(jsonStr).message;
  let data = JSON.stringify({
    message: message
  });

  if (messaging.peerSocket.readyState === messaging.peerSocket.OPEN) {
    // App側へデータ送る
    messaging.peerSocket.send({ key: "api_response", newValue: data});
  }
})
.catch(err => console.log("Fetching " + url + " failed: " + err));
```
CompanionからのデータをpeerSocket.onmessageで受け取る。

```javascript:app
messaging.peerSocket.onmessage = evt => {
  console.log(`App received: ${JSON.stringify(evt)}`);
  
  if (evt.data.key === "api_response") {
    console.log("api_response: " + evt.data.newValue);
  }
  
};
```

### Settingsとのデータやり取り
SettingsはCompanionとデータのやり取りを行うわけですが、流れとしてはSettingsが管理しているsettingsStrageをCompanionが監視していて、settingsStorageになんらかの変更が加わるとCompanionのonchangeイベントが発火します。

```html:settings
<Section title={<Text bold align="center">Login</Text>}>
  <TextInput label="ID" settingsKey="id" type="email" placeholder="Please enter the ID." />
  <TextInput label="Password" settingsKey="password" type="password" placeholder="Please enter the Password." />
  <Button label="Login" onClick={(event) => {
      if (event.type == "click") {
        props.settingsStorage.setItem("btn_login", "1");  
      }
   }}
  />
</Section>
```
上ではログインボタン押下ベントを発火するためにsettingStorageに変更を加えている。
TextInputの変更値は自動でsettingsStrageに反映されるのでsetItemとかをしてやらなくてもOK。

```javascript:companion
settingsStorage.onchange = evt => {
  console.log("onchange.key: " + evt.key); 
  console.log("onchange.newValue: " + evt.newValue);
  
  // ログインボタンが押された
  if (evt.key === "btn_login") {    
    let id = (settingsStorage.getItem("id") != null) ? JSON.parse(settingsStorage.getItem("id")).name : null;
    let password = (settingsStorage.getItem("password") != null) ? JSON.parse(settingsStorage.getItem("password")).name : null;
    
    if (id != null && password != null && id != "" && password != "") {
      fetchLogin(id, password);

    } else {
      // TODO ここでID・パスワード入力されてないよー！って表示とかする

    }
  }
};
```

### Facebookログイン
連携先サービスにFacebookでログインしていた場合の対応が必要だったのですが、FitbitのSettings APIにあるOAuth Buttonを使ってaccess_tokenを取得できる

```html:settings
 <Oauth
   settingsKey="facebook"
   title="Login"
   label="Facebook Login"
   status="Login"
   authorizeUrl="https://www.facebook.com/dialog/oauth"
   requestTokenUrl="https://graph.facebook.com/v2.3/oauth/access_token"
   clientId="FacebookのクライアントID"
   clientSecret="Facebookのクライアントシークレット"
   scope="email"
   onAccessToken={async (data) => {
     console.log("json: ", JSON.stringify(data));
   }}
 />
```
ボタンを押すとブラウザ連携でFacebookのOAuth認証が表示され、redirect_urlに https://app-settings.fitbitdevelopercontent.com/simple-redirect.html を設定しておくことで読み出し元の画面(Setting)に戻してくれる。この時code値のtoken交換までautoでやってくれる。

```html:console
json:  {"access_token":"EAACJqX6ZC2NgBAGAx9mAhlI5b --省略-- ZBCu1wxIU4pDVmdy5u","token_type":"bearer","expires_in":5183893}
```
facebookのOAuth.クライアントシークレットをアプリ側に埋め込む形になるのでセキュリティレベルが若干低下することもあり、自社のWebに飛ばして認証させるのが良いかと思います。
※プリインのStravaみたく

### Ionicデバイスボタンのイベント
Ionic左側面にあるハードボタンは"戻る"に使われているが、例えばアプリ内での画面戻りとかを制御する場合は以下のようになる

```javascript:app
let screen1 = document.getElementById("screen-1");
let screen2 = document.getElementById("screen-2");

document.onkeypress = function(evt) {
  if (evt.key === "back") {
    if (screen2.style.display === "inline") {
      screen1.style.display = "inline";
      screen2.style.display = "none";

      evt.preventDefault();
    }
  }
}
```
### IonicとVersaの切り分け
たいていの場合、Ionic(348x250)とVersa(300x300)でデザインを切り分ける必要がでてくると思うが、リソース系はファイル名によって自動で出し分けられる。

```
/resources/styles~300x300.css // Versa用
/resources/styles.css // Ionic用(デフォルト)
```
コード側での処理は、Device APIのdevice.srceenを使ってできそうだが、注意点としてデフォルト設定をしておく必要があるらしい。

```javascript:app
import { me as device } from "device";
if (!device.screen) device.screen = { width: 348, height: 250 };
console.log(`Dimensions: $‌{device.screen.width}x$‌{device.screen.height}`);
```


# 開発コミュニティ頼みになるケースが多々

**オフィシャルガイドの内容が薄いため**、UIまわりの実装に手間取りました。
これについては、時間と共に解決されていくとは思いますが。

##### 【更新：2020.12.08】 SDK5.xではPanoramaViewが廃止されました。

例えば、Panorama Component（AndroidでいうPageView)の実装においては、[デザインガイドライン](https://dev.fitbit.com/build/guides/design-guidelines/ui-components/#panorama)はあるものの、コンポーネントガイドやSVGガイド等に一切実装方法が無いので困りました。

こんなときのために、fitbitが[開発者コミュニティ](https://community.fitbit.com/t5/SDK-Development/bd-p/sdk)を作ってくれています。
Panorama Componentも率直に実装方法が分からない旨をコミュニティに投稿しました。

<img width="800" alt="img_fitbit_1.png" src="https://qiita-image-store.s3.amazonaws.com/0/103992/788e8277-5157-4ead-1d2c-731ecc746f19.png">

すると、Fitbit中の方が回答をくれました。

<img width="800" alt="img_fitbit_2.png" src="https://qiita-image-store.s3.amazonaws.com/0/103992/9851527b-3176-2519-643d-a92c9830518c.png">

まだガイドに情報が無いそうです。:sweat_smile:
ただ、スニペット付きだったので助かりました。

**困ったら悩まずコミュニティに聞くが解決の近道かと思います。**

なお、**Panorama Component**は、こんな感じでUIに追加できます。

```html:widgets.gui
<link rel='import' href='/mnt/sysassets/widgets/baseview_widget.gui'/>
<link rel='import' href='/mnt/sysassets/widgets/panoramaview_widget.gui'/>
<link rel='import' href='/mnt/sysassets/widgets/pagination_dots.gui'/>
```

```html:index.gui
  <defs>
    <symbol id='panorama-symbol'>
      <use id='container' href='#panoramaview' overflow='hidden'>
        
        <!-- ページ１ -->
        <use id="page-1" href="#panoramaview-item">
          <text class="defaultText" x="125" y="50%" text-anchor="start">ページ１</text>
        </use>
        
        <!-- ページ２ -->
        <use id="page-2" href="#panoramaview-item">
          <text class="defaultText" x="125" y="50%" text-anchor="start">ページ２</text>
        </use>
        
        <!-- ページ３ -->
        <use id="page-3" href="#panoramaview-item">
          <text class="defaultText" x="125" y="50%" text-anchor="start">ページ３</text>
        </use>
        
        <!-- ページ４ -->
        <use id="page-4" href="#panoramaview-item">
          <text class="defaultText" x="125" y="50%" text-anchor="start">ページ４</text>
        </use>

        <!-- panorama view　のページDot (y値で高さの配置調整)-->
        <use id='pagination-dots' class='pagination' href='#pagination-widget' y='230'>
          <!-- 背景固定Dot (ページ数分配置する) -->
          <use href='#pagination-dot'/>
          <use href='#pagination-dot'/>
          <use href='#pagination-dot'/>
          <use href='#pagination-dot'/>
          <!-- ページ移動に追随する動くハイライトDot -->
          <use href='#pagination-highlight-dot'/>
        </use>
        
      </use>
    </symbol>
  </defs>
  <svg>
    <use href="#panorama-symbol" />
  </svg>
```

# Scroll Viewの仕様が微妙

とても長い文章を表示するUIがあったので、上下にスクロールさせる必要がありました。
コンポーネントガイドをみると[Scroll View](https://dev.fitbit.com/build/guides/user-interface/components/views/)があるので、これを使ってみるわけですが、想像した動きとは違いました。

普段、Androidアプリを開発していると、こんな感じで書いてしまいがちです。

```html
 <svg>
   <use href="#scrollview">
     <use href="#scrollview-item">
       <textarea id="message" class="mytextarea">　ここにながーい文章を書く　</textarea>
     </use>
   </use>
 </svg>
```
ただ、これでは、まったくもってスクロールしません。。。。
色々やってみた結果、おそらくとても長いscrollview-item x１をスクロールする仕様ではなく、複数のscrollview-itemを切り替えるようにスクロールするためのモノみたいです。Androidで言うとこの縦版PageViewのスナップ効かないバージョンみたいといいますか、公式ガイドのGIFアニメの動きどおりです。
~~しょうがないので今回は１画面になんとか収まりそうな文字数を決めて、文章をsplitし５ページ固定で設置する対応をしました。~~

```html
<use href="#scrollview">
  <use href="#scrollview-item">
    <textarea id="detail-page-1" class="message" text-length="1024"></textarea>
  </use>
  <use href="#scrollview-item">
    <textarea id="detail-page-2" class="message" text-length="1024"></textarea>
  </use>
  <use href="#scrollview-item">
    <textarea id="detail-page-3" class="message" text-length="1024"></textarea>
  </use>
  <use href="#scrollview-item">
    <textarea id="detail-page-4" class="message" text-length="1024"></textarea>
  </use>
  <use href="#scrollview-item">
    <textarea id="detail-page-5" class="message" text-length="1024"></textarea>
  </use>
</use>
```
~~無論、この実装だと短い文章の場合にブランクのページが出来てしまい不細工です。
文章の長さに応じてscrollview-itemのdisplayを"none"又は"inline"→"none"に変更もできそうでしたが、、、~~

![advice-detail-scroll-bug.png](https://qiita-image-store.s3.amazonaws.com/0/103992/efca9a95-460b-799c-a4a3-e0e700f54acb.png)

~~みたいな感じで初期表示のみ全scrollview-itemが重なって表示されました。
ただ、画面に一度触るとぱっと正常に戻るっていうOSのバグらしき動きになりましたので動的なdisplay変更は断念しました。~~
TextAreaのY座標をonmousedownとonmousemoveイベントで拾って、制御することにより擬似的にスクロールっぽく実装する事が可能です。
参考：[fitbit community: How to scroll a Textarea on Versa watch](https://community.fitbit.com/t5/SDK-Development/How-to-scroll-a-Textarea-on-Versa-watch/m-p/4324253/highlight/true#M12411)

# 所感
シンプルな要件下でのアプリ開発であったため、大きく困るケースは少なかったし、比較的サクサク開発していけました。始めてみれば楽しかったと思います。なお、オフィシャルガイドの内容が薄かったり、fitbit studioで出来ることが少なかったり、ionicの挙動がしょっちゅう微妙になったり、アプリ管理コンソールにはアナリティクス的な機能は一切なく、まだまだこれからって感じのプラットフォームではあります。しかし、モバイルアプリのプラットフォームにおいては２強時代にある今、一石を投じることが出来るかどうか期待したいとこです。