IMBoxクライアント

Flutter(Dart)を用いて intra-mart Accel Platform の IMBox のクライアントを作成しました。
Flutter歴0日だけど案外できた。
Flutter なので、一つのコードでモバイルアプリ(iOS, Android), デスクトップアプリ(MacOS, Windows), Webアプリすべてで動作するのが熱い。

以下の機能があります。

* 複数環境/複数アカウント対応
 * Twitter のように、異なる Accel Platforom や同じ Accel Platform 上の異なるアカウントを複数登録し、それぞれのアカウントに切り替えてタイムラインをチェックできる機能
* スレッド毎に既読/未読を管理/表示する機能
* MyBox/DirectMessageBox/AppicationBox/CompanyBox/GroupBox毎にスレッド/タイムラインを表示する機能
* JSONで必要なデータのみやり取り/表示はネイティブアプリによる高速化
 * IMBoxウェブアプリはサーバサイドでHTMLを生成、クライアントブラウザでレンダリングする仕様となっている為、表示されるまで非常に時間がかかる（特にブラウザのレンダリング）点を改善
* 添付ファイルを表示/投稿する機能（画像はインラインで表示、取得後はキャッシュ）
 * 仕様しているプラグイン(file_picker, image_picker)の仕様上、添付ファイルの送信はデスクトップ環境(MacOS, Windows)では不可
* 返信されたメッセージのみ表示するタイムライン機能（どのような経緯で返信があったのか分かりやすくする為）
* テーマ対応
 * OS の設定に追従してライトモード/ダークモードが自動で切り替わります

## デモ

<blockquote class="twitter-tweet"><p lang="und" dir="ltr"><a href="https://t.co/4ZaQ0Grm0X">pic.twitter.com/4ZaQ0Grm0X</a></p>&mdash; manymanyuni (@manymanyuni) <a href="https://twitter.com/manymanyuni/status/1246480673566625792?ref_src=twsrc%5Etfw">April 4, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## ソースコード

GitHub は Private リポジトリで開発しているのと Public にして全部公開するのも嫌なので要所のソースコードのみ抜粋して以下に掲載。

### サーバサイド(Java)

```java:AttachFilesExecutor.java
public class AttachFilesExecutor implements ResourceExecutor<Void> {
    @Override
    public Void execute(final HttpServletRequest request, final HttpServletResponse response) {
        SessionCookie.invalidateRequestedSessionCookie(request);

        final String method = request.getMethod();

        if ("GET".equals(method.toUpperCase())) {
            try {
                @SuppressWarnings("unchecked")
                Map<String, Object> map = (Map<String, Object>) new ObjectMapper().readValue(Cryption.decryptString(request.getParameter("q")), Map.class);
                int attachSize = ((Number) map.get("attach_size")).intValue();

                response.setContentType((String) map.get("attach_mime_type"));
                if (attachSize > 0) {
                    response.setContentLength(attachSize);
                }

                String encodedFileName = ResponseUtil.encodeFileName(request, ServerContext.getInstance().getServerCharset(), (String) map.get("attach_name"));
                if (((String) map.get("attach_mime_type")).contains("image")) {
                    response.setHeader("Content-Disposition", "inline; " + encodedFileName);
                } else {
                    response.setHeader("Content-Disposition", "attachment; " + encodedFileName);
                }

                var storage = new PublicStorage((String) map.get("attach_path"));
                
                try (var istr = storage.open()) {
                    IOUtil.transfer(istr, response.getOutputStream());
                }
            } catch (IOException | GeneralSecurityException e) {
                e.printStackTrace();
            }
        }

        return null;
    }
}

final class Cryption {
    private static final Key SECRET_KEY = new SecretKeySpec(UIPictureConfig.KEY.getBytes(StandardCharsets.UTF_8), UIPictureConfig.ALGORITHM);

    public static final byte[] encrypt(final byte[] src) throws GeneralSecurityException {
        final Cipher cipher = Cipher.getInstance(UIPictureConfig.ALGORITHM);

        cipher.init(Cipher.ENCRYPT_MODE, SECRET_KEY);

        return cipher.doFinal(src);
    }

    public static final byte[] decrypt(final byte[] src) throws GeneralSecurityException {
        final Cipher cipher = Cipher.getInstance(UIPictureConfig.ALGORITHM);

        cipher.init(Cipher.DECRYPT_MODE, SECRET_KEY);

        return cipher.doFinal(src);
    }

    public static final String encryptString(final String src) throws GeneralSecurityException {
        return Base64.encodeBase64URLSafeString(encrypt(src.getBytes(StandardCharsets.UTF_8)));
    }

    public static final String decryptString(final String src) throws GeneralSecurityException {
        return new String(decrypt(Base64.decodeBase64(src)), StandardCharsets.UTF_8);
    }
}
```

```java:CompaniesExecutor.java
public class CompaniesExecutor implements ResourceExecutor<List<Map<String, String>>> {
    @Override
    @ResponseType("application/json")
    public List<Map<String, String>> execute(final HttpServletRequest request, final HttpServletResponse response) {
        SessionCookie.invalidateRequestedSessionCookie(request);
        
        final String method = request.getMethod();

        if ("GET".equals(method.toUpperCase())) {
            CompanyOperations operation = Services.get(CompanyOperations.class);

            try {
                var companies = operation.getAttachCompanies(null);
                var result = new ArrayList<Map<String, String>>();

                for (Company company : companies) {
                    var map = new HashMap<String, String>();

                    map.put("company_name", company.getCompanyName());
                    map.put("box_cd", company.getCompanyboxCd());

                    result.add(map);
                }

                return result;
            } catch (IMBoxException e) {
                e.printStackTrace();
                
                return new ArrayList<Map<String, String>>();
            }
        } else {
            return null;
        }
    }
}
```

```java:GroupsExecutor.java
public class GroupsExecutor implements ResourceExecutor<List<Map<String, String>>> {
    @Override
    @ResponseType("application/json")
    public List<Map<String, String>> execute(final HttpServletRequest request, final HttpServletResponse response) {
        SessionCookie.invalidateRequestedSessionCookie(request);
        
        final String method = request.getMethod();

        if ("GET".equals(method.toUpperCase())) {
            GroupOperations operation = Services.get(GroupOperations.class);

            try {
                var groups = operation.getAllJoinGroups();
                var result = new ArrayList<Map<String, String>>();

                for (Group group : groups) {
                    var map = new HashMap<String, String>();

                    map.put("group_name", group.getGroupName());
                    map.put("box_cd", group.getBoxCd());

                    result.add(map);
                }

                return result;
            } catch (IMBoxException e) {
                e.printStackTrace();
                
                return new ArrayList<Map<String, String>>();
            }
        } else {
            return null;
        }
    }
}
```

```java:LikesExecutor.java
public class LikesExecutor implements ResourceExecutor<Void> {
    @Override
    @ResponseType("application/json")
    public Void execute(final HttpServletRequest request, final HttpServletResponse response) {
        SessionCookie.invalidateRequestedSessionCookie(request);
        
        final String method = request.getMethod();

        if ("POST".equals(method.toUpperCase())) {
            final String box = request.getParameter("box");
            final String threadId = request.getParameter("thread_id");
            final String messageId = request.getParameter("message_id");
            MessageOperations operations;

            if ("MyBox".equals(box)) {
                operations = Services.get(MyBoxService.class);
            } else if ("DirectMessageBox".equals(box)) {
                operations = Services.get(DirectMessageBoxService.class);
            } else if ("ApplicationBox".equals(box)) {
                operations = Services.get(ApplicationBoxService.class);
            } else if ("CompanyBox".equals(box)) {
                operations = Services.get(CompanyBoxService.class);
            } else if ("GroupBox".equals(box)) {
                operations = Services.get(GroupBoxService.class);
            } else {
                operations = Services.get(UnitBoxService.class);
            }

            try {
                if (operations.isLike(messageId)) {
                    operations.unlike(new MessageKey(threadId, messageId));
                } else {
                    operations.like(new MessageKey(threadId, messageId));
                }
            } catch (IMBoxException e) {
                e.printStackTrace();
            }

            return null;
        } else {
            return null;
        }
    }
}
```

```java:MessagesExecutor.java
public class MessagesExecutor implements ResourceExecutor<List<Map<String, Object>>> {
    @Override
    @ResponseType("application/json")
    public List<Map<String, Object>> execute(final HttpServletRequest request, final HttpServletResponse response) {
        SessionCookie.invalidateRequestedSessionCookie(request);
        
        final String method = request.getMethod();

        if ("GET".equals(method.toUpperCase())) {
            try {
                Messages messages;
                String box = request.getParameter("box");

                if ("MyBox".equals(box)) {
                    MyBoxService service = Services.get(MyBoxService.class);
                    messages = service.getMessagesInThread(request.getParameter("thread_id"));
                } else if ("DirectMessageBox".equals(box)) {
                    DirectMessageBoxService service = Services.get(DirectMessageBoxService.class);
                    messages = service.getMessagesInThread(request.getParameter("thread_id"));
                } else if ("ApplicationBox".equals(box)) {
                    ApplicationBoxService service = Services.get(ApplicationBoxService.class);
                    messages = service.getMessagesInThread(request.getParameter("thread_id"));
                } else if ("CompanyBox".equals(box)) {
                    CompanyBoxService service = Services.get(CompanyBoxService.class);
                    messages = service.getMessagesInThread(request.getParameter("thread_id"));
                } else if ("GroupBox".equals(box)) {
                    GroupBoxService service = Services.get(GroupBoxService.class);
                    messages = service.getMessagesInThread(request.getParameter("thread_id"));
                } else {
                    UnitBoxService service = Services.get(UnitBoxService.class);
                    messages = service.getMessagesInThread(request.getParameter("thread_id"));
                }

                var result = new ArrayList<Map<String, Object>>();
                var accountContext = Contexts.get(AccountContext.class);

                for (Message message : messages) {
                    var map = new HashMap<String, Object>();

                    map.put("attach_files", convert(request, message.getAttachFiles()));
                    map.put("attach_id", message.getAttachId());
                    map.put("attach_mime_type", message.getAttachMimeType());
                    map.put("attach_name", message.getAttachName());
                    map.put("attach_path", message.getAttachPath());
                    map.put("attributes", message.getAttributes());
                    map.put("box_cd", message.getBoxCd());
                    map.put("box_name", message.getBoxName());
                    map.put("delete_flag", message.getDeleteFlag());
                    map.put("edited_flag", message.getEditedFlag());
                    map.put("like_flag", message.getLikeFlag());
                    map.put("like_users", convert(message.getLikeUsers()));
                    map.put("liked_user_count", message.getLikedUserCount());
                    map.put("message_id", message.getMessageId());
                    map.put("message_text", message.getMessageText());
                    map.put("message_type_cd", message.getMessageTypeCd());
                    map.put("notice_users", convert(message.getNoticeUsers()));
                    map.put("post_date", message.getPostDate());
                    map.put("post_type_cd", message.getPostTypeCd());
                    map.put("post_user_cd", message.getPostUserCd());
                    map.put("post_user_delete_flag", message.getPostUserDeleteFlag());
                    map.put("post_user_name", message.getPostUserName());
                    map.put("read_count", message.getReadCount());
                    map.put("reply_message_id", message.getReplyMessageId());
                    map.put("reply_user_cd", message.getReplyUserCd());
                    map.put("reply_user_delete_flag", message.getReplyUserDeleteFlag());
                    map.put("reply_user_name", message.getReplyUserName());
                    map.put("thread_id", message.getThreadId());
                    map.put("unread_flag", message.getUnreadFlag());
                    map.put("uri", message.getUri());
                    map.put("uri_attach_id", message.getUriAttachId());
                    map.put("uri_attach_path", message.getUriAttachPath());
                    map.put("uri_text", message.getUriText());
                    map.put("uri_title", message.getUriTitle());

                    if ("MyBox".equals(box)) {
                        map.put("thread_url", ServerContext.getInstance().getBaseURL(request) + "/imbox/unitbox/"
                                + message.getBoxCd() + "/" + message.getThreadId());
                    } else if ("DirectMessageBox".equals(box)) {
                        map.put("thread_url", ServerContext.getInstance().getBaseURL(request)
                                + "/imbox/unitbox/BOXCD_DIRECTMESSAGE/" + message.getThreadId());
                    } else if ("ApplicationBox".equals(box)) {
                        map.put("thread_url", ServerContext.getInstance().getBaseURL(request)
                                + "/imbox/unitbox/BOXCD_APPLICATION/" + message.getThreadId());
                    } else if ("CompanyBox".equals(box)) {
                        map.put("thread_url", ServerContext.getInstance().getBaseURL(request) + "/imbox/unitbox/"
                                + message.getBoxCd() + "/" + message.getThreadId());
                    } else if ("GroupBox".equals(box)) {
                        map.put("thread_url", ServerContext.getInstance().getBaseURL(request) + "/imbox/unitbox/"
                                + message.getBoxCd() + "/" + message.getThreadId());
                    } else {
                        map.put("thread_url", ServerContext.getInstance().getBaseURL(request) + "/imbox/unitbox/"
                                + message.getBoxCd() + "/" + message.getThreadId());
                    }

                    map.put("has_liked_flag", contains(message.getLikeUsers(), accountContext.getUserCd()));
                    map.put("has_noticed_flag", contains(message.getNoticeUsers(), accountContext.getUserCd()));
                    map.put("like_user_names", getUserNames(message.getLikeUsers()));
                    map.put("notice_user_names", getUserNames(message.getNoticeUsers()));
                    map.put("post_date_format",
                            new SimpleDateFormat("yyyy-MM-dd(E) HH:mm:ss.SSS").format(message.getPostDate()));

                    result.add(map);
                }

                return result;
            } catch (IMBoxException e) {
                e.printStackTrace();

                return new ArrayList<Map<String, Object>>();
            }
        } else if ("POST".equals(method.toUpperCase())) {
            final String box = request.getParameter("box");
            final String threadId = request.getParameter("thread_id");
            final boolean newThread = StringUtil.isBlank(threadId);
            final Entry4SendMessage entry = new Entry4SendMessage();
            MessageOperations operations;

            if ("MyBox".equals(box)) {
                operations = Services.get(MyBoxService.class);

                entry.setBoxCd(request.getParameter("box_cd"));
                if (!StringUtil.isBlank(request.getParameter("to_users"))) {
                    entry.setNoticeUsers(makeUsers(request.getParameter("to_users")));
                }
            } else if ("DirectMessageBox".equals(box)) {
                operations = Services.get(DirectMessageBoxService.class);

                if (!newThread) {
                    entry.setBoxCd("BOXCD_DIRECTMESSAGE");
                }
                if (!StringUtil.isBlank(request.getParameter("to_users"))) {
                    entry.setDmReferenceUsers(makeUsers(request.getParameter("to_users")));
                }
            } else if ("ApplicationBox".equals(box)) {
                operations = Services.get(ApplicationBoxService.class);

                if (!newThread) {
                    entry.setBoxCd("BOXCD_APPLICATION");
                }
                if (!StringUtil.isBlank(request.getParameter("to_users"))) {
                    entry.setNoticeUsers(makeUsers(request.getParameter("to_users")));
                }
            } else if ("CompanyBox".equals(box)) {
                operations = Services.get(CompanyBoxService.class);

                entry.setBoxCd(request.getParameter("box_cd"));
                if (!StringUtil.isBlank(request.getParameter("to_users"))) {
                    entry.setNoticeUsers(makeUsers(request.getParameter("to_users")));
                }
            } else if ("GroupBox".equals(box)) {
                operations = Services.get(GroupBoxService.class);

                entry.setBoxCd(request.getParameter("box_cd"));
                if (!StringUtil.isBlank(request.getParameter("to_users"))) {
                    entry.setNoticeUsers(makeUsers(request.getParameter("to_users")));
                }
            } else {
                operations = Services.get(UnitBoxService.class);

                entry.setBoxCd(request.getParameter("box_cd"));
                if (!StringUtil.isBlank(request.getParameter("to_users"))) {
                    entry.setNoticeUsers(makeUsers(request.getParameter("to_users")));
                }
            }

            var accountContext = Contexts.get(AccountContext.class);

            entry.setMessageText(request.getParameter("message_text"));
            entry.setPostUserCd(accountContext.getUserCd());
            entry.setMessageTypeCd("MESSAGE_TYPE_MESSAGE");

            if (!StringUtil.isBlank(request.getParameter("reply_user_cd"))) {
                entry.setReplyUserCd(request.getParameter("reply_user_cd"));
            }
            if (!StringUtil.isBlank(request.getParameter("reply_message_id"))) {
                entry.setReplyMessageId(request.getParameter("reply_message_id"));
            }

            if (ServletFileUpload.isMultipartContent(request)) {
                final AttachmentService attachmentService = Services.get(AttachmentService.class);

                try {
                    MultipartFormData multipartFormData = new MultipartFormData(request);

                    if (!multipartFormData.isEmpty()) {
                        final AttachFiles attachFiles = new AttachFiles();

                        for (int i = 0; i < multipartFormData.size(); ++i) {
                            final Entity entity = multipartFormData.getEntity(i);

                            if (StringUtil.isBlank(entity.getFileName())) {
                                continue;
                            }

                            final AttachFile attachFile = new AttachFile();
                            final String attachId = new Identifier().get();
                            final String directory = makeAttachDirectory(attachId, AttachTypes.ATTACH_TYPE_MESSAGE);
                            final String extension = getExtension(entity.getFileName());
                            String attachName = attachId;
                            if (extension != null && !extension.isEmpty()) {
                                attachName = attachName.concat(".").concat(extension);
                            }

                            final PublicStorage parent = new PublicStorage(directory);
                            if (!parent.isDirectory()) {
                                parent.makeDirectories();
                            }

                            final PublicStorage storage = new PublicStorage(parent, attachName);
                            try (final InputStream istr = entity.getInputStream(); final OutputStream ostr = storage.create()) {
                                IOUtil.transfer(istr, ostr);
                            }

                            if (extension != null && !extension.isEmpty()) {
                                attachmentService.createThumbnail(AttachTypes.ATTACH_TYPE_MESSAGE.toString(), attachId, attachName, directory.concat("/"), extension);
                            }

                            attachFile.setAttachId(attachId);
                            attachFile.setAttachName(entity.getFileName());
                            attachFile.setAttachPath(storage.getPath());
                            attachFile.setAttachSize(entity.getContentLength());
                            attachFile.setAttachMimeType(attachmentService.getMimeType(attachId, attachFile.getAttachPath()));

                            attachFiles.add(attachFile);
                        }

                        entry.setAttachFiles(attachFiles);
                    }
                } catch (IMBoxException | IOException e) {
                    e.printStackTrace();
                }
            }

            try {
                if (newThread) {
                    operations.send(entry);
                } else {
                    operations.send(entry, threadId);
                }
            } catch (IMBoxException e) {
                e.printStackTrace();
            }

            return null;
        } else {
            return null;
        }
    }
}
```

```java:ThreadsExecutor.java
public class ThreadsExecutor implements ResourceExecutor<List<Map<String, Object>>> {
    @Override
    @ResponseType("application/json")
    public List<Map<String, Object>> execute(final HttpServletRequest request, final HttpServletResponse response) {
        SessionCookie.invalidateRequestedSessionCookie(request);
        
        final String method = request.getMethod();

        if ("GET".equals(method.toUpperCase())) {
            try {
                Threads threads;
                String box = request.getParameter("box");

                switch (box) {
                case "MyBox": {
                        MyBoxService service = Services.get(MyBoxService.class);
                        threads = service.getThreads(null);
                    }

                    break;
                case "DirectMessageBox": {
                        DirectMessageBoxService service = Services.get(DirectMessageBoxService.class);
                        threads = service.getThreads(null);
                    }

                    break;
                case "ApplicationBox": {
                        ApplicationBoxService service = Services.get(ApplicationBoxService.class);
                        threads = service.getThreads(null);
                    }

                    break;
                case "CompanyBox": {
                        CompanyBoxService service = Services.get(CompanyBoxService.class);
                        threads = service.getThreads(request.getParameter("box_cd"), null);
                    }

                    break;
                case "GroupBox": {
                        GroupBoxService service = Services.get(GroupBoxService.class);
                        threads = service.getThreads(request.getParameter("box_cd"), null);
                    }

                    break;
                default:
                    return new ArrayList<Map<String, Object>>();
                }

                var accountContext = Contexts.get(AccountContext.class);
                var result = new ArrayList<Map<String, Object>>();

                for (Thread thread : threads) {
                    var map = new HashMap<String, Object>();
                    long count = thread.getMessageCount();

                    map.put("dm_reference_user_count", Long.valueOf(thread.getDmReferenceUserCount()));
                    map.put("dm_reference_users", convert(thread.getDmReferenceUsers()));
                    map.put("dm_reference_user_names", getUserNames(thread.getDmReferenceUsers()));
                    map.put("message_count", Long.valueOf(count));
                    map.put("tags", convert(thread.getTags()));
                    map.put("tag_names", getTagNames(thread.getTags()));
                    map.put("me_cd", accountContext.getUserCd());

                    if (count > 0) {
                        Message message = thread.getMessages().get(0);

                        map.put("message_id", message.getMessageId());
                        map.put("thread_id", message.getThreadId());
                        map.put("message_text", message.getMessageText());
                        map.put("post_date", message.getPostDate());
                        map.put("post_user_cd", message.getPostUserCd());
                        map.put("post_user_name", message.getPostUserName());

                        if ("MyBox".equals(box)) {
                            map.put("thread_url", ServerContext.getInstance().getBaseURL(request) + "/imbox/unitbox/" + message.getBoxCd() + "/" + message.getThreadId());
                        } else if ("DirectMessageBox".equals(box)) {
                            map.put("thread_url", ServerContext.getInstance().getBaseURL(request) + "/imbox/unitbox/BOXCD_DIRECTMESSAGE/" + message.getThreadId());
                        } else if ("ApplicationBox".equals(box)) {
                            map.put("thread_url", ServerContext.getInstance().getBaseURL(request) + "/imbox/unitbox/APPLICATION_CD_FOLLOW/" + message.getThreadId());
                        } else if ("CompanyBox".equals(box)) {
                            map.put("thread_url", ServerContext.getInstance().getBaseURL(request) + "/imbox/unitbox/" + message.getBoxCd() + "/" + message.getThreadId());
                        } else if ("GroupBox".equals(box)) {
                            map.put("thread_url", ServerContext.getInstance().getBaseURL(request) + "/imbox/unitbox/" + message.getBoxCd() + "/" + message.getThreadId());
                        } else {
                            map.put("thread_url", ServerContext.getInstance().getBaseURL(request) + "/imbox/unitbox/" + message.getBoxCd() + "/" + message.getThreadId());
                        }
                    }

                    result.add(map);
                }

                return result;
            } catch (IMBoxException e) {
                e.printStackTrace();
                
                return new ArrayList<Map<String, Object>>();
            }
        } else {
            return null;
        }
    }
}
```

### クライアントサイド(Flutter, Dart)

```dart:lib/account_settings.dart
class AccountSettingsWidget extends StatefulWidget {
  const AccountSettingsWidget({Key key}) : super(key: key);

  @override
  State<StatefulWidget> createState() {
    return _AccountSettingsState();
  }
}

class _AccountSettingsState extends State<AccountSettingsWidget> {
  List<Account> accounts = [];

  Widget makeItem(BuildContext context, int index) {
    if (index < accounts.length) {
      final account = accounts[index];

      return Dismissible(
        direction: DismissDirection.endToStart,
        key: Key(account.name),
        onDismissed: (direction) {
          setState(() {
            accounts.removeAt(index);
          });

          SharedPreferences.getInstance().then(
            (prefs) {
              setState(() {
                Preferences.setAccounts(prefs, accounts);
              });
            }
          );

          Scaffold.of(context).showSnackBar(SnackBar(content: Text("${account.name}を削除しました")));
        },
        background: new Container(
          padding: EdgeInsets.only(right: 20.0),
          color: Colors.red,
          child: new Align(
            alignment: Alignment.centerRight,
            child: new Text(
              '削除',
              textAlign: TextAlign.right,
              style: new TextStyle(color: Colors.white)
            ),
          ),
        ),
        child: ListTile(
          title: Row(
            mainAxisAlignment: MainAxisAlignment.start,
            children: [
              account.active ? Icon(Icons.person, color: Colors.blue) : Icon(Icons.person_outline, color: Colors.blue),
              Text('　'),
              Flexible(
                child: Text('${account.name}\n${account.url}'),
              ),
            ],
          ),
          onTap: () {
            setState(() {
              for (var account in accounts) {
                account.active = false;
              }
              accounts[index].active = true;

              Preferences.activeAccount = accounts[index];
              SharedPreferences.getInstance().then(
                (prefs) {
                  Preferences.setAccounts(prefs, accounts);
                  Navigator.of(context).pop();
                }
              );
            });
          },
        ),
      );
    } else {
      return ListTile(
        title: Icon(Icons.person_add, color: Colors.blue),
        onTap: () {
          _askNewUrl(context).then(
            (url) {
              if (url != null) {
                launch(url + '/oauth/authorize?response_type=token&client_id=imbox-client&redirect_uri=imbox_client/oauth/callback', enableJavaScript: true);

                _askNewAccount(context).then(
                  (content) {
                    if (content != null) {
                      var account = Account(false, url, content.value, content.key);

                      setState(() {
                        accounts.add(account);
                      });

                      SharedPreferences.getInstance().then(
                        (prefs) {
                          Preferences.setAccounts(prefs, accounts);
                        }
                      );
                    }
                  }
                );
              }
            }
          );
        },
      );
    }
  }

  Future<String> _askNewUrl(BuildContext context) async {
    String url;

    return await showDialog<String>(
      context: context,
      builder: (BuildContext context) {
        return AlertDialog(
          title: Text('接続先 intra-mart Accel Platform の URL を入力してください'),
          content: Column(mainAxisSize: MainAxisSize.min, children: [
            TextField(
              controller: TextEditingController(),
              decoration: InputDecoration(labelText: 'URL', hintText: 'http://localhost:8080/imart'),
              onChanged: (value) {
                url = value;
                if (url.endsWith('/')) {
                  url = url.substring(0, url.length - 1);
                }
              },
            ),
          ]),
          actions: <Widget>[
            FlatButton(
              child: new Text('Cancel'),
              onPressed: () {
                Navigator.of(context).pop();
              },
            ),
            FlatButton(
              child: new Text('OK'),
              onPressed: () {
                Navigator.of(context).pop(url);
              },
            )
          ],
        );
      }
    );
  }

  Future<MapEntry<String, String>> _askNewAccount(BuildContext context) async {
    String code, name;

    return await showDialog<MapEntry<String, String>>(
      context: context,
      builder: (BuildContext context) {
        return AlertDialog(
          title: Text('ブラウザに表示されたコードと名前（任意）を入力してください'),
          content: Column(mainAxisSize: MainAxisSize.min, children: [
            TextField(
              controller: TextEditingController(),
              decoration: InputDecoration(labelText: 'コード', hintText: ''),
              onChanged: (value) {
                code = value;
              },
            ),
            TextField(
              controller: TextEditingController(),
              decoration: InputDecoration(labelText: '名前', hintText: '青柳辰巳'),
              onChanged: (value) {
                name = value;
              },
            ),
          ]),
          actions: <Widget>[
            FlatButton(
              child: new Text('Cancel'),
              onPressed: () {
                Navigator.of(context).pop();
              },
            ),
            FlatButton(
              child: new Text('OK'),
              onPressed: () {
                Navigator.of(context).pop(MapEntry<String, String>(code, name));
              },
            )
          ],
        );
      }
    );
  }

  @override
  @protected
  @mustCallSuper
  void initState() {
    super.initState();
    
    SharedPreferences.getInstance().then(
      (prefs) {
        setState(() {
          if (Preferences.hasAccounts(prefs)) {
            accounts = Preferences.getAccounts(prefs);
          } else {
            accounts = [];
          }
        });
      }
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('アカウント'),
      ),
      body: ListView.builder(
        itemCount: accounts.length + 1,
        itemBuilder: (context, index) {
          return makeItem(context, index);
        },
      ),
    );
  }
}
```

```dart:lib/app_thread_list.dart
class AppThreadListWidget extends ThreadListWidget {
  const AppThreadListWidget() : super('ApplicationBox');

  @override
  String getTitle() {
    return getBox();
  }

  String getBox() {
    return 'ApplicationBox';
  }

  @override
  String getBoxCd() {
    return null;
  }

  @override
  Future<List<Thread>> getThreads() async {
    if (Preferences.activeAccount == null || Preferences.activeAccount.accessToken == null) {
      return [];
    }

    var url = Preferences.activeAccount.url + '/imbox_client/api/v1/threads?box=ApplicationBox';
    var headers = {
      'Authorization': 'Bearer ' + Preferences.activeAccount.accessToken
    };
    var response = await http.get(url, headers: headers);
    var decoded = json.decode(response.body);
    
    if (!(decoded is List)) {
      return [];
    }

    List list = decoded as List;
    List<Thread> result = [];

    for (var data in list) {
      var thread = Thread.fromJson(data);
      result.add(thread);
    }

    return result;
  }
}
```

```dart:lib/board_list.dart
abstract class BoardListWidget extends StatefulWidget {
  const BoardListWidget({Key key}) : super(key: key);

  @override
  State<StatefulWidget> createState() {
    return _BoardListState(this);
  }

  String getTitle();
  Board newBoard(String name, String boxCd);
  Future<bool> setBoards(SharedPreferences prefs, List<Board> boards);
  Future<List<Board>> getBoardsFromPref();
  Future<List<Board>> getBoardsFromServer();
  ThreadListWidget getThreadListWidget(String title, String boxCd);
  List<IconData> getIcons();
  List<String> askNewItem();
}

void openThreadListPage(BoardListWidget boardListWidget, BuildContext context, int index, Board board) {
  Navigator.push(context, MaterialPageRoute(
    builder: (BuildContext context) {
      return boardListWidget.getThreadListWidget(board.name, board.boxCD);
    },
  ));
}

Widget makeBoard(BoardListWidget boardListWidget, _BoardListState state, BuildContext context, int index) {
  var icons = boardListWidget.getIcons();

  if (index < state.boards.length) {
    final board = state.boards[index];

    return Dismissible(
      direction: DismissDirection.endToStart,

      key: Key(board.name),
      onDismissed: (direction) {
        state.removeBoard(index);

        Scaffold.of(context).showSnackBar(SnackBar(duration: Duration(milliseconds: 700), content: Text("${board.name}を削除しました")));
      },
      background: new Container(
        padding: EdgeInsets.only(right: 20.0),
        color: Colors.red,
        child: new Align(
          alignment: Alignment.centerRight,
          child: new Text(
            '削除',
            textAlign: TextAlign.right,
            style: new TextStyle(color: Colors.white)
          ),
        ),
      ),
      child: ListTile(
        title: Row(
          mainAxisAlignment: MainAxisAlignment.start,
          children: [
            Icon(icons[0], color: Colors.blue),
            Text('　'),
            Text('${board.name}'),
          ],
        ),
        onTap: () {
          openThreadListPage(boardListWidget, context, index, board);
        },
      ),
    );
  } else {
    return ListTile(
      title: Icon(icons[1], color: Colors.blue),
      onTap: () {
        var future = state._askNewItem(context);
        future.then(
          (content) {
            if (content != null) {
              state.addBoard(boardListWidget.newBoard(content.value, content.key));
             }
          }
        );
      },
    );
  }
}

class _BoardListState extends State<BoardListWidget> {
  bool loading = false;
  BoardListWidget boardListWidget;
  List<Board> boards = [];

  _BoardListState(BoardListWidget boardListWidget) {
    this.boardListWidget = boardListWidget;
    
  }

  @override
  @protected
  @mustCallSuper
  void initState() {
    super.initState();

    boardListWidget.getBoardsFromPref().then(
      (boards) {
        if (boards != null) {
          setState(() {
            this.boards = boards;
          });
        }
      }
    );
  }

  Future<MapEntry<String, String>> _askNewItem(BuildContext context) async {
    var labels = boardListWidget.askNewItem();
    String id, name;

    return await showDialog<MapEntry<String, String>>(
      context: context,
      builder: (BuildContext context) {
        return AlertDialog(
          title: Text(labels[0]),
          content: Column(mainAxisSize: MainAxisSize.min, children: [
            TextField(
              controller: TextEditingController(),
              decoration: InputDecoration(labelText: labels[1], hintText: labels[2]),
              onChanged: (value) {
                id = value;
              },
            ),
            TextField(
              controller: TextEditingController(),
              decoration: InputDecoration(labelText: labels[3], hintText: labels[4]),
              onChanged: (value) {
                name = value;
              },
            ),
          ]),
          actions: <Widget>[
            FlatButton(
              child: new Text('Calcel'),
              onPressed: () {
                Navigator.of(context).pop();
              },
            ),
            FlatButton(
              child: new Text('OK'),
              onPressed: () {
                Navigator.of(context).pop(MapEntry<String, String>(id, name));
              },
            )
          ],
        );
      }
    );
  }

  void addBoard(Board newBoard) {
    setState(() {
      this.boards.add(newBoard);

      SharedPreferences.getInstance().then(
        (prefs) {
          boardListWidget.setBoards(prefs, this.boards);
        }
      );
    });
  }

  void removeBoard(int index) {
    setState(() {
      this.boards.removeAt(index);

      SharedPreferences.getInstance().then(
        (prefs) {
          boardListWidget.setBoards(prefs, this.boards);
        }
      );
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(boardListWidget.getTitle()),
        actions: <Widget>[
          IconButton(
            icon: const Icon(Icons.sync),
            tooltip: '同期',
            onPressed: () {
              setState(() {
                this.loading = true;
              });

              boardListWidget.getBoardsFromPref().then(
                (boards) {
                  if (boards == null) {
                    this.boards = [];
                  } else {
                    this.boards = boards;
                  }

                  this.boardListWidget.getBoardsFromServer().then(
                    (boards) {
                      if (boards != null && mounted) {
                        setState(() {
                          for (Board newBoard in boards) {
                            bool found = false;
                            for (Board oldBoard in this.boards) {
                              if (oldBoard.boxCD == newBoard.boxCD) {
                                found = true;
                                break;
                              }
                            }

                            if (!found) {
                              this.boards.add(newBoard);
                            }
                          }

                          SharedPreferences.getInstance().then(
                            (prefs) {
                              boardListWidget.setBoards(prefs, this.boards);
                            }
                          );

                          this.loading = false;
                        });
                      }
                    }
                  );
                }
              );
            },
          ),
        ],
      ),
      body: loading ? Center(child: CircularProgressIndicator()) : ListView.builder(
        itemCount: boards.length + 1,
        itemBuilder: (context, index) {
          return makeBoard(boardListWidget, this, context, index);
        },
      ),
    );
  }
}
```

```dart:lib/company_list.dart
class CompanyListWidget extends BoardListWidget {
  @override
  String getTitle() {
    return "会社";
  }

  @override
  Board newBoard(String name, String boxCd) {
    return Company(name, boxCd);
  }

  @override
  Future<bool> setBoards(SharedPreferences prefs, List<Board> boards) {
    return Preferences.setCompanies(prefs, List.castFrom<Board, Company>(boards));
  }

  Future<List<Board>> getBoardsFromPref() async {
    final prefs = await SharedPreferences.getInstance();

    return Preferences.getCompanies(prefs);
  }

  @override
  Future<List<Board>> getBoardsFromServer() async {
    if (Preferences.activeAccount == null || Preferences.activeAccount.accessToken == null) {
      return [];
    }

    var url = Preferences.activeAccount.url + '/imbox_client/api/v1/companies';
    var headers = {
      'Authorization': 'Bearer ' + Preferences.activeAccount.accessToken
    };
    var response = await http.get(url, headers: headers);
    var decoded = json.decode(response.body);
    
    if (!(decoded is List)) {
      return [];
    }

    List list = decoded as List;
    List<Board> result = [];

    for (var data in list) {
      var company = Company.fromJson(data);
      result.add(company);
    }

    return result;
  }

  @override
  ThreadListWidget getThreadListWidget(String title, String boxCd) {
    return CompanyThreadListWidget(title, boxCd);
  }

  @override
  List<IconData> getIcons() {
    return [Icons.business, Icons.add];
  }

  @override
  List<String> askNewItem() {
    return ['会社識別子と名前（任意）を入力してください', '会社識別子', 'samplec', '名前（任意）', '株式会社テスト'];
  }
}
```

```dart:lib/company_thread_list.dart
class CompanyThreadListWidget extends ThreadListWidget {
  final String title;
  final String boxCd;

  const CompanyThreadListWidget(this.title, this.boxCd) : super(title);

  @override
  String getTitle() {
    return title;
  }

  String getBox() {
    return 'CompanyBox';
  }

  @override
  String getBoxCd() {
    return boxCd;
  }

  @override
  Future<List<Thread>> getThreads() async {
    if (Preferences.activeAccount == null || Preferences.activeAccount.accessToken == null) {
      return [];
    }

    var url = Preferences.activeAccount.url + '/imbox_client/api/v1/threads?box=CompanyBox&box_cd=${this.boxCd}';
    var headers = {
      'Authorization': 'Bearer ' + Preferences.activeAccount.accessToken
    };
    var response = await http.get(url, headers: headers);
    var decoded = json.decode(response.body);
    
    if (!(decoded is List)) {
      return [];
    }

    List list = decoded as List;
    List<Thread> result = [];

    for (var data in list) {
      var thread = Thread.fromJson(data);
      result.add(thread);
    }

    return result;
  }
}
```

```dart:lib/dm_thread_list.dart
class DMThreadListWidget extends ThreadListWidget {
  const DMThreadListWidget() : super('DirectMessageBox');

  @override
  String getTitle() {
    return getBox();
  }

  String getBox() {
    return 'DirectMessageBox';
  }

  @override
  String getBoxCd() {
    return null;
  }

  @override
  Future<List<Thread>> getThreads() async {
    if (Preferences.activeAccount == null || Preferences.activeAccount.accessToken == null) {
      return [];
    }

    var url = Preferences.activeAccount.url + '/imbox_client/api/v1/threads?box=DirectMessageBox';
    var headers = {
      'Authorization': 'Bearer ' + Preferences.activeAccount.accessToken
    };
    var response = await http.get(url, headers: headers);
    var decoded = json.decode(response.body);
    
    if (!(decoded is List)) {
      return [];
    }

    List list = decoded as List;
    List<Thread> result = [];

    for (var data in list) {
      var thread = Thread.fromJson(data);
      result.add(thread);
    }

    return result;
  }
}
```

```dart:lib/group_list.dart
class GroupListWidget extends BoardListWidget {
  @override
  String getTitle() {
    return "グループ";
  }

  @override
  Board newBoard(String name, String boxCd) {
    return Group(name, boxCd);
  }

  @override
  Future<bool> setBoards(SharedPreferences prefs, List<Board> boards) {
    return Preferences.setGroups(prefs, List.castFrom<Board, Group>(boards));
  }
  
  Future<List<Board>> getBoardsFromPref() async {
    final prefs = await SharedPreferences.getInstance();

    return Preferences.getGroups(prefs);
  }

  @override
  Future<List<Board>> getBoardsFromServer() async {
    if (Preferences.activeAccount == null || Preferences.activeAccount.accessToken == null) {
      return [];
    }

    var url = Preferences.activeAccount.url + '/imbox_client/api/v1/groups';
    var headers = {
      'Authorization': 'Bearer ' + Preferences.activeAccount.accessToken
    };
    var response = await http.get(url, headers: headers);
    var decoded = json.decode(response.body);
    
    if (!(decoded is List)) {
      return [];
    }

    List list = decoded as List;
    List<Board> result = [];

    for (var data in list) {
      var group = Group.fromJson(data);
      result.add(group);
    }

    return result;
  }

  @override
  ThreadListWidget getThreadListWidget(String title, String boxCd) {
    return GroupThreadListWidget(title, boxCd);
  }

  @override
  List<IconData> getIcons() {
    return [Icons.group, Icons.group_add];
  }
  
  @override
  List<String> askNewItem() {
    return ['グループ識別子と名前（任意）を入力してください', 'グループ識別子', '5i3a40fcpvo63g', '名前（任意）', 'テストグループ'];
  }
}
```

```dart:lib/group_thread_list.dart
class GroupThreadListWidget extends ThreadListWidget {
  final String title;
  final String boxCd;

  GroupThreadListWidget(this.title, this.boxCd) : super(title);

  @override
  String getTitle() {
    return title;
  }

  String getBox() {
    return 'GroupBox';
  }

  @override
  String getBoxCd() {
    return boxCd;
  }

  @override
  Future<List<Thread>> getThreads() async {
    if (Preferences.activeAccount == null || Preferences.activeAccount.accessToken == null) {
      return [];
    }

    var url = Preferences.activeAccount.url + '/imbox_client/api/v1/threads?box=GroupBox&box_cd=${this.boxCd}';
    var headers = {
      'Authorization': 'Bearer ' + Preferences.activeAccount.accessToken
    };
    var response = await http.get(url, headers: headers);
    var decoded = json.decode(response.body);
    
    if (!(decoded is List)) {
      return [];
    }

    List list = decoded as List;
    List<Thread> result = [];

    for (var data in list) {
      var thread = Thread.fromJson(data);
      result.add(thread);
    }

    return result;
  }
}
```

```dart:lib/main.dart
void main() async {
  runApp(IMBoxClientApp());
}

class IMBoxClientApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    Preferences.initialize();

    return MaterialApp(
      title: 'IMBox Client',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      darkTheme: ThemeData.dark(),
      home: AppBarStatelessWidget(),
    );
  }
}

class AppBarStatelessWidget extends StatelessWidget {
  AppBarStatelessWidget({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        leading: IconButton(
          icon: const Icon(Icons.person),
          tooltip: 'アカウント',
          onPressed: () {
            Navigator.push(context, MaterialPageRoute(
              builder: (BuildContext context) {
                return AccountSettingsWidget();
              },
            )).then(
              (_) {
                if (bnb.barWidgets.elementAt(bnb.selectedIndex) is MyBoxThreadListWidget) {
                  myBoxThreadListWidgetKey.currentState.getThreads();
                } else if (bnb.barWidgets.elementAt(bnb.selectedIndex) is DMThreadListWidget) {
                  dmThreadListWidgetKey.currentState.getThreads();
                } else if (bnb.barWidgets.elementAt(bnb.selectedIndex) is AppThreadListWidget) {
                  appThreadListWidgetKey.currentState.getThreads();
                } else if (bnb.barWidgets.elementAt(bnb.selectedIndex) is CompanyListWidget) {
                  companyListWidgetKey.currentState.getBoards();
                } else if (bnb.barWidgets.elementAt(bnb.selectedIndex) is GroupListWidget) {
                  groupListWidgetKey.currentState.getBoards();
                }
              }
            );
          },
        ),
        title: const Text('IMBox Client'),
        actions: <Widget>[
          IconButton(
            icon: const Icon(Icons.settings),
            tooltip: '設定',
            onPressed: () {
              Navigator.push(context, MaterialPageRoute(
                builder: (BuildContext context) {
                  return GeneralSettingsWidget();
                },
              ));
            },
          ),
        ],
      ),
      body: BottomNavigationStatefulWidget(),
    );
  }
}

class BottomNavigationStatefulWidget extends StatefulWidget {
  BottomNavigationStatefulWidget({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _BottomNavigationStatefulWidgetState createState() => _BottomNavigationStatefulWidgetState();
}

class _BottomNavigationStatefulWidgetState extends State<BottomNavigationStatefulWidget> {
  void _onItemTapped(int index) {
    setState(() {
      bnb.selectedIndex = index;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: bnb.barWidgets.elementAt(bnb.selectedIndex),
      ),
      bottomNavigationBar: BottomNavigationBar(
        type: BottomNavigationBarType.fixed,
        items: bnb.barItems,
        currentIndex: bnb.selectedIndex,
        selectedItemColor: Colors.amber[800],
        onTap: _onItemTapped,
      ),
    );
  }
}
```

```dart:lib/mybox_thread_list.dart
class MyBoxThreadListWidget extends ThreadListWidget {
  MyBoxThreadListWidget() : super('MyBox');

  @override
  String getTitle() {
    return getBox();
  }

  String getBox() {
    return 'MyBox';
  }

  @override
  String getBoxCd() {
    return null;
  }

  @override
  Future<List<Thread>> getThreads() async {
    if (Preferences.activeAccount == null || Preferences.activeAccount.accessToken == null) {
      Preferences.getAccounts(await SharedPreferences.getInstance());
    }
    if (Preferences.activeAccount == null || Preferences.activeAccount.accessToken == null) {
      return [];
    }

    var url = Preferences.activeAccount.url + '/imbox_client/api/v1/threads?box=MyBox';
    var headers = {
      'Authorization': 'Bearer ' + Preferences.activeAccount.accessToken
    };
    var response = await http.get(url, headers: headers);
    var decoded = json.decode(response.body);
    
    if (!(decoded is List)) {
      return [];
    }

    List list = decoded as List;
    List<Thread> result = [];

    for (var data in list) {
      var thread = Thread.fromJson(data);
      result.add(thread);
    }

    return result;
  }
}
```

```dart:lib/post_message.dart
class PostMessage {
  static Future<http.StreamedResponse> postMessage(Map<String, String> params, List<File> files) async {
    var uri = Uri.parse(Constants.baseURL + '/imbox_client/api/v1/messages');
    var headers = {
      'Authorization': 'Bearer ' + Preferences.account.accessToken
    };
    var request = http.MultipartRequest('POST', uri);

    request.headers.addAll(headers);
    request.fields.addAll(params);

    if (files != null && files.length > 0) {
      for (var file in files) {
        request.files.add(await http.MultipartFile.fromPath(
          path.basename(file.path),
          file.path,
          contentType: MediaType('application', 'octet-stream'),
        ));
      }
    }

    return request.send();
  }
}
```

```dart:lib/thread_list.dart
abstract class ThreadListWidget extends StatefulWidget {
  final String title;

  const ThreadListWidget(this.title, {Key key, }) : super(key: key);

  @override
  State<StatefulWidget> createState() {
    return ThreadListState();
  }

  String getTitle();
  String getBox();
  String getBoxCd();
  Future<List<Thread>> getThreads();
}

void openTimelinePage(ThreadListWidget threadListWidget, context, Thread thread) {
  Navigator.push(context, MaterialPageRoute(
    builder: (BuildContext context) {
      return TimelineWidget(threadListWidget.getTitle(), threadListWidget.getBox(), thread);
    },
  ));
}

String makeThreadId(String threadId) {
  return Preferences.getActiveAccountId() + '_' + threadId;
}

Widget makeThread(ThreadListWidget threadListWidget, ThreadListState state, BuildContext context, int index, Thread thread) {
  int readCount = Preferences.threadReadCount[makeThreadId(thread.threadId)];
  if (readCount == null) {
    readCount = 0;
  }

  return ListTile(
    leading: Icon(Icons.lens, color: readCount == state.threads[index].messageCount ? Colors.grey : Colors.blue),
    title: Text(state.threads[index].postUserName + '\n' + state.threads[index].messageText, maxLines: 5),
    trailing: Text('$readCount/${state.threads[index].messageCount}'),
    onTap: () {
      openTimelinePage(threadListWidget, context, thread);
      state.markAsReadAll(state.threads[index]);
    },
  );
}

class ThreadListState extends State<ThreadListWidget> {
  final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>();
  bool loading = true;
  String title;
  List<Thread> threads = [];

  @override
  @protected
  @mustCallSuper
  void initState() {
    super.initState();

    this.title = widget.getTitle();
    getThreads();
  }

  void getThreads() {
    setState(() {
      this.loading = true;
    });

    widget.getThreads().then(
      (threads) {
        if (threads != null && mounted) {
          setState(() {
            this.threads = threads;
            this.loading = false;
          });
        }
      }
    );
  }

  void markAsReadAll(Thread thread) {
    SharedPreferences.getInstance().then(
      (prefs) {
        setState(() {
          Preferences.setThreadReadCount(prefs, makeThreadId(thread.threadId), thread.messageCount);
        });
      }
    );
  }

  @override
  Widget build(BuildContext context) {
    List<Widget> actions = [];

    if (widget is CompanyThreadListWidget || widget is GroupThreadListWidget) {
      actions.add(IconButton(
        icon: const Icon(Icons.message),
        tooltip: '投稿',
        onPressed: () {
          if (Preferences.activeAccount == null || Preferences.activeAccount.accessToken == null) {
            return;
          }

          TimelineState.askNewMessage(context, scaffoldKey).then(
            (entry) {
              if (entry != null) {
                var body = {
                  'box': widget is CompanyThreadListWidget ? 'CompanyBox' : 'GroupBox',
                  'box_cd': widget.getBoxCd(),
                  'message_text': entry.key,
                };

                PostMessage.postMessage(body, entry.value).then(
                  (response) {
                    if (response != null && response.statusCode == 200) {
                      scaffoldKey.currentState.showSnackBar(SnackBar(
                        duration: Duration(milliseconds: 1000),
                        content: Text('投稿しました'),
                      ));
                      getThreads();
                    }
                  }
                );
              }
            }
          );

          return;
        },
      ));
    }

    return Scaffold(
      key: scaffoldKey,
      appBar: AppBar(
        title: Text(title),
        actions: actions,
      ),
      body: loading ? Center(child: CircularProgressIndicator()) : RefreshIndicator(
        onRefresh: () async {
          getThreads();
        },
        child: ListView.builder(
          physics: const AlwaysScrollableScrollPhysics(),
          itemCount: threads.length,
          itemBuilder: (context, index) {
            return Column(
              children: <Widget>[
                makeThread(widget, this, context, index, threads[index]),
                Divider(),
              ],
            );
          },
        ),
      ),
    );
  }
}
```

```dart:lib/timeline.dart
class TimelineWidget extends StatefulWidget {
  final String title;
  final String box;
  final Thread thread;

  const TimelineWidget(this.title, this.box, this.thread, {Key key}) : super(key: key);

  @override
  State<StatefulWidget> createState() {
    return TimelineState();
  }

  Future<List<Message>> getMessages() async {
    if (Preferences.activeAccount == null || Preferences.activeAccount.accessToken == null) {
      return [];
    }

    var url = Preferences.activeAccount.url + '/imbox_client/api/v1/messages?thread_id=${thread.threadId}';
    var headers = {
      'Authorization': 'Bearer ' + Preferences.activeAccount.accessToken
    };
    var response = await http.get(url, headers: headers);
    var decoded = json.decode(response.body);
    
    if (!(decoded is List)) {
      return [];
    }

    List list = decoded as List;
    List<Message> result = [];

    for (var data in list) {
      var message = Message.fromJson(data);
      result.add(message);
    }

    return result;
  }
}

Widget makeMessage(TimelineWidget timelineWidget, TimelineState state, BuildContext context, int index) {
  List<Widget> widgets = [];

  widgets.add(Text(state.messages[index].postDateFormat, maxLines: 2));

  if (index == 0 && timelineWidget.thread.tagNames.length > 0) {
    List<Widget> rows = [];

    for (var tag in timelineWidget.thread.tagNames) {
      rows.add(Card(
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Icon(Icons.label, color: Colors.blue),
            Text(tag),
          ],
        )
      ));
    }

    widgets.add(Row(
      mainAxisAlignment: MainAxisAlignment.start, children: <Widget>[
        Expanded(
          child: Wrap(
            alignment: WrapAlignment.start,
            direction: Axis.horizontal,
            children: rows,
          ),
        ),
      ],
    ));
  }

  if (state.messages[index].replyMessageId != null && state.messages[index].replyMessageId.isNotEmpty) {
    Text text;
    TextStyle textStyle = TextStyle();

    if (!(timelineWidget is ReplyTimelineWidget)) {
      if (state.messages[index].replyUserCd == timelineWidget.thread.meCd) {
        textStyle = TextStyle(color: Colors.blue, decoration: TextDecoration.underline, fontWeight: FontWeight.bold);
      } else {
        textStyle = TextStyle(color: Colors.blue, decoration: TextDecoration.underline);
      }
    }
    text = Text(state.messages[index].replyUserName + 'さんへの返信です\n', maxLines: 10, style: textStyle);

    widgets.add(
      timelineWidget is ReplyTimelineWidget ?
      text :
      GestureDetector(
        onTap: () {
          Navigator.push(context, MaterialPageRoute(
            builder: (BuildContext context) {
              return ReplyTimelineWidget(timelineWidget.title, timelineWidget.box, timelineWidget.thread, state.messages, state.messages[index]);
            },
          ));
        },
        child: text,
      )
    );
  }

  widgets.add(SelectableLinkify(
    onOpen: (link) => launch(link.url),
    text: state.messages[index].messageText,
    options: LinkifyOptions(humanize: false),
  ));

  if (state.messages[index].likeUserNames.length > 0) {
    List<Widget> rows = [];

    for (var likeUser in state.messages[index].likeUserNames) {
      rows.add(Card(
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Icon(Icons.favorite, color: Colors.red),
            Text(likeUser),
          ],
        )
      ));
    }

    widgets.add(Row(
      mainAxisAlignment: MainAxisAlignment.start, children: <Widget>[
        Expanded(
          child: Wrap(
            alignment: WrapAlignment.start,
            direction: Axis.horizontal,
            children: rows,
          ),
        ),
      ],
    ));
  }
  if (state.messages[index].noticeUserNames.length > 0) {
    List<Widget> rows = [];

    state.messages[index].noticeUserNames.asMap().forEach((i, noticeUser) {
      rows.add(Card(
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            state.messages[index].noticeUsers[i].userCd == timelineWidget.thread.meCd ? Icon(Icons.notifications_active, color: Colors.blueAccent) : Icon(Icons.notifications, color: Colors.lightBlue),
            Text(noticeUser),
          ],
        )
      ));
    });

    widgets.add(Row(
      mainAxisAlignment: MainAxisAlignment.start, children: <Widget>[
        Expanded(
          child: Wrap(
            alignment: WrapAlignment.start,
            direction: Axis.horizontal,
            children: rows,
          ),
        ),
      ],
    ));
  }
  if (index == 0 && timelineWidget.thread.dmReferenceUserNames.length > 0) {
    List<Widget> rows = [];

    timelineWidget.thread.dmReferenceUserNames.asMap().forEach((i, dmReferenceUser) {
      rows.add(Card(
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            timelineWidget.thread.dmReferenceUsers[i].userCd == timelineWidget.thread.meCd ? Icon(Icons.notifications_active, color: Colors.blueAccent) : Icon(Icons.notifications, color: Colors.lightBlue),
            Text(dmReferenceUser),
          ],
        )
      ));
    });

    widgets.add(Row(
      mainAxisAlignment: MainAxisAlignment.start, children: <Widget>[
        Expanded(
          child: Wrap(
            alignment: WrapAlignment.start,
            direction: Axis.horizontal,
            children: rows,
          ),
        ),
      ],
    ));
  }

  for (AttachFile file in state.messages[index].attachFiles) {
    var headers = {
      'Authorization': 'Bearer ' + Preferences.activeAccount.accessToken
    };

    widgets.add(
      GestureDetector(
        onTap: () {
          launch(file.attachPathEncrypt, headers: headers, enableJavaScript: true);
        },
        child: Text(file.attachName, style: TextStyle(decoration: TextDecoration.underline)),
      ),
    );

    if (file.attachMimeType.contains("image")) {
      widgets.add(CachedNetworkImage(
        imageUrl: file.attachPathEncrypt,
        fit: BoxFit.contain,
        placeholder: (context, url) => CircularProgressIndicator(),
        errorWidget: (context, url, error) => Icon(Icons.error),
        httpHeaders: headers,
      ));
    }
  }

  Widget title = Container(
    alignment: Alignment.topLeft,
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: widgets,
    ),
  );

  return ListTile(
    leading: Text(state.messages[index].postUserName),
    title: title,
    subtitle: Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
      GestureDetector(
        onTap: () {
          if (Preferences.activeAccount == null || Preferences.activeAccount.accessToken == null) {
            return;
          }

          TimelineState.askNewMessage(context, state.scaffoldKey).then(
            (entry) {
              if (entry != null) {
                var body = {
                  'box': timelineWidget.box,
                  'box_cd': state.messages[index].boxCd,
                  'thread_id': state.messages[index].threadId,
                  'reply_user_cd': state.messages[index].postUserCd,
                  'reply_message_id': state.messages[index].messageId,
                  'message_text': entry.key,
                };

                PostMessage.postMessage(body, entry.value).then(
                  (response) {
                    if (response != null && response.statusCode == 200) {
                      state.scaffoldKey.currentState.showSnackBar(SnackBar(
                        duration: Duration(milliseconds: 1000),
                        content: Text('投稿しました'),
                      ));
                      state.getMessages();
                    }
                  }
                );
              }
            }
          );

          return;
        },
        child: Icon(Icons.reply, color: Colors.blue),
      ),
      GestureDetector(
        onTap: () {
          if (Preferences.activeAccount == null || Preferences.activeAccount.accessToken == null) {
            return;
          }

          var url = Preferences.activeAccount.url + '/imbox_client/api/v1/likes';
          var headers = {
            'Authorization': 'Bearer ' + Preferences.activeAccount.accessToken
          };
          var body = {
            'box': timelineWidget.box,
            'thread_id': state.messages[index].threadId,
            'message_id': state.messages[index].messageId,
          };

          http.post(url, headers: headers, body: body).then(
            (response) async {
              if (response != null && response.statusCode == 200) {
                state.favorite(index);
              }
            }
          );

          return;
        },
        child: Icon(state.messages[index].hasLikedFlag ? Icons.favorite : Icons.favorite_border, color: Colors.red),
      ),
    ]),
    // trailing: Text('2020-04-01'),
  );
}

Widget makeBody(TimelineWidget timelineWidget, TimelineState state, BuildContext context) {
  if (state.tappedIndex != null) {
    int selectedIndex = state.tappedIndex;
    state.tappedIndex = null;
    return bnb.barWidgets[selectedIndex];
  }

  return state.loading ? Center(child: CircularProgressIndicator()) : RefreshIndicator(
    onRefresh: () async {
      state.getMessages();
    },
    child: ListView.builder(
      controller: state.scrollController,
      physics: const AlwaysScrollableScrollPhysics(),
      itemCount: state.messages.length,
      itemBuilder: (context, index) {
        return Column(
          children: <Widget>[
            makeMessage(timelineWidget, state, context, index),
            Divider(),
          ],
        );
      },
    ),
  );
}

class TimelineState extends State<TimelineWidget> {
  final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>();
  ScrollController scrollController = ScrollController();
  double scrollRate;
  bool loading = true;
  List<Message> messages = [];
  int tappedIndex;

  static Future<MapEntry<String, List<File>>> askNewMessage(BuildContext context, GlobalKey<ScaffoldState> scaffoldKey) {
    return Navigator.of(context).push(AskNewMessage(MediaQuery.of(context).platformBrightness == Brightness.dark));
  }

  @override
  @protected
  @mustCallSuper
  void initState() {
    super.initState();
    
    getMessages();
    scrollController.addListener(scrollListener);
  }

  @override
  @protected
  @mustCallSuper
  void dispose() {
    SharedPreferences.getInstance().then((prefs) {
      Preferences.setThreadScrollRate(prefs, widget.thread.threadId, scrollRate);
    });

    scrollController.dispose();
    super.dispose();
  }

  void scrollListener() {
    scrollRate = scrollController.offset / scrollController.position.maxScrollExtent;
  }

  void getMessages() {
    setState(() {
      this.loading = true;
    });

    widget.getMessages().then(
      (messages) {
        if (messages != null && mounted) {
          setState(() {
            this.messages = messages;
            this.loading = false;
          });

          SharedPreferences.getInstance().then((prefs) {
            WidgetsBinding.instance.addPostFrameCallback((_) {
              double rate = Preferences.getThreadScrollRate(prefs, widget.thread.threadId);
              scrollController.animateTo(
                scrollController.position.maxScrollExtent * rate,
                duration: Duration(milliseconds: 300),
                curve: Curves.easeInOut,
              );
            });
          });
        }
      }
    );
  }

  void favorite(int i) {
    setState(() {
      this.messages[i].hasLikedFlag = !this.messages[i].hasLikedFlag;
    });

    scaffoldKey.currentState.showSnackBar(SnackBar(
      duration: Duration(milliseconds: 700),
      content: Text(this.messages[i].hasLikedFlag ? 'Like! しました' : 'Like! を取り消しました'),
    ));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      key: scaffoldKey,
      appBar: AppBar(
        title: Text(widget.title),
        actions: <Widget>[
          IconButton(
            icon: const Icon(Icons.refresh),
            tooltip: '更新',
            onPressed: () {
              getMessages();
            },
          ),
          IconButton(
            icon: const Icon(Icons.open_in_browser),
            tooltip: 'ブラウザで開く',
            onPressed: () {
              launch(widget.thread.threadUrl, enableJavaScript: true);
            },
          ),
          IconButton(
            icon: const Icon(Icons.message),
            tooltip: '投稿',
            onPressed: () {
              if (Preferences.activeAccount == null || Preferences.activeAccount.accessToken == null || messages.length < 1) {
                return;
              }

              askNewMessage(context, scaffoldKey).then(
                (entry) {
                  if (entry != null) {
                    var body = {
                      'box': widget.box,
                      'box_cd': messages[0].boxCd,
                      'thread_id': widget.thread.threadId,
                      'message_text': entry.key,
                    };

                    PostMessage.postMessage(body, entry.value).then(
                      (response) {
                        if (response != null && response.statusCode == 200) {
                          scaffoldKey.currentState.showSnackBar(SnackBar(
                            duration: Duration(milliseconds: 1000),
                            content: Text('投稿しました'),
                          ));
                          getMessages();
                        }
                      }
                    );
                  }
                }
              );

              return;
            },
          ),
        ],
      ),
      body: makeBody(widget, this, context),
      bottomNavigationBar: BottomNavigationBar(
        type: BottomNavigationBarType.fixed,
        items: bnb.barItems,
        currentIndex: bnb.selectedIndex,
        selectedItemColor: Colors.amber[800],
        onTap: (int index) {
          setState(() {
            bnb.selectedIndex = index;
            tappedIndex = index;
          });
        },
      ),
    );
  }
}
```