├── .gitignore ├── DETodo ├── DETodo.xcodeproj │ └── project.pbxproj ├── DETodo │ ├── DEAppDelegate.h │ ├── DEAppDelegate.m │ ├── DELoginViewController.h │ ├── DELoginViewController.m │ ├── DETodo-Info.plist │ ├── DETodo-Prefix.pch │ ├── DETodoListViewController.h │ ├── DETodoListViewController.m │ ├── DETodoViewController.h │ ├── DETodoViewController.m │ ├── Default-568h@2x.png │ ├── Default.png │ ├── Default@2x.png │ ├── en.lproj │ │ ├── InfoPlist.strings │ │ └── MainStoryboard.storyboard │ └── main.m ├── DETodoTests │ ├── DETodoTests-Info.plist │ ├── DETodoTests.h │ ├── DETodoTests.m │ └── en.lproj │ │ └── InfoPlist.strings ├── Models │ ├── DEAPIRequest.h │ ├── DEAPIRequest.m │ ├── DEAPIResponse.h │ ├── DEAPIResponse.m │ ├── DEModel.h │ ├── DETodoItem.h │ ├── DETodoItem.m │ ├── DETodoList.h │ ├── DETodoList.m │ ├── DEUser.h │ └── DEUser.m ├── Server.md ├── Views │ ├── DEItemCell.h │ ├── DEItemCell.m │ ├── DEListCell.h │ └── DEListCell.m ├── api_document.md ├── detodo-server │ ├── app.js │ ├── detodo-server.iml │ ├── detodo-server.ipr │ ├── detodo-server.iws │ ├── models │ │ ├── index.js │ │ ├── todoitem.js │ │ ├── todolist.js │ │ └── user.js │ ├── package.json │ ├── public │ │ └── stylesheets │ │ │ └── style.css │ ├── routes │ │ ├── api.js │ │ ├── auth.js │ │ └── index.js │ └── test │ │ ├── mocha.opts │ │ ├── request.js │ │ ├── test.js │ │ └── utils │ │ └── utils.js ├── model-relation.png └── test-server-node │ ├── app.js │ ├── package.json │ ├── public │ └── stylesheets │ │ └── style.css │ ├── test-server-node.iml │ ├── test-server-node.ipr │ └── test-server-node.iws ├── LTAPIRequest ├── LTAPIRequest.h ├── LTAPIRequest.m ├── LTAPIResponse.h ├── LTAPIResponse.m ├── LTModel.h └── LTModel.m ├── LTTwDemo ├── LTTwDemo.xcodeproj │ └── project.pbxproj ├── LTTwDemo │ ├── AppDelegate.h │ ├── AppDelegate.m │ ├── Default-568h@2x.png │ ├── Default.png │ ├── Default@2x.png │ ├── FirstViewController.h │ ├── FirstViewController.m │ ├── LTTwDemo-Info.plist │ ├── LTTwDemo-Prefix.pch │ ├── SecondViewController.h │ ├── SecondViewController.m │ ├── TimelineViewController.h │ ├── TimelineViewController.m │ ├── en.lproj │ │ ├── InfoPlist.strings │ │ └── MainStoryboard.storyboard │ ├── first.png │ ├── first@2x.png │ ├── main.m │ ├── second.png │ └── second@2x.png ├── LTTwDemoTests │ ├── LTTwDemoTests-Info.plist │ ├── LTTwDemoTests.h │ ├── LTTwDemoTests.m │ └── en.lproj │ │ └── InfoPlist.strings ├── Models │ ├── DEAPIRequest.h │ ├── DEAPIRequest.m │ ├── DEAPIResponse.h │ ├── DEAPIResponse.m │ ├── DETimeline.h │ ├── DETimeline.m │ ├── DETweet.h │ ├── DETweet.m │ ├── DEUser.h │ └── DEUser.m ├── model-relation.png └── owner-ship.png └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | .DS_Store 3 | build/ 4 | *.pbxuser 5 | !default.pbxuser 6 | *.mode1v3 7 | !default.mode1v3 8 | *.mode2v3 9 | !default.mode2v3 10 | *.perspectivev3 11 | !default.perspectivev3 12 | *.xcworkspace 13 | !default.xcworkspace 14 | xcuserdata 15 | profile 16 | *.moved-aside 17 | DerivedData 18 | .idea/ 19 | DETodo/test-server-node/node_modules/ 20 | DETodo/detodo-server/node_modules/ 21 | -------------------------------------------------------------------------------- /DETodo/DETodo/DEAppDelegate.h: -------------------------------------------------------------------------------- 1 | // 2 | // DEAppDelegate.h 3 | // DETodo 4 | // 5 | // Created by ito on 2013/03/19. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | @interface DEAppDelegate : UIResponder 12 | 13 | @property (strong, nonatomic) UIWindow *window; 14 | 15 | @end 16 | -------------------------------------------------------------------------------- /DETodo/DETodo/DEAppDelegate.m: -------------------------------------------------------------------------------- 1 | // 2 | // DEAppDelegate.m 3 | // DETodo 4 | // 5 | // Created by ito on 2013/03/19. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "DEAppDelegate.h" 10 | 11 | @implementation DEAppDelegate 12 | 13 | - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions 14 | { 15 | // Override point for customization after application launch. 16 | return YES; 17 | } 18 | 19 | - (void)applicationWillResignActive:(UIApplication *)application 20 | { 21 | // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. 22 | // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game. 23 | } 24 | 25 | - (void)applicationDidEnterBackground:(UIApplication *)application 26 | { 27 | // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. 28 | // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. 29 | } 30 | 31 | - (void)applicationWillEnterForeground:(UIApplication *)application 32 | { 33 | // Called as part of the transition from the background to the inactive state; here you can undo many of the changes made on entering the background. 34 | } 35 | 36 | - (void)applicationDidBecomeActive:(UIApplication *)application 37 | { 38 | // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. 39 | } 40 | 41 | - (void)applicationWillTerminate:(UIApplication *)application 42 | { 43 | // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. 44 | } 45 | 46 | @end 47 | -------------------------------------------------------------------------------- /DETodo/DETodo/DELoginViewController.h: -------------------------------------------------------------------------------- 1 | // 2 | // DELoginViewController.h 3 | // DETodo 4 | // 5 | // Created by ito on 2013/03/19. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | @interface DELoginViewController : UITableViewController 12 | 13 | - (IBAction)cancelTapped:(id)sender; 14 | - (IBAction)loginTapped:(id)sender; 15 | @property (weak, nonatomic) IBOutlet UITextField *loginField; 16 | 17 | @end 18 | -------------------------------------------------------------------------------- /DETodo/DETodo/DELoginViewController.m: -------------------------------------------------------------------------------- 1 | // 2 | // DELoginViewController.m 3 | // DETodo 4 | // 5 | // Created by ito on 2013/03/19. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "DELoginViewController.h" 10 | #import "DEUser.h" 11 | 12 | @interface DELoginViewController () 13 | 14 | @end 15 | 16 | @implementation DELoginViewController 17 | 18 | - (void)viewDidLoad 19 | { 20 | [super viewDidLoad]; 21 | } 22 | 23 | 24 | - (IBAction)cancelTapped:(id)sender 25 | { 26 | [self.presentingViewController dismissViewControllerAnimated:YES completion:nil]; 27 | } 28 | 29 | - (IBAction)loginTapped:(UIButton*)sender 30 | { 31 | // Login ボタンを連打できないようにする 32 | sender.enabled = NO; 33 | [DEUser loginWithUserID:self.loginField.text callback:^(BOOL success) { 34 | if (success) { 35 | [self.presentingViewController dismissViewControllerAnimated:YES completion:nil]; 36 | } else { 37 | sender.enabled = YES; 38 | } 39 | }]; 40 | } 41 | 42 | @end 43 | -------------------------------------------------------------------------------- /DETodo/DETodo/DETodo-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleDisplayName 8 | ${PRODUCT_NAME} 9 | CFBundleExecutable 10 | ${EXECUTABLE_NAME} 11 | CFBundleIdentifier 12 | jp.novi.${PRODUCT_NAME:rfc1034identifier} 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | ${PRODUCT_NAME} 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleSignature 22 | ???? 23 | CFBundleVersion 24 | 1.0 25 | LSRequiresIPhoneOS 26 | 27 | UIMainStoryboardFile 28 | MainStoryboard 29 | UIRequiredDeviceCapabilities 30 | 31 | armv7 32 | 33 | UISupportedInterfaceOrientations 34 | 35 | UIInterfaceOrientationPortrait 36 | UIInterfaceOrientationLandscapeLeft 37 | UIInterfaceOrientationLandscapeRight 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /DETodo/DETodo/DETodo-Prefix.pch: -------------------------------------------------------------------------------- 1 | // 2 | // Prefix header for all source files of the 'DETodo' target in the 'DETodo' project 3 | // 4 | 5 | #import 6 | 7 | #ifndef __IPHONE_5_0 8 | #warning "This project uses features only available in iOS SDK 5.0 and later." 9 | #endif 10 | 11 | #ifdef __OBJC__ 12 | #import 13 | #import 14 | #endif 15 | -------------------------------------------------------------------------------- /DETodo/DETodo/DETodoListViewController.h: -------------------------------------------------------------------------------- 1 | // 2 | // DETodoListViewController.h 3 | // DETodo 4 | // 5 | // Created by ito on 2013/03/19. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | @interface DETodoListViewController : UITableViewController 12 | 13 | @end 14 | -------------------------------------------------------------------------------- /DETodo/DETodo/DETodoListViewController.m: -------------------------------------------------------------------------------- 1 | // 2 | // DETodoListViewController.m 3 | // DETodo 4 | // 5 | // Created by ito on 2013/03/19. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "DETodoListViewController.h" 10 | #import "DEUser.h" 11 | #import "DETodoList.h" 12 | #import 13 | #import "DETodoViewController.h" 14 | #import "DEListCell.h" 15 | 16 | @interface DETodoListViewController () 17 | { 18 | __weak DEUser* _user; 19 | } 20 | @end 21 | 22 | @implementation DETodoListViewController 23 | 24 | // ログイン通知をModelから受けとる 25 | - (void)userDidLogin:(NSNotification*)notif 26 | { 27 | // ユーザーがログイン完了したら一覧を読み込む 28 | if (self.refreshControl) { 29 | [self refresh:self.refreshControl]; 30 | } 31 | } 32 | 33 | - (id)initWithCoder:(NSCoder *)aDecoder 34 | { 35 | self = [super initWithCoder:aDecoder]; 36 | if (self) { 37 | // User Model 38 | _user = [DEUser me]; 39 | [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(userDidLogin:) name:DEUserDidLoginNotification object:nil]; 40 | } 41 | return self; 42 | } 43 | 44 | -(void)dealloc 45 | { 46 | [[NSNotificationCenter defaultCenter] removeObserver:self]; 47 | } 48 | 49 | - (void)viewDidLoad 50 | { 51 | [super viewDidLoad]; 52 | 53 | self.navigationItem.rightBarButtonItem = self.editButtonItem; 54 | 55 | UIRefreshControl *refreshControl = [[UIRefreshControl alloc] init]; 56 | [refreshControl addTarget:self action:@selector(refresh:) forControlEvents:UIControlEventValueChanged]; 57 | self.refreshControl = refreshControl; 58 | } 59 | 60 | -(void)viewDidAppear:(BOOL)animated 61 | { 62 | [super viewDidAppear:animated]; 63 | 64 | if ([DEUser isAuthenticated]) { 65 | // ログイン済みなら左上をAdd Listボタンに 66 | UIBarButtonItem* addItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAdd target:self action:@selector(addList:)]; 67 | [self.navigationItem setLeftBarButtonItem:addItem animated:animated]; 68 | } 69 | } 70 | 71 | - (void)addList:(id)sender 72 | { 73 | UIAlertView* alertView = [[UIAlertView alloc] initWithTitle:@"新規Todoリスト" message:nil delegate:self cancelButtonTitle:@"キャンセル" otherButtonTitles:@"作成", nil]; 74 | alertView.alertViewStyle = UIAlertViewStylePlainTextInput; 75 | [alertView show]; 76 | } 77 | 78 | -(void)alertView:(UIAlertView *)alertView didDismissWithButtonIndex:(NSInteger)buttonIndex 79 | { 80 | if (buttonIndex == 1) { 81 | // 新規リスト作成 82 | NSString* text = [alertView textFieldAtIndex:0].text; 83 | if (text.length > 0) { 84 | // リストModelを作成 85 | DETodoList* list = [[DETodoList alloc] initWithTitle:text user:_user]; 86 | // API に投げる 87 | [_user addTodoList:list callback:^(BOOL success, BOOL collectionChanged) { 88 | if (collectionChanged) { 89 | [self.tableView reloadData]; 90 | } 91 | }]; 92 | } 93 | } 94 | } 95 | 96 | 97 | 98 | - (void)refresh:(UIRefreshControl*)sender 99 | { 100 | // リスト一覧更新 101 | [sender beginRefreshing]; 102 | [_user refreshTodoListsWithCallback:^(BOOL success, BOOL collectionChanged) { 103 | [sender endRefreshing]; 104 | if (collectionChanged) { 105 | [self.tableView reloadData]; 106 | } 107 | }]; 108 | } 109 | 110 | #pragma mark - Table view data source 111 | 112 | - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section 113 | { 114 | return _user.todoLists.count; 115 | } 116 | 117 | - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath 118 | { 119 | static NSString *CellIdentifier = @"Cell"; 120 | DEListCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier forIndexPath:indexPath]; 121 | 122 | DETodoList* list = [_user.todoLists objectAtIndex:indexPath.row]; 123 | 124 | // View(Cell) に Model (list) をセット 125 | cell.list = list; 126 | 127 | return cell; 128 | } 129 | 130 | - (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath 131 | { 132 | if (editingStyle == UITableViewCellEditingStyleDelete) { 133 | // すぐに削除 134 | [_user deleteTodoListAtIndex:indexPath.row callback:^(BOOL success, BOOL collectionChanged) { 135 | }]; 136 | [tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationFade]; 137 | } 138 | } 139 | 140 | #pragma mark - Table view delegate 141 | 142 | -(void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender 143 | { 144 | if ([segue.identifier isEqualToString:@"showItems"]) { 145 | NSIndexPath* indexPath = [self.tableView indexPathForCell:sender]; 146 | DETodoList* list = [_user.todoLists objectAtIndex:indexPath.row]; 147 | // アイテム表示用のViewControllerを設定 148 | DETodoViewController* vc = (id)segue.destinationViewController; 149 | // アイテムを保持する親のリストModelをセット 150 | vc.todoList = list; 151 | } 152 | } 153 | 154 | - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath 155 | { 156 | 157 | } 158 | 159 | @end 160 | -------------------------------------------------------------------------------- /DETodo/DETodo/DETodoViewController.h: -------------------------------------------------------------------------------- 1 | // 2 | // DETodoViewController.h 3 | // DETodo 4 | // 5 | // Created by ito on 2013/03/19. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | @class DETodoList; 12 | @interface DETodoViewController : UITableViewController 13 | 14 | @property (nonatomic, weak) DETodoList* todoList; 15 | - (IBAction)addTextFieldEnded:(UITextField *)sender; 16 | 17 | @end 18 | -------------------------------------------------------------------------------- /DETodo/DETodo/DETodoViewController.m: -------------------------------------------------------------------------------- 1 | // 2 | // DETodoViewController.m 3 | // DETodo 4 | // 5 | // Created by ito on 2013/03/19. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "DETodoViewController.h" 10 | #import "DETodoList.h" 11 | #import "DETodoItem.h" 12 | #import "DEItemCell.h" 13 | 14 | @interface DETodoViewController () 15 | 16 | @end 17 | 18 | @implementation DETodoViewController 19 | 20 | - (id)initWithCoder:(NSCoder *)aDecoder 21 | { 22 | self = [super initWithCoder:aDecoder]; 23 | if (self) { 24 | // TodoItemが1つアップデートされたらそのRowをリロードする 25 | [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(todoItemUpdated:) name:DETodoItemDidChangeNotification object:nil]; 26 | } 27 | return self; 28 | } 29 | 30 | -(void)dealloc 31 | { 32 | [[NSNotificationCenter defaultCenter] removeObserver:self]; 33 | } 34 | 35 | - (void)viewDidLoad 36 | { 37 | [super viewDidLoad]; 38 | 39 | self.navigationItem.rightBarButtonItem = self.editButtonItem; 40 | 41 | UIRefreshControl *refreshControl = [[UIRefreshControl alloc] init]; 42 | [refreshControl addTarget:self action:@selector(refresh:) forControlEvents:UIControlEventValueChanged]; 43 | self.refreshControl = refreshControl; 44 | } 45 | 46 | -(void)viewWillAppear:(BOOL)animated 47 | { 48 | [super viewWillAppear:animated]; 49 | if (self.refreshControl && _todoList && _todoList.todoItems.count == 0) { 50 | [self refresh:self.refreshControl]; 51 | } 52 | } 53 | 54 | - (void)refresh:(UIRefreshControl*)sender 55 | { 56 | if (!_todoList) { 57 | return; 58 | } 59 | 60 | [sender beginRefreshing]; 61 | [_todoList refreshTodoItemsWithCallback:^(BOOL success, BOOL shouldBeReloaded) { 62 | [sender endRefreshing]; 63 | if (shouldBeReloaded) { 64 | [self.tableView reloadData]; 65 | } 66 | }]; 67 | } 68 | 69 | 70 | -(void)setTodoList:(DETodoList *)todoList 71 | { 72 | _todoList = todoList; 73 | self.navigationItem.title = todoList.title; 74 | self.title = todoList.title; 75 | [self.tableView reloadData]; 76 | } 77 | 78 | #pragma mark - Table view data source 79 | 80 | -(NSInteger)numberOfSectionsInTableView:(UITableView *)tableView 81 | { 82 | return 2; 83 | } 84 | 85 | - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section 86 | { 87 | // Section 0 は追加用のTextField用 88 | if (section == 0) { 89 | return 1; 90 | } else if (section == 1) { 91 | return _todoList.todoItems.count; 92 | } 93 | return 0; 94 | } 95 | 96 | - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath 97 | { 98 | 99 | if (indexPath.section == 0) { 100 | static NSString *CellIdentifier = @"addCell"; 101 | UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier forIndexPath:indexPath]; 102 | 103 | return cell; 104 | } else if (indexPath.section == 1) { 105 | static NSString *CellIdentifier = @"Cell"; 106 | DEItemCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier forIndexPath:indexPath]; 107 | 108 | DETodoItem* item = [_todoList.todoItems objectAtIndex:indexPath.row]; 109 | 110 | // View(Cell)にModelをセット 111 | [cell setItem:item forIndex:indexPath.row]; 112 | 113 | [cell.doneSwitch removeTarget:self action:NULL forControlEvents:UIControlEventValueChanged]; 114 | [cell.doneSwitch addTarget:self action:@selector(swChanged:) forControlEvents:UIControlEventValueChanged]; 115 | 116 | return cell; 117 | 118 | } 119 | return nil; 120 | 121 | } 122 | 123 | 124 | - (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath 125 | { 126 | if (indexPath.section == 1) { 127 | return YES; 128 | } 129 | return NO; 130 | } 131 | 132 | - (void)swChanged:(UISwitch*)sender 133 | { 134 | // Done状態の更新 135 | __weak DETodoItem* item = [_todoList.todoItems objectAtIndex:sender.tag]; 136 | [item setDone:sender.on callback:^(BOOL success) { 137 | 138 | }]; 139 | } 140 | 141 | - (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath 142 | { 143 | if (editingStyle == UITableViewCellEditingStyleDelete) { 144 | [_todoList deleteTodoItemAtIndex:indexPath.row callback:^(BOOL success, BOOL shouldBeReloaded) { 145 | if (shouldBeReloaded) { 146 | [self.tableView reloadData]; 147 | } 148 | }]; 149 | [tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationFade]; 150 | } 151 | } 152 | 153 | #pragma mark - 154 | 155 | // 新規TodoItem追加 156 | - (IBAction)addTextFieldEnded:(UITextField *)sender 157 | { 158 | [sender resignFirstResponder]; 159 | if (sender.text.length > 0) { 160 | // Todo Item のModelを作成 161 | DETodoItem* item = [[DETodoItem alloc] initWithTitle:sender.text list:_todoList]; 162 | sender.text = @""; 163 | // リクエストを送る 164 | [_todoList addTodoItem:item callback:^(BOOL success, BOOL itemsChanged, NSIndexSet *insertedIndex) { 165 | if (insertedIndex) { 166 | // 挿入された場所のTableを更新 167 | [self.tableView insertRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:insertedIndex.firstIndex inSection:1]] withRowAnimation:UITableViewRowAnimationMiddle]; 168 | } else if (itemsChanged) { 169 | [self.tableView reloadData]; 170 | } 171 | }]; 172 | } 173 | } 174 | 175 | 176 | - (void)todoItemUpdated:(NSNotification*)notif 177 | { 178 | // Todo Item の値(isDoneなど)が更新されたらそのRowをリロード 179 | NSInteger index = [_todoList.todoItems indexOfObjectIdenticalTo:notif.object]; 180 | if (index != NSNotFound) { 181 | [self.tableView reloadRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:index inSection:1]] withRowAnimation:UITableViewRowAnimationAutomatic]; 182 | } 183 | } 184 | 185 | 186 | @end 187 | -------------------------------------------------------------------------------- /DETodo/DETodo/Default-568h@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/novi/LTAPIRequest/a396a26d2cb1b4851ded3f91e1def57a0aaab3ad/DETodo/DETodo/Default-568h@2x.png -------------------------------------------------------------------------------- /DETodo/DETodo/Default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/novi/LTAPIRequest/a396a26d2cb1b4851ded3f91e1def57a0aaab3ad/DETodo/DETodo/Default.png -------------------------------------------------------------------------------- /DETodo/DETodo/Default@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/novi/LTAPIRequest/a396a26d2cb1b4851ded3f91e1def57a0aaab3ad/DETodo/DETodo/Default@2x.png -------------------------------------------------------------------------------- /DETodo/DETodo/en.lproj/InfoPlist.strings: -------------------------------------------------------------------------------- 1 | /* Localized versions of Info.plist keys */ 2 | 3 | -------------------------------------------------------------------------------- /DETodo/DETodo/en.lproj/MainStoryboard.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | -------------------------------------------------------------------------------- /DETodo/DETodo/main.m: -------------------------------------------------------------------------------- 1 | // 2 | // main.m 3 | // DETodo 4 | // 5 | // Created by ito on 2013/03/19. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | #import "DEAppDelegate.h" 12 | 13 | int main(int argc, char *argv[]) 14 | { 15 | @autoreleasepool { 16 | return UIApplicationMain(argc, argv, nil, NSStringFromClass([DEAppDelegate class])); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /DETodo/DETodoTests/DETodoTests-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | ${EXECUTABLE_NAME} 9 | CFBundleIdentifier 10 | jp.novi.${PRODUCT_NAME:rfc1034identifier} 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundlePackageType 14 | BNDL 15 | CFBundleShortVersionString 16 | 1.0 17 | CFBundleSignature 18 | ???? 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /DETodo/DETodoTests/DETodoTests.h: -------------------------------------------------------------------------------- 1 | // 2 | // DETodoTests.h 3 | // DETodoTests 4 | // 5 | // Created by ito on 2013/03/19. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | @interface DETodoTests : SenTestCase 12 | 13 | @end 14 | -------------------------------------------------------------------------------- /DETodo/DETodoTests/DETodoTests.m: -------------------------------------------------------------------------------- 1 | // 2 | // DETodoTests.m 3 | // DETodoTests 4 | // 5 | // Created by ito on 2013/03/19. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "DETodoTests.h" 10 | 11 | @implementation DETodoTests 12 | 13 | - (void)setUp 14 | { 15 | [super setUp]; 16 | 17 | // Set-up code here. 18 | } 19 | 20 | - (void)tearDown 21 | { 22 | // Tear-down code here. 23 | 24 | [super tearDown]; 25 | } 26 | 27 | - (void)testExample 28 | { 29 | STFail(@"Unit tests are not implemented yet in DETodoTests"); 30 | } 31 | 32 | @end 33 | -------------------------------------------------------------------------------- /DETodo/DETodoTests/en.lproj/InfoPlist.strings: -------------------------------------------------------------------------------- 1 | /* Localized versions of Info.plist keys */ 2 | 3 | -------------------------------------------------------------------------------- /DETodo/Models/DEAPIRequest.h: -------------------------------------------------------------------------------- 1 | // 2 | // DEAPIRequest.h 3 | // DETodo 4 | // 5 | // Created by ito on 2013/03/21. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "LTAPIRequest.h" 10 | 11 | @class DEAPIResponse; 12 | typedef void(^DEAPIRequestCallback)(DEAPIResponse* res); 13 | 14 | @interface DEAPIRequest : LTAPIRequest 15 | 16 | -(void)sendRequestWithCallback:(DEAPIRequestCallback)callback; 17 | 18 | @end 19 | -------------------------------------------------------------------------------- /DETodo/Models/DEAPIRequest.m: -------------------------------------------------------------------------------- 1 | // 2 | // DEAPIRequest.m 3 | // DETodo 4 | // 5 | // Created by ito on 2013/03/21. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "DEAPIRequest.h" 10 | #import "DEAPIResponse.h" 11 | 12 | #define DEBUG (1) 13 | 14 | #if DEBUG 15 | #warning DEBUG is Enabled 16 | #endif 17 | 18 | @implementation DEAPIRequest 19 | 20 | -(NSURLRequest *)prepareRequest 21 | { 22 | NSString* host = @"http://localhost:3000"; 23 | NSString* url; 24 | if ([self.path hasPrefix:@"/auth"]) { 25 | url = [NSString stringWithFormat:@"%@%@", host, self.path]; 26 | } else { 27 | url = [NSString stringWithFormat:@"%@/api%@", host, self.path]; 28 | } 29 | 30 | NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url] cachePolicy:NSURLRequestUseProtocolCachePolicy timeoutInterval:20]; 31 | 32 | if (self.method == LTAPIRequestMethodPOST || self.method == LTAPIRequestMethodPUT) { 33 | if (self.params) { 34 | [request setHTTPBody:[NSJSONSerialization dataWithJSONObject:self.params options:0 error:nil]]; 35 | [request addValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; 36 | } 37 | } 38 | 39 | request.HTTPMethod = self.methodString; 40 | [request addValue:@"application/json" forHTTPHeaderField:@"Accept"]; 41 | return request; 42 | } 43 | 44 | -(void)sendRequestWithCallback:(DEAPIRequestCallback)callback 45 | { 46 | #if DEBUG 47 | // UIデバッグ用にリクエストを遅らせる場合 48 | // サーバー側でやったほうが良いかもしれない 49 | double delayInSeconds = 0.0; 50 | dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC)); 51 | dispatch_after(popTime, dispatch_get_main_queue(), ^(void){ 52 | [super sendRequestWithCallback:^(LTAPIResponse *res) { 53 | callback((id)res); 54 | }]; 55 | }); 56 | #else 57 | [super sendRequestWithCallback:^(LTAPIResponse *res) { 58 | callback((id)res); 59 | }]; 60 | #endif 61 | } 62 | 63 | +(Class)APIResponseClass 64 | { 65 | return [DEAPIResponse class]; 66 | } 67 | 68 | @end 69 | -------------------------------------------------------------------------------- /DETodo/Models/DEAPIResponse.h: -------------------------------------------------------------------------------- 1 | // 2 | // DEAPIResponse.h 3 | // DETodo 4 | // 5 | // Created by ito on 2013/03/21. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "LTAPIResponse.h" 10 | 11 | @interface DEAPIResponse : LTAPIResponse 12 | 13 | @end 14 | -------------------------------------------------------------------------------- /DETodo/Models/DEAPIResponse.m: -------------------------------------------------------------------------------- 1 | // 2 | // DEAPIResponse.m 3 | // DETodo 4 | // 5 | // Created by ito on 2013/03/21. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "DEAPIResponse.h" 10 | 11 | @implementation DEAPIResponse 12 | 13 | -(BOOL)success 14 | { 15 | if (self.error || !self.responseData || self.parseError) { 16 | return NO; 17 | } 18 | if (self.HTTPResponse.statusCode >= 200 && self.HTTPResponse.statusCode < 300) { 19 | return YES; 20 | } 21 | return NO; 22 | } 23 | 24 | - (void)showErrorAlert 25 | { 26 | // エラー時にアラートを出す 27 | // Main Queue 以外から呼ばれる可能性があるので Main Queue で実行する 28 | dispatch_async(dispatch_get_main_queue(), ^{ 29 | if (self.error) { 30 | UIAlertView* alert = [[UIAlertView alloc] initWithTitle:self.error.localizedDescription message:self.error.localizedFailureReason delegate:nil cancelButtonTitle:@"OK" otherButtonTitles:nil, nil]; 31 | [alert show]; 32 | } else { 33 | UIAlertView* alert = [[UIAlertView alloc] initWithTitle:[NSString stringWithFormat:@"%d", self.HTTPResponse.statusCode] message:[[NSString alloc] initWithData:self.responseData encoding:NSUTF8StringEncoding] delegate:nil cancelButtonTitle:@"OK" otherButtonTitles:nil, nil]; 34 | [alert show]; 35 | } 36 | }); 37 | } 38 | 39 | @end 40 | -------------------------------------------------------------------------------- /DETodo/Models/DEModel.h: -------------------------------------------------------------------------------- 1 | // 2 | // DEModel.h 3 | // DETodo 4 | // 5 | // Created by ito on 2013/03/21. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "LTModel.h" 10 | #import "DEAPIRequest.h" 11 | #import "DEAPIResponse.h" 12 | 13 | 14 | -------------------------------------------------------------------------------- /DETodo/Models/DETodoItem.h: -------------------------------------------------------------------------------- 1 | // 2 | // DETodoItem.h 3 | // DETodo 4 | // 5 | // Created by ito on 2013/03/19. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "DEModel.h" 10 | 11 | // TodoItem の Attributes (isDoneやisCreating)が変更された時 12 | extern NSString* const DETodoItemDidChangeNotification; // NSNotification.object is self 13 | 14 | @class DETodoList; 15 | @interface DETodoItem : LTModel 16 | 17 | @property (nonatomic, readonly, weak) DETodoList* list; // parent 18 | @property (nonatomic, readonly, copy) NSString* title; 19 | @property (nonatomic, readonly) BOOL isDone; 20 | 21 | @property (nonatomic, readonly) BOOL isCreating; // 作成中か(作成中はまだIDが無い) 22 | 23 | - (id)initWithTitle:(NSString*)title list:(DETodoList*)list; // for CREATION 24 | - (void)setDone:(BOOL)done callback:(LTModelGeneralCallback)callback; 25 | 26 | // Private 27 | - (id)initWithData:(NSDictionary*)data list:(DETodoList*)list; 28 | - (void)itemCreatedWithData:(NSDictionary*)data; 29 | 30 | @end 31 | -------------------------------------------------------------------------------- /DETodo/Models/DETodoItem.m: -------------------------------------------------------------------------------- 1 | // 2 | // DETodoItem.m 3 | // DETodo 4 | // 5 | // Created by ito on 2013/03/19. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "DETodoItem.h" 10 | #import "DETodoList.h" 11 | 12 | extern void DELAY_TEST(dispatch_block_t block); 13 | 14 | NSString* const DETodoItemDidChangeNotification = @"DETodoItemDidChangeNotification"; 15 | 16 | @implementation DETodoItem 17 | 18 | -(id)init 19 | { 20 | [self doesNotRecognizeSelector:_cmd]; 21 | return nil; 22 | } 23 | 24 | -(id)initWithData:(NSDictionary *)data list:(DETodoList *)list 25 | { 26 | self = [super init]; 27 | if (self) { 28 | [self replaceAttributesFromDictionary:data]; 29 | _list = list; 30 | } 31 | return self; 32 | } 33 | 34 | -(id)initWithTitle:(NSString *)title list:(DETodoList *)list 35 | { 36 | self = [super init]; 37 | if (self) { 38 | [self setAttribute:title forKey:@"title"]; 39 | _list = list; 40 | } 41 | return self; 42 | } 43 | 44 | -(void)itemCreatedWithData:(NSDictionary *)data 45 | { 46 | [self replaceAttributesFromDictionary:data]; 47 | [[NSNotificationCenter defaultCenter] postNotificationName:DETodoItemDidChangeNotification object:self]; 48 | } 49 | 50 | #pragma mark - API 51 | 52 | -(BOOL)isCreating 53 | { 54 | if (!self.ID) { 55 | return YES; 56 | } 57 | return NO; 58 | } 59 | 60 | -(void)setDone:(BOOL)done callback:(LTModelGeneralCallback)callback 61 | { 62 | BOOL oldVal = self.isDone; 63 | [self setAttribute:@(done) forKey:@"done"]; 64 | [[NSNotificationCenter defaultCenter] postNotificationName:DETodoItemDidChangeNotification object:self]; 65 | 66 | [[[DEAPIRequest alloc] initWithAPI:[NSString stringWithFormat:@"/list/%@/item/%@", self.list.ID, self.ID] method:LTAPIRequestMethodPUT params:@{@"done": @(done)}] sendRequestWithCallback:^(DEAPIResponse *res) { 67 | if (!res.success) { 68 | [self setAttribute:@(oldVal) forKey:@"done"]; // 失敗したら戻す 69 | callback(NO); 70 | [[NSNotificationCenter defaultCenter] postNotificationName:DETodoItemDidChangeNotification object:self]; 71 | return; 72 | } 73 | callback(YES); 74 | }]; 75 | } 76 | 77 | #pragma mark - 78 | 79 | -(NSString *)ID 80 | { 81 | return [self attributeForKey:@"_id"]; 82 | } 83 | 84 | 85 | -(BOOL)isDone 86 | { 87 | return [[self attributeForKey:@"done"] boolValue]; 88 | } 89 | 90 | -(NSString *)title 91 | { 92 | return [self attributeForKey:@"title"]; 93 | } 94 | 95 | @end 96 | -------------------------------------------------------------------------------- /DETodo/Models/DETodoList.h: -------------------------------------------------------------------------------- 1 | // 2 | // DETodoList.h 3 | // DETodo 4 | // 5 | // Created by ito on 2013/03/19. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "DEModel.h" 10 | 11 | typedef void(^DETodoListTodoItemAddCallback)(BOOL success, BOOL itemsChanged, NSIndexSet* insertedIndex); 12 | 13 | // TodoList の Attributes (titleなど) が変更された時(ただし変更, PUT は無いので今回は使わない) 14 | extern NSString* const DETodoListDidChangeNotification; // object is self, may be not in use 15 | // TodoList の Items が変更された時 16 | extern NSString* const DETodoListTodoItemsDidChangeNotification; // object is self 17 | 18 | @class DETodoItem, DEUser; 19 | @interface DETodoList : LTModel 20 | 21 | @property (nonatomic, readonly, weak) DEUser* user; // parent 22 | @property (nonatomic, readonly, copy) NSString* title; 23 | 24 | @property (nonatomic, readonly, copy) NSArray* todoItems; // DETodoItem 25 | 26 | - (id)initWithTitle:(NSString*)title user:(DEUser*)user; // for CREATION 27 | 28 | - (void)refreshTodoItemsWithCallback:(LTModelCollectionCallback)callback; 29 | - (void)addTodoItem:(DETodoItem*)item callback:(DETodoListTodoItemAddCallback)callback; // callbackは2度呼ばれます 30 | - (void)deleteTodoItemAtIndex:(NSUInteger)index callback:(LTModelCollectionCallback)callback; 31 | 32 | // Private 33 | - (id)initWithData:(NSDictionary *)data user:(DEUser *)user; 34 | - (NSDictionary*)createDictionary; 35 | - (void)listCreatedWithData:(NSDictionary*)data; 36 | 37 | @end 38 | -------------------------------------------------------------------------------- /DETodo/Models/DETodoList.m: -------------------------------------------------------------------------------- 1 | // 2 | // DETodoList.m 3 | // DETodo 4 | // 5 | // Created by ito on 2013/03/19. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "DETodoList.h" 10 | #import "DETodoItem.h" 11 | 12 | #import "LTAPIRequest.h" 13 | void DELAY_TEST(dispatch_block_t block) 14 | { 15 | [LTAPIRequest beginNetworkConnection]; 16 | double delayInSeconds = 5.0; 17 | dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC)); 18 | dispatch_after(popTime, dispatch_get_main_queue(), ^() { 19 | [LTAPIRequest endNetworkConnection]; 20 | block(); 21 | }); 22 | } 23 | 24 | NSString* const DETodoListDidChangeNotification = @"DETodoListDidChangeNotification"; 25 | NSString* const DETodoListTodoItemsDidChangeNotification = @"DETodoListTodoItemsDidChangeNotification"; 26 | 27 | 28 | @interface DETodoList () 29 | { 30 | NSMutableArray* _items; 31 | } 32 | @end 33 | 34 | @implementation DETodoList 35 | 36 | -(id)init 37 | { 38 | [self doesNotRecognizeSelector:_cmd]; 39 | return nil; 40 | } 41 | 42 | -(id)initWithTitle:(NSString *)title user:(DEUser *)user 43 | { 44 | self = [super init]; 45 | if (self) { 46 | _user = user; 47 | [self setAttribute:title forKey:@"title"]; 48 | _items = [NSMutableArray array]; 49 | } 50 | return self; 51 | } 52 | 53 | -(id)initWithData:(NSDictionary *)data user:(DEUser *)user 54 | { 55 | self = [super init]; 56 | if (self) { 57 | [self replaceAttributesFromDictionary:data]; 58 | _user = user; 59 | _items = [NSMutableArray array]; 60 | } 61 | return self; 62 | } 63 | 64 | #pragma mark - 65 | 66 | 67 | -(NSArray *)todoItems 68 | { 69 | return _items; 70 | } 71 | 72 | -(NSString *)ID 73 | { 74 | return [self attributeForKey:@"_id"]; 75 | } 76 | 77 | 78 | -(NSString *)title 79 | { 80 | return [self attributeForKey:@"title"]; 81 | } 82 | 83 | -(NSDictionary *)createDictionary 84 | { 85 | return @{@"title": [self attributeForKey:@"title"]}; 86 | // return [self attributes]; // or 87 | } 88 | 89 | -(void)listCreatedWithData:(NSDictionary *)data 90 | { 91 | [self replaceAttributesFromDictionary:data]; 92 | } 93 | 94 | #pragma mark - API 95 | 96 | -(void)refreshTodoItemsWithCallback:(LTModelCollectionCallback)callback 97 | { 98 | [[[DEAPIRequest alloc] initWithAPI:[NSString stringWithFormat:@"/list/%@/item", self.ID] method:LTAPIRequestMethodGET params:nil] sendRequestWithCallback:^(DEAPIResponse *res) { 99 | if (!res.success) { 100 | callback(NO, NO); 101 | return; 102 | } 103 | _items = [NSMutableArray array]; 104 | for (NSDictionary* dict in [res.json objectForKey:@"items"]) { 105 | DETodoItem* item = [[DETodoItem alloc] initWithData:dict list:self]; 106 | [_items addObject:item]; 107 | } 108 | [[NSNotificationCenter defaultCenter] postNotificationName:DETodoListTodoItemsDidChangeNotification object:self]; 109 | callback(YES, YES); 110 | }]; 111 | } 112 | 113 | -(void)addTodoItem:(DETodoItem *)item callback:(DETodoListTodoItemAddCallback)callback 114 | { 115 | // 作成してすぐに _items に追加して、失敗したら戻す設計にした (UI的に) 116 | [_items insertObject:item atIndex:0]; 117 | callback(YES, YES, [NSIndexSet indexSetWithIndex:0]); 118 | 119 | [[[DEAPIRequest alloc] initWithAPI:[NSString stringWithFormat:@"/list/%@/item", self.ID] method:LTAPIRequestMethodPOST params:@{@"title": item.title}] sendRequestWithCallback:^(DEAPIResponse *res) { 120 | if (!res.success) { 121 | [_items removeObjectIdenticalTo:item]; // rollback 122 | callback(NO, YES, nil); 123 | [[NSNotificationCenter defaultCenter] postNotificationName:DETodoListTodoItemsDidChangeNotification object:self]; 124 | return; 125 | } 126 | [item itemCreatedWithData:res.json]; 127 | callback(YES, NO, nil); 128 | }]; 129 | 130 | [[NSNotificationCenter defaultCenter] postNotificationName:DETodoListTodoItemsDidChangeNotification object:self]; 131 | } 132 | 133 | -(void)deleteTodoItemAtIndex:(NSUInteger)index callback:(LTModelCollectionCallback)callback 134 | { 135 | // TableView(or CollectionView)を使うので _items からはすぐに消す 136 | 137 | DETodoItem* oldItem = [_items objectAtIndex:index]; 138 | 139 | [_items removeObjectAtIndex:index]; 140 | 141 | [[[DEAPIRequest alloc] initWithAPI:[NSString stringWithFormat:@"/list/%@/item/%@", self.ID, oldItem.ID] method:LTAPIRequestMethodDELETE params:nil] sendRequestWithCallback:^(DEAPIResponse *res) { 142 | if (!res.success) { 143 | [_items addObject:oldItem]; // failed, roll back 144 | callback(NO, YES); 145 | [[NSNotificationCenter defaultCenter] postNotificationName:DETodoListTodoItemsDidChangeNotification object:self]; 146 | return; 147 | } 148 | callback(YES, NO); 149 | }]; 150 | 151 | // TableViewの特性から_itemsが変更されているがCallbackは呼ばない 152 | 153 | // 154 | [[NSNotificationCenter defaultCenter] postNotificationName:DETodoListTodoItemsDidChangeNotification object:self]; 155 | } 156 | 157 | 158 | @end 159 | -------------------------------------------------------------------------------- /DETodo/Models/DEUser.h: -------------------------------------------------------------------------------- 1 | // 2 | // DEUser.h 3 | // DETodo 4 | // 5 | // Created by ito on 2013/03/19. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "DEModel.h" 10 | 11 | // todoLists が変更されたら通知する 12 | extern NSString* const DEUserTodoListsDidChangeNotification; // object is self 13 | 14 | // ログイン完了後 15 | extern NSString* const DEUserDidLoginNotification; // object is self; 16 | 17 | @class DETodoList; 18 | @interface DEUser : LTModel 19 | 20 | + (DEUser*)me; 21 | + (BOOL)isAuthenticated; 22 | 23 | + (void)loginWithUserID:(NSString*)userID callback:(LTModelGeneralCallback)callback; 24 | @property (nonatomic, readonly, copy) NSString* userID; 25 | 26 | @property (nonatomic, readonly, copy) NSArray* todoLists; // DETodoList 27 | 28 | - (void)refreshTodoListsWithCallback:(LTModelCollectionCallback)callback; 29 | - (void)addTodoList:(DETodoList*)todolist callback:(LTModelCollectionCallback)callback; 30 | - (void)deleteTodoListAtIndex:(NSUInteger)index callback:(LTModelCollectionCallback)callback; 31 | 32 | @end 33 | -------------------------------------------------------------------------------- /DETodo/Models/DEUser.m: -------------------------------------------------------------------------------- 1 | // 2 | // DEUser.m 3 | // DETodo 4 | // 5 | // Created by ito on 2013/03/19. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "DEUser.h" 10 | #import "DETodoList.h" 11 | 12 | extern void DELAY_TEST(dispatch_block_t block); 13 | 14 | NSString* const DEUserTodoListsDidChangeNotification = @"DEUserTodoListsDidChangeNotification"; 15 | NSString* const DEUserDidLoginNotification = @"DEUserDidLoginNotification"; 16 | 17 | @interface DEUser () 18 | { 19 | BOOL _isAuthenticated; 20 | NSMutableArray* _todolists; 21 | } 22 | @end 23 | 24 | @implementation DEUser 25 | 26 | - (id)init 27 | { 28 | self = [super init]; 29 | if (self) { 30 | _todolists = [NSMutableArray array]; 31 | } 32 | return self; 33 | } 34 | 35 | +(DEUser *)me 36 | { 37 | static id me; 38 | static dispatch_once_t onceToken; 39 | dispatch_once(&onceToken, ^{ 40 | me = [[self alloc] init]; 41 | }); 42 | return me; 43 | } 44 | 45 | +(BOOL)isAuthenticated 46 | { 47 | return [[self me] userID] ? YES : NO; 48 | } 49 | 50 | +(void)loginWithUserID:(NSString *)userID callback:(LTModelGeneralCallback)callback 51 | { 52 | [[[DEAPIRequest alloc] initWithAPI:@"/auth/login" method:LTAPIRequestMethodPOST params:@{@"user_id": userID}] sendRequestWithCallback:^(DEAPIResponse *res) { 53 | if (!res.success) { 54 | callback(NO); 55 | return; 56 | } 57 | [[DEUser me] replaceAttributesFromDictionary:res.json]; 58 | callback(YES); 59 | [[NSNotificationCenter defaultCenter] postNotificationName:DEUserDidLoginNotification object:[DEUser me]]; 60 | }]; 61 | } 62 | 63 | -(NSArray *)todoLists 64 | { 65 | return _todolists; 66 | } 67 | 68 | -(NSString *)ID 69 | { 70 | return [self attributeForKey:@"_id"]; 71 | } 72 | 73 | -(NSString *)userID 74 | { 75 | return [self attributeForKey:@"user_id"]; 76 | } 77 | 78 | #pragma mark - API 79 | 80 | -(void)refreshTodoListsWithCallback:(LTModelCollectionCallback)callback 81 | { 82 | [[[DEAPIRequest alloc] initWithAPI:@"/list" method:LTAPIRequestMethodGET params:nil] sendRequestWithCallback:^(DEAPIResponse *res) { 83 | if (!res.success) { 84 | callback(NO, NO); 85 | return; 86 | } 87 | _todolists = [NSMutableArray array]; 88 | for (NSDictionary* dict in [res.json objectForKey:@"lists"]) { 89 | DETodoList* list = [[DETodoList alloc] initWithData:dict user:self]; 90 | [_todolists addObject:list]; 91 | } 92 | [[NSNotificationCenter defaultCenter] postNotificationName:DEUserTodoListsDidChangeNotification object:self]; 93 | callback(YES, YES); 94 | }]; 95 | } 96 | 97 | 98 | -(void)addTodoList:(DETodoList *)todolist callback:(LTModelCollectionCallback)callback 99 | { 100 | [[[DEAPIRequest alloc] initWithAPI:@"/list" method:LTAPIRequestMethodPOST params:todolist.createDictionary] sendRequestWithCallback:^(DEAPIResponse *res) { 101 | if (!res.success) { 102 | callback(NO, NO); 103 | return ; 104 | } 105 | // リクエストに成功してから _todolist に追加するような設計にした 106 | [todolist listCreatedWithData:res.json]; 107 | [_todolists insertObject:todolist atIndex:0]; 108 | 109 | [[NSNotificationCenter defaultCenter] postNotificationName:DEUserTodoListsDidChangeNotification object:self]; 110 | callback(YES, YES); 111 | }]; 112 | } 113 | 114 | -(void)deleteTodoListAtIndex:(NSUInteger)index callback:(LTModelCollectionCallback)callback 115 | { 116 | DETodoList* list = [_todolists objectAtIndex:index]; 117 | 118 | // TableView を使うのですぐに _todolists から削除 119 | [_todolists removeObjectAtIndex:index]; 120 | 121 | [[[DEAPIRequest alloc] initWithAPI:[NSString stringWithFormat:@"/list/%@", list.ID] method:LTAPIRequestMethodDELETE params:nil] sendRequestWithCallback:^(DEAPIResponse *res) { 122 | // 失敗しても(APIRequestレベルで)アラートを表示するだけでrollbackしない 123 | //[[NSNotificationCenter defaultCenter] postNotificationName:DEUserTodoListsDidChangeNotification object:self]; 124 | //callback(YES, NO); 125 | }]; 126 | 127 | // 同じく TableView を使うので _todolists が変更されているがCallbackしない 128 | } 129 | 130 | @end 131 | -------------------------------------------------------------------------------- /DETodo/Server.md: -------------------------------------------------------------------------------- 1 | ## DETodo Server 2 | 3 | `detodo-server` がAPIサーバーで、`test-server-node` がiOS側アプリテスト用のモックAPIサーバーです。 4 | Node.js と MongoDB が必要となりますので、実行するには先にインストールして起動しておいてください。 5 | OS X 環境へは、それぞれ、Homebrew または MacPorts で簡単にインストールできます。 6 | 7 | 実行するには 8 | 9 | $ cd detodo-server or test-server-node 10 | $ npm install 11 | $ node app 12 | 13 | とします。 14 | 15 | デフォルトではどちらも localhost:3000 を使うので同時には立ち上げられません。セッションストレージはオンメモリなので、Nodeを再起動するとクリアされます。 16 | (要再ログイン) 17 | 18 | また、サーバサイドのテストを実行するには 19 | 20 | $ npm install -g mocha 21 | 22 | で、 Mocha.js をインストールして、 23 | 24 | $ NODE_ENV=test node app 25 | 26 | test 環境でサーバーを起動しておきます。テストを実行するには、 27 | 28 | $ cd detodo-server 29 | $ npm install should 30 | $ mocha 31 | 32 | とします。(#自動化したい) 33 | 34 | モックサーバーである `test-server-node` は app.js のみで構成されています。今回必要なAPIを実装して、 35 | 固定のJSONを返すようになっています。 36 | また、UIのデバッグ用にHTTPレスポンスを返すのを数秒遅らせています。 37 | 38 | 少しいじって、タイムアウトさせる(レスポンスを返さない)シミュレーションをしてもいいかもしれません。 39 | 40 | -------------------------------------------------------------------------------- /DETodo/Views/DEItemCell.h: -------------------------------------------------------------------------------- 1 | // 2 | // DEItemCell.h 3 | // DETodo 4 | // 5 | // Created by ito on 2013/05/19. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | @class DETodoItem; 12 | @interface DEItemCell : UITableViewCell 13 | 14 | 15 | @property (nonatomic, readonly) DETodoItem* item; 16 | - (void)setItem:(DETodoItem *)item forIndex:(NSUInteger)index; 17 | @property (nonatomic, readonly) UISwitch* doneSwitch; 18 | 19 | @end 20 | -------------------------------------------------------------------------------- /DETodo/Views/DEItemCell.m: -------------------------------------------------------------------------------- 1 | // 2 | // DEItemCell.m 3 | // DETodo 4 | // 5 | // Created by ito on 2013/05/19. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "DEItemCell.h" 10 | #import "DETodoItem.h" 11 | 12 | @implementation DEItemCell 13 | 14 | - (void)setSelected:(BOOL)selected animated:(BOOL)animated 15 | { 16 | [super setSelected:selected animated:animated]; 17 | 18 | // Configure the view for the selected state 19 | } 20 | 21 | - (id)initWithCoder:(NSCoder *)aDecoder 22 | { 23 | self = [super initWithCoder:aDecoder]; 24 | if (self) { 25 | UISwitch* sw = [[UISwitch alloc] initWithFrame:CGRectZero]; 26 | [sw sizeToFit]; 27 | self.accessoryView = sw; 28 | } 29 | return self; 30 | } 31 | 32 | -(void)prepareForReuse 33 | { 34 | [super prepareForReuse]; 35 | } 36 | 37 | -(UISwitch *)doneSwitch 38 | { 39 | return (id)self.accessoryView; 40 | } 41 | 42 | -(void)setItem:(DETodoItem *)item forIndex:(NSUInteger)index 43 | { 44 | _item = item; 45 | 46 | self.textLabel.text = item.title; 47 | 48 | UISwitch* sw = self.doneSwitch; 49 | sw.on = item.isDone; 50 | sw.tag = index; 51 | if(sw.on) { 52 | self.textLabel.textColor = [UIColor lightGrayColor]; 53 | } else { 54 | self.textLabel.textColor = [UIColor blackColor]; 55 | } 56 | 57 | // 作成中は切り替えできないように 58 | if (item.isCreating) { 59 | sw.enabled = NO; 60 | } else { 61 | sw.enabled = YES; 62 | } 63 | } 64 | 65 | @end 66 | -------------------------------------------------------------------------------- /DETodo/Views/DEListCell.h: -------------------------------------------------------------------------------- 1 | // 2 | // DEListCell.h 3 | // DETodo 4 | // 5 | // Created by ito on 2013/05/19. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | @class DETodoList; 12 | @interface DEListCell : UITableViewCell 13 | 14 | 15 | @property (nonatomic) DETodoList* list; 16 | 17 | @end 18 | -------------------------------------------------------------------------------- /DETodo/Views/DEListCell.m: -------------------------------------------------------------------------------- 1 | // 2 | // DEListCell.m 3 | // DETodo 4 | // 5 | // Created by ito on 2013/05/19. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "DEListCell.h" 10 | #import "DETodoList.h" 11 | 12 | @implementation DEListCell 13 | 14 | - (id)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier 15 | { 16 | self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; 17 | if (self) { 18 | // Initialization code 19 | } 20 | return self; 21 | } 22 | 23 | - (void)setSelected:(BOOL)selected animated:(BOOL)animated 24 | { 25 | [super setSelected:selected animated:animated]; 26 | 27 | // Configure the view for the selected state 28 | } 29 | 30 | -(void)setList:(DETodoList *)list 31 | { 32 | _list = list; 33 | 34 | self.textLabel.text = list.title; 35 | //self.detailTextLabel.text = [NSString stringWithFormat:@"%d項目", list.todoItems.count]; 36 | } 37 | 38 | @end 39 | -------------------------------------------------------------------------------- /DETodo/api_document.md: -------------------------------------------------------------------------------- 1 | ## DETodo API Document 2 | 3 | ### リクエスト 4 | 5 | POSTとPUT時はヘッダーに以下を追加して、BodyにJSONのデータを入れる or フォームエンコード 6 | 7 | Content-Type: application/json 8 | 9 | ### ステータスコード 10 | 11 | * 200, 201 -> 成功 12 | * 400 -> パラメータが正しくない, POST時のBodyとContent-Typeが正しくない 13 | * 403 -> セッションで認証されていない (要ログイン) 14 | * 404 -> APIのパスが正しくない 15 | * 500 -> サーバーエラー 16 | * 503 -> サーバーメンテナンス中 17 | 18 | ### 認証 19 | 20 | URI 21 | 22 | http://<>/auth/<> 23 | 24 | ログイン 25 | 26 | POST /login {user_id:"NEW USER ID or EXIST USER ID"} 27 | 28 | -> {_id:"98765efef", user_id:"USER ID"} (create new user if not exist) 29 | 30 | 31 | ### リソース (要ログイン) 32 | 33 | URI 34 | 35 | http://<>/api/<> 36 | 37 | Todoリスト一覧 38 | 39 | GET /list 40 | 41 | -> {lists:[{_id:"123ab", title:"LIST TITLE"}, …]} 42 | 43 | Todoリスト作成 44 | 45 | POST /list {title:"NEW LIST TITLE"} 46 | 47 | -> {_id:"456de", "title:"NEW LIST TITLE"} 48 | 49 | Todoリスト削除 50 | 51 | DELETE /list/<<_id, e.g. 456de>> 52 | 53 | -> {} (リスト内のアイテムはすべて削除される) 54 | 55 | Todoアイテム一覧 56 | 57 | GET /list/<>/item 58 | 59 | -> {items:[{_id:"123ab", title:"TODO ITEM TITLE is HERE", done:true}, …]} 60 | 61 | Todoアイテム作成 62 | 63 | POST /list/<>/item {title:"NEW ITEM TITLE", done:false} 64 | 65 | -> {_id:"456de", title:"NEW ITEM TITLE", done:false} 66 | 67 | Todoアイテムアップデート 68 | 69 | PUT /list/<>/item/<> {done: true} 70 | 71 | -> {_id:"456de", title:"ITEM TITLE", done:true} 72 | 73 | Todoアイテム削除 74 | 75 | DELETE /list/<>/item/<> 76 | 77 | -> {} 78 | 79 | -------------------------------------------------------------------------------- /DETodo/detodo-server/app.js: -------------------------------------------------------------------------------- 1 | var express = require('express'), 2 | http = require('http'), 3 | path = require('path'), 4 | sync = require('synchronize'); 5 | 6 | var app = express(); 7 | models = require('./models'), 8 | mongoose = require('mongoose'); 9 | 10 | app.configure(function(){ 11 | app.set('port', process.env.PORT || 3000); 12 | app.set('views', __dirname + '/views'); 13 | app.set('view engine', 'jade'); 14 | app.use(express.favicon()); 15 | app.use(express.logger('dev')); 16 | app.use(express.bodyParser()); 17 | app.use(express.methodOverride()); 18 | app.use(express.cookieParser('your secret here')); 19 | app.use(express.session()); 20 | app.use(function(req, res, next){ 21 | sync.fiber(next); 22 | }) 23 | app.use(app.router); 24 | app.use(express.static(path.join(__dirname, 'public'))); 25 | }); 26 | 27 | app.configure('development', function(){ 28 | mongoose.set('debug', true); 29 | app.set('dbname', 'detodo'); 30 | app.use(express.errorHandler()); 31 | }); 32 | 33 | app.configure('test', function(){ 34 | console.warn('test env'); 35 | mongoose.set('debug', true); 36 | app.set('dbname', 'forAPITesting'); 37 | app.use(express.errorHandler()); 38 | }); 39 | 40 | mongoose.connect('localhost', app.get('dbname')); 41 | 42 | var routes = require('./routes'); 43 | routes.start(app); 44 | 45 | 46 | http.createServer(app).listen(app.get('port'), function(){ 47 | console.log("Express server listening on port " + app.get('port')); 48 | }); -------------------------------------------------------------------------------- /DETodo/detodo-server/detodo-server.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /DETodo/detodo-server/detodo-server.ipr: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 23 | 24 | 25 | 26 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | DOM issuesJavaScript 38 | 39 | 40 | JavaScript 41 | 42 | 43 | XPath 44 | 45 | 46 | 47 | 48 | CFML 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /DETodo/detodo-server/models/index.js: -------------------------------------------------------------------------------- 1 | require('./user'); 2 | require('./todolist'); 3 | require('./todoitem'); 4 | -------------------------------------------------------------------------------- /DETodo/detodo-server/models/todoitem.js: -------------------------------------------------------------------------------- 1 | 2 | var mongoose = require('mongoose'), 3 | Schema = mongoose.Schema, 4 | ObjectId = Schema.ObjectId; 5 | 6 | var TodoItem = new Schema({ 7 | user: {type: ObjectId, required:true, ref:'User'}, 8 | list: {type: ObjectId, required:true, ref:'TodoList'}, 9 | title: {type: String, required: true}, 10 | done: {type: Boolean, required:true, default:false}, 11 | createdAt: {type: Date} 12 | }); 13 | 14 | TodoItem.pre('save', function(next) { 15 | if(this.isNew) { 16 | this.createdAt = new Date(); 17 | } 18 | next(); 19 | }); 20 | 21 | /** 22 | * 新規アイテム 23 | * @param userId 24 | * @param listId 25 | * @param title 26 | * //@param callback 27 | */ 28 | TodoItem.statics.createItem = function(userId, listId, title, callback) { 29 | var list = new this({title:title, user:userId, list:listId}); 30 | list.save(callback); 31 | }; 32 | 33 | /** 34 | * アイテムを削除 35 | * @param userId 36 | * @param id 37 | * //@param callback 38 | */ 39 | TodoItem.statics.deleteItem = function(userId, id, callback) { 40 | this.remove({_id:id, user:userId}, callback); 41 | }; 42 | 43 | /** 44 | * アイテムをアップデート 45 | * @param userId 46 | * @param id 47 | * @param done 48 | * //@param callback 49 | */ 50 | TodoItem.statics.updateDone = function(userId, id, done, callback) { 51 | this.findOne({_id:id, user:userId}, function(error, item) { 52 | if (error) return callback(error); 53 | if (item) { 54 | item.done = done; 55 | return item.save(callback); 56 | } else { 57 | return callback(null, null); 58 | } 59 | }); 60 | }; 61 | 62 | /** 63 | * アイテム一覧 64 | * @param userId 65 | * @param listId 66 | * //@param callback 67 | */ 68 | TodoItem.statics.findAllItems = function(userId, listId, callback) { 69 | this.find({user:userId, list:listId}, null, {sort:{createdAt:-1}}, callback); 70 | }; 71 | 72 | 73 | mongoose.model('TodoItem', TodoItem); 74 | -------------------------------------------------------------------------------- /DETodo/detodo-server/models/todolist.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'), 2 | Schema = mongoose.Schema, 3 | ObjectId = Schema.ObjectId; 4 | 5 | var TodoList = new Schema({ 6 | user: {type: ObjectId, required:true, ref:'User'}, 7 | title: {type: String, required: true}, 8 | createdAt: {type: Date} 9 | }); 10 | 11 | TodoList.pre('save', function(next) { 12 | if(this.isNew) { 13 | this.createdAt = new Date(); 14 | } 15 | next(); 16 | }); 17 | 18 | /** 19 | * 新規リスト 20 | * @param userId 21 | * @param title 22 | * //@param callback 23 | */ 24 | TodoList.statics.createList = function(userId, title, callback) { 25 | var list = new this({title:title, user:userId}); 26 | list.save(callback); 27 | }; 28 | 29 | 30 | /** 31 | * リスト一覧 32 | * @param userId 33 | * //@param callback 34 | */ 35 | TodoList.statics.findAllList = function(userId, callback) { 36 | this.find({user:userId}, null, {sort:{createdAt:-1}}, callback); 37 | }; 38 | 39 | /** 40 | * リストを削除 41 | * @param userId 42 | * @param id 43 | * //@param callback 44 | */ 45 | TodoList.statics.deleteList = function(userId, id, callback) { 46 | var TodoItem = this.model('TodoItem'), self = this; 47 | TodoItem.remove({list:id, user:userId}, function(error) { 48 | if(error) return callback(error); 49 | self.count({_id:id, user:userId}, function(error, count) { 50 | if (error) return callback(error); 51 | if (count == 0) return callback(null, false); 52 | self.remove({_id:id, user:userId}, function(error) { 53 | if (error) return callback(error); 54 | callback(null, true); 55 | }); 56 | }); 57 | }); 58 | }; 59 | 60 | /** 61 | * リストが存在するか? 62 | * @param userId 63 | * @param id 64 | * //@param callback 65 | */ 66 | TodoList.statics.hasList = function(userId, id, callback) { 67 | this.count({user:userId, _id:id}, callback); 68 | }; 69 | 70 | mongoose.model('TodoList', TodoList); -------------------------------------------------------------------------------- /DETodo/detodo-server/models/user.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'), 2 | Schema = mongoose.Schema, 3 | ObjectId = Schema.ObjectId; 4 | 5 | var User = new Schema({ 6 | userId: {type: String, required: true, index: {unique:true}} 7 | }); 8 | 9 | /** 10 | * 新規ユーザー 11 | * @param userId 12 | * //@param callback 13 | */ 14 | User.statics.createUser = function(userId, callback) { 15 | var user = new this({userId:userId}); 16 | user.save(callback); 17 | }; 18 | 19 | /** 20 | * ユーザーを取得 21 | * @param userId 22 | * //@param callback 23 | */ 24 | User.statics.findByUserId = function(userId, callback) { 25 | this.findOne({userId:userId}, callback); 26 | }; 27 | 28 | mongoose.model('User', User); -------------------------------------------------------------------------------- /DETodo/detodo-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "application-name", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "start": "node app" 7 | }, 8 | "dependencies": { 9 | "express": "3.1.0", 10 | "jade": "*", 11 | "mongoose": "3.6.0", 12 | "synchronize": "*" 13 | } 14 | } -------------------------------------------------------------------------------- /DETodo/detodo-server/public/stylesheets/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 50px; 3 | font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; 4 | } 5 | 6 | a { 7 | color: #00B7FF; 8 | } -------------------------------------------------------------------------------- /DETodo/detodo-server/routes/api.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'), 2 | sync = require('synchronize'); 3 | 4 | exports.start = function(app) { 5 | 6 | 7 | 8 | var TodoList = mongoose.model('TodoList'), TodoItem = mongoose.model('TodoItem'); 9 | 10 | var API_PATH = '/api'; 11 | 12 | /** 13 | * TodoList API 14 | */ 15 | sync(TodoList, 'findAllList'); 16 | app.get(API_PATH + '/list', function(req, res, next) { 17 | var lists = TodoList.findAllList(req.session.uid); 18 | return res.json({lists:lists}); 19 | }); 20 | 21 | sync(TodoList, 'createList'); 22 | app.post(API_PATH + '/list', function(req, res, next) { 23 | var title = req.param('title'); 24 | if (!title || (title && title.length <= 0)) return res.json(400,{}); 25 | var list = TodoList.createList(req.session.uid, title); 26 | return res.json(list); 27 | }); 28 | 29 | sync(TodoList, 'deleteList'); 30 | app.del(API_PATH + '/list/:id', function(req, res, next) { 31 | var success = TodoList.deleteList(req.session.uid, req.param('id')); 32 | if(success) return res.json({}); 33 | else return res.json(404, {}); 34 | }); 35 | 36 | /** 37 | * TodoItem API 38 | */ 39 | sync(TodoList, 'hasList'); 40 | var hasList = function(req, res, next) { 41 | var hasList = TodoList.hasList(req.session.uid, req.param('lid')); 42 | if(!hasList) { 43 | return res.json(404, {}); 44 | } 45 | return next() 46 | } 47 | 48 | sync(TodoItem, 'findAllItems'); 49 | app.get(API_PATH + '/list/:lid/item', hasList, function(req, res, next) { 50 | var items = TodoItem.findAllItems(req.session.uid, req.param('lid')); 51 | return res.json({items:items}); 52 | }); 53 | 54 | sync(TodoItem, 'createItem'); 55 | app.post(API_PATH + '/list/:lid/item', hasList, function(req, res, next) { 56 | var title = req.param('title'); 57 | if (!title || (title && title.length <= 0)) return res.json(400,{}); 58 | var item = TodoItem.createItem(req.session.uid, req.param('lid'), title); 59 | return res.json(item); 60 | }); 61 | 62 | sync(TodoItem, 'updateDone'); 63 | app.put(API_PATH + '/list/:lid/item/:iid', function(req, res, next) { 64 | var done = req.param('done'); 65 | if (done == null) return res.json(400,{}); 66 | var item = TodoItem.updateDone(req.session.uid, req.param('iid'), done); 67 | if (item) return res.json(item); 68 | else return res.json(404, {}); 69 | }); 70 | 71 | sync(TodoItem, 'deleteItem'); 72 | app.del(API_PATH + '/list/:lid/item/:iid', hasList, function(req, res, next) { 73 | TodoItem.deleteItem(req.session.uid, req.param('iid')); 74 | return res.json({}); 75 | }); 76 | }; -------------------------------------------------------------------------------- /DETodo/detodo-server/routes/auth.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'), 2 | sync = require('synchronize'); 3 | 4 | exports.start = function(app) { 5 | 6 | var User = mongoose.model('User'); 7 | 8 | 9 | sync(User, 'findByUserId', 'createUser'); 10 | app.post('/auth/login', function(req, res, next) { 11 | var userId = req.param('user_id'); 12 | if (!userId || (userId && userId.length <= 0)) return res.json(400,{}); 13 | var user = User.findByUserId(userId); 14 | if (user) { 15 | req.session.uid = user._id; 16 | return res.json({_id:user._id, user_id:user.userId}); 17 | } else { 18 | user = User.createUser(userId); 19 | req.session.uid = user._id; 20 | user.user_id = user.userId; 21 | return res.json({_id:user._id, user_id:user.userId}); 22 | } 23 | }); 24 | 25 | 26 | app.all('/api/*', function(req, res, next) { 27 | if(!req.session.uid) return res.json(403, {}); 28 | next(); 29 | }); 30 | 31 | }; -------------------------------------------------------------------------------- /DETodo/detodo-server/routes/index.js: -------------------------------------------------------------------------------- 1 | var api = require('./api'), 2 | auth = require('./auth'); 3 | 4 | exports.start = function(app) { 5 | auth.start.apply(this, arguments); 6 | api.start.apply(this, arguments); 7 | }; -------------------------------------------------------------------------------- /DETodo/detodo-server/test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require should 2 | -R spec 3 | --ui bdd 4 | --timeout 5000 5 | 6 | 7 | -------------------------------------------------------------------------------- /DETodo/detodo-server/test/request.js: -------------------------------------------------------------------------------- 1 | var request_ = require('supertest'), 2 | utils = require('./utils/utils'), 3 | clearSession = utils.clearSession; 4 | /* 5 | var spawn = require('child_process').spawn; 6 | var child = spawn('/usr/local/bin/node', ['app.js'], {env:{NODE_ENV:'test'}, cwd:'.'}) 7 | child.stderr.setEncoding('utf8'); 8 | child.stderr.on('data', function(data) { 9 | //console.log(data) 10 | }) 11 | */ 12 | 13 | var request = function() { 14 | return request_('http://localhost:3000'); 15 | } 16 | 17 | function CleanDB (done){ 18 | utils.cleanDB("mongodb://localhost:27017/forAPITesting?w=1", function(err) { 19 | if (err) return err; 20 | done() 21 | //setTimeout(done, 500) 22 | }) 23 | } 24 | 25 | 26 | describe('User API', function() { 27 | 28 | // DBを削除 29 | before(CleanDB) 30 | 31 | it('should return 400 when requested with no body', function(done) { 32 | request().post('/auth/login').expect(400, done) 33 | }) 34 | 35 | var t_id; 36 | it('should return 200 when requested with valid post body', function(done) { 37 | request().post('/auth/login').send({user_id:'test user'}) 38 | .expect(200, function(error, res) { 39 | if (error) return done(error); 40 | //console.log(res.body); 41 | t_id = res.body._id; 42 | res.body._id.should.be.ok 43 | res.body.user_id.should.equal('test user') 44 | done() 45 | }) 46 | }) 47 | it('should return 200 with same _id before when requested with same user name', function(done) { 48 | request().post('/auth/login').send({user_id:'test user'}) 49 | .expect(200).end(function(error, res) { 50 | if (error) return done(error); 51 | res.body._id.should.equal(t_id) 52 | res.body.user_id.should.equal('test user') 53 | done() 54 | }) 55 | }) 56 | }) 57 | 58 | var TEST_USER_ID1 = 'test user', 59 | TEST_USER_ID2 = 'test user2'; 60 | 61 | var TEST_Login = function(user, callback) { 62 | request().post('/auth/login').send({user_id:user}).withSession(user).expect(200,callback) 63 | } 64 | 65 | describe('Todo List GET API', function() { 66 | 67 | before(function(done) { 68 | TEST_Login(TEST_USER_ID1, done) 69 | }) 70 | 71 | after(function(done) { 72 | clearSession(TEST_USER_ID1, done) 73 | }) 74 | 75 | it('should return 403 if not logged in', function(done) { 76 | request().get('/api/list').expect(403, done) 77 | }) 78 | 79 | it('should return empty List with 200', function(done) { 80 | request().get('/api/list').withSession(TEST_USER_ID1).expect(200, function(err, res) { 81 | if (err) return done(err); 82 | res.body.lists.should.have.length(0); 83 | done() 84 | }) 85 | }) 86 | 87 | }) 88 | 89 | describe('Todo List POST and DELETE API', function() { 90 | 91 | before(function(done) { 92 | TEST_Login(TEST_USER_ID1, function(err) { 93 | if(err) return done(err); 94 | TEST_Login(TEST_USER_ID2, done) 95 | }) 96 | }) 97 | 98 | after(function(done) { 99 | clearSession(TEST_USER_ID1, function() { 100 | clearSession(TEST_USER_ID2, done) 101 | }) 102 | }) 103 | 104 | it('should return 403 if not logged in', function(done) { 105 | request().post('/api/list').expect(403, done) 106 | }) 107 | 108 | it('should return 400 when requested with no post body', function(done) { 109 | request().post('/api/list').withSession(TEST_USER_ID1).expect(400, done) 110 | }) 111 | 112 | var TEST_list_id; 113 | it('creates List and return 200 with created data', function(done) { 114 | request().post('/api/list').send({title:'hoge'}).withSession(TEST_USER_ID1).expect(200, function(err, res) { 115 | if (err) return done(err); 116 | res.body._id.should.be.ok 117 | res.body.title.should.equal('hoge') 118 | TEST_list_id = res.body._id; 119 | // 作成されたか確認 120 | request().get('/api/list').withSession(TEST_USER_ID1).expect(200, function(err, res) { 121 | if (err) return done(err); 122 | res.body.lists.should.have.length(1) 123 | res.body.lists[0]._id.should.equal(TEST_list_id) 124 | res.body.lists[0].title.should.equal('hoge') 125 | done() 126 | }) 127 | }) 128 | }) 129 | 130 | it('should not delete another users list', function(done) { 131 | request().del('/api/list/' + TEST_list_id).withSession(TEST_USER_ID2).expect(404, done) 132 | }) 133 | 134 | it('deletes created List', function(done) { 135 | request().del('/api/list/' + TEST_list_id).withSession(TEST_USER_ID1).expect(200, function(err) { 136 | if (err) return done(err) 137 | request().get('/api/list').withSession(TEST_USER_ID1).expect(200, function(err, res) { 138 | if (err) return done(err) 139 | res.body.lists.should.have.length(0) 140 | done() 141 | }) 142 | }) 143 | }) 144 | 145 | it('deletes created List one more and should returns 404', function(done) { 146 | request().del('/api/list/' + TEST_list_id).withSession(TEST_USER_ID1).expect(404, done) 147 | }) 148 | 149 | }) 150 | 151 | 152 | 153 | describe('Todo Item API', function() { 154 | 155 | var TEST_list_id_1_for_user1, 156 | TEST_list_id_1_for_user2; 157 | before(function(done) { 158 | CleanDB(function(err) { 159 | if(err) return done(err); 160 | TEST_Login(TEST_USER_ID1, function(err) { 161 | if(err) return done(err); 162 | TEST_Login(TEST_USER_ID2, function(err) { 163 | request().post('/api/list').send({title:'fuga1'}).withSession(TEST_USER_ID1).expect(200, function(err, res) { 164 | if(err) return done(err) 165 | TEST_list_id_1_for_user1 = res.body._id; 166 | request().post('/api/list').send({title:'fuga2'}).withSession(TEST_USER_ID2).expect(200, function(err, res) { 167 | if(err) return done(err) 168 | TEST_list_id_1_for_user2 = res.body._id; 169 | done() 170 | }) 171 | }) 172 | }) 173 | }) 174 | }) 175 | }) 176 | 177 | after(function(done) { 178 | clearSession(TEST_USER_ID1, function() { 179 | clearSession(TEST_USER_ID2, done) 180 | }) 181 | }) 182 | 183 | 184 | it('gets empty Item with 200 first', function(done) { 185 | request().get('/api/list/' + TEST_list_id_1_for_user1 + '/item').withSession(TEST_USER_ID1).expect(200, function(err, res) { 186 | if (err) return done(err); 187 | res.body.items.should.have.length(0) 188 | done() 189 | }) 190 | }) 191 | 192 | var TEST_item_1_for_user1, 193 | TEST_item_1_for_user2; 194 | it('creates new Item and returns 200 with created data', function(done) { 195 | request().post('/api/list/' + TEST_list_id_1_for_user1 + '/item').send({title:'hoge item'}).withSession(TEST_USER_ID1).expect(200, function(err, res) { 196 | if(err) return done(err); 197 | res.body._id.should.be.ok 198 | res.body.title.should.equal('hoge item') 199 | TEST_item_1_for_user1 = res.body._id; 200 | request().post('/api/list/' + TEST_list_id_1_for_user1 + '/item').send({title:'hoge item'}).withSession(TEST_USER_ID2).expect(404, done) 201 | }) 202 | }) 203 | 204 | it('should returns 200 with created Item before', function(done) { 205 | request().get('/api/list/' + TEST_list_id_1_for_user1 + '/item').withSession(TEST_USER_ID1).expect(200, function(err, res) { 206 | if (err) return done(err); 207 | res.body.items.should.have.length(1) 208 | res.body.items[0]._id.should.equal(TEST_item_1_for_user1) 209 | res.body.items[0].title.should.equal('hoge item') 210 | res.body.items[0].done.should.be.false 211 | done() 212 | }) 213 | }) 214 | 215 | it('should returns empty Item when accessing other users List', function(done){ 216 | request().get('/api/list/' + TEST_list_id_1_for_user1 + '/item').withSession(TEST_USER_ID2).expect(404, done) 217 | }) 218 | 219 | it('updates Item and returns updated data', function(done) { 220 | request().put('/api/list/' + TEST_list_id_1_for_user1 + '/item/' + TEST_item_1_for_user1).send({done:true}).withSession(TEST_USER_ID1).expect(200, function(err, res) { 221 | if (err) return done(err) 222 | res.body.done.should.be.true 223 | res.body._id.should.be.equal(TEST_item_1_for_user1) 224 | request().get('/api/list/' + TEST_list_id_1_for_user1 + '/item').withSession(TEST_USER_ID1).expect(200, function(err, res) { 225 | if (err) return done(err); 226 | res.body.items.should.have.length(1) 227 | res.body.items[0]._id.should.equal(TEST_item_1_for_user1) 228 | res.body.items[0].done.should.be.true 229 | done() 230 | }) 231 | }) 232 | }) 233 | 234 | it('updates other users Item and returns 404', function(done) { 235 | request().put('/api/list/' + TEST_list_id_1_for_user1 + '/item/' + TEST_item_1_for_user1).send({done:true}).withSession(TEST_USER_ID2).expect(404, done) 236 | }) 237 | 238 | it('deletes other users Item and returns 404', function(done) { 239 | request().del('/api/list/' + TEST_list_id_1_for_user1 + '/item/' + TEST_item_1_for_user1).withSession(TEST_USER_ID2).expect(404, done) 240 | }) 241 | 242 | it('deletes created Item and returns 200', function(done) { 243 | request().del('/api/list/' + TEST_list_id_1_for_user1 + '/item/' + TEST_item_1_for_user1).withSession(TEST_USER_ID1).expect(200, function(err, res){ 244 | if(err) return done(err); 245 | // 削除されたか確認 246 | request().get('/api/list/' + TEST_list_id_1_for_user1 + '/item').withSession(TEST_USER_ID1).expect(200, function(err, res) { 247 | if (err) return done(err); 248 | res.body.items.should.have.length(0) 249 | done() 250 | }) 251 | }) 252 | }) 253 | 254 | }) 255 | 256 | /* 257 | describe('Kill test node', function() { 258 | it('should be killed', function(done) { 259 | child.kill('SIGHUP'); 260 | done() 261 | }) 262 | })*/ -------------------------------------------------------------------------------- /DETodo/detodo-server/test/test.js: -------------------------------------------------------------------------------- 1 | var sync = require('synchronize'), 2 | async = sync.asyncIt; 3 | 4 | 5 | var mongoose = require('mongoose'), 6 | model = require('../models'); // Load models 7 | 8 | //mongoose.set('debug', true); 9 | mongoose.connect('localhost', 'forTesting'); 10 | 11 | describe('User', function() { 12 | var User = mongoose.model('User'); 13 | sync(User, 'createUser', 'findByUserId', 'remove'); 14 | 15 | var TEST_USER_ID = "test user _/_/::**"; 16 | var userid_created = undefined; 17 | 18 | before(async(function(){ 19 | User.remove(); 20 | })) 21 | 22 | // create と read をまとめたほうがいいのか分からんけどとりあえず動く 23 | it('create user', async(function() { 24 | var user = User.createUser(TEST_USER_ID); 25 | 26 | user.should.be.ok; 27 | user.userId.should.equal(TEST_USER_ID); 28 | userid_created = user._id.toString(); 29 | })) 30 | 31 | it('read user', async(function() { 32 | var user = User.findByUserId(TEST_USER_ID); 33 | 34 | user.should.be.ok; 35 | user.userId.should.equal(TEST_USER_ID); 36 | user._id.toString().should.equal(userid_created); 37 | })) 38 | }) 39 | 40 | describe('Todo List', function() { 41 | var User = mongoose.model('User'), TodoList = mongoose.model('TodoList'); 42 | sync(TodoList, 'createList', 'findAllList', 'deleteList', 'remove'); 43 | 44 | var TEST_LIST_TITLE1 = 'list hoge あいう *** ♨', 45 | TEST_LIST_TITLE2 = 'list 321 漢字 123'; 46 | var uid; 47 | 48 | // User を準備 49 | before(async(function() { 50 | User.remove(); 51 | TodoList.remove(); 52 | uid = User.createUser('user id')._id; 53 | })) 54 | 55 | var list2_id = undefined; 56 | it('create and get list', async(function() { 57 | var list1 = TodoList.createList(uid, TEST_LIST_TITLE1); 58 | var list2 = TodoList.createList(uid, TEST_LIST_TITLE2); 59 | 60 | var lists = TodoList.findAllList(uid); 61 | lists.should.have.length(2); 62 | lists[0].user.toString().should.be.ok; 63 | lists[0].user.toString().should.equal(uid.toString()); 64 | lists[0].title.should.equal(TEST_LIST_TITLE2); // 後に作成したほうが先に 65 | lists[1].title.should.equal(TEST_LIST_TITLE1); 66 | list2_id = list2._id; 67 | })) 68 | 69 | it('remove list', async(function() { 70 | TodoList.deleteList(uid, list2_id); // 2 を消す 71 | var lists = TodoList.findAllList(uid); 72 | 73 | lists.should.have.length(1); // 1 だけ残る 74 | lists[0].title.should.equal(TEST_LIST_TITLE1); 75 | })) 76 | }) 77 | 78 | 79 | describe('Todo Item', function() { 80 | var User = mongoose.model('User'), TodoList = mongoose.model('TodoList'), TodoItem = mongoose.model('TodoItem'); 81 | sync(TodoItem, 'createItem', 'findAllItems', 'remove'); 82 | 83 | var TEST_ITEM_TITLE1 = 'item hoge あいう *** ♨', 84 | TEST_ITEM_TITLE2 = 'item 321 漢字 123'; 85 | var uid, lid; 86 | 87 | // User と List を準備 88 | before(async(function() { 89 | User.remove(); 90 | TodoList.remove(); 91 | TodoItem.remove(); 92 | uid = User.createUser('user id')._id; 93 | lid = TodoList.createList(uid, 'list title')._id; 94 | })) 95 | 96 | var item2_id = undefined; 97 | it('create and get item', async(function() { 98 | var item1 = TodoItem.createItem(uid, lid, TEST_ITEM_TITLE1); 99 | var item2 = TodoItem.createItem(uid, lid, TEST_ITEM_TITLE2); 100 | 101 | var items = TodoItem.findAllItems(uid, lid); 102 | items[0].user.toString().should.be.ok; 103 | items[0].user.toString().should.equal(uid.toString()); 104 | items[0].list.toString().should.be.ok; 105 | items[0].list.toString().should.equal(lid.toString()); 106 | items[0].title.should.equal(TEST_ITEM_TITLE2); 107 | items[1].title.should.equal(TEST_ITEM_TITLE1); 108 | item2_id = items[0]._id; 109 | })) 110 | 111 | sync(TodoItem, 'updateDone', 'deleteItem'); 112 | it('update item', async(function() { 113 | var item = TodoItem.createItem(uid, lid, 'update test item'); 114 | 115 | item.done.should.equal(false); // 初期値 done == false 116 | var itemUpdated = TodoItem.updateDone(uid, item._id, true); 117 | 118 | itemUpdated.done.should.be.ok; // アップデートして done == true に 119 | 120 | var items = TodoItem.findAllItems(uid, lid); 121 | var itemIncludes; 122 | items.forEach(function(i) { 123 | if (i._id.toString() === itemUpdated._id.toString()) itemIncludes = i; 124 | }) 125 | 126 | // Item リストには 先ほどのものが含まれている 127 | itemIncludes._id.toString().should.equal(item._id.toString()); 128 | itemIncludes.done.should.be.ok; 129 | itemIncludes.title.should.equal('update test item'); 130 | 131 | })) 132 | 133 | it('delete item', async(function() { 134 | TodoItem.deleteItem(uid, item2_id); 135 | 136 | var items = TodoItem.findAllItems(uid, lid); 137 | var itemIncludes = ''; 138 | items.forEach(function(i) { 139 | if (i._id.toString() === item2_id.toString()) itemIncludes = i; 140 | }) 141 | 142 | items.should.have.length(2); 143 | itemIncludes.should.not.be.ok; 144 | 145 | })) 146 | 147 | }) 148 | 149 | describe('Permissions', function() { 150 | var User = mongoose.model('User'), TodoList = mongoose.model('TodoList'), TodoItem = mongoose.model('TodoItem'); 151 | 152 | var uid1, lid1, uid2; 153 | 154 | // User と List を準備 155 | before(async(function() { 156 | User.remove(); 157 | TodoList.remove(); 158 | TodoItem.remove(); 159 | uid1 = User.createUser('user id 1')._id; 160 | uid2 = User.createUser('user id 2')._id; 161 | lid1 = TodoList.createList(uid1, 'list title 1')._id; 162 | })) 163 | 164 | it('prevent another user from deleting my list', async(function() { 165 | TodoList.deleteList(uid2, lid1); // user 2 が user 1 のリストを削除 166 | var lists1 = TodoList.findAllList(uid1); 167 | var lists2 = TodoList.findAllList(uid2); 168 | 169 | lists1.should.have.length(1); 170 | lists2.should.have.length(0); 171 | 172 | TodoList.deleteList(uid1, lid1); 173 | lists1 = TodoList.findAllList(uid1); 174 | lists1.should.have.length(0); 175 | })) 176 | }) -------------------------------------------------------------------------------- /DETodo/detodo-server/test/utils/utils.js: -------------------------------------------------------------------------------- 1 | var Test = require('supertest/lib/test'); 2 | 3 | var Cookies = {}; 4 | 5 | exports.clearSession = function (name, callback) { 6 | delete Cookies[name]; 7 | callback(); 8 | } 9 | 10 | Test.prototype.withSession = function(name) { 11 | this._withSessionName = name; 12 | return this; 13 | } 14 | 15 | var fend = Test.prototype.end; 16 | Test.prototype.end = function(fn) { 17 | var self = this; 18 | //console.log(this); 19 | if(Cookies[self._withSessionName]) { 20 | //console.log('req', Cookies[self._withSessionName], this._url, this._data); 21 | this.cookies = Cookies[self._withSessionName]; 22 | this.set('Cookie', Cookies[self._withSessionName]) 23 | } else { 24 | //throw new Error('no session for ' + self._withSessionName); 25 | } 26 | fend.call(this, function(err, res) { 27 | if (self._withSessionName && res.headers['set-cookie']) { 28 | Cookies[self._withSessionName] = res.headers['set-cookie'].pop().split(';')[0]; 29 | //console.log('res', Cookies); 30 | } 31 | fn.apply(this, arguments); 32 | }) 33 | } 34 | 35 | 36 | var Db = require('mongodb').Db; 37 | exports.cleanDB = function(dbpath, done) { 38 | Db.connect(dbpath, function(err, db) { 39 | if (err) return done(err); 40 | db.dropDatabase(function(err) { 41 | if (err) return done(err); 42 | db.close() 43 | done() 44 | }) 45 | }) 46 | } -------------------------------------------------------------------------------- /DETodo/model-relation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/novi/LTAPIRequest/a396a26d2cb1b4851ded3f91e1def57a0aaab3ad/DETodo/model-relation.png -------------------------------------------------------------------------------- /DETodo/test-server-node/app.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var express = require('express') 7 | , http = require('http') 8 | , path = require('path'); 9 | 10 | var app = express(); 11 | 12 | app.configure(function(){ 13 | app.set('port', process.env.PORT || 3000); 14 | app.set('views', __dirname + '/views'); 15 | app.set('view engine', 'jade'); 16 | app.use(express.favicon()); 17 | app.use(express.logger('dev')); 18 | app.use(express.bodyParser()); 19 | app.use(express.methodOverride()); 20 | app.use(express.cookieParser('your secret here')); 21 | app.use(express.session()); 22 | app.use(app.router); 23 | app.use(express.static(path.join(__dirname, 'public'))); 24 | }); 25 | 26 | app.configure('development', function(){ 27 | app.use(express.errorHandler()); 28 | }); 29 | 30 | /*app.all('*', function(req, res, next) { 31 | var j = res.json; 32 | var statuses = [400, 404, 403, 500, 503]; 33 | res.json = function(data) { 34 | j.call(this, statuses[Math.floor(Math.random()*statuses.length)], data); 35 | }; 36 | next(); 37 | }); 38 | */ 39 | 40 | // UIのデバッグ用にリクエストを遅らせる 41 | app.all('*', function(req, res, next) { 42 | setTimeout(next, 2000); 43 | }); 44 | 45 | // ログインが必要な API, セッションが無ければ 403 46 | app.all('/api/*', function(req, res, next) { 47 | if (!req.session.user_id) return res.json(403,{}); 48 | next(); 49 | }); 50 | 51 | app.post('/auth/login', function(req, res) { 52 | if (req.param('user_id').length <= 0) return res.json(400,{}); 53 | req.session.user_id = "u123abc"; 54 | res.json({user_id:req.param('user_id'), _id:"u123abc"}); 55 | }); 56 | 57 | var API_PATH = '/api'; 58 | 59 | // Lists 60 | 61 | app.get(API_PATH + '/list', function(req, res) { 62 | // テスト用に _id を l? にする 63 | res.json({lists:[{_id:"l1", title:'t1'}, {_id:"l2", title:'t2'}, {_id:"l3", title:'t3'}]}); 64 | }); 65 | 66 | app.post(API_PATH + '/list', function(req, res) { 67 | res.json(201,{_id:"lfffff", title:req.param('title')}); 68 | }); 69 | 70 | // API は DELETE /list/l? という形式のみacceptする, それ以外はiOS側の呼び出しバグなので404が返る 71 | app.del(API_PATH + '/list/l:id', function(req, res) { 72 | res.json({debug:'l' + req.param('id') + ' deleted'}); 73 | }); 74 | 75 | // 76 | // Items 77 | 78 | // 同じく GET /list/l? 79 | app.get(API_PATH + '/list/l:id/item', function(req, res) { 80 | // _id の形式を l?_i? にする (l=list, i=item, e.g. l1_i2) 81 | res.json({items:[{_id:'l'+req.param('id')+'_i1', title:'list '+req.param('id') + " item 1", done:false}, {_id:'l'+req.param('id')+'_i2', title:'list '+req.param('id') + " item 2", done:true}]}); 82 | }); 83 | 84 | // POST /list/l? 85 | app.post(API_PATH + '/list/l:id/item', function(req, res) { 86 | res.json(201,{_id:'l'+req.param('id')+'_i3', title:req.param('title'), done:false}); 87 | }); 88 | 89 | // PUT /list/l?/item/l?_i? 90 | app.put(API_PATH + '/list/l:a/item/l:b_i:item', function(req, res) { 91 | res.json({_id:'i' + req.param('item'), title:'original title', done:req.param('done')}); 92 | }); 93 | 94 | // DELETE /list/l?/item/l?_i? 95 | app.del(API_PATH + '/list/l:a/item/l:b_i:item', function(req, res) { 96 | res.json({debug:'l'+req.param('b') + '_i' + req.param('item') + ' deleted'}); 97 | }); 98 | 99 | http.createServer(app).listen(app.get('port'), function(){ 100 | console.log("Express server listening on port " + app.get('port')); 101 | }); 102 | -------------------------------------------------------------------------------- /DETodo/test-server-node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "application-name", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "start": "node app" 7 | }, 8 | "dependencies": { 9 | "express": "3.1.0", 10 | "jade": "*" 11 | } 12 | } -------------------------------------------------------------------------------- /DETodo/test-server-node/public/stylesheets/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 50px; 3 | font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; 4 | } 5 | 6 | a { 7 | color: #00B7FF; 8 | } -------------------------------------------------------------------------------- /DETodo/test-server-node/test-server-node.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /DETodo/test-server-node/test-server-node.ipr: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 23 | 24 | 25 | 26 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | DOM issuesJavaScript 38 | 39 | 40 | JavaScript 41 | 42 | 43 | XPath 44 | 45 | 46 | 47 | 48 | CFML 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /DETodo/test-server-node/test-server-node.iws: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 14 | 15 | 16 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 39 | 40 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 88 | 89 | 90 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 128 | 129 | 130 | 131 | 134 | 135 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 169 | 170 | 171 | 172 | 173 | 174 | 185 | 186 | 187 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 214 | 215 | 220 | 221 | 223 | 224 | localhost 225 | 5050 226 | 227 | 228 | 229 | 230 | 231 | 232 | 1363850077451 233 | 1363850077451 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 264 | 265 | 276 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | -------------------------------------------------------------------------------- /LTAPIRequest/LTAPIRequest.h: -------------------------------------------------------------------------------- 1 | // 2 | // LTAPIRequest.h 3 | // LTAPIRequest 4 | // 5 | // Created by Yusuke Ito on 2013/03/16. 6 | // Copyright (c) 2013 Yusuke Ito. All rights reserved. 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | #import 22 | 23 | //#define LTAPIRequestDebug (1) 24 | 25 | typedef NS_ENUM(NSUInteger, LTAPIRequestMethod) { 26 | LTAPIRequestMethodInvalid = 0, 27 | LTAPIRequestMethodGET = 1, 28 | LTAPIRequestMethodPOST, 29 | LTAPIRequestMethodDELETE, 30 | LTAPIRequestMethodPUT 31 | }; 32 | 33 | @class LTAPIResponse; 34 | typedef void(^LTAPIRequestCallback)(LTAPIResponse* res); 35 | 36 | @interface LTAPIRequest : NSObject 37 | 38 | // イニシャライザ 39 | - (id)initWithAPI:(NSString*)path method:(LTAPIRequestMethod)method params:(NSDictionary*)dict; 40 | @property (nonatomic, readonly, copy) NSString* path; 41 | @property (nonatomic, readonly) LTAPIRequestMethod method; 42 | @property (nonatomic, readonly, copy) NSString* methodString; // LTAPIRequestMethodGET -> "GET"... 43 | @property (nonatomic, readonly, copy) NSDictionary* params; 44 | @property (nonatomic, readonly, weak) LTAPIResponse* response; 45 | 46 | // サブクラスでオーバーライドする 47 | - (id)sendRequestWithCallback:(LTAPIRequestCallback)callback; // APIのリクエストを送信 48 | + (Class)APIResponseClass; // APIResponseのクラス 49 | - (NSURLRequest*)prepareRequest; // 実際に送信するRequestを作成 50 | 51 | // ユーティリティメソッド 52 | // (handlerはqueueのスレッドで呼ばれる) 53 | + (id)lt_sendAsynchronousRequest:(NSURLRequest *)request queue:(NSOperationQueue*) queue completionHandler:(void (^)(NSURLResponse*, NSData*, NSError*)) handler; 54 | 55 | + (NSOperationQueue*)imageRequestQueue; // 画像ダウンロード用 Queue 56 | + (NSOperationQueue*)APIRequestQueue; // APIリクエスト用 Queue 57 | 58 | + (void)beginNetworkConnection; // ネットワークインジケータ制御 59 | + (void)endNetworkConnection; 60 | 61 | @end 62 | -------------------------------------------------------------------------------- /LTAPIRequest/LTAPIRequest.m: -------------------------------------------------------------------------------- 1 | // 2 | // LTAPIRequest.m 3 | // LTAPIRequest 4 | // 5 | // Created by Yusuke Ito on 2013/03/16. 6 | // Copyright (c) 2013 Yusuke Ito. All rights reserved. 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | #import "LTAPIRequest.h" 22 | #import "LTAPIResponse.h" 23 | 24 | #if LTAPIRequestDebug 25 | #warning LTAPIRequestDebug is enabled 26 | #define LTAPIRequestDebugLog NSLog 27 | #else 28 | #define LTAPIRequestDebugLog(...) { do {} while (0);} 29 | #endif 30 | 31 | static int networkCount = 0; 32 | 33 | @interface LTAPIRequest () 34 | { 35 | NSURLRequest* _request; 36 | } 37 | 38 | @end 39 | 40 | @implementation LTAPIRequest 41 | 42 | // 標準のイニシャライザは無効に 43 | // 外部からインスタンス化できない 44 | -(id)init 45 | { 46 | [self doesNotRecognizeSelector:_cmd]; 47 | return nil; 48 | } 49 | 50 | -(id)initWithAPI:(NSString *)path method:(LTAPIRequestMethod)method params:(NSDictionary *)dict 51 | { 52 | self = [super init]; 53 | if (self) { 54 | _path = [path copy]; 55 | _method = method; 56 | _params = [dict copy]; 57 | } 58 | return self; 59 | } 60 | 61 | -(NSString *)methodString 62 | { 63 | return [self methodStringDictionary][@(self.method)]; 64 | } 65 | 66 | #pragma mark - API 67 | 68 | -(id)sendRequestWithCallback:(LTAPIRequestCallback)callback 69 | { 70 | _request = [self prepareRequest]; 71 | LTAPIRequestDebugLog(@"%@", [self descriptionWithRequest:_request]); 72 | 73 | [[self class] beginNetworkConnection]; 74 | 75 | return [[self class] lt_sendAsynchronousRequest:_request queue:[[self class] APIRequestQueue] completionHandler:^(NSURLResponse * urlResponse, NSData * responseData, NSError *error) { 76 | [[self class] endNetworkConnection]; 77 | LTAPIResponse* res = [[[[self class] APIResponseClass] alloc] init]; 78 | res.responseData = responseData; 79 | res.response = urlResponse; 80 | res.error = error; 81 | res.request = self; 82 | _response = res; 83 | if (!res.success) { 84 | [res showErrorAlert]; 85 | return dispatch_async(dispatch_get_main_queue(), ^{ 86 | LTAPIRequestDebugLog(@"Request failed: %@", res); 87 | return callback(res); 88 | }); 89 | } 90 | dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{ 91 | NSError* error = [res parseJSON]; 92 | if (error) { 93 | res.parseError = error; 94 | [res showErrorAlert]; 95 | return dispatch_async(dispatch_get_main_queue(), ^{ 96 | LTAPIRequestDebugLog(@"Parse Error: %@", res); 97 | return callback(res); 98 | }); 99 | } else { 100 | return dispatch_async(dispatch_get_main_queue(), ^{ 101 | LTAPIRequestDebugLog(@"JSON: %@", res.json); 102 | return callback(res); 103 | }); 104 | } 105 | }); 106 | }]; 107 | } 108 | 109 | +(Class)APIResponseClass 110 | { 111 | [[NSException exceptionWithName:NSGenericException reason:@"shoud be overriden in subclass" userInfo:nil] raise]; 112 | return Nil; 113 | } 114 | 115 | -(NSMutableURLRequest *)prepareRequest 116 | { 117 | [[NSException exceptionWithName:NSGenericException reason:@"shoud be overriden in subclass" userInfo:nil] raise]; 118 | return nil; 119 | } 120 | 121 | -(NSString *)descriptionWithRequest:(NSURLRequest*)request 122 | { 123 | return [NSString stringWithFormat:@"%@ %@ (%@)\n%@\n%@", [self methodStringDictionary][@(self.method)], self.path, self.params, request.allHTTPHeaderFields, request]; 124 | } 125 | 126 | -(NSString *)description 127 | { 128 | return [NSString stringWithFormat:@"%@, %@", [super description], [self descriptionWithRequest:_request]]; 129 | } 130 | 131 | - (NSDictionary*)methodStringDictionary 132 | { 133 | static NSDictionary* methods; 134 | static dispatch_once_t onceToken; 135 | dispatch_once(&onceToken, ^{ 136 | methods = @{@(LTAPIRequestMethodPUT): @"PUT", @(LTAPIRequestMethodGET): @"GET", 137 | @(LTAPIRequestMethodPOST): @"POST", @(LTAPIRequestMethodDELETE): @"DELETE"}; 138 | }); 139 | return methods; 140 | } 141 | 142 | #pragma mark - 143 | 144 | +(id)lt_sendAsynchronousRequest:(NSURLRequest *)request queue:(NSOperationQueue *)queue completionHandler:(void (^)(NSURLResponse *, NSData *, NSError *))handler 145 | { 146 | dispatch_async(dispatch_get_main_queue(), ^{ 147 | [queue addOperationWithBlock:^{ 148 | NSURLResponse* res = nil; 149 | NSError* error = nil; 150 | NSData* data = [NSURLConnection sendSynchronousRequest:request returningResponse:&res error:&error]; 151 | handler(res, data, error); 152 | }]; 153 | }); 154 | 155 | return nil; 156 | } 157 | 158 | +(NSOperationQueue *)imageRequestQueue 159 | { 160 | static NSOperationQueue* queue; 161 | static dispatch_once_t onceToken; 162 | dispatch_once(&onceToken, ^{ 163 | queue = [[NSOperationQueue alloc] init]; 164 | queue.maxConcurrentOperationCount = 3; 165 | }); 166 | return queue; 167 | } 168 | 169 | +(NSOperationQueue *)APIRequestQueue 170 | { 171 | static NSOperationQueue* queue; 172 | static dispatch_once_t onceToken; 173 | dispatch_once(&onceToken, ^{ 174 | queue = [[NSOperationQueue alloc] init]; 175 | queue.maxConcurrentOperationCount = 1; 176 | }); 177 | return queue; 178 | } 179 | 180 | +(void)beginNetworkConnection 181 | { 182 | dispatch_async(dispatch_get_main_queue(), ^{ 183 | networkCount++; 184 | if (networkCount > 0) { 185 | [UIApplication sharedApplication].networkActivityIndicatorVisible = YES; 186 | } 187 | }); 188 | } 189 | 190 | +(void)endNetworkConnection 191 | { 192 | dispatch_async(dispatch_get_main_queue(), ^{ 193 | if (networkCount > 0) { 194 | networkCount--; 195 | if (networkCount == 0) { 196 | [UIApplication sharedApplication].networkActivityIndicatorVisible = NO; 197 | } 198 | } 199 | }); 200 | } 201 | 202 | @end 203 | -------------------------------------------------------------------------------- /LTAPIRequest/LTAPIResponse.h: -------------------------------------------------------------------------------- 1 | // 2 | // LTAPIResponse.h 3 | // LTAPIRequest 4 | // 5 | // Created by Yusuke Ito on 2013/03/16. 6 | // Copyright (c) 2013 Yusuke Ito. All rights reserved. 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | #import 22 | 23 | @class LTAPIRequest; 24 | @interface LTAPIResponse : NSObject 25 | 26 | @property (nonatomic, readonly) BOOL success; // サブクラスでオーバーライド 27 | 28 | @property (nonatomic, readonly, copy) id json; 29 | 30 | 31 | // サブクラスから参照する (セット禁止) 32 | @property (nonatomic, readonly) NSHTTPURLResponse* HTTPResponse; 33 | @property (nonatomic) NSData* responseData; 34 | @property (nonatomic) NSURLResponse* response; 35 | @property (nonatomic) NSError* error; 36 | @property (nonatomic) NSError* parseError; 37 | @property (nonatomic) LTAPIRequest* request; 38 | 39 | // APIリクエストのエラー時に呼ばれる 40 | - (void)showErrorAlert; // リクエスト用の Queue で呼ばれるので注意 41 | 42 | // +-+-+-+-+-+-+ Private +-+-+-+-+-+-+ // 43 | - (NSError*)parseJSON __attribute__((objc_requires_super)); // if retuns nil, success, JSONパース後何かしたい場合はオーバーライドする, パース用の Queue で呼ばれるので注意 44 | 45 | @end 46 | -------------------------------------------------------------------------------- /LTAPIRequest/LTAPIResponse.m: -------------------------------------------------------------------------------- 1 | // 2 | // LTAPIResponse.m 3 | // LTAPIRequest 4 | // 5 | // Created by Yusuke Ito on 2013/03/16. 6 | // Copyright (c) 2013 Yusuke Ito. All rights reserved. 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | #import "LTAPIResponse.h" 22 | #import "LTAPIRequest.h" 23 | 24 | @implementation LTAPIResponse 25 | 26 | -(NSError *)parseJSON 27 | { 28 | if (!_responseData) { 29 | _json = nil; 30 | return [NSError errorWithDomain:NSStringFromClass([LTAPIRequest class]) code:1 userInfo:@{NSLocalizedFailureReasonErrorKey: @"response data is nil"}]; 31 | } 32 | NSError* error = nil; 33 | _json = (id)[NSJSONSerialization JSONObjectWithData:_responseData options:0 error:&error]; 34 | return error; 35 | } 36 | 37 | -(NSHTTPURLResponse *)HTTPResponse 38 | { 39 | if ([self.response isKindOfClass:[NSHTTPURLResponse class]]) { 40 | return (id)self.response; 41 | } 42 | return nil; 43 | } 44 | 45 | -(BOOL)success 46 | { 47 | [[NSException exceptionWithName:NSGenericException reason:@"success: should be implemented on subclass" userInfo:nil] raise]; 48 | return NO; 49 | } 50 | 51 | -(void)showErrorAlert 52 | { 53 | 54 | } 55 | 56 | -(NSString *)description 57 | { 58 | return [NSString stringWithFormat:@"%d -> %@ (Error: %@, %@)", self.HTTPResponse.statusCode, self.responseData ? [[NSString alloc] initWithData:self.responseData encoding:NSUTF8StringEncoding] : @"", self.error.localizedDescription, self.error.localizedFailureReason]; 59 | } 60 | 61 | @end 62 | -------------------------------------------------------------------------------- /LTAPIRequest/LTModel.h: -------------------------------------------------------------------------------- 1 | // 2 | // LTModel.h 3 | // LTAPIRequest 4 | // 5 | // Created by Yusuke Ito on 2013/03/16. 6 | // Copyright (c) 2013 Yusuke Ito. All rights reserved. 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | #import 22 | 23 | typedef void(^LTModelGeneralCallback)(BOOL success); 24 | typedef void(^LTModelCollectionCallback)(BOOL success, BOOL collectionChanged); 25 | 26 | @interface LTModel : NSObject 27 | 28 | - (instancetype)init; 29 | @property (nonatomic, copy, readonly) NSString* ID; 30 | 31 | // Model の属性を set/get する, 引数はすべて nil 以外, サブクラスのみから呼ぶ(ViewやViewControllerに対してはPrivate) 32 | - (id)attributeForKey:(NSString*)key; 33 | - (void)setAttribute:(id)attr forKey:(NSString*)key; 34 | - (void)replaceAttributesFromDictionary:(NSDictionary*)dict; 35 | - (void)mergeAttributesFromDictionary:(NSDictionary*)dict; 36 | - (void)removeAttributeForKey:(NSString*)key; 37 | - (void)removeAllAttributes; 38 | - (NSDictionary*)attributes; 39 | 40 | + (NSString*)IDKey; 41 | 42 | @end 43 | 44 | 45 | @interface LTModel(ModelStore) 46 | 47 | // 同じIDのModelは同じインスタンスを使用する場合, サブクラスでオーバーライド 48 | - (instancetype)initWithID:(NSString*)ID; 49 | // 同じIDのModelは同じインスタンスを返す(まだ無ければ生成) 50 | + (instancetype)modelWithID:(NSString*)ID; 51 | 52 | + (void)encodeModelStore:(NSCoder*)aCoder; 53 | + (void)decodeModelStore:(NSCoder*)aDecoder; 54 | 55 | + (void)clearAllModelStore; // remove all caches 56 | 57 | @end -------------------------------------------------------------------------------- /LTAPIRequest/LTModel.m: -------------------------------------------------------------------------------- 1 | // 2 | // LTModel.m 3 | // LTAPIRequest 4 | // 5 | // Created by Yusuke Ito on 2013/03/16. 6 | // Copyright (c) 2013 Yusuke Ito. All rights reserved. 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | #import "LTModel.h" 22 | 23 | static NSMutableDictionary* g_lt_allStore; 24 | static NSString* const LTModelClassName = @"LTModel"; 25 | 26 | 27 | @interface LTModel () 28 | { 29 | NSMutableDictionary* _data; 30 | } 31 | @end 32 | 33 | @implementation LTModel 34 | 35 | - (id)initWithCoder:(NSCoder *)aDecoder 36 | { 37 | self = [super init]; 38 | if (self) { 39 | _data = [NSMutableDictionary dictionaryWithDictionary:[aDecoder decodeObjectForKey:@"lt_data"]]; 40 | } 41 | return self; 42 | } 43 | 44 | -(void)encodeWithCoder:(NSCoder *)aCoder 45 | { 46 | [aCoder encodeObject:_data forKey:@"lt_data"]; 47 | } 48 | 49 | #pragma mark - 50 | 51 | - (instancetype)init 52 | { 53 | self = [super init]; 54 | if (self) { 55 | _data = [NSMutableDictionary dictionary]; 56 | } 57 | return self; 58 | } 59 | 60 | + (NSString*)IDKey 61 | { 62 | return @"id"; 63 | } 64 | 65 | -(NSString *)ID 66 | { 67 | return [[self attributeForKey:[[self class] IDKey]] copy]; 68 | } 69 | 70 | #pragma mark - Attributes management 71 | 72 | -(NSDictionary *)attributes 73 | { 74 | return [_data copy]; 75 | } 76 | 77 | - (void)removeAllAttributes 78 | { 79 | _data = [NSMutableDictionary dictionary]; 80 | } 81 | 82 | -(void)setAttribute:(id)attr forKey:(NSString *)key 83 | { 84 | [_data setObject:[attr copy] forKey:key]; 85 | } 86 | 87 | -(void)removeAttributeForKey:(NSString *)key 88 | { 89 | [_data removeObjectForKey:key]; 90 | } 91 | 92 | -(id)attributeForKey:(NSString *)key 93 | { 94 | return [_data objectForKey:key]; 95 | } 96 | 97 | -(void)replaceAttributesFromDictionary:(NSDictionary *)dict 98 | { 99 | if (!dict) { 100 | [[NSException exceptionWithName:NSInvalidArgumentException reason:@"replaceAttributesFromDictionary: given dictionary is nil" userInfo:nil] raise]; 101 | return; 102 | } 103 | _data = [dict mutableCopy]; 104 | } 105 | 106 | -(void)mergeAttributesFromDictionary:(NSDictionary *)dict 107 | { 108 | if (!dict) { 109 | [[NSException exceptionWithName:NSInvalidArgumentException reason:@"mergeAttributesFromDictionary: given dictionary is nil" userInfo:nil] raise]; 110 | return; 111 | } 112 | [dict enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { 113 | [_data setObject:obj forKey:[key copy]]; 114 | }]; 115 | } 116 | 117 | @end 118 | 119 | @implementation LTModel(ModelStore) 120 | 121 | -(instancetype)initWithID:(NSString *)ID 122 | { 123 | [[NSException exceptionWithName:NSGenericException reason:@"initWithID: should be overidden on subclass" userInfo:nil] raise]; 124 | return nil; 125 | } 126 | 127 | + (NSMutableDictionary*)modelStoreForModelClass:(Class)class 128 | { 129 | static dispatch_once_t onceToken; 130 | dispatch_once(&onceToken, ^{ 131 | g_lt_allStore = [NSMutableDictionary dictionary]; 132 | }); 133 | 134 | NSString* key = NSStringFromClass(class); 135 | NSMutableDictionary* store = [g_lt_allStore objectForKey:key]; 136 | if (!store) { 137 | store = [NSMutableDictionary dictionary]; 138 | [g_lt_allStore setObject:store forKey:key]; 139 | } 140 | return store; 141 | } 142 | 143 | +(instancetype)modelWithID:(NSString *)ID 144 | { 145 | if (!ID) { 146 | [[NSException exceptionWithName:NSInvalidArgumentException reason:@"modelWithID: given ID is nil" userInfo:nil] raise]; 147 | return nil; 148 | } 149 | 150 | NSMutableDictionary* store = [self modelStoreForModelClass:[self class]]; 151 | 152 | id model = [store objectForKey:ID]; 153 | if (!model) { 154 | model = [[self alloc] initWithID:ID]; 155 | [store setObject:model forKey:ID]; 156 | } 157 | return model; 158 | } 159 | 160 | + (void)encodeModelStore:(NSCoder*)aCoder 161 | { 162 | if ([NSStringFromClass(self) isEqualToString:LTModelClassName]) { 163 | [[NSException exceptionWithName:NSGenericException reason:@"should be encoded with LTModel's subclass(your model)" userInfo:nil] raise]; 164 | return; 165 | } 166 | NSString* key = [NSString stringWithFormat:@"lt_modelstore_for_%@", NSStringFromClass([self class])]; 167 | 168 | [aCoder encodeObject:[self modelStoreForModelClass:[self class]] forKey:key]; 169 | } 170 | 171 | + (void)decodeModelStore:(NSCoder*)aDecoder 172 | { 173 | if ([NSStringFromClass(self) isEqualToString:LTModelClassName]) { 174 | [[NSException exceptionWithName:NSGenericException reason:@"should be encoded with LTModel's subclass(your model)" userInfo:nil] raise]; 175 | return; 176 | } 177 | 178 | NSString* key = [NSString stringWithFormat:@"lt_modelstore_for_%@", NSStringFromClass([self class])]; 179 | 180 | NSDictionary* dict = [[aDecoder decodeObjectForKey:key] mutableCopy]; 181 | if (dict) { 182 | NSMutableDictionary* store = [self modelStoreForModelClass:[self class]]; 183 | [store removeAllObjects]; 184 | [store setDictionary:dict]; 185 | } 186 | } 187 | 188 | +(void)clearAllModelStore 189 | { 190 | [g_lt_allStore removeAllObjects]; 191 | } 192 | 193 | 194 | @end 195 | -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemo/AppDelegate.h: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.h 3 | // LTTwDemo 4 | // 5 | // Created by ito on 2013/03/16. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | @interface AppDelegate : UIResponder 12 | 13 | @property (strong, nonatomic) UIWindow *window; 14 | 15 | @end 16 | -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemo/AppDelegate.m: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.m 3 | // LTTwDemo 4 | // 5 | // Created by ito on 2013/03/16. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "AppDelegate.h" 10 | 11 | @implementation AppDelegate 12 | 13 | - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions 14 | { 15 | // Override point for customization after application launch. 16 | return YES; 17 | } 18 | 19 | - (void)applicationWillResignActive:(UIApplication *)application 20 | { 21 | // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. 22 | // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game. 23 | } 24 | 25 | - (void)applicationDidEnterBackground:(UIApplication *)application 26 | { 27 | // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. 28 | // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. 29 | } 30 | 31 | - (void)applicationWillEnterForeground:(UIApplication *)application 32 | { 33 | // Called as part of the transition from the background to the inactive state; here you can undo many of the changes made on entering the background. 34 | } 35 | 36 | - (void)applicationDidBecomeActive:(UIApplication *)application 37 | { 38 | // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. 39 | } 40 | 41 | - (void)applicationWillTerminate:(UIApplication *)application 42 | { 43 | // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. 44 | } 45 | 46 | @end 47 | -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemo/Default-568h@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/novi/LTAPIRequest/a396a26d2cb1b4851ded3f91e1def57a0aaab3ad/LTTwDemo/LTTwDemo/Default-568h@2x.png -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemo/Default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/novi/LTAPIRequest/a396a26d2cb1b4851ded3f91e1def57a0aaab3ad/LTTwDemo/LTTwDemo/Default.png -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemo/Default@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/novi/LTAPIRequest/a396a26d2cb1b4851ded3f91e1def57a0aaab3ad/LTTwDemo/LTTwDemo/Default@2x.png -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemo/FirstViewController.h: -------------------------------------------------------------------------------- 1 | // 2 | // FirstViewController.h 3 | // LTTwDemo 4 | // 5 | // Created by ito on 2013/03/16. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import 10 | #import 11 | #import 12 | 13 | 14 | @interface FirstViewController : UIViewController 15 | 16 | - (IBAction)login:(id)sender; 17 | 18 | @end 19 | -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemo/FirstViewController.m: -------------------------------------------------------------------------------- 1 | // 2 | // FirstViewController.m 3 | // LTTwDemo 4 | // 5 | // Created by ito on 2013/03/16. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "FirstViewController.h" 10 | #import "DEAPIRequest.h" 11 | #import "DEUser.h" 12 | #import "TimelineViewController.h" 13 | 14 | @interface FirstViewController () 15 | 16 | @end 17 | 18 | @implementation FirstViewController 19 | 20 | - (void)viewDidLoad 21 | { 22 | [super viewDidLoad]; 23 | } 24 | 25 | - (IBAction)login:(UIButton*)sender 26 | { 27 | // ボタン連打防止 28 | sender.enabled = NO; 29 | ACAccountStore* store = [DEAPIRequest accountStore]; 30 | [store requestAccessToAccountsWithType:[store accountTypeWithAccountTypeIdentifier:ACAccountTypeIdentifierTwitter] options:nil completion:^(BOOL granted, NSError *error) { 31 | // completion は Main Queue で実行されない場合があるので注意 32 | if (granted && !error) { 33 | // success 34 | dispatch_async(dispatch_get_main_queue(), ^{ 35 | // 2つ以上のアカウントがある場合どちらかが適当に選択される 36 | ACAccount* account = [[store accounts] lastObject]; 37 | if (!account) { 38 | sender.enabled = YES; 39 | return (void)[[[UIAlertView alloc] initWithTitle:@"no twitter account." message:nil delegate:nil cancelButtonTitle:@"OK" otherButtonTitles:nil, nil] show]; 40 | } 41 | NSLog(@"%@", account); 42 | DEUser* me = [DEUser me]; 43 | me.account = account; 44 | 45 | [me refreshUserInfoWithCallback:^(BOOL success) { 46 | if (success) { 47 | [self performSegueWithIdentifier:@"timeline" sender:me.homeTimeline]; 48 | } 49 | sender.enabled = YES; 50 | return; 51 | }]; 52 | }); 53 | 54 | } else { 55 | dispatch_async(dispatch_get_main_queue(), ^{ 56 | sender.enabled = YES; 57 | return; 58 | }); 59 | } 60 | }]; 61 | } 62 | 63 | -(void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender 64 | { 65 | if ([segue.identifier isEqualToString:@"timeline"]) { 66 | TimelineViewController* vc = (id)segue.destinationViewController; 67 | vc.timeline = sender; 68 | } 69 | } 70 | 71 | @end 72 | -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemo/LTTwDemo-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleDisplayName 8 | ${PRODUCT_NAME} 9 | CFBundleExecutable 10 | ${EXECUTABLE_NAME} 11 | CFBundleIdentifier 12 | jp.novi.${PRODUCT_NAME:rfc1034identifier} 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | ${PRODUCT_NAME} 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleSignature 22 | ???? 23 | CFBundleVersion 24 | 1.0 25 | LSRequiresIPhoneOS 26 | 27 | UIMainStoryboardFile 28 | MainStoryboard 29 | UIRequiredDeviceCapabilities 30 | 31 | armv7 32 | 33 | UIStatusBarTintParameters 34 | 35 | UINavigationBar 36 | 37 | Style 38 | UIBarStyleDefault 39 | Translucent 40 | 41 | 42 | 43 | UISupportedInterfaceOrientations 44 | 45 | UIInterfaceOrientationPortrait 46 | UIInterfaceOrientationLandscapeLeft 47 | UIInterfaceOrientationLandscapeRight 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemo/LTTwDemo-Prefix.pch: -------------------------------------------------------------------------------- 1 | // 2 | // Prefix header for all source files of the 'LTTwDemo' target in the 'LTTwDemo' project 3 | // 4 | 5 | #import 6 | 7 | #ifndef __IPHONE_5_0 8 | #warning "This project uses features only available in iOS SDK 5.0 and later." 9 | #endif 10 | 11 | #ifdef __OBJC__ 12 | #import 13 | #import 14 | #endif 15 | -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemo/SecondViewController.h: -------------------------------------------------------------------------------- 1 | // 2 | // SecondViewController.h 3 | // LTTwDemo 4 | // 5 | // Created by ito on 2013/03/16. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | @interface SecondViewController : UIViewController 12 | 13 | - (IBAction)searchFieldEditEnded:(UITextField *)sender; 14 | @property (weak, nonatomic) IBOutlet UITextField *searchField; 15 | - (IBAction)viewTapped:(id)sender; 16 | 17 | @end 18 | -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemo/SecondViewController.m: -------------------------------------------------------------------------------- 1 | // 2 | // SecondViewController.m 3 | // LTTwDemo 4 | // 5 | // Created by ito on 2013/03/16. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "SecondViewController.h" 10 | #import "DETimeline.h" 11 | #import "TimelineViewController.h" 12 | 13 | @interface SecondViewController () 14 | 15 | @end 16 | 17 | @implementation SecondViewController 18 | 19 | - (void)viewDidLoad 20 | { 21 | [super viewDidLoad]; 22 | } 23 | 24 | -(void)viewWillAppear:(BOOL)animated 25 | { 26 | [super viewWillAppear:animated]; 27 | [self.searchField becomeFirstResponder]; 28 | } 29 | 30 | - (IBAction)searchFieldEditEnded:(UITextField *)sender 31 | { 32 | [sender resignFirstResponder]; 33 | DETimeline* timeline = [[DETimeline alloc] initSearchTimelineWithQuery:sender.text]; 34 | [self performSegueWithIdentifier:@"timeline" sender:timeline]; 35 | } 36 | 37 | -(void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender 38 | { 39 | if ([segue.identifier isEqualToString:@"timeline"]) { 40 | TimelineViewController* vc = (id)segue.destinationViewController; 41 | vc.timeline = sender; 42 | } 43 | } 44 | 45 | - (IBAction)viewTapped:(id)sender 46 | { 47 | [self.searchField resignFirstResponder]; 48 | } 49 | @end 50 | -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemo/TimelineViewController.h: -------------------------------------------------------------------------------- 1 | // 2 | // TimelineViewController.h 3 | // LTTwDemo 4 | // 5 | // Created by ito on 2013/03/16. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | 12 | @class DETimeline; 13 | @interface TimelineViewController : UITableViewController 14 | 15 | @property (nonatomic) DETimeline* timeline; 16 | 17 | @end 18 | -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemo/TimelineViewController.m: -------------------------------------------------------------------------------- 1 | // 2 | // TimelineViewController.m 3 | // LTTwDemo 4 | // 5 | // Created by ito on 2013/03/16. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "TimelineViewController.h" 10 | #import "DETimeline.h" 11 | #import "DETweet.h" 12 | #import "DEAPIRequest.h" 13 | #import "DEUser.h" 14 | #import 15 | 16 | @interface TimelineViewController () 17 | { 18 | BOOL _loadingMore; 19 | } 20 | @end 21 | 22 | @implementation TimelineViewController 23 | 24 | -(void)setTimeline:(DETimeline *)timeline 25 | { 26 | _timeline = timeline; 27 | [self.tableView reloadData]; 28 | [self updateViews]; 29 | } 30 | 31 | - (void)updateViews 32 | { 33 | self.navigationItem.title = _timeline.localizedTitle; 34 | } 35 | 36 | - (void)viewDidLoad 37 | { 38 | [super viewDidLoad]; 39 | 40 | UIRefreshControl *refreshControl = [[UIRefreshControl alloc] init]; 41 | [refreshControl addTarget:self action:@selector(refresh:) forControlEvents:UIControlEventValueChanged]; 42 | self.refreshControl = refreshControl; 43 | 44 | [self updateViews]; 45 | } 46 | 47 | - (NSArray*)indexPathsFromRowIndexSet:(NSIndexSet*)indexset 48 | { 49 | NSMutableArray* indexPaths = [NSMutableArray arrayWithCapacity:indexset.count]; 50 | [indexset enumerateIndexesUsingBlock:^(NSUInteger idx, BOOL *stop) { 51 | [indexPaths addObject:[NSIndexPath indexPathForRow:idx inSection:0]]; 52 | }]; 53 | return indexPaths; 54 | } 55 | 56 | - (void)refresh:(UIRefreshControl*)sender 57 | { 58 | [sender beginRefreshing]; 59 | [_timeline refreshWithCallback:^(BOOL success, NSIndexSet *insertedIndexSet) { 60 | [sender endRefreshing]; 61 | if (success) { 62 | [self.tableView insertRowsAtIndexPaths:[self indexPathsFromRowIndexSet:insertedIndexSet] withRowAnimation:UITableViewRowAnimationNone]; 63 | } 64 | }]; 65 | } 66 | 67 | -(void)viewWillAppear:(BOOL)animated 68 | { 69 | [super viewWillAppear:animated]; 70 | 71 | if (!_timeline.tweets.count && self.refreshControl) { 72 | [self refresh:nil]; 73 | } 74 | } 75 | 76 | #pragma mark - Table view data source 77 | 78 | - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView 79 | { 80 | return 2; 81 | } 82 | 83 | - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section 84 | { 85 | // section == 0 がツイートのリスト 86 | // section == 1 がLoadMoreボタン 87 | if (section == 0) { 88 | return _timeline.tweets.count; 89 | } 90 | return 1; 91 | } 92 | 93 | - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath 94 | { 95 | static NSString *CellIdentifier = @"Cell"; 96 | UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier forIndexPath:indexPath]; 97 | 98 | if (indexPath.section == 0) { 99 | DETweet* tweet = [_timeline.tweets objectAtIndex:indexPath.row]; 100 | cell.textLabel.text = tweet.text; 101 | cell.detailTextLabel.text = [NSString stringWithFormat:@"%@, %@/%@", tweet.ID, tweet.byUser.screenName, tweet.byUser.name]; 102 | objc_setAssociatedObject(cell, "lttw_image_url", tweet.byUser.profileImageURL, OBJC_ASSOCIATION_COPY_NONATOMIC); 103 | [DEAPIRequest downloadUserImageWithURL:tweet.byUser.profileImageURL callback:^(UIImage *image, NSString *imageURL) { 104 | NSString* cellURL = objc_getAssociatedObject(cell, "lttw_image_url"); 105 | if ([cellURL isEqualToString:imageURL]) { 106 | cell.imageView.image = image; 107 | } else { 108 | NSLog(@"%@\n%@", cellURL, imageURL); 109 | } 110 | }]; 111 | } else { 112 | cell.textLabel.text = @"Load more..."; 113 | cell.detailTextLabel.text = _loadingMore ? @"loading...." : nil; 114 | cell.imageView.image = nil; 115 | } 116 | 117 | return cell; 118 | } 119 | 120 | 121 | #pragma mark - Table view delegate 122 | 123 | - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath 124 | { 125 | if (indexPath.section == 0) { 126 | DETweet* tweet = [_timeline.tweets objectAtIndex:indexPath.row]; 127 | DETimeline* timeline = tweet.byUser.usersTimeline; 128 | //Timeline* home = tweet.byUser.homeTimeline; 129 | [[self.navigationController.viewControllers objectAtIndex:0] performSegueWithIdentifier:@"timeline" sender:timeline]; 130 | } else { 131 | // 2重送信しないためにフラグを立てる 132 | if (!_loadingMore) { 133 | _loadingMore = YES; 134 | [self.tableView reloadSections:[NSIndexSet indexSetWithIndex:indexPath.section] withRowAnimation:UITableViewRowAnimationNone]; 135 | [_timeline loadMoreWithCallback:^(BOOL success, NSIndexSet *insertedIndexSet) { 136 | // リクエストが終わったらフラグを戻す (成功失敗に関わらず) 137 | _loadingMore = NO; 138 | if (success) { 139 | [self.tableView insertRowsAtIndexPaths:[self indexPathsFromRowIndexSet:insertedIndexSet] withRowAnimation:UITableViewRowAnimationNone]; 140 | } else { 141 | [self.tableView reloadSections:[NSIndexSet indexSetWithIndex:indexPath.section] withRowAnimation:UITableViewRowAnimationNone]; 142 | } 143 | }]; 144 | } 145 | } 146 | } 147 | 148 | @end 149 | -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemo/en.lproj/InfoPlist.strings: -------------------------------------------------------------------------------- 1 | /* Localized versions of Info.plist keys */ 2 | 3 | -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemo/en.lproj/MainStoryboard.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 69 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemo/first.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/novi/LTAPIRequest/a396a26d2cb1b4851ded3f91e1def57a0aaab3ad/LTTwDemo/LTTwDemo/first.png -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemo/first@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/novi/LTAPIRequest/a396a26d2cb1b4851ded3f91e1def57a0aaab3ad/LTTwDemo/LTTwDemo/first@2x.png -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemo/main.m: -------------------------------------------------------------------------------- 1 | // 2 | // main.m 3 | // LTTwDemo 4 | // 5 | // Created by ito on 2013/03/16. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | #import "AppDelegate.h" 12 | 13 | int main(int argc, char *argv[]) 14 | { 15 | @autoreleasepool { 16 | return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemo/second.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/novi/LTAPIRequest/a396a26d2cb1b4851ded3f91e1def57a0aaab3ad/LTTwDemo/LTTwDemo/second.png -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemo/second@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/novi/LTAPIRequest/a396a26d2cb1b4851ded3f91e1def57a0aaab3ad/LTTwDemo/LTTwDemo/second@2x.png -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemoTests/LTTwDemoTests-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | ${EXECUTABLE_NAME} 9 | CFBundleIdentifier 10 | jp.novi.${PRODUCT_NAME:rfc1034identifier} 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundlePackageType 14 | BNDL 15 | CFBundleShortVersionString 16 | 1.0 17 | CFBundleSignature 18 | ???? 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemoTests/LTTwDemoTests.h: -------------------------------------------------------------------------------- 1 | // 2 | // LTTwDemoTests.h 3 | // LTTwDemoTests 4 | // 5 | // Created by ito on 2013/03/16. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | @interface LTTwDemoTests : SenTestCase 12 | 13 | @end 14 | -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemoTests/LTTwDemoTests.m: -------------------------------------------------------------------------------- 1 | // 2 | // LTTwDemoTests.m 3 | // LTTwDemoTests 4 | // 5 | // Created by ito on 2013/03/16. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "LTTwDemoTests.h" 10 | 11 | @implementation LTTwDemoTests 12 | 13 | - (void)setUp 14 | { 15 | [super setUp]; 16 | 17 | // Set-up code here. 18 | } 19 | 20 | - (void)tearDown 21 | { 22 | // Tear-down code here. 23 | 24 | [super tearDown]; 25 | } 26 | 27 | - (void)testExample 28 | { 29 | STFail(@"Unit tests are not implemented yet in LTTwDemoTests"); 30 | } 31 | 32 | @end 33 | -------------------------------------------------------------------------------- /LTTwDemo/LTTwDemoTests/en.lproj/InfoPlist.strings: -------------------------------------------------------------------------------- 1 | /* Localized versions of Info.plist keys */ 2 | 3 | -------------------------------------------------------------------------------- /LTTwDemo/Models/DEAPIRequest.h: -------------------------------------------------------------------------------- 1 | // 2 | // Request.h 3 | // LTTwDemo 4 | // 5 | // Created by ito on 2013/03/16. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "LTAPIRequest.h" 10 | #import 11 | #import 12 | 13 | @class DEAPIResponse, DEUser; 14 | 15 | typedef void(^RequestCallback)(DEAPIResponse* response); 16 | typedef void(^RequestUserImageCallback)(UIImage* image, NSString* imageURL); 17 | 18 | @interface DEAPIRequest : LTAPIRequest 19 | 20 | // Callbackの型は `RequestCallback` なのでオーバーライド 21 | - (void)sendRequestWithCallback:(RequestCallback)callback; 22 | 23 | // 共通で使用する AccountStore 24 | + (ACAccountStore*)accountStore; 25 | 26 | // アイコンダウンロードのためのユーティリティメソッド 27 | + (void)downloadUserImageWithURL:(NSString*)url callback:(RequestUserImageCallback)callback; 28 | 29 | 30 | @end 31 | -------------------------------------------------------------------------------- /LTTwDemo/Models/DEAPIRequest.m: -------------------------------------------------------------------------------- 1 | // 2 | // Request.m 3 | // LTTwDemo 4 | // 5 | // Created by ito on 2013/03/16. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "DEAPIRequest.h" 10 | #import "DEUser.h" 11 | #import "DEAPIResponse.h" 12 | 13 | @interface DEAPIRequest () 14 | { 15 | 16 | } 17 | @end 18 | 19 | @implementation DEAPIRequest 20 | 21 | // ----- オーバーライド 22 | 23 | -(void)sendRequestWithCallback:(RequestCallback)callback 24 | { 25 | [super sendRequestWithCallback:(id)callback]; 26 | } 27 | 28 | -(NSMutableURLRequest *)prepareRequest 29 | { 30 | // 送信するリクエストを作成 31 | // Twitter は iOS 標準の SLRequest で NSURLRequest を生成できる 32 | 33 | SLRequestMethod method = SLRequestMethodGET; 34 | if (self.method == LTAPIRequestMethodGET) { 35 | method = SLRequestMethodGET; 36 | } else if (self.method == LTAPIRequestMethodPOST) { 37 | NSLog(@"not supported yet"); 38 | } 39 | 40 | SLRequest* req = [SLRequest requestForServiceType:SLServiceTypeTwitter requestMethod:method URL:[NSURL URLWithString:[NSString stringWithFormat:@"https://api.twitter.com/1.1%@.json", self.path]] parameters:self.params]; 41 | req.account = [DEUser me].account; 42 | 43 | NSMutableURLRequest* reqs = [[req preparedURLRequest] mutableCopy]; 44 | [reqs setTimeoutInterval:20]; 45 | return reqs; 46 | } 47 | 48 | +(Class)APIResponseClass 49 | { 50 | // API のレスポンスに使用するクラスを返す 51 | // アプリで作成した `APIResponse` を使用 52 | return [DEAPIResponse class]; 53 | } 54 | 55 | #pragma mark - 56 | 57 | -(void)dealloc 58 | { 59 | NSLog(@"dealloc %p, %@", self, NSStringFromClass([self class])); 60 | } 61 | 62 | +(ACAccountStore *)accountStore 63 | { 64 | static ACAccountStore* store; 65 | static dispatch_once_t onceToken; 66 | dispatch_once(&onceToken, ^{ 67 | store = [[ACAccountStore alloc] init]; 68 | }); 69 | return store; 70 | } 71 | 72 | 73 | +(void)downloadUserImageWithURL:(NSString*)url callback:(RequestUserImageCallback)callback 74 | { 75 | url = [url copy]; 76 | NSURLRequest* req = [NSURLRequest requestWithURL:[NSURL URLWithString:url]]; 77 | 78 | [self lt_sendAsynchronousRequest:req queue:[self imageRequestQueue] completionHandler:^(NSURLResponse * res, NSData * data, NSError * error) { 79 | if (!data) { 80 | dispatch_async(dispatch_get_main_queue(), ^{ 81 | callback(nil, url); // failed 82 | }); 83 | return; 84 | } 85 | UIImage* image = [UIImage imageWithData:data]; 86 | if (!image) { 87 | dispatch_async(dispatch_get_main_queue(), ^{ 88 | callback(nil, url); // failed 89 | }); 90 | return; 91 | } 92 | dispatch_async(dispatch_get_main_queue(), ^{ 93 | callback(image, url); 94 | }); 95 | return; 96 | }]; 97 | } 98 | 99 | 100 | @end 101 | -------------------------------------------------------------------------------- /LTTwDemo/Models/DEAPIResponse.h: -------------------------------------------------------------------------------- 1 | // 2 | // APIResponse.h 3 | // LTTwDemo 4 | // 5 | // Created by ito on 2013/03/16. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "LTAPIResponse.h" 10 | 11 | @interface DEAPIResponse : LTAPIResponse 12 | 13 | @property (nonatomic, readonly, copy) NSArray* statuses; 14 | 15 | @end 16 | -------------------------------------------------------------------------------- /LTTwDemo/Models/DEAPIResponse.m: -------------------------------------------------------------------------------- 1 | // 2 | // APIResponse.m 3 | // LTTwDemo 4 | // 5 | // Created by ito on 2013/03/16. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "DEAPIResponse.h" 10 | #import "DEAPIRequest.h" 11 | 12 | @implementation DEAPIResponse 13 | 14 | // 15 | #pragma mark - 16 | // オーバーライド 17 | 18 | //パース後処理, 任意, コメントを外す 19 | -(NSError *)parseJSON 20 | { 21 | NSError* parseError = [super parseJSON]; 22 | if (!parseError) { 23 | dispatch_async(dispatch_get_main_queue(), ^{ 24 | NSLog(@"json parsed"); 25 | }); 26 | } 27 | return parseError; 28 | } 29 | 30 | 31 | -(BOOL)success 32 | { 33 | // 成功時か失敗かの判定をここに記述する 34 | if (self.error || !self.responseData) { 35 | return NO; 36 | } 37 | if (self.HTTPResponse.statusCode >= 200 && self.HTTPResponse.statusCode < 300) { 38 | return YES; 39 | } 40 | return NO; 41 | } 42 | 43 | - (void)showErrorAlert 44 | { 45 | // エラー時にアラートを出す 46 | // Main Queue 以外から呼ばれる可能性があるので Main Queue で実行する 47 | dispatch_async(dispatch_get_main_queue(), ^{ 48 | if (self.error) { 49 | UIAlertView* alert = [[UIAlertView alloc] initWithTitle:self.error.localizedDescription message:self.error.localizedFailureReason delegate:nil cancelButtonTitle:@"OK" otherButtonTitles:nil, nil]; 50 | [alert show]; 51 | } else { 52 | UIAlertView* alert = [[UIAlertView alloc] initWithTitle:[NSString stringWithFormat:@"%d", self.HTTPResponse.statusCode] message:[[NSString alloc] initWithData:self.responseData encoding:NSUTF8StringEncoding] delegate:nil cancelButtonTitle:@"OK" otherButtonTitles:nil, nil]; 53 | [alert show]; 54 | } 55 | }); 56 | } 57 | 58 | #pragma mark - 59 | 60 | -(NSArray *)statuses 61 | { 62 | // API によってツイート一覧(statuses)のキーが違うので差違をここで吸収する 63 | 64 | if ([self.request.path hasPrefix:@"/search"]) { 65 | return [self.json objectForKey:@"statuses"]; 66 | } 67 | return self.json; 68 | } 69 | 70 | -(void)dealloc 71 | { 72 | NSLog(@"dealloc response %p - %@, request: %p - %@", self, NSStringFromClass([self class]), self.request, NSStringFromClass([self.request class])); 73 | } 74 | 75 | @end 76 | -------------------------------------------------------------------------------- /LTTwDemo/Models/DETimeline.h: -------------------------------------------------------------------------------- 1 | // 2 | // Timeline.h 3 | // LTTwDemo 4 | // 5 | // Created by ito on 2013/03/16. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "LTModel.h" 10 | 11 | typedef NS_ENUM(NSUInteger, TimelineType) { 12 | TimelineTypeInvalid, 13 | TimelineTypeHome, 14 | TimelineTypeUsers, 15 | TimelineTypeSearch, 16 | }; 17 | 18 | typedef void(^TimelineRefreshCallback)(BOOL success, NSIndexSet* insertedIndexSet); 19 | 20 | @class DEUser; 21 | @interface DETimeline : LTModel 22 | 23 | @property (nonatomic, readonly) TimelineType type; 24 | @property (nonatomic, readonly, weak) DEUser* user; // この Timeline の User (親), 循環参照を避けるため weak 25 | @property (nonatomic, readonly, copy) NSString* localizedTitle; // NavigationBar に表示するタイトルなど 26 | @property (nonatomic, readonly, copy) NSArray* tweets; // Tweet 一覧 27 | 28 | @property (nonatomic, readonly, copy) NSString* query; // 検索語 (self.type == TimelineTypeSearch 時) 29 | 30 | - (instancetype)initSearchTimelineWithQuery:(NSString*)query; 31 | 32 | // タイムラインを更新 33 | - (void)refreshWithCallback:(TimelineRefreshCallback)callback; 34 | 35 | // 続きを取得 (間は未実装) 36 | - (void)loadMoreWithCallback:(TimelineRefreshCallback)callback; 37 | 38 | // +-+-+-+-+-+-+ Private +-+-+-+-+-+-+ // 39 | // ViewController や View から見てプライベートなメソッド 40 | - (instancetype)initWithType:(TimelineType)type user:(DEUser*)user; 41 | - (void)setSearchQuery:(NSString*)query; 42 | 43 | @end 44 | -------------------------------------------------------------------------------- /LTTwDemo/Models/DETimeline.m: -------------------------------------------------------------------------------- 1 | // 2 | // Timeline.m 3 | // LTTwDemo 4 | // 5 | // Created by ito on 2013/03/16. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "DETimeline.h" 10 | #import "DETweet.h" 11 | #import "DEUser.h" 12 | #import "DEAPIRequest.h" 13 | #import "DEAPIResponse.h" 14 | 15 | @interface DETimeline () 16 | { 17 | NSMutableArray* _tweets; 18 | __weak DEUser* _user; 19 | } 20 | @end 21 | 22 | @implementation DETimeline 23 | 24 | // 標準のイニシャライザは無効に 25 | // 外部からインスタンス化できない 26 | -(id)init 27 | { 28 | [self doesNotRecognizeSelector:_cmd]; 29 | return nil; 30 | } 31 | 32 | - (id)initWithCoder:(NSCoder *)aDecoder 33 | { 34 | self = [super initWithCoder:aDecoder]; 35 | if (self) { 36 | _tweets = [aDecoder decodeObjectForKey:@"tweets"]; 37 | _user = [aDecoder decodeObjectForKey:@"user"]; 38 | _type = [aDecoder decodeIntegerForKey:@"type"]; 39 | NSLog(@"decode %@, %@", self, _user); 40 | } 41 | return self; 42 | } 43 | 44 | -(void)encodeWithCoder:(NSCoder *)aCoder 45 | { 46 | [super encodeWithCoder:aCoder]; 47 | 48 | [aCoder encodeObject:_tweets forKey:@"tweets"]; 49 | [aCoder encodeConditionalObject:_user forKey:@"user"]; 50 | [aCoder encodeInteger:_type forKey:@"type"]; 51 | } 52 | 53 | 54 | - (instancetype)initWithType:(TimelineType)type user:(DEUser *)user 55 | { 56 | self = [super init]; 57 | if (self) { 58 | _type = type; 59 | _user = user; 60 | _tweets = [NSMutableArray array]; 61 | } 62 | return self; 63 | } 64 | 65 | -(void)setSearchQuery:(NSString *)query 66 | { 67 | _query = query; 68 | } 69 | 70 | -(instancetype)initSearchTimelineWithQuery:(NSString *)query 71 | { 72 | typeof(self) obj = [self initWithType:TimelineTypeSearch user:[DEUser me]]; 73 | [obj setSearchQuery:query]; 74 | return obj; 75 | } 76 | 77 | 78 | -(void)refreshWithCallback:(TimelineRefreshCallback)callback 79 | { 80 | NSString* path; 81 | NSMutableDictionary* params = [(_tweets.count ? @{@"since_id": [[_tweets objectAtIndex:0] ID]} : @{}) mutableCopy]; 82 | if (self.type == TimelineTypeHome) { 83 | path = @"/statuses/home_timeline"; 84 | } else if (self.type == TimelineTypeUsers) { 85 | path = @"/statuses/user_timeline"; 86 | params[@"user_id"] = self.user.ID; 87 | } else if (self.type == TimelineTypeSearch) { 88 | path = @"/search/tweets"; 89 | params[@"q"] = self.query; 90 | } else { 91 | [[NSException exceptionWithName:NSInvalidArgumentException reason:@"invalid timeline type" userInfo:nil] raise]; 92 | } 93 | 94 | DEAPIRequest* req = [[DEAPIRequest alloc] initWithAPI:path method:LTAPIRequestMethodGET params:params]; 95 | [req sendRequestWithCallback:^(DEAPIResponse *response) { 96 | if (!response.success) { 97 | callback(NO, nil); 98 | return; 99 | } 100 | for (NSDictionary* dict in [response.statuses reverseObjectEnumerator]) { 101 | DETweet* tweet = [[DETweet alloc] initWithData:dict timeline:self]; 102 | [_tweets insertObject:tweet atIndex:0]; 103 | } 104 | callback(YES, [NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, [response.statuses count])]); 105 | dispatch_async(dispatch_get_main_queue(), ^{ 106 | [req params]; 107 | NSLog(@"request will be deallocated"); 108 | }); 109 | }]; 110 | 111 | } 112 | 113 | -(void)loadMoreWithCallback:(TimelineRefreshCallback)callback 114 | { 115 | NSString* path; 116 | NSMutableDictionary* params = [(_tweets.count ? @{@"max_id": [[_tweets lastObject] ID]} : @{}) mutableCopy]; // 正しくは max_id は tweets最後のTweet id + 1 117 | if (self.type == TimelineTypeHome) { 118 | path = @"/statuses/home_timeline"; 119 | } else if (self.type == TimelineTypeUsers) { 120 | path = @"/statuses/user_timeline"; 121 | params[@"user_id"] = self.user.ID; 122 | } else if (self.type == TimelineTypeSearch) { 123 | path = @"/search/tweets"; 124 | params[@"q"] = self.query; 125 | } else { 126 | [[NSException exceptionWithName:NSInvalidArgumentException reason:@"invalid timeline type" userInfo:nil] raise]; 127 | } 128 | 129 | DEAPIRequest* req = [[DEAPIRequest alloc] initWithAPI:path method:LTAPIRequestMethodGET params:params]; 130 | [req sendRequestWithCallback:^(DEAPIResponse *response) { 131 | if (!response.success) { 132 | callback(NO, nil); 133 | return; 134 | } 135 | NSUInteger oldCount = _tweets.count; 136 | for (NSDictionary* dict in response.statuses) { 137 | DETweet* tweet = [[DETweet alloc] initWithData:dict timeline:self]; 138 | [_tweets addObject:tweet]; 139 | } 140 | callback(YES, [NSIndexSet indexSetWithIndexesInRange:NSMakeRange(oldCount, [response.statuses count])]); 141 | }]; 142 | } 143 | 144 | -(NSString *)description 145 | { 146 | return [NSString stringWithFormat:@"%@, type:%d", [super description], self.type]; 147 | } 148 | 149 | 150 | #pragma mark - Attributes 151 | 152 | 153 | -(NSArray *)tweets 154 | { 155 | return _tweets; // 本当はcopyのほうが良いが、パフォーマンスの都合で... 156 | } 157 | 158 | -(NSString *)localizedTitle 159 | { 160 | if (self.type == TimelineTypeHome) { 161 | return [NSString stringWithFormat:@"Home Timeline (%@)", self.user.name]; 162 | } else if (self.type == TimelineTypeUsers) { 163 | return [NSString stringWithFormat:@"%@'s Timeline", self.user.name]; 164 | } else if (self.type == TimelineTypeSearch) { 165 | return [NSString stringWithFormat:@"Search %@", self.query]; 166 | } 167 | return nil; 168 | } 169 | 170 | @end 171 | -------------------------------------------------------------------------------- /LTTwDemo/Models/DETweet.h: -------------------------------------------------------------------------------- 1 | // 2 | // Tweet.h 3 | // LTTwDemo 4 | // 5 | // Created by ito on 2013/03/16. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "LTModel.h" 10 | 11 | @class DETimeline, DEUser; 12 | @interface DETweet : LTModel 13 | 14 | @property (nonatomic, readonly, weak) DETimeline* timeline; // このツイートが含まれる Timeline オブジェクト (親), 循環参照を避けるため weak 15 | 16 | // Attributes 17 | @property (nonatomic, readonly, copy) NSString* text; // ツイート 18 | @property (nonatomic, readonly, weak) DEUser* byUser; // ツイートしたユーザー, 循環参照を避けるため weak 19 | //@property (nonatomic, readonly, weak) User* originalUser; 20 | 21 | // +-+-+-+-+-+-+ Private +-+-+-+-+-+-+ // 22 | // ViewController や View から見てプライベートなメソッド 23 | - (id)initWithData:(NSDictionary*)dict timeline:(DETimeline*)timeline; // timeline: このツイートが含まれる Timeline オブジェクト (親) 24 | 25 | @end 26 | -------------------------------------------------------------------------------- /LTTwDemo/Models/DETweet.m: -------------------------------------------------------------------------------- 1 | // 2 | // Tweet.m 3 | // LTTwDemo 4 | // 5 | // Created by ito on 2013/03/16. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "DETweet.h" 10 | #import "DEUser.h" 11 | 12 | @interface DETweet () 13 | { 14 | __weak DETimeline* _timeline; 15 | __weak DEUser* _byUser; 16 | } 17 | @end 18 | 19 | @implementation DETweet 20 | 21 | // 標準のイニシャライザは無効に 22 | // 外部からインスタンス化できない 23 | -(id)init 24 | { 25 | [self doesNotRecognizeSelector:_cmd]; 26 | return nil; 27 | } 28 | 29 | - (id)initWithCoder:(NSCoder *)aDecoder 30 | { 31 | self = [super initWithCoder:aDecoder]; 32 | if (self) { 33 | _timeline = [aDecoder decodeObjectForKey:@"timeline"]; 34 | _byUser = [aDecoder decodeObjectForKey:@"byUser"]; 35 | NSLog(@"decode %@, %@", self, _timeline); 36 | } 37 | return self; 38 | } 39 | 40 | -(void)encodeWithCoder:(NSCoder *)aCoder 41 | { 42 | [super encodeWithCoder:aCoder]; 43 | 44 | [aCoder encodeConditionalObject:_timeline forKey:@"timeline"]; 45 | if (_byUser) { 46 | [aCoder encodeConditionalObject:_byUser forKey:@"byUser"]; 47 | } 48 | } 49 | 50 | -(id)initWithData:(NSDictionary *)dict timeline:(DETimeline *)timeline 51 | { 52 | self = [super init]; 53 | if (self) { 54 | _timeline = timeline; 55 | [self replaceAttributesFromDictionary:dict]; 56 | } 57 | return self; 58 | } 59 | 60 | -(NSString *)ID 61 | { 62 | return [self attributeForKey:@"id_str"]; 63 | } 64 | 65 | #pragma mark - Attributes 66 | 67 | -(NSString *)text 68 | { 69 | return [[self attributeForKey:@"text"] copy]; 70 | } 71 | 72 | -(DEUser *)byUser 73 | { 74 | if (!_byUser) { 75 | _byUser = [DEUser userWithUserID:[[self attributeForKey:@"user"] objectForKey:@"id_str"]]; 76 | [_byUser mergeAttributesFromDictionary:[self attributeForKey:@"user"]]; 77 | } 78 | return _byUser; 79 | } 80 | 81 | @end 82 | -------------------------------------------------------------------------------- /LTTwDemo/Models/DEUser.h: -------------------------------------------------------------------------------- 1 | // 2 | // User.h 3 | // LTTwDemo 4 | // 5 | // Created by ito on 2013/03/16. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "LTModel.h" 10 | 11 | @class DETimeline, ACAccount; 12 | // 接頭語をとりあえず DE(Demo) に 13 | @interface DEUser : LTModel 14 | 15 | // ユーザーの持つ Timeline 16 | @property (nonatomic, readonly) DETimeline* homeTimeline; // 自分のみ有効 17 | @property (nonatomic, readonly) DETimeline* usersTimeline; 18 | //@property (nonatomic, readonly) Timeline* mentionTimeline; 19 | 20 | // ユーザーの情報 21 | // View などから参照する 22 | // 必要があれば追加していく 23 | @property (nonatomic, readonly, copy) NSString* screenName; 24 | @property (nonatomic, readonly, copy) NSString* name; 25 | @property (nonatomic, readonly, copy) NSString* profileImageURL; 26 | 27 | + (DEUser*)me; // 自分(ログインしているユーザー) 28 | @property (nonatomic, readonly) BOOL isMe; // User は自分か 29 | 30 | - (void)refreshUserInfoWithCallback:(LTModelGeneralCallback)callback; // User の情報を取得 (Twitter API GET user/show) 31 | 32 | 33 | - (void)save; 34 | 35 | // +-+-+-+-+-+-+ Private +-+-+-+-+-+-+ // 36 | // ViewController や View から見てプライベートなメソッド 37 | 38 | // User ID で User を返すメソッド, 同じ User ID は同じインスタンスが返る, 未生成ならばインスタンス化して返す 39 | + (DEUser*)userWithUserID:(NSString*)userID; 40 | @property (nonatomic) ACAccount* account; 41 | 42 | @end 43 | -------------------------------------------------------------------------------- /LTTwDemo/Models/DEUser.m: -------------------------------------------------------------------------------- 1 | // 2 | // User.m 3 | // LTTwDemo 4 | // 5 | // Created by ito on 2013/03/16. 6 | // Copyright (c) 2013年 novi. All rights reserved. 7 | // 8 | 9 | #import "DEUser.h" 10 | #import "DETimeline.h" 11 | #import "DEAPIRequest.h" 12 | #import "DEAPIResponse.h" 13 | 14 | @interface DEUser () 15 | { 16 | DETimeline* _homeTimeline; 17 | DETimeline* _usersTimeline; 18 | } 19 | @end 20 | 21 | @implementation DEUser 22 | 23 | + (NSString*)cacheFilePath 24 | { 25 | NSArray* paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES); 26 | NSString* base = [paths objectAtIndex:0]; 27 | return [base stringByAppendingPathComponent:@"default_v1.data"]; 28 | } 29 | 30 | 31 | +(DEUser *)me 32 | { 33 | static DEUser* me; 34 | static dispatch_once_t onceToken; 35 | dispatch_once(&onceToken, ^{ 36 | // キャッシュを読み込む 37 | NSData* data = [NSData dataWithContentsOfFile:[self cacheFilePath]]; 38 | // 読み込んだので削除 39 | [[NSFileManager defaultManager] removeItemAtPath:[self cacheFilePath] error:nil]; 40 | // デコード 41 | NSKeyedUnarchiver* unarchiver = [[NSKeyedUnarchiver alloc] initForReadingWithData:data]; 42 | DEUser* storedUser = [unarchiver decodeObjectForKey:@"user"]; 43 | if (storedUser) { 44 | [self decodeModelStore:unarchiver]; 45 | me = storedUser; 46 | } else { 47 | me = [[self alloc] initWithID:nil]; 48 | } 49 | // バックグラウンド時にsave 50 | [[NSNotificationCenter defaultCenter] addObserver:me selector:@selector(save) name:UIApplicationDidEnterBackgroundNotification object:nil]; 51 | }); 52 | return me; 53 | } 54 | 55 | -(BOOL)isMe 56 | { 57 | return [[[self class] me] isEqual:self]; 58 | } 59 | 60 | // 標準のイニシャライザは無効に 61 | // 外部からインスタンス化できない 62 | -(id)init 63 | { 64 | [self doesNotRecognizeSelector:_cmd]; 65 | return nil; 66 | } 67 | 68 | - (id)initWithCoder:(NSCoder *)aDecoder 69 | { 70 | self = [super initWithCoder:aDecoder]; 71 | if (self) { 72 | _homeTimeline = [aDecoder decodeObjectForKey:@"homeTimeline"]; 73 | _usersTimeline = [aDecoder decodeObjectForKey:@"usersTimeline"]; 74 | NSLog(@"decode %@, %@, %@", self, _homeTimeline, _usersTimeline); 75 | } 76 | return self; 77 | } 78 | 79 | -(void)encodeWithCoder:(NSCoder *)aCoder 80 | { 81 | [super encodeWithCoder:aCoder]; 82 | 83 | [aCoder encodeObject:_homeTimeline forKey:@"homeTimeline"]; 84 | [aCoder encodeObject:_usersTimeline forKey:@"usersTimeline"]; 85 | } 86 | 87 | - (void)save 88 | { 89 | NSLog(@"saved"); 90 | 91 | NSMutableData* data = [NSMutableData data]; 92 | NSKeyedArchiver* archiver = [[NSKeyedArchiver alloc] initForWritingWithMutableData:data]; 93 | 94 | [[self class] encodeModelStore:archiver]; 95 | [archiver encodeObject:self forKey:@"user"]; 96 | 97 | [archiver finishEncoding]; 98 | 99 | [data writeToFile:[[self class] cacheFilePath] atomically:NO]; 100 | } 101 | 102 | 103 | 104 | // 指定イニシャライザ, 同じ ID の User は同じインスタンスを使うためこれが呼ばれる 105 | - (id)initWithID:(NSString *)ID 106 | { 107 | self = [super init]; 108 | if (self) { 109 | if (ID) { 110 | [self setAttribute:ID forKey:@"id_str"]; 111 | } 112 | } 113 | return self; 114 | } 115 | 116 | +(DEUser *)userWithUserID:(NSString *)userID 117 | { 118 | // userIDが自分だったら、[User me] を返す 119 | if ([[self me].ID isEqualToString:userID]) { 120 | return [self me]; 121 | } 122 | return [self modelWithID:userID]; 123 | } 124 | 125 | #pragma mark - 126 | 127 | -(void)refreshUserInfoWithCallback:(LTModelGeneralCallback)callback 128 | { 129 | NSString* screenName = self.account.username; 130 | 131 | DEAPIRequest* req = [[DEAPIRequest alloc] initWithAPI:@"/users/show" method:LTAPIRequestMethodGET params:self.ID ? @{@"user_id":self.ID} : @{@"screen_name":screenName} ]; 132 | [req sendRequestWithCallback:^(DEAPIResponse *response) { 133 | if (!response.success) { 134 | callback(NO); 135 | return; 136 | } 137 | [self replaceAttributesFromDictionary:response.json]; 138 | callback(YES); 139 | }]; 140 | } 141 | 142 | 143 | #pragma mark - Timelines 144 | 145 | -(DETimeline *)homeTimeline 146 | { 147 | // 自分のみ 148 | if (!self.isMe) { 149 | [[NSException exceptionWithName:NSGenericException reason:@"other users home timeline is not available." userInfo:nil] raise]; 150 | return nil; 151 | } 152 | if (!_homeTimeline) { 153 | _homeTimeline = [[DETimeline alloc] initWithType:TimelineTypeHome user:self]; 154 | } 155 | return _homeTimeline; 156 | } 157 | 158 | -(DETimeline *)usersTimeline 159 | { 160 | if (!_usersTimeline) { 161 | _usersTimeline = [[DETimeline alloc] initWithType:TimelineTypeUsers user:self]; 162 | } 163 | return _usersTimeline; 164 | } 165 | 166 | #pragma mark - Attributes 167 | 168 | -(NSString *)description 169 | { 170 | return [NSString stringWithFormat:@"%@, %@: %@ / %@", [super description], self.ID, self.screenName, self.name]; 171 | } 172 | 173 | -(NSString *)ID 174 | { 175 | return [self attributeForKey:@"id_str"]; 176 | } 177 | 178 | -(NSString *)screenName 179 | { 180 | return [self attributeForKey:@"screen_name"]; 181 | } 182 | 183 | -(NSString *)name 184 | { 185 | return [self attributeForKey:@"name"]; 186 | } 187 | 188 | -(NSString *)profileImageURL 189 | { 190 | return [self attributeForKey:@"profile_image_url"]; 191 | } 192 | 193 | @end 194 | -------------------------------------------------------------------------------- /LTTwDemo/model-relation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/novi/LTAPIRequest/a396a26d2cb1b4851ded3f91e1def57a0aaab3ad/LTTwDemo/model-relation.png -------------------------------------------------------------------------------- /LTTwDemo/owner-ship.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/novi/LTAPIRequest/a396a26d2cb1b4851ded3f91e1def57a0aaab3ad/LTTwDemo/owner-ship.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | LTAPIRequest 2 | ========= 3 | 4 | RESTful な API を持つ Web サービス用クライアント型iOSアプリのひな形 5 | 6 | ## 目的 7 | 8 | LTAPIRequest は**クライアントアプリ**でよく使われるだろうモデルとネットワークリクエストのための 9 | **ベースクラス** (`LTModel`, `LTAPIRequest`, `LTAPIResponse`)を提供します。 10 | 11 | フレームワークとしてのフル機能ではなく、クライアントアプリを作成するための最低限の枠組み、 12 | パターン、ひな形、実装方法、TIPS を提供します。 13 | 14 | そのため、フルスタックのモデルフレームワークのようにすぐに使えるような機能がすべて揃ったものではありませんし、いくつかの部分はサブクラス化して自分で実装する必要があります。 15 | 16 | 全体的に薄い(Lightweightな)コード、程よい粗結合、1方向のデータの流れになっており、これらはデバッグを楽にしたり、コードを追いやすくしたりするようになっています。もちろん、iOS標準以外のライブラリに依存することはありません。 17 | 18 | 逆にフルスタックのフレームワークのように、使いこなすために膨大なドキュメントを読んだり、問題解決のために元のフレームワークのコードを追わないといけないということもありません。 19 | 20 | 基本的に LTAPIRequest は [Arch Way](https://wiki.archlinux.org/index.php/The_Arch_Way_%28%E6%97%A5%E6%9C%AC%E8%AA%9E%29) , [v2.0](https://wiki.archlinux.org/index.php/The_Arch_Way_v2.0_%28%E6%97%A5%E6%9C%AC%E8%AA%9E%29) の考え方を継承しています (ただし、作者は Arch を使っているとも限りませんが)。 21 | 下で述べる問題があったり、この考え方が自分に合わない場合は NSRails や RestKit などのフルスタックのフレームワークを使うことをおすすめします。 22 | 23 | APIの種類や認証方法、その他の場合によっては、**ベースクラス**自体を変更する必要があるかもしれませんが、完全なライブラリよりも実装方法を提供するのが目的であるため、必要に応じて拡張してもかまいません。 24 | 25 | だだし、これ以上ベースクラスが複雑になるような場合は、そもそも作ろうとしているアプリがこのパターンに当てはまらないか、アプリの機能(要求定義)がiOSで提供すべきものとしては著しく大きい可能性があります。 26 | また、Web側のAPIとの組み合わせがうまくいかない場合は、Web側がRESTfulなAPIとして設計されていないか、アプリ側の設計が正しくない場合があります。 27 | 28 | ## 実装例 1 29 | 30 | 同梱されている、 `LTTwDemo` は Twitter の RESTful API を使った、Twitter クライアントです。それぞれの実装例と**ベースクラス**両方のコードを追いながら読むとよいと思います。 31 | アプリを作るためには、実装例にあるように、**ベースクラス**以外にベースクラスを継承した Model, View, ViewController が必要になります。 32 | 33 | iOSアプリの MVC については、参考書または [Cocoa iOS デザインパターン](http://yusukeito.me/post/41267296089/cocoa-ios) を参照してください。 34 | 35 | ### Model と Request を実装する 36 | 37 | DEという接頭辞が付いたものが、Model と Request を継承して実装したものです。 38 | Model は `DEUser`, `DETimeline`, `DETweet` があります。Request は `DEAPIRequest`, `DEAPIResponse` です。 39 | 40 | 設計した Model は以下のような関係を持ちます。CoreData の ER 図と同じような書き方です。 41 | 42 | Twitter のサービス自体、ユーザーがホームタイムラインとユーザー自身のユーザータイムラインを持ちます。 43 | 各タイムラインには複数個のツイートを含みます。 44 | 45 | 実際に Twitter REST API 1.1 もこのモデルと同じような設計になっています。 46 | 47 | ![Model Relation](LTTwDemo/model-relation.png) 48 | 49 | これはオーナシップ図で、メモリ参照(インスタンスの参照)的に親と子の関係を示しています。 50 | `[User me]`, `modelWithID:` で DEUser を static 変数としてアプリ内にグローバルに持ちます。 51 | 52 | それ以下に DETimeline や DETweet を含みます。DETweet から親である DETimeline を参照するための `DETweet.timeline` というインスタンス変数を持ちますが、これは循環参照を避けるために weak にする必要があります。 53 | 54 | また、リクエスト系のクラスに関してはリクエストを送信して、受信している時のみiOS(Operation Queue)が、DEAPIRequest のオーナーになります。 55 | 56 | リクエストが終わると、コールバック(completionHandler, callback)が発行されて、スコープから抜けると、不要になったRequestは解放されます。 57 | 1リクエストごとに1インスタンスとなります。 58 | 59 | ![Owner](LTTwDemo/owner-ship.png) 60 | 61 | ### ViewController を実装する 62 | 63 | ViewController のコアの実装はとてもシンプルですし、このようにシンプルにするべきです。Timeline を表示する `TimelineViewController` があります。 64 | これは Model, DETimeline をセットすると、この ViewController がこのモデルのデータを取得、表示します。 65 | 66 | 基本的にはデータを取得するために DETimeline の `-refreshWithCallback:` と `-loadMoreWithCallback:` を呼ぶだけです。2重送信を避けるためにフラグを立てておくべきです。 67 | 68 | リクエストに成功すると DETimeline の `tweets` に取得したツイートを追加して、成功か失敗か(`success`)と追加された行の IndexSet を返します。 69 | また、この Callback はリクエストが終わったとに APIRequest と同時に解放されるので、weak self などのメモリ管理は必要ありません。 70 | 71 | ## 実装例2 72 | 73 | `DETodo` は クラウドで同期できる簡単な TODO アプリです。 74 | (_デモを作ってから思ったのですが、ToDoアプリはWebサービスのクライアントというよりは同期型のほうがいいですね。Model åのラッパーを作れば同期型にすることも可能です。_) 75 | 76 | [Server側](DETodo/Server.md)の説明と[APIドキュメント](DETodo/api_document.md)も合わせて参照してください。 77 | 78 | 上の例と同じく、内部的な Model の関係は以下のようになります。 79 | User は自分(認証されたユーザー)のみです。 80 | 81 | ![Model Relation](DETodo/model-relation.png) 82 | 83 | また、それぞれの Model が対応する API のパスは図の右のようになります。 84 | 85 | ViewController は Twitter の場合と変わりませんので、ソースコード内のコメントを参照してください。 86 | 87 | サーバサイドとして、本番で使う API のサーバー(`detodo-server`)以外にも、`test-server-node` というAPIのテスト用のサーバーを作成しました。これは、iOS 側の API 呼び出しが正しいかどうかを確かめるものです。また、各種ステータスコードのテストやレスポンスが極端に遅い場合、レスポンスが返らない場合などがテストできます。test-server-node/app.js のコメントも参照してください。 88 | 89 | ## キャッシュと永続化 90 | 91 | 92 | 93 | ## ライセンス 94 | 95 | コード: [Apache License 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt), 文書: [CC BY 2.1](http://creativecommons.org/licenses/by/2.1/jp/) 96 | 97 | 98 | --------------------------------------------------------------------------------