From 2ba11af53b13c1139c29199d9fe93a9811356eca Mon Sep 17 00:00:00 2001 From: Miguel de Icaza Date: Sun, 16 May 2010 20:30:56 -0400 Subject: [PATCH] Initial import --- README | 57 + TODO | 112 ++ TweetStation.sln | 36 + TweetStation/Data/Database.cs | 24 + TweetStation/Data/ImageStore.cs | 251 ++++ TweetStation/Data/Tweet.cs | 511 +++++++ TweetStation/Data/TwitterAccount.cs | 266 ++++ TweetStation/Dialogs/EditAccount.cs | 92 ++ TweetStation/Images/arrow.png | Bin 0 -> 384 bytes .../Images/default_profile_4_normal.png | Bin 0 -> 2096 bytes TweetStation/Images/fav.png | Bin 0 -> 919 bytes TweetStation/Images/friends.png | Bin 0 -> 330 bytes TweetStation/Images/gps.png | Bin 0 -> 322 bytes TweetStation/Images/home.png | Bin 0 -> 431 bytes TweetStation/Images/lupa.png | Bin 0 -> 571 bytes TweetStation/Images/messages.png | Bin 0 -> 629 bytes TweetStation/Images/replies.png | Bin 0 -> 750 bytes TweetStation/Images/shrink.png | Bin 0 -> 330 bytes TweetStation/Images/star-off.png | Bin 0 -> 1006 bytes TweetStation/Images/star-on.png | Bin 0 -> 1098 bytes TweetStation/Info.plist | 19 + TweetStation/Main.cs | 116 ++ TweetStation/MainWindow.xib | 395 ++++++ TweetStation/MainWindow.xib.designer.cs | 47 + TweetStation/Settings.bundle/Root.plist | 10 + TweetStation/SqliteNet/SQLite.cs | 1171 +++++++++++++++++ TweetStation/TweetStation.csproj | 147 +++ TweetStation/UI/Composer.cs | 303 +++++ TweetStation/UI/Conversation.cs | 92 ++ TweetStation/UI/DetailTweetViewController.cs | 164 +++ TweetStation/UI/Favorites.cs | 30 + TweetStation/UI/Follow.cs | 14 + TweetStation/UI/FullProfileView.cs | 211 +++ TweetStation/UI/PictureViewer.cs | 17 + TweetStation/UI/SearchTab.cs | 269 ++++ TweetStation/UI/SearchUser.cs | 87 ++ TweetStation/UI/ShortProfile.cs | 120 ++ TweetStation/UI/Timeline.cs | 295 +++++ TweetStation/UI/TweetCell.cs | 261 ++++ TweetStation/UI/TweetView.cs | 218 +++ TweetStation/UI/Web.cs | 73 + TweetStation/Utilities/ButtonsView.cs | 50 + TweetStation/Utilities/Graphics.cs | 37 + TweetStation/Utilities/LRUCache.cs | 89 ++ TweetStation/Utilities/Locale.cs | 17 + TweetStation/Utilities/Util.cs | 181 +++ 46 files changed, 5782 insertions(+) create mode 100644 README create mode 100644 TODO create mode 100644 TweetStation.sln create mode 100644 TweetStation/Data/Database.cs create mode 100644 TweetStation/Data/ImageStore.cs create mode 100644 TweetStation/Data/Tweet.cs create mode 100644 TweetStation/Data/TwitterAccount.cs create mode 100644 TweetStation/Dialogs/EditAccount.cs create mode 100644 TweetStation/Images/arrow.png create mode 100644 TweetStation/Images/default_profile_4_normal.png create mode 100644 TweetStation/Images/fav.png create mode 100644 TweetStation/Images/friends.png create mode 100644 TweetStation/Images/gps.png create mode 100644 TweetStation/Images/home.png create mode 100644 TweetStation/Images/lupa.png create mode 100644 TweetStation/Images/messages.png create mode 100644 TweetStation/Images/replies.png create mode 100644 TweetStation/Images/shrink.png create mode 100644 TweetStation/Images/star-off.png create mode 100644 TweetStation/Images/star-on.png create mode 100644 TweetStation/Info.plist create mode 100644 TweetStation/Main.cs create mode 100644 TweetStation/MainWindow.xib create mode 100644 TweetStation/MainWindow.xib.designer.cs create mode 100644 TweetStation/Settings.bundle/Root.plist create mode 100644 TweetStation/SqliteNet/SQLite.cs create mode 100644 TweetStation/TweetStation.csproj create mode 100644 TweetStation/UI/Composer.cs create mode 100644 TweetStation/UI/Conversation.cs create mode 100644 TweetStation/UI/DetailTweetViewController.cs create mode 100644 TweetStation/UI/Favorites.cs create mode 100644 TweetStation/UI/Follow.cs create mode 100644 TweetStation/UI/FullProfileView.cs create mode 100644 TweetStation/UI/PictureViewer.cs create mode 100644 TweetStation/UI/SearchTab.cs create mode 100644 TweetStation/UI/SearchUser.cs create mode 100644 TweetStation/UI/ShortProfile.cs create mode 100644 TweetStation/UI/Timeline.cs create mode 100644 TweetStation/UI/TweetCell.cs create mode 100644 TweetStation/UI/TweetView.cs create mode 100644 TweetStation/UI/Web.cs create mode 100644 TweetStation/Utilities/ButtonsView.cs create mode 100644 TweetStation/Utilities/Graphics.cs create mode 100644 TweetStation/Utilities/LRUCache.cs create mode 100644 TweetStation/Utilities/Locale.cs create mode 100644 TweetStation/Utilities/Util.cs diff --git a/README b/README new file mode 100644 index 0000000..01a84ca --- /dev/null +++ b/README @@ -0,0 +1,57 @@ +TweetStation + + TweetStation was originally a sample program that I created + for my own use. I was a fan of the older UI in Echofon and + liked many of the elements of Tweetie2, but like every + programmer, I wanted to make some changes. + + It was also a test bed for testing APIs and answering + MonoTouch users's questions. It is the original application + that lead to the creation of MonoTouch.Dialog, as I figured + that there should be a better way of constructing dialog boxes + than creating models and delegates left and right. + + TweetStation is an open source, MIT X11 licensed twitter + client written for MonoTouch. + +Design Goals +============ + + Exceptions + + The code uses try/catch extensively in the code in areas that + have to process data from twitter, I assume that the data + might be broken or that my original assumptions or their + documentation might be wrong. You will notice in the code + that all exceptions are printed out, I want to keep that this + way for that reason. + + MonoTouch.Dialog + + Most of the UI was created with MonoTouch.Dialog and various + custom views and Elements designed for twitter. There is not + a single UITableView coded in the traditional style. + + Memory Usage + + In some parts of the code I tried to minimize memory usage by + not creating thousands of objects that would be thrown out + (Tweet parsing for example), so I just recycle some instances + sometimes. + + Singletons + + There are a handful of singleton classes as well, I tend to + reset those instead of creating new instances as they would + avoid creating expensive objects or objects that are known to + leak in CocoaTouch anyways. + + Pending Task Queue + + I never know if a tweet has been starred or not when there is + no network connectivity. With TweetStation all the pending + requests are kept in a queue and flushed at periodic intervals. + + This is used both to post tweets and favorite posts, allowing + the settings to take place right away, even if there is no + network connectivity diff --git a/TODO b/TODO new file mode 100644 index 0000000..8231eb5 --- /dev/null +++ b/TODO @@ -0,0 +1,112 @@ +* Missing Stuff + Refresh user pictures when they change. + Currently, once we download a pic, we never update it + + Protocol: + When posting a tweet, the POST data result should + be added to the main view. + + When favoriting/unfavoriting, we need to add those + tweets to Favorites tab, or recompute that tab + on demand. + + TweetCell: Use only Draw calls? + + On Load: + Hook up "Load more" and add better artwork + Custom control to show "Load more" with some better artwork + Support removal of the control on load, instead than on tap, + to give a visual cue of what is happening. + + Search text + With history of previous searches + Nearby tweets + Goto user + composer: + Sending Direct Messages + Uploading pictures + Looking up the list of friends + TweetCell - Main Display + Show star info, geo info, and pic info? + + Remove old tweets from the database, 2 week old tweets? + Showing User Pictures + Loading medium sized picture + Add picture shadow + Chicken noises for reload + Music for composing + Turning a search into a saved search + Lists: + Instead of managing lists, be able to "add user to list" in the UI in the profile view. + Delete from list. + Edit, Delete, Info + Special List timeline controller that lets edit + User profile: + Red Color for Blocking users + Showing first joined twitter + Listing friends + Listing followers + Merge friends + followers in a single cell to save space? + Showing a map for the user profile + Show if the tweet is protected/user protected + Account editor + New accont + Hooking up OAuth + Show timeline separator (like tweetie, after X hours shows a gap) + Reloads on gap + Remember last tab + FullProfileView + Needs an animating element while loading data from the network, + +* Post 1.0: + + Direct Messages special view + Grouped per user, blend conversation together + +* Http Stack + + Since all calls to the twitter API are to the same server, + we should have an API that serializes all calls, maybe even + reuses the WebClient instance. + + WebClient does not use the ThreadPool, instead it uses a + new thread, perhaps move to HttpWebRequest? + + Need HttpWebRequest for POST anyways (needed for inserting + the result of a post). + + Should support high-priority vs low-priority requests. Some + operations like trend fetching do not have to be a top + priority. + +* Idea: Memory management + + Modify the TImelineController to reload data on demand, + since CocoaTouch does not really kill leaf instances + of view controllers so we can end up carrying a lot of + data around. + + Problem: we can only do this if a child is not currently + visible. + +* ImageStore: + + Support downloading different kinds of image sizes + The 73x73 image for large views + The full version for full display + +* TweetDetail + + Put a border around the image loaded. + Add support for loading twitpic images. + + +* Application + + Remember where we are (drilled down on a tweet? or search) + +* Accounts + + Link to create account + + Support OAuth diff --git a/TweetStation.sln b/TweetStation.sln new file mode 100644 index 0000000..adc48d5 --- /dev/null +++ b/TweetStation.sln @@ -0,0 +1,36 @@ + +Microsoft Visual Studio Solution File, Format Version 10.00 +# Visual Studio 2008 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TweetStation", "TweetStation\TweetStation.csproj", "{7C7D3161-2DFB-4201-B70E-88ACD3976AA9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MonoTouch.Dialog", "..\MonoTouch.Dialog\MonoTouch.Dialog\MonoTouch.Dialog.csproj", "{3FFBFFF8-5560-4EDE-82E5-3FFDFBBA8A50}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|iPhoneSimulator = Debug|iPhoneSimulator + Release|iPhoneSimulator = Release|iPhoneSimulator + Debug|iPhone = Debug|iPhone + Release|iPhone = Release|iPhone + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {3FFBFFF8-5560-4EDE-82E5-3FFDFBBA8A50}.Debug|iPhone.ActiveCfg = Debug|iPhone + {3FFBFFF8-5560-4EDE-82E5-3FFDFBBA8A50}.Debug|iPhone.Build.0 = Debug|iPhone + {3FFBFFF8-5560-4EDE-82E5-3FFDFBBA8A50}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator + {3FFBFFF8-5560-4EDE-82E5-3FFDFBBA8A50}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator + {3FFBFFF8-5560-4EDE-82E5-3FFDFBBA8A50}.Release|iPhone.ActiveCfg = Release|iPhone + {3FFBFFF8-5560-4EDE-82E5-3FFDFBBA8A50}.Release|iPhone.Build.0 = Release|iPhone + {3FFBFFF8-5560-4EDE-82E5-3FFDFBBA8A50}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator + {3FFBFFF8-5560-4EDE-82E5-3FFDFBBA8A50}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator + {7C7D3161-2DFB-4201-B70E-88ACD3976AA9}.Debug|iPhone.ActiveCfg = Debug|iPhone + {7C7D3161-2DFB-4201-B70E-88ACD3976AA9}.Debug|iPhone.Build.0 = Debug|iPhone + {7C7D3161-2DFB-4201-B70E-88ACD3976AA9}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator + {7C7D3161-2DFB-4201-B70E-88ACD3976AA9}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator + {7C7D3161-2DFB-4201-B70E-88ACD3976AA9}.Release|iPhone.ActiveCfg = Release|iPhone + {7C7D3161-2DFB-4201-B70E-88ACD3976AA9}.Release|iPhone.Build.0 = Release|iPhone + {7C7D3161-2DFB-4201-B70E-88ACD3976AA9}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator + {7C7D3161-2DFB-4201-B70E-88ACD3976AA9}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator + EndGlobalSection + GlobalSection(MonoDevelopProperties) = preSolution + StartupItem = TweetStation\TweetStation.csproj + EndGlobalSection +EndGlobal diff --git a/TweetStation/Data/Database.cs b/TweetStation/Data/Database.cs new file mode 100644 index 0000000..46f11db --- /dev/null +++ b/TweetStation/Data/Database.cs @@ -0,0 +1,24 @@ +using System; +using SQLite; + +namespace TweetStation +{ + public class Database : SQLiteConnection { + internal Database (string file) : base (file) + { + CreateTable (); + CreateTable (); + CreateTable (); + CreateTable (); + } + + static Database () + { + System.IO.File.Delete ("tweets.db"); + Main = new Database ("tweets.db"); + } + + static public Database Main { get; private set; } + } +} + diff --git a/TweetStation/Data/ImageStore.cs b/TweetStation/Data/ImageStore.cs new file mode 100644 index 0000000..abcdf72 --- /dev/null +++ b/TweetStation/Data/ImageStore.cs @@ -0,0 +1,251 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading; + +using MonoTouch.Foundation; +using MonoTouch.UIKit; +using MonoTouch.CoreGraphics; + +namespace TweetStation +{ + public interface IImageUpdated { + void UpdatedImage (long id); + } + + // + // Provides an interface to download pictures in the background + // and keep a local cache of the original files + rounded versions + // + public static class ImageStore + { + const int MaxRequests = 4; + static string PicDir, SmallPicDir, TmpDir, RoundedPicDir; + public readonly static UIImage DefaultImage; + static LRUCache cache; + + // A list of requests that have been issues, with a list of objects to notify. + static Dictionary> pendingRequests; + + // A list of updates that have completed, we must notify the main thread about them. + static HashSet queuedUpdates; + + // A queue used to avoid flooding the network stack with HTTP requests + static Queue requestQueue; + + static NSString nsDispatcher = new NSString ("x"); + + static ImageStore () + { + PicDir = Path.Combine (Util.BaseDir, "Library/Caches/Pictures"); + TmpDir = Path.Combine (Util.BaseDir, "tmp/downloads/"); + SmallPicDir = Path.Combine (PicDir, "Scaled/"); + RoundedPicDir = Path.Combine (PicDir, "Rounded/"); + + if (!Directory.Exists (SmallPicDir)) + Directory.CreateDirectory (SmallPicDir); + + if (!Directory.Exists (TmpDir)) + Directory.CreateDirectory (TmpDir); + + if (!Directory.Exists (RoundedPicDir)) + Directory.CreateDirectory (RoundedPicDir); + + DefaultImage = UIImage.FromFile ("Images/default_profile_4_normal.png"); + cache = new LRUCache (200); + pendingRequests = new Dictionary> (); + queuedUpdates = new HashSet(); + requestQueue = new Queue (); + } + + public static UIImage GetLocalProfilePicture (long id) + { + UIImage ret; + + lock (cache){ + ret = cache [id]; + if (ret != null) + return ret; + } + + if (pendingRequests.ContainsKey (id)) + return null; + + string picfile = RoundedPicDir + id + ".png"; + if (File.Exists (picfile)){ + ret = UIImage.FromFileUncached (picfile); + lock (cache) + cache [id] = ret; + return ret; + } if (File.Exists (SmallPicDir + id + ".jpg")) + return RoundedPic (id); + else + return null; + } + + public static UIImage GetLocalProfilePicture (string screenname) + { + var user = User.FromName (screenname); + if (user == null) + return null; + return GetLocalProfilePicture (user.Id); + } + + + public static UIImage RequestProfilePicture (long id, string optionalUrl, IImageUpdated notify) + { + var pic = GetLocalProfilePicture (id); + if (pic == null){ + QueueRequestForPicture (id, optionalUrl, notify); + return DefaultImage; + } + + return pic; + } + + public static Uri GetPicUrlFromId (long id, string optionalUrl) + { + Uri url; + + if (optionalUrl == null){ + var user = User.FromId (id); + if (user == null) + return null; + optionalUrl = user.PicUrl; + } + if (!Uri.TryCreate (optionalUrl, UriKind.Absolute, out url)) + return null; + + return url; + } + + static string Name (long id) + { + var user = User.FromId (id); + return user.Screenname; + } + + // + // Requests that the picture for "id" be downloaded, the optional url prevents + // one lookup, it can be null if not known + // + public static void QueueRequestForPicture (long id, string optionalUrl, IImageUpdated notify) + { + if (notify == null) + throw new ArgumentNullException ("notify"); + + Console.WriteLine ("Requesting Pic: {0} at {1}", id, optionalUrl); + Uri url = GetPicUrlFromId (id, optionalUrl); + if (url == null) + return; + + if (pendingRequests.ContainsKey (id)){ + pendingRequests [id].Add (notify); + return; + } + var slot = new List (4); + slot.Add (notify); + pendingRequests [id] = slot; + if (pendingRequests.Count >= MaxRequests){ + lock (requestQueue) + requestQueue.Enqueue (id); + } else { + ThreadPool.QueueUserWorkItem (delegate { + try { + StartPicDownload (id, url); + } catch (Exception e){ + Console.WriteLine (e); + } + }); + } + } + + static void StartPicDownload (long id, Uri url) + { + do { + var buffer = new byte [4*1024]; + + using (var file = new FileStream (SmallPicDir+ id + ".jpg", FileMode.Create, FileAccess.Write, FileShare.Read)) { + var req = WebRequest.Create (url) as HttpWebRequest; + + using (var resp = req.GetResponse()) { + using (var s = resp.GetResponseStream()) { + int n; + while ((n = s.Read (buffer, 0, buffer.Length)) > 0){ + file.Write (buffer, 0, n); + } + } + } + } + + // Cluster all updates together + bool doInvoke = false; + lock (queuedUpdates){ + queuedUpdates.Add (id); + + // If this is the first queued update, must notify + if (queuedUpdates.Count == 1) + doInvoke = true; + } + + // Try to get more jobs. + lock (requestQueue){ + if (requestQueue.Count > 0){ + id = requestQueue.Dequeue (); + url = GetPicUrlFromId (id, null); + if (url == null) + id = -1; + } else + id = -1; + } + if (doInvoke) + nsDispatcher.BeginInvokeOnMainThread (NotifyImageListeners); + } while (id != -1); + } + + // Runs on the main thread + static void NotifyImageListeners () + { + try { + lock (queuedUpdates){ + foreach (var qid in queuedUpdates){ + var list = pendingRequests [qid]; + pendingRequests.Remove (qid); + foreach (var pr in list){ + Console.WriteLine ("Notifying of picture {0}", qid); + pr.UpdatedImage (qid); + } + } + queuedUpdates.Clear (); + } + } catch (Exception e){ + Console.WriteLine (e); + } + + } + + static UIImage RoundedPic (long id) + { + lock (cache){ + string smallpic = SmallPicDir + id + ".jpg"; + + using (var pic = UIImage.FromFileUncached (smallpic)){ + if (pic == null) + return null; + + var cute = Graphics.RemoveSharpEdges (pic); + var bytes = cute.AsPNG (); + NSError err; + bytes.Save (RoundedPicDir + id + ".png", false, out err); + + // we might as well add it to the cache + cache [id] = cute; + + return cute; + } + } + } + } +} diff --git a/TweetStation/Data/Tweet.cs b/TweetStation/Data/Tweet.cs new file mode 100644 index 0000000..27de3a1 --- /dev/null +++ b/TweetStation/Data/Tweet.cs @@ -0,0 +1,511 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Json; +using System.Linq; +using SQLite; + +namespace TweetStation +{ + /// + /// Represents a tweet in memory. Not all the data from the original tweet + /// is kept around, most of the data is discarded. + /// + public class Tweet { + + [PrimaryKey] + public long Id { get; set; } + public int LocalAccountId { get; set; } + public TweetKind Kind { get; set; } + + [Indexed] + public long CreatedAt { get; set; } + public string Text { get; set; } + public string Source { get; set; } + public bool Favorited { get; set; } + public long InReplyToStatus { get; set; } + public long InReplyToUser { get; set; } + public string InReplyToUserName { get; set; } + + // Retweet information + public string Retweeter { get; set; } + public string RetweeterPicUrl { get; set; } + public long RetweeterId { get; set; } + + // These are here just for convenience, to avoid doing an extra lookup on the User DB + public long UserId { get; set; } + public string Screename { get; set; } + public string PicUrl { get; set; } + + // Negative values for the UserId indicate that this Tweet + // is the result of search and is not complete + public bool Complete { + get { + return UserId > 0; + } + } + // + // The "source" might be surrounted by an anchor + // this strips it + // + + static long GetLong (JsonObject json, string key) + { + if (json [key].JsonType == JsonType.Number) + return (long) json [key]; + else + return 0; + } + + static long ParseCreation (JsonObject json) + { + return DateTime.ParseExact (json ["created_at"], "ddd MMM dd HH:mm:ss zzz yyyy", CultureInfo.InvariantCulture).ToUniversalTime ().Ticks; + } + + // Yes, they even use different formats for the dates they return + static long ParseCreationSearch (JsonObject json) + { + return DateTime.ParseExact (json ["created_at"], "ddd, dd MMM yyyy HH:mm:ss zzz", CultureInfo.InvariantCulture).ToUniversalTime ().Ticks; + } + + static string ParseText (JsonObject json) + { + return (((string)json ["text"]) ?? "").Replace ("\n", " ").Replace ("\r", " "); + } + + bool TryPopulate (JsonObject json) + { + try { + Id = json ["id"]; + CreatedAt = ParseCreation (json); + Text = ParseText (json); + Source = Util.StripHtml (json ["source"] ?? ""); + Favorited = json ["favorited"]; + InReplyToStatus = GetLong (json, "in_reply_to_status_id"); + InReplyToUser = GetLong (json, "in_reply_to_user_id"); + InReplyToUserName = (string) json ["in_reply_to_screen_name"]; + + if (json.ContainsKey ("retweeted_status")){ + var sub = json ["retweeted_status"]; + var subuser = sub ["user"]; + + // These are swapped out later. + Retweeter = subuser ["screen_name"]; + RetweeterPicUrl = subuser ["profile_image_url"]; + RetweeterId = subuser ["id"]; + if (Text.StartsWith ("RT ")) + Text = Text.Substring (3); + } else { + RetweeterPicUrl = null; + Retweeter = null; + RetweeterId = 0; + } + return true; + } catch (Exception e) { + Console.WriteLine (e); + return false; + } + } + + bool TryPopulateDirect (JsonObject json) + { + try { + Id = json ["id"]; + CreatedAt = ParseCreation (json); + Text = ParseText (json); + return true; + } catch (Exception e) { + Console.WriteLine (e); + return false; + } + } + + /// + /// Saves the tweet into the database + /// + void Insert (Database db) + { + db.Insert (this, "OR IGNORE"); + } + + public void Replace (Database db) + { + db.Insert (this, "OR REPLACE"); + } + + static bool ParseUser (JsonObject juser, User user, HashSet usersSeen) + { + try { + user.UpdateFromJson ((JsonObject) juser); + if (!usersSeen.Contains (user.Id)){ + usersSeen.Add (user.Id); + Database.Main.Insert (user, "OR REPLACE"); + } + } catch { + return false; + } + return true; + } + + /// + /// Loads the tweets encoded in the JSon response from the server + /// into the database. Users that are detected in the stream + /// are entered in the user database as well. + /// + static public int LoadJson (Stream stream, int localAccount, TweetKind kind) + { + Database db = Database.Main; + int count = 0; + JsonValue root; + string userKey; + + try { + root = JsonValue.Load (stream); + if (kind == TweetKind.Direct) + userKey = "sender"; + else + userKey = "user"; + } catch (Exception e) { + Console.WriteLine (e); + return -1; + } + + // These are reusable instances that we used during population + var tweet = new Tweet () { Kind = kind, LocalAccountId = localAccount }; + var user = new User (); + + var usersSeen = new HashSet (); + foreach (JsonObject jentry in root){ + var juser = jentry [userKey]; + bool result; + + if (!ParseUser ((JsonObject) juser, user, usersSeen)) + continue; + + if (kind == TweetKind.Direct) + result = tweet.TryPopulateDirect (jentry); + else + result = tweet.TryPopulate (jentry); + + if (result){ + PopulateUser (tweet, user); + tweet.Insert (db); + count++; + } + + // Repeat user loading for the retweet info + if (tweet.Retweeter != null) + ParseUser ((JsonObject)(jentry ["retweeted_status"]["user"]), user, usersSeen); + } + return count; + } + + /// + /// Creates an IEnumerable of the tweets, does not store in the database. + /// + /// + /// If the referenceUser is null, the users are parsed from the stream + /// and stored on the database, if not, the tweet information is copied + /// from the reference user. + /// + public static IEnumerable TweetsFromStream (Stream stream, User referenceUser) + { + JsonValue root; + + try { + root = JsonValue.Load (stream); + } catch (Exception e) { + Console.WriteLine (e); + yield break; + } + + var usersSeen = referenceUser == null ? new HashSet () : null; + var user = referenceUser == null ? new User () : referenceUser; + + foreach (JsonObject jentry in root){ + if (referenceUser == null){ + var juser = jentry ["user"]; + ParseUser ((JsonObject) juser, user, usersSeen); + } + + var tweet = FromJsonEntry (jentry, user); + if (referenceUser == null && tweet.Retweeter != null){ + ParseUser ((JsonObject)(jentry ["retweeted_status"]["user"]), user, usersSeen); + } + if (tweet != null) + yield return tweet; + } + } + + // Returns an IEnumerable of tweets when parsing search + // results from twitter. The returned Tweet objects are + // not really complete and have the UserId busted (negative + // numbers) since the userids returned by twitter for + // searches have no relationship with the rest of the system + public static IEnumerable TweetsFromSearchResults (Stream stream) + { + JsonValue root; + long serial = 1; + + try { + root = JsonValue.Load (stream); + } catch (Exception e) { + Console.WriteLine (e); + yield break; + } + + foreach (JsonObject result in root ["results"]){ + Tweet tweet; + + try { + tweet = new Tweet () { + CreatedAt = ParseCreationSearch (result), + Id = (long) result ["id"], + Text = ParseText (result), + Source = Util.StripHtml (result ["source"] ?? ""), + UserId = -(serial++), + Screename = (string) result ["from_user"], + PicUrl = (string) result ["profile_image_url"] + }; + } catch (Exception e){ + Console.WriteLine (e); + tweet = null; + } + if (tweet != null) + yield return tweet; + } + } + + public static Tweet ParseTweet (Stream stream) + { + JsonObject jentry; + + try { + jentry = (JsonObject) JsonValue.Load (stream); + } catch (Exception e) { + Console.WriteLine (e); + return null; + } + try { + var user = new User (); + user.UpdateFromJson ((JsonObject) jentry ["user"]); + Database.Main.Insert (user, "OR REPLACE"); + + return FromJsonEntry (jentry, user); + } catch (Exception e){ + Console.WriteLine (e); + return null; + } + } + + static Tweet FromJsonEntry (JsonObject jentry, User user) + { + var tweet = new Tweet () { Kind = TweetKind.Transient }; + + if (!tweet.TryPopulate (jentry)) + return null; + + PopulateUser (tweet, user); + if (tweet.Retweeter != null){ + user = new User (); + user.UpdateFromJson ((JsonObject)(jentry ["retweeted_status"]["user"])); + Database.Main.Insert (user, "OR REPLACE"); + } + return tweet; + } + + // Populates a Tweet object with cached and useful user information + static void PopulateUser (Tweet tweet, User user) + { + if (tweet.Retweeter != null){ + tweet.UserId = tweet.RetweeterId; + tweet.Screename = tweet.Retweeter; + tweet.PicUrl = tweet.RetweeterPicUrl; + + tweet.RetweeterId = user.Id; + tweet.Retweeter = user.Screenname; + tweet.RetweeterPicUrl = user.PicUrl; + if (tweet.RetweeterPicUrl == null) + Console.WriteLine ("TWEFF"); + } else { + tweet.UserId = user.Id; + tweet.Screename = user.Screenname; + tweet.PicUrl = user.PicUrl; + } + } + + + // + // Creates a tweet from a given ID + // + public static Tweet FromId (long id) + { + return Database.Main.Query ("SELECT * FROM Tweet WHERE Id = ?", id).FirstOrDefault (); + } + + public delegate void LoadCallback (Tweet tweet); + + public static void LoadFullTweet (long id, LoadCallback callback) + { + TwitterAccount.CurrentAccount.Download (new Uri ("http://api.twitter.com/1/statuses/show.json?id="+id), result => { + if (result == null) + callback (null); + + var tweet = Tweet.ParseTweet (new MemoryStream (result)); + if (tweet == null) + callback (null); + + callback (tweet); + }); + } + + public override string ToString () + { + return String.Format ("{0} - {1}", Id, Text); + } + } + + // + // Common fields are stored in the database, the rest is stored as the + // json string representation. Load the uncommon data on demand + // + public class User { + [PrimaryKey] + public long Id { get; set; } + + [Indexed] + public string Screenname { get; set; } + public string PicUrl { get; set; } + public string JsonString { get; set; } + + JsonValue _json; + JsonValue Json { + get { + if (_json == null) + _json = JsonValue.Parse (JsonString); + return _json; + } + } + + // + // Not stored in the database, but parsed from the Json + // as it is not very common + // + public string Name { + get { + return (string) Json ["name"]; + } + } + + public long FollowersCount { + get { + return (long) Json ["followers_count"]; + } + } + + public long FriendsCount { + get { + return (long) Json ["friends_count"]; + } + } + + public long StatusesCount { + get { + return (long) Json ["statuses_count"]; + } + } + + public long FavCount { + get { + return (long) Json ["favourites_count"]; + } + } + + public string Location { + get { + return (string) Json ["location"]; + } + } + + public string Url { + get { + return (string) Json ["url"]; + } + } + + public string Description { + get { + return (string) Json ["description"]; + } + } + + public void UpdateFromJson (JsonObject json) + { + try { + Id = json ["id"]; + Screenname = json ["screen_name"]; + PicUrl = json ["profile_image_url"]; + JsonString = json.ToString (); + } catch (Exception e){ + Console.WriteLine (e); + } + } + + // + // Loads the users from the stream, as a convenience, + // returns the last user loaded (which during lookups is a single one) + // + static public User LoadUsers (Stream source) + { + JsonValue root; + + try { + root = (JsonValue) JsonValue.Load (source); + } catch (Exception e) { + Console.WriteLine (e); + return null; + } + + User user = new User (); + foreach (JsonObject juser in root){ + user.UpdateFromJson (juser); + Database.Main.Insert (user, "OR REPLACE"); + } + return user; + } + + // + // Loads a single user from the stream + // + static public User LoadUser (Stream source) + { + JsonValue root; + + try { + root = JsonValue.Load (source); + } catch (Exception e){ + Console.WriteLine (e); + return null; + } + User user = new User (); + user.UpdateFromJson ((JsonObject) root); + Database.Main.Insert (user, "OR REPLACE"); + return user; + } + + // + // Loads a user from the database + // + public static User FromId (long id) + { + return Database.Main.Query ("SELECT * FROM User WHERE Id = ?", id).FirstOrDefault (); + } + + public static User FromName (string screenname) + { + return Database.Main.Query ("SELECT * From User WHERE Screenname = ?", screenname).FirstOrDefault (); + } + } +} + diff --git a/TweetStation/Data/TwitterAccount.cs b/TweetStation/Data/TwitterAccount.cs new file mode 100644 index 0000000..fe4eb3d --- /dev/null +++ b/TweetStation/Data/TwitterAccount.cs @@ -0,0 +1,266 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading; +using SQLite; +using MonoTouch.UIKit; +using MonoTouch.Foundation; +using System.Text; + +namespace TweetStation +{ + public enum TweetKind { + Home, + Replies, + Direct, + Transient, + } + + public class TwitterAccount + { + const string timelineUri = "http://api.twitter.com/1/statuses/home_timeline.json"; + const string mentionsUri = "http://api.twitter.com/1/statuses/mentions.json"; + const string directUri = "http://api.twitter.com/1/direct_messages.json"; + + const string DEFAULT_ACCOUNT = "defaultAccount"; + + [PrimaryKey, AutoIncrement] + public int LocalAccountId { get; set; } + + public long AccountId { get; set; } + public long LastLoaded { get; set; } + public string Username { get; set; } + public string Password { get; set; } + + static NSString invoker = new NSString (""); + + static Dictionary accounts = new Dictionary (); + + public static TwitterAccount FromId (int id) + { + if (accounts.ContainsKey (id)){ + return accounts [id]; + } + + var account = Database.Main.Query ("select * from TwitterAccount where LocalAccountId = ?", id).FirstOrDefault (); + if (account != null) + accounts [account.LocalAccountId] = account; + + return account; + } + + public static TwitterAccount CurrentAccount { get; set; } + + public static TwitterAccount GetDefaultAccount () + { + if (File.Exists ("/Users/miguel/tpass")){ + using (var f = System.IO.File.OpenText ("/Users/miguel/tpass")){ + var ta = new TwitterAccount () { + Username = f.ReadLine (), + Password = f.ReadLine () + }; + Database.Main.Insert (ta, "OR IGNORE"); + using (var f2 = File.OpenRead ("home_timeline.json")){ + Tweet.LoadJson (f2, ta.LocalAccountId, TweetKind.Home); + } + accounts [ta.LocalAccountId] = ta; + CurrentAccount = ta; + return ta; + } + } + + var account = FromId (Util.Defaults.IntForKey (DEFAULT_ACCOUNT)); + CurrentAccount = account; + return account; + } + + public void ReloadTimeline (TweetKind kind, long? since, long? max_id, Action done) + { + string uri = null; + switch (kind){ + case TweetKind.Home: + uri = timelineUri; break; + case TweetKind.Replies: + uri = mentionsUri; break; + case TweetKind.Direct: + uri = directUri; break; + } + var req = new Uri (uri + "?count=200" + + (since.HasValue ? "&since_id=" + since.Value : "") + + (max_id.HasValue ? "&max_id=" + max_id.Value : "")); + + Download (req, result => { + if (result == null) + done (-1); + else { + int count = -1; + try { + count = Tweet.LoadJson (new MemoryStream (result), LocalAccountId, kind); + } catch (Exception e) { + Console.WriteLine (e); + } + done (count); + } + }); + } + + internal struct Request { + public Uri Url; + public Action Callback; + + public Request (Uri url, Action callback) + { + Url = url; + Callback = callback; + } + } + + const int MaxPending = 200; + static Queue queue = new Queue (); + static int pending; + + /// + /// Throttled data download from the specified url and invokes the callback with + /// the resulting data on the main UIKit thread. + /// + public void Download (Uri url, Action callback) + { + lock (queue){ + pending++; + if (pending++ < MaxPending) + Launch (url, callback); + else { + queue.Enqueue (new Request (url, callback)); + //Console.WriteLine ("Queued: {0}", url); + } + } + } + + // This is required because by default WebClient wont authenticate + // until challenged to. Twitter does not do that, so we need to force + // the pre-authentication + class AuthenticatedWebClient : WebClient { + protected override WebRequest GetWebRequest (Uri address) + { + var req = (HttpWebRequest) WebRequest.Create (address); + req.PreAuthenticate = true; + + return req; + } + } + + WebClient GetClient () + { + System.Net.ServicePointManager.Expect100Continue = false; + return new AuthenticatedWebClient (){ + Credentials = new NetworkCredential (Username, Password), + }; + } + + void Launch (Uri url, Action callback) + { + var client = GetClient (); + + client.DownloadDataCompleted += delegate(object sender, DownloadDataCompletedEventArgs e) { + lock (queue) + pending--; + + Util.PopNetworkActive (); + + invoker.BeginInvokeOnMainThread (delegate { + try { + if (e == null) + callback (null); + callback (e.Result); + } catch (Exception ex){ + Console.WriteLine (ex); + } + }); + + lock (queue){ + if (queue.Count > 0){ + var request = queue.Dequeue (); + Launch (request.Url, request.Callback); + } + } + }; + Util.PushNetworkActive (); + Console.WriteLine ("Fetching: {0}", url); + client.DownloadDataAsync (url); + } + + public void SetDefaultAccount () + { + NSUserDefaults.StandardUserDefaults.SetInt (LocalAccountId, DEFAULT_ACCOUNT); + } + + // + // Posts the @contents to the @url. The post is done in a queue + // system that is flushed regularly, so it is safe to call Post to + // fire and forget + // + public void Post (string url, string content) + { + var qtask = new QueuedTask () { + AccountId = LocalAccountId, + Url = url, + PostData = content, + }; + Database.Main.Insert (qtask); + + FlushTasks (); + } + + void FlushTasks () + { + var tasks = Database.Main.Query ("SELECT * FROM QueuedTask ORDER BY TaskId DESC").ToArray (); + ThreadPool.QueueUserWorkItem (delegate { PostTask (tasks); }); + } + + // + // TODO ITEMS: + // * Need to change this to use HttpWebRequest, since I need to erad + // the result back and create a tweet out of it, and insert in DB. + // + // * Report error to the user? Perhaps have a priority flag + // (posts show dialog, btu starring does not? + // + // Runs on a thread from the threadpool. + void PostTask (QueuedTask [] tasks) + { + var client = GetClient (); + try { + Util.PushNetworkActive (); + foreach (var task in tasks){ + client.UploadData (new Uri (task.Url), "POST", Encoding.UTF8.GetBytes (task.PostData)); + invoker.BeginInvokeOnMainThread (delegate { + try { + Database.Main.Execute ("DELETE FROM QueuedTask WHERE TaskId = ?", task.TaskId); + } catch (Exception e){ + Console.WriteLine (e); + } + }); + } + } catch (Exception e) { + Console.WriteLine (e); + } finally { + Util.PopNetworkActive (); + } + } + + public class QueuedTask { + [PrimaryKey, AutoIncrement] + public int TaskId { get; set; } + public long AccountId { get; set; } + public string Url { get; set; } + + public string PostData { get; set; } + } + } + + public interface IAccountContainer { + TwitterAccount Account { get; set; } + } +} diff --git a/TweetStation/Dialogs/EditAccount.cs b/TweetStation/Dialogs/EditAccount.cs new file mode 100644 index 0000000..2fe2143 --- /dev/null +++ b/TweetStation/Dialogs/EditAccount.cs @@ -0,0 +1,92 @@ +using System; +using System.Net; +using MonoTouch.Dialog; +using MonoTouch.UIKit; + +namespace TweetStation +{ + public class EditAccount : UINavigationController { + class AccountInfo { + [Section ("Account", "If you do not have a twitter account,\nvisit http://twitter.com")] + + [Entry ("Twitter screename")] + public string Login; + + [Password ("Twitter password")] + public string Password; + } + + void CheckCredentials (AccountInfo info, Action result) + { + Util.PushNetworkActive (); + var http = (HttpWebRequest) WebRequest.Create ("http://api.twitter.com/1/statuses/home_timeline.json"); + http.Credentials = new NetworkCredential (info.Login, info.Password); + http.BeginGetResponse (delegate (IAsyncResult iar){ + HttpWebResponse response = null; + try { + response = (HttpWebResponse) http.EndGetResponse (iar); + } catch (WebException we){ + BeginInvokeOnMainThread (delegate { + result (we.Message); + }); + return; + } catch (Exception) { + BeginInvokeOnMainThread (delegate { + result ("General error"); + }); + return; + } + + BeginInvokeOnMainThread (delegate { + result (response.StatusCode == HttpStatusCode.OK ? null : "Attempted to login failed");}); + }, null); + } + + public EditAccount (IAccountContainer container, TwitterAccount account, bool pushing) + { + var info = new AccountInfo (); + bool newAccount = account == null; + + if (newAccount) + account = new TwitterAccount (); + else { + info.Login = account.Username; + info.Password = account.Password; + } + + var bc = new BindingContext (this, info, "Edit Account"); + var dvc = new DialogViewController (bc.Root, true); + PushViewController (dvc, false); + UIBarButtonItem done = null; + done = new UIBarButtonItem (UIBarButtonSystemItem.Done, delegate { + bc.Fetch (); + + done.Enabled = false; + CheckCredentials (info, delegate (string errorMessage) { + Util.PopNetworkActive (); + done.Enabled = true; + + if (errorMessage == null){ + account.Username = info.Login; + account.Password = info.Password; + + if (newAccount) + Database.Main.Insert (account); + else + Database.Main.Update (account); + + account.SetDefaultAccount (); + DismissModalViewControllerAnimated (true); + container.Account = account; + } else { + using (var dlg = new UIAlertView ("Login error", errorMessage, null, "Close")){ + dlg.Show (); + } + } + }); + }); + + dvc.NavigationItem.SetRightBarButtonItem (done, false); + } + } +} diff --git a/TweetStation/Images/arrow.png b/TweetStation/Images/arrow.png new file mode 100644 index 0000000000000000000000000000000000000000..54ec74629034d82e9a07cc74e86661be36acf974 GIT binary patch literal 384 zcmeAS@N?(olHy`uVBq!ia0vp^{6H+s!3HG#4~4G+QY^(zo*^7SP{WbZ0pxQQctjR6 zFmQbaVaE5OqF;f6k|nMYCBgY=CFO}lsSM@i<$9TU*~Q6;1*v-ZMd`EO*+>Bu{q=Nl z46*2adwH)Hv!h7cLv{N$7e|g8H!`v%9l7^1ePr5pKTmP*gWM&z@@{X~rlc91nV6{c zk#CEDb-jS9nBNtZ-C606uk%!&`+ZI(?Z&AJo3JSwC(8<&{@V1O@0#H?QAYJ9+qsP` z)j7{IYrZZ9^0f3eG)cu3cAvHBUB7?RRmnn0=Pk_|YjSy<>*M++t!ZSuc45ZM4T7(l z57eFtoD_P&o&D@i?+Sn8FTWfATkLGN-Qsn`Gpg~m|m6yL-xk%;pgDok4+}}LQw3#KciuJoy9D0VKQe#;!QkoY=d#Wzp$Pzk_ns2~ literal 0 HcmV?d00001 diff --git a/TweetStation/Images/default_profile_4_normal.png b/TweetStation/Images/default_profile_4_normal.png new file mode 100644 index 0000000000000000000000000000000000000000..2a46f9d5cfa7abd67da65f751ed28deca4cf1f72 GIT binary patch literal 2096 zcmV-02+#M4P){36cVB z|9XE%KNKliw1FMiiDc{IXjj?`$vOQn9CFCz>bCmQ18BvaA!pwA+&L?M`|(KmKbH8R z7nB?3A6i>m5s(|c_@M^0wr|t!{N3f=1f=}|fT16dze7Yo!PehFRjdE^!*M49(zy_? zX<5Io>q;6-)F$Q;C(zcTcAhtJ_`Jp2{kUk zJpuaCD#JM9yXRxFLBt?3SXB-u87G%3PG&0?3s8e@#nD||r~xAzH7|i>LeQbsMI89Z z2;aRJGf9(rZni8rnl3oHSWsF^WTdJQPV=@}$=3)M6D~V-$!u^T39g%w7I@adw*tHR z`{T1w3p_xY7+&vZyqaWux?J-9e9pXZM8d-6L&2}uM`E3T&nTg`do{m49=cs&l}Q%! zY&2ZkVh|aK-1S$39S%lA{{3mj@pMHTbuCf3*9KQI;g?Ohyp@6If^YtezqcZNxS#cG zxh`oE@%_sQFZYHNrEO_mv;pDkM=ycU_ZzTb97qeS)MJ5FL@-9MR@u)I9%X}D5%BWO z!I*IxQ);tcUFm~fB;;V)OLSE3iL~;2kXlJeCz^0GQ#Wqj6c1aFk2SA zEq5ym)xp{-sX>lc$PzF5b3V^ewHRaAOSgN`d$K#=udlyg7#Vg3 z5kH^LIi4*^DmPMQRa&wn z+KPaP@I{u8mzE!or~Gts$sjhQam1o1>nsH=3rN_a*CYh6R(U!eGD#Cwg_0MRG%*b0 z#sl}#m^aVH9M6`-+xSl)^SmUBBmVs5J^=4O&$(I^Ef2Z27xGc83VGpD<9O&Ev3>p+ z35S#PLBIoyhcQWHIJ;W$?rhF3}?+%zQ3gSq};%KWNy^Z|* zdCrH=a~4I(vapz{#)RO7o;2Dgd1*-^;qmT(G}_L;Z!4#lD}I|7Saq#Ow4tcf1U9v- zQl-iBtDHq%-nls4M@m(eg=G*+U0eF_)mf-J=u;+W9WY0;C8t*_9_v#-Oyd-}^q$ z(z-O@+prWthz*Ds=BtvQPOmt+Sn_n_tdc~x3aM+wDjZK2fil<6i$W5u9WB1=3!c#C z=tKRyf-ypDgv(XIFBgm6w0a*oo-UX!3Zm-3t80O7M6}-r#PuI}=^j(*m@MQ4tV&B_ z3~vs`t)g~cDXsGMY>xCSZrrLT)XSST2_T+7#qHM zKB|k`w(|DeRoWvO0r&3);d}BLf#z64_{XnPemLCYFJJC6T@-v?Q+L z<6-h3F5~z8@5zjJ=ZiX#SbqO_m+_YE@g|w)CI9+3 z`3VG+}2D5gTd!E}*yr+`LrgtAdMV z$!wW3TNTW6iz-A0V&ele@Oc`|H{aDfslzx)7e$O(N&8&wfZ9!FJT91>FKG$;^g=6ZbQ(n+yeZK z1pEdfz8Y8O_tk98O`C%+4^O)5(AI3jC&=re)_d-~Gl4DwZ;ikxb=14#KWRPEMy0)~dg^;^JnP>B^l-tWUAfSi_I`BR{JhpYJ?h z-B$a1=yrDn5BK<`KJ-`ddaK<@)|#!p=evOK3ObannjGz12zqNU+kg(`LA$grDA_Rn akn%rvHkt4t+=!b100003P-LAPHGeFcKjuK`~L{ zB?k|=xFC@n1-%3WF~*Ca7YTuQ$W4!;2k|I)5){FBh_HBxF~JQ+Wu0C(*_~N78)o`> zm>w85yEC0(&0e;k=;HCK_r7{n^@QuSD=7_G+`)o*UQC zSQ$u?@MRHR0bVqdH^&V=Hh@`hwm$^k5Rn}Z3}AAyb_93<nwN1p(vfZZEp>_)x5 z{P?wNwhAC3J5=58z#{{)9QYf!0WvFMUpJerFGm81h^V@GAO_lC-ve`I@=p*j2h6Rm z2H!3&E-nl@iQ|cVl}a!zB2#9zA7lz_KS)?eu4h^6v2|0hc2D9sJg(}NRh_AZZ;!LD zy|dwtsk&CrkNbEd-MU8aqob{mX-SjB^2xsnqs{2dTnfKvGqgNhXD1iS>lITbo;CpVt+Los7$!=GE0cNtS z_0&c>5vi)W6<`JAaF#7y9qPDfBP(I2LsA^pnPECJ>zLV}z^?iE)@+&Jr2s_a`QA79 zv4nO8c*y(kiA@859zeHimjI@wWS45~jQ3H~``YCssT~~<7g-7pZ3=*h2-tJL;;mcl zE8e@;+U@995jzLOV9%J@58l^4OA?ow$qYcA+vWhou{!|l0b5XwF7=*Y+XsA6tyUie zIRQ2ccFIhyirBFpIHduG-U9DyuY-L$VEPX7cd~5xa^J9buk5~a$Gr!f0d^MV+gaAC zmjM`Y2JAYp09*k%k!7uy27?3KylE?0)_PwgI0*7hVO~npG`z>%x*QK$!8;-MT+OLG}_)Usv{9ERtf%Qhyq%o&tqVdAc};Se$-4 z`=F7b=hEIc&)+*pH~LtI3*3GAYHB5eOu&g<6TU7m`FnHnxwdO}W}n#=dh40S z&+4}89wI+yf1sBif8lwaoHGG9(x4)=5a|DTXm`_P`z z!1}>~p)7TM)o0y|Ngq$FQHV7QOTN*-n6Wi~>;0azvvW1No;|re@lE>Tx#!YE?lqiC z5RF1bhj?rQ7fWBey MboFyt=akR{0E*IotN;K2 literal 0 HcmV?d00001 diff --git a/TweetStation/Images/home.png b/TweetStation/Images/home.png new file mode 100644 index 0000000000000000000000000000000000000000..3a6e77aca1fb5c67248071dddf6e04b358cc1de1 GIT binary patch literal 431 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz#^NA%Cx&(BWL^R}Y)RhkE)4%c zaKYZ?lYt_f1s;*b3=G`DAk4@xYmNj^kiEpy*OmPiD>JLAQS<3CG(J%@6Ddwu1MnB4^gXZj`W+F zTKDtB4?X-;h&~?{9 z@a%_;P$Gdc@QR4P`bf0Hu($8=w2rcmuA TMaDzGpkwfK^>bP0l+XkK#KEUV literal 0 HcmV?d00001 diff --git a/TweetStation/Images/lupa.png b/TweetStation/Images/lupa.png new file mode 100644 index 0000000000000000000000000000000000000000..b8c878beaadd7f6de1261990656f8b28eac1c7e5 GIT binary patch literal 571 zcmV-B0>u4^P)Px#24YJ`L;(K){{a7>y{D4^000SaNLh0L01FcU01FcV0GgZ_00007bV*G`2igS# z1vEC-e-ZKk000?uMObu0Z*6U5Zgc=ca%Ew3Wn>_CX>@2HM@dakSAh-}0004&Nkl0}!M?k+91U6NZb6^VS1|q;LkXGY2K-9ANf-1fT1pdHu0jCPLZ`C{s6Jr4MY!WS(F zR8+x$13sYe6$=e~sXXLRFR1cY3j%j4A9JWTs`6_K0%t0BvD#^sA6jT2uGTaM3^wqL z7s2L%UMn*?P$W`f!8%5Ob>LCq7A>u~Pxf@Rcv}2FEEc>bVy%vEdvO>I{4%9lPYIau z@<>hqyTF6&^AAz`iR5MA5{NbXq7oDx6oF4*xfQpi7?6>r$hgizharJCxebj2oiZya z(M3~d;X<0`fMhF{A}P(Qf}~Y~EXZzYczjw%0ayW^{l6HO_yN)3d@RfQ+{6F?002ov JPDHLkV1iC@;uZh^ literal 0 HcmV?d00001 diff --git a/TweetStation/Images/messages.png b/TweetStation/Images/messages.png new file mode 100644 index 0000000000000000000000000000000000000000..624eafbae1042f53b32c400e5dc544f821ffa545 GIT binary patch literal 629 zcmV-*0*d{KP)3Nx0o)Q|5Z+4pnPA<%tz~101pe}2WV?#yIWPiYj^Mq@2Dr9nW~Ed^Z0@Vyux4; zzz0jLMLa7B_AUFXuiy}BrR)RvhRKK+i-_)>);uD*BVr7b_=z)kUEIgk9058DyC)Uf zWqiU%jO-xmk+SDAu2k%I3&y$ij$jReVy|Hwztvwy|0clwWz7=qp{K#WhQOAMC^Nri z<~0oA2&N)pG$J}HGlAZ1>{~-%!&baS)n;)iBF4-Ar@M*6CbM9hM0O3lEG?ggRRYUH;ou(N zWurl6E?^d?t52-Sa<=@wU$HdY((%IXK59)IMjg0WV!y2V8pm-BFK|-5o6R*T^PWOt z9_KT2dIhseppfXoEezwh`jq@vnZtA3&dd+1@EQs1wftAlaG$_FftJW0NS{%u<Px#24YJ`L;(K){{a7>y{D4^000SaNLh0L01FcU01FcV0GgZ_00007bV*G`2igS# z1Uepli~g|y000?uMObu0Z*6U5Zgc=ca%Ew3Wn>_CX>@2HM@dakSAh-}0006>NklB_`fr!7{5_L(y=fJK%_~ZcXPQv@5CU9B;jRUa|zHQ?B&R7a1JAkAGNCFiR z3b-vvbcS`lDbAN7GFWO2nFi*=6cGnfQvFuPo)26GrfiKTfs?=!;1w_yz`|MI1dfYS zk7Fyg376W2y)wQ<@WI8l*kaNk`Xk473)pFThb2^LJ0v5MqCa)|%1g#2>ng$b3qIx) za93=tf)@&&@MWM_^cm5QiGBlEkpD%ZPl^6D%F#b3S#K14LU8v$(!S7cFA@B;ZvtP$ zDNpc303LUmFjs8Vg5UF9g3kfeswn*j9ox@1l#6ZHu=fr# zOENGldR+j1-a5S9CY%@BIiLVI0UR*wlpR`LvRkTo3uGH!WfN(&DWkbR;86MnoQZOd z4@r=tf;ZbYKM6br>J5(pSMAdB!|noimh&mDw?@mXU%}tFGhPuE9@u4LO=Yvw;X}ao zwN7FSFd#{M1FqTac$=}3XWQ}u(7hT-WZRbd=y4;S4jb4<(!i)y{>*kjmb?{G_AYo~ gXGQ<|lk1<6-z9X@3!rjjzyJUM07*qoM6N<$g0=!YE&u=k literal 0 HcmV?d00001 diff --git a/TweetStation/Images/shrink.png b/TweetStation/Images/shrink.png new file mode 100644 index 0000000000000000000000000000000000000000..bcf758641ecac0431a9e45dc99597b79aabf1f2b GIT binary patch literal 330 zcmeAS@N?(olHy`uVBq!ia0vp^Vj#@H1|*Mc$*~4fjKx9jP7LeL$-D$|*pj^6T^Rm@ z;DWu&Cj&(|3p^r=85p>QK$!8;-MT+OLG}_)Usv{9ERtf%Qhyq%o&tqVdAc};Se$-4 z`=F7b=hEIc&)+*pH~LtI3*3GAYHB5eOu&g<6TU7m`FnHnxwdO}W}n#=dh40S z}2KX-x!Mth+=2b&5ud)Uab+A1YqcJ}0s5Jdkubqvh-ma&=h3zHD}H=Q zzq^aY#YHO~55-K=j4LH^5s7Q9KNewnVq%(b_zVCmD+TKHDkAck>+-d2yTuC^E>{K( zCi(o*uRszw3&?SUKp@D}RO+Z*t5qnKmXDj2fnxj6MWu{y)9FmMTjH*DET7L_0pT@4i1_e95hkNjRH|%4g}NWJaFx?!NTFa zrFz}|QEOf@41+{s3fms(<^n(@5+f4%2iSF8HaGudXJ-olt@U5MVsxGS6BAeVYBlv5 z@SW?rtgICD?ryCo*m0H18%Izg`omOecA=L~zmwm{<(9q@;Vr{3bRsb&q0pJ0PFic$ z)*eu)Yy;91QEw-cbKmrnu7>h&r>MZ|bLnSB1I zev^5rV|r$0?gJ71C1@&@?$cy5pP+q}0E;RtZKhV|6G>_KRMmjy~c+TS0 zL8*562nNGl?E(QC5dla$j^lr#V2ZQ8P_6C|i;WYFp2N0-G#Yyt1{t7hr9UoD zBPJsAo$F#T$NGAy1N95&SAg3}xom9w!TtMrlxlBewN@D~dBFSDdLE$B*z2_OBHT(Q zXTLve-_GT-S3!T?XzWin8vC6Nc?R=1`So`DEbE}(O{EIYJpu*5%uMD7pKtV%)_f$w zVGs1US1>OH7JytXdsS=y&*^l!=eH@J&pr$Kx?!kKE?&I6^+dt`FFm#Yl|wME8iIM% c5X`Io2G9FNJy<=3)&Kwi07*qoM6N<$g24dMjsO4v literal 0 HcmV?d00001 diff --git a/TweetStation/Images/star-on.png b/TweetStation/Images/star-on.png new file mode 100644 index 0000000000000000000000000000000000000000..0b1175babf8fed24eaab0d79d929744ca93a7759 GIT binary patch literal 1098 zcmV-Q1hxB#P)d(J)QmwV5tde_M(j42KX=K!R#szW>%%AcN;> zYYfZy71BWN-sct@G&aOngK8~`s%KNS+2DgKr8*edVIw} zYARc^2I0uSNWFh2vA13PBO~HW5GpgwOofS2A!=0!@&>^Edmja$$g_`tua?-2h}WGl z`-V>kKXZwDqO0q-xLq*G-S&tKpAL?S$o&fU0CKB`x2T$j9z2F>3e_a@u`7&U9lRwB zF;ONInGkeB)%v`rJNiQ|u$((Kbgr&V#A;VS#EIJ#z*SI+%jKiM>Ba3TrlzuO)0%z$ z1KhX&5fYhnF%NaKwA|M!%+`2$pyg*I&I%rcYHzc9Nn1AtoALaw~9Me#q|7D-kG%AXdYKb^V3#mld2 zEIz(f%zx?6>yevK@ODpkq<2SP0EZ4;On)~Tc~0TeOeP^?<9)gi%UK>9Qnk1`b&2Tg z1i+fAJ|FIydT%>xw-Z^>2ZW`NQD%uX<_Z`gX=k@pz5kzV1?{9Y5&10+W>F;lLc>^2Aa((e<>Z)6*E^naN<0p}f14heT1yeEFQ4oX+V{eg2PG=3mH)6{B>d=== zbM49BqZgQ&{}E4NN!D&?i=8~S!BA-jWqEaSec~roc{AKKwcfFQIdmoz7OamFsdz&o z6<=Rx8_ZVmD=cP5GK + + + + CFBundleURLTypes + + + CFBundleURLName + org.tirania.tweetstation + CFBundleURLSchemes + + tweetstation + + + + SBUsesNetwork + + + diff --git a/TweetStation/Main.cs b/TweetStation/Main.cs new file mode 100644 index 0000000..d8fded8 --- /dev/null +++ b/TweetStation/Main.cs @@ -0,0 +1,116 @@ + +using System; +using System.IO; +using System.Collections.Generic; +using System.Linq; +using System.Json; +using MonoTouch.Foundation; +using MonoTouch.UIKit; +using MonoTouch.Dialog; + +namespace TweetStation +{ + public class Application + { + static void Main (string[] args) + { + UIApplication.Main (args); + } + } + + // The name AppDelegate is referenced in the MainWindow.xib file. + public partial class AppDelegate : UIApplicationDelegate, IAccountContainer + { + TwitterAccount account; + TimelineViewController main, mentions, messages; + SearchesViewController searches; + StreamedTimelineViewController favorites; + public UIView MainView; + + UINavigationController [] navigationRoots; + + public override bool FinishedLaunching (UIApplication app, NSDictionary options) + { + CreatePhoneGui (); + if (options != null){ + var url = options.ObjectForKey (UIApplication.LaunchOptionsUrlKey) as NSUrl; + Console.WriteLine ("The url was: {0}", url.AbsoluteUrl); + } + return true; + } + + void CreatePhoneGui () + { + MainView = tabbarController.View; + window.AddSubview (MainView); + + main = new TimelineViewController ("Friends", TweetKind.Home, false); + mentions = new TimelineViewController ("Mentions", TweetKind.Replies, false); + messages = new TimelineViewController ("Messages", TweetKind.Direct, false); + searches = new SearchesViewController (); + favorites = new StreamedTimelineViewController ("Favorites", new Uri ("http://api.twitter.com/version/favorites.json")); + + navigationRoots = new UINavigationController [5] { + new UINavigationController (main) { + TabBarItem = new UITabBarItem ("Friends", UIImage.FromFileUncached ("Images/home.png"), 0), + }, + new UINavigationController (mentions) { + TabBarItem = new UITabBarItem ("Mentions", UIImage.FromFileUncached ("Images/replies.png"), 1) + }, + new UINavigationController (messages) { + TabBarItem = new UITabBarItem ("Messages", UIImage.FromFileUncached ("Images/messages.png"), 2) + }, + new UINavigationController (favorites) { + TabBarItem = new UITabBarItem ("Favorites", UIImage.FromFileUncached ("Images/fav.png"), 3) + }, + new UINavigationController (searches) { + TabBarItem = new UITabBarItem ("Search", UIImage.FromFileUncached ("Images/lupa.png"), 3) + } + }; + + tabbarController.SetViewControllers (navigationRoots, false); + window.MakeKeyAndVisible (); + + var defaultAccount = TwitterAccount.GetDefaultAccount (); + if (defaultAccount == null){ + var editor = new EditAccount (this, null, false); + tabbarController.PresentModalViewController (editor, false); + } + Account = defaultAccount; + } + + public TwitterAccount Account { + get { + return account; + } + + set { + this.account = value; + + main.Account = account; + searches.Account = account; + mentions.Account = account; + favorites.Account = account; + messages.Account = account; + } + } + + // + // Dispatcher that can open various assorted link-like text entries + // + public void Open (DialogViewController controller, string data) + { + if (data.Length == 0) + return; + if (data [0] == '@'){ + var profile = new FullProfileView (Util.CleanName (data.Substring (1))); + controller.ActivateController (profile); + } else if (data [0] == '#'){ + var search = new SearchViewController (data.Substring (1)) { Account = TwitterAccount.CurrentAccount }; + controller.ActivateController (search); + } else + WebViewController.OpenUrl (controller, data); + } + + } +} diff --git a/TweetStation/MainWindow.xib b/TweetStation/MainWindow.xib new file mode 100644 index 0000000..4969d52 --- /dev/null +++ b/TweetStation/MainWindow.xib @@ -0,0 +1,395 @@ + + + + 768 + 10D573 + 785 + 1038.29 + 460.00 + + com.apple.InterfaceBuilder.IBCocoaTouchPlugin + 110 + + + YES + + + + + YES + com.apple.InterfaceBuilder.IBCocoaTouchPlugin + + + YES + + YES + + + YES + + + + YES + + IBFilesOwner + IBCocoaTouchFramework + + + IBFirstResponder + IBCocoaTouchFramework + + + IBCocoaTouchFramework + + + + 1316 + + {320, 480} + + + 1 + MSAxIDEAA + + NO + NO + + IBCocoaTouchFramework + + + + + NO + + + + 1 + + IBCocoaTouchFramework + NO + + + IBCocoaTouchFramework + + 4 + + + + + 1 + + IBCocoaTouchFramework + NO + + + 256 + {0, 0} + NO + YES + YES + IBCocoaTouchFramework + + + YES + + + + Friends + + IBCocoaTouchFramework + 1 + + 13 + + + IBCocoaTouchFramework + 1 + + 7 + + IBCocoaTouchFramework + + + + 1 + + IBCocoaTouchFramework + NO + + + + + YES + + + + + 266 + {{129, 330}, {163, 49}} + + 3 + MCAwAA + + NO + IBCocoaTouchFramework + + + + + + YES + + + delegate + + + + 5 + + + + window + + + + 7 + + + + tabbarController + + + + 53 + + + + + YES + + 0 + + + + + + 2 + + + YES + + + + + -1 + + + File's Owner + + + 4 + + + App Delegate + + + -2 + + + + + 47 + + + YES + + + + + + + 48 + + + + + 54 + + + YES + + + + + + + + 56 + + + + + 55 + + + YES + + + + + + 57 + + + YES + + + + + + + 58 + + + + + 59 + + + + + 60 + + + + + + + YES + + YES + -1.CustomClassName + -2.CustomClassName + 2.IBAttributePlaceholdersKey + 2.IBEditorWindowLastContentRect + 2.IBPluginDependency + 2.UIWindow.visibleAtLaunch + 4.CustomClassName + 4.IBPluginDependency + 47.IBEditorWindowLastContentRect + 47.IBPluginDependency + 48.IBPluginDependency + 54.IBEditorWindowLastContentRect + 54.IBPluginDependency + 55.IBPluginDependency + 56.IBPluginDependency + 57.IBPluginDependency + 59.IBPluginDependency + 60.IBPluginDependency + + + YES + UIApplication + UIResponder + + YES + + + YES + + + {{225, 276}, {320, 480}} + com.apple.InterfaceBuilder.IBCocoaTouchPlugin + + AppDelegate + com.apple.InterfaceBuilder.IBCocoaTouchPlugin + {{225, 123}, {320, 480}} + com.apple.InterfaceBuilder.IBCocoaTouchPlugin + com.apple.InterfaceBuilder.IBCocoaTouchPlugin + {{21, 342}, {320, 480}} + com.apple.InterfaceBuilder.IBCocoaTouchPlugin + com.apple.InterfaceBuilder.IBCocoaTouchPlugin + com.apple.InterfaceBuilder.IBCocoaTouchPlugin + com.apple.InterfaceBuilder.IBCocoaTouchPlugin + com.apple.InterfaceBuilder.IBCocoaTouchPlugin + com.apple.InterfaceBuilder.IBCocoaTouchPlugin + + + + YES + + + YES + + + + + YES + + + YES + + + + 60 + + + + YES + + AppDelegate + + YES + + YES + tabbarController + window + + + YES + id + id + + + + YES + + YES + tabbarController + window + + + YES + + tabbarController + id + + + window + id + + + + + IBUserSource + + + + + + 0 + IBCocoaTouchFramework + + com.apple.InterfaceBuilder.CocoaTouchPlugin.iPhoneOS + + + + com.apple.InterfaceBuilder.CocoaTouchPlugin.InterfaceBuilder3 + + + YES + + 3 + 110 + + diff --git a/TweetStation/MainWindow.xib.designer.cs b/TweetStation/MainWindow.xib.designer.cs new file mode 100644 index 0000000..2f16543 --- /dev/null +++ b/TweetStation/MainWindow.xib.designer.cs @@ -0,0 +1,47 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Mono Runtime Version: 2.0.50727.1433 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ + +namespace TweetStation { + + + // Base type probably should be MonoTouch.Foundation.NSObject or subclass + [MonoTouch.Foundation.Register("AppDelegate")] + public partial class AppDelegate { + + private MonoTouch.UIKit.UIWindow __mt_window; + + private MonoTouch.UIKit.UITabBarController __mt_tabbarController; + + #pragma warning disable 0169 + [MonoTouch.Foundation.Connect("window")] + private MonoTouch.UIKit.UIWindow window { + get { + this.__mt_window = ((MonoTouch.UIKit.UIWindow)(this.GetNativeField("window"))); + return this.__mt_window; + } + set { + this.__mt_window = value; + this.SetNativeField("window", value); + } + } + + [MonoTouch.Foundation.Connect("tabbarController")] + private MonoTouch.UIKit.UITabBarController tabbarController { + get { + this.__mt_tabbarController = ((MonoTouch.UIKit.UITabBarController)(this.GetNativeField("tabbarController"))); + return this.__mt_tabbarController; + } + set { + this.__mt_tabbarController = value; + this.SetNativeField("tabbarController", value); + } + } + } +} diff --git a/TweetStation/Settings.bundle/Root.plist b/TweetStation/Settings.bundle/Root.plist new file mode 100644 index 0000000..c757dae --- /dev/null +++ b/TweetStation/Settings.bundle/Root.plist @@ -0,0 +1,10 @@ +{ + Title = AppSettings; + StringsTable = Root; + PreferenceSpecifiers = ( + { + Type = PSGroupSpecifier; + Title = Options; + }, + ); +} \ No newline at end of file diff --git a/TweetStation/SqliteNet/SQLite.cs b/TweetStation/SqliteNet/SQLite.cs new file mode 100644 index 0000000..f4c0e09 --- /dev/null +++ b/TweetStation/SqliteNet/SQLite.cs @@ -0,0 +1,1171 @@ +// +// Copyright (c) 2009-2010 Krueger Systems, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +using System; +using System.Runtime.InteropServices; +using System.Collections.Generic; +using System.Reflection; +using System.Linq; +using System.Linq.Expressions; + +namespace SQLite +{ + public class SQLiteException : System.Exception + { + public SQLite3.Result Result { get; private set; } + protected SQLiteException (SQLite3.Result r, string message) : base(message) + { + Result = r; + } + public static SQLiteException New (SQLite3.Result r, string message) + { + return new SQLiteException (r, message); + } + } + + /// + /// Represents an open connection to a SQLite database. + /// + public class SQLiteConnection : IDisposable + { + private bool _open; + private Dictionary _mappings = null; + private Dictionary _tables = null; + + private System.Diagnostics.Stopwatch _sw; + private long _elapsedMilliseconds = 0; + + public IntPtr Handle { get; private set; } + public string DatabasePath { get; private set; } + + public int MaxExecuteAttempts { get; set; } + public bool TimeExecution { get; set; } + public bool Trace { get; set; } + + /// + /// Constructs a new SQLiteConnection and opens a SQLite database specified by databasePath. + /// + /// + /// Specifies the path to the database file. + /// + public SQLiteConnection (string databasePath) + { + MaxExecuteAttempts = 10; + DatabasePath = databasePath; + IntPtr handle; + var r = SQLite3.Open (DatabasePath, out handle); + Handle = handle; + if (r != SQLite3.Result.OK) { + throw SQLiteException.New (r, "Could not open database file: " + DatabasePath); + } + _open = true; + } + + /// + /// Returns the mappings from types to tables that the connection + /// currently understands. + /// + public IEnumerable TableMappings { + get { + if (_tables == null) { + return Enumerable.Empty (); + } else { + return _tables.Values; + } + } + } + + /// + /// Retrieves the mapping that is automatically generated for the given type. + /// + /// + /// The type whose mapping to the database is returned. + /// + /// + /// The mapping represents the schema of the columns of the database and contains + /// methods to set and get properties of objects. + /// + public TableMapping GetMapping (Type type) + { + if (_mappings == null) { + _mappings = new Dictionary (); + } + TableMapping map; + if (!_mappings.TryGetValue (type.FullName, out map)) { + map = new TableMapping (type); + _mappings[type.FullName] = map; + } + return map; + } + + /// + /// Executes a "create table if not exists" on the database. It also + /// creates any specified indexes on the columns of the table. It uses + /// a schema automatically generated from the specified type. You can + /// later access this schema by calling GetMapping. + /// + /// + /// The number of entries added to the database schema. + /// + public int CreateTable () + { + var ty = typeof(T); + + if (_tables == null) { + _tables = new Dictionary (); + } + TableMapping map; + if (!_tables.TryGetValue (ty.FullName, out map)) { + map = GetMapping (ty); + _tables.Add (ty.FullName, map); + } + var query = "create table if not exists \"" + map.TableName + "\"(\n"; + + var decls = map.Columns.Select (p => Orm.SqlDecl (p)); + var decl = string.Join (",\n", decls.ToArray ()); + query += decl; + query += ")"; + + var count = Execute (query); + + foreach (var p in map.Columns.Where (x => x.IsIndexed)) { + var indexName = map.TableName + "_" + p.Name; + var q = string.Format ("create index if not exists \"{0}\" on \"{1}\"(\"{2}\")", indexName, map.TableName, p.Name); + count += Execute (q); + } + + return count; + } + + /// + /// Creates a new SQLiteCommand given the command text with arguments. Place a '?' + /// in the command text for each of the arguments. + /// + /// + /// The fully escaped SQL. + /// + /// + /// Arguments to substitute for the occurences of '?' in the command text. + /// + /// + /// A + /// + public SQLiteCommand CreateCommand (string cmdText, params object[] ps) + { + if (!_open) { + throw SQLiteException.New (SQLite3.Result.Error, "Cannot create commands from unopened database"); + } else { + var cmd = new SQLiteCommand (this); + cmd.CommandText = cmdText; + foreach (var o in ps) { + cmd.Bind (o); + } + return cmd; + } + } + + /// + /// Creates a SQLiteCommand given the command text (SQL) with arguments. Place a '?' + /// in the command text for each of the arguments and then executes that command. + /// Use this method instead of Query when you don't expect rows back. Such cases include + /// INSERTs, UPDATEs, and DELETEs. + /// You can set the Trace or TimeExecution properties of the connection + /// to profile execution. + /// + /// + /// The fully escaped SQL. + /// + /// + /// Arguments to substitute for the occurences of '?' in the query. + /// + /// + /// The number of rows modified in the database as a result of this execution. + /// + public int Execute (string query, params object[] args) + { + var cmd = CreateCommand (query, args); + if (Trace) { + Console.WriteLine ("Executing: " + cmd); + } + if (TimeExecution) { + if (_sw == null) { + _sw = new System.Diagnostics.Stopwatch (); + } + _sw.Reset (); + _sw.Start (); + } + + int r = cmd.ExecuteNonQuery (); + + if (TimeExecution) { + _sw.Stop (); + _elapsedMilliseconds += _sw.ElapsedMilliseconds; + Console.WriteLine ("Finished in {0} ms ({1:0.0} s total)", _sw.ElapsedMilliseconds, _elapsedMilliseconds / 1000.0); + } + + return r; + } + + /// + /// Creates a SQLiteCommand given the command text (SQL) with arguments. Place a '?' + /// in the command text for each of the arguments and then executes that command. + /// It returns each row of the result using the mapping automatically generated for + /// the given type. + /// + /// + /// The fully escaped SQL. + /// + /// + /// Arguments to substitute for the occurences of '?' in the query. + /// + /// + /// An enumerable with one result for each row returned by the query. + /// + public IEnumerable Query (string query, params object[] args) where T : new() + { + var cmd = CreateCommand (query, args); + return cmd.ExecuteQuery (); + } + + /// + /// Creates a SQLiteCommand given the command text (SQL) with arguments. Place a '?' + /// in the command text for each of the arguments and then executes that command. + /// It returns each row of the result using the specified mapping. This function is + /// only used by libraries in order to query the database via introspection. It is + /// normally not used. + /// + /// + /// A to use to convert the resulting rows + /// into objects. + /// + /// + /// The fully escaped SQL. + /// + /// + /// Arguments to substitute for the occurences of '?' in the query. + /// + /// + /// An enumerable with one result for each row returned by the query. + /// + public IEnumerable Query (TableMapping map, string query, params object[] args) + { + var cmd = CreateCommand (query, args); + return cmd.ExecuteQuery (map); + } + + /// + /// Returns a queryable interface to the table represented by the given type. + /// + /// + /// A queryable object that is able to translate Where, OrderBy, and Take + /// queries into native SQL. + /// + public TableQuery Table () where T : new() + { + return new TableQuery (this); + } + + /// + /// Attempts to retrieve an object with the given primary key from the table + /// associated with the specified type. Use of this method requires that + /// the given type have a designated PrimaryKey (using the PrimaryKeyAttribute). + /// + /// + /// The primary key. + /// + /// + /// The object with the given primary key. Throws a not found exception + /// if the object is not found. + /// + public T Get (object pk) where T : new() + { + var map = GetMapping (typeof(T)); + string query = string.Format ("select * from \"{0}\" where \"{1}\" = ?", map.TableName, map.PK.Name); + return Query (query, pk).First (); + } + + /// + /// Inserts all specified objects. + /// + /// + /// An of the objects to insert. + /// + /// + /// The number of rows added to the table. + /// + public int InsertAll (IEnumerable objects) + { + var c = 0; + foreach (var r in objects) { + c += Insert (r); + } + return c; + } + + /// + /// Inserts the given object and retrieves its + /// auto incremented primary key if it has one. + /// + /// + /// The object to insert. + /// + /// + /// The number of rows added to the table. + /// + public int Insert (object obj, string extra) + { + if (obj == null) { + return 0; + } + + var map = GetMapping(obj.GetType()); + var vals = from c in map.InsertColumns + select c.GetValue (obj); + + var count = Execute (map.InsertSql (extra), vals.ToArray ()); + + var id = SQLite3.LastInsertRowid (Handle); + map.SetAutoIncPK (obj, id); + map.SetConnection (obj, this); + + return count; + } + + public int Insert (object obj) + { + return Insert (obj, ""); + } + + /// + /// Updates all of the columns of a table using the specified object + /// except for its primary key. + /// The object is required to have a primary key. + /// + /// + /// The object to update. It must have a primary key designated using the PrimaryKeyAttribute. + /// + /// + /// The number of rows updated. + /// + public int Update (object obj) + { + if (obj == null) { + return 0; + } + + var map = GetMapping (obj.GetType ()); + + var pk = map.PK; + + if (pk == null) { + throw new NotSupportedException ("Cannot update " + map.TableName + ": it has no PK"); + } + + map.SetConnection(obj, this); + + var cols = from p in map.Columns + where p != pk + select p; + var vals = from c in cols + select c.GetValue (obj); + var ps = new List (vals); + ps.Add (pk.GetValue (obj)); + var q = string.Format ("update \"{0}\" set {1} where {2} = ? ", map.TableName, string.Join (",", (from c in cols + select "\"" + c.Name + "\" = ? ").ToArray ()), pk.Name); + return Execute (q, ps.ToArray ()); + } + + /// + /// Deletes the given object from the database using its primary key. + /// + /// + /// The object to delete. It must have a primary key designated using the PrimaryKeyAttribute. + /// + /// + /// The number of rows deleted. + /// + public int Delete (T obj) + { + var map = GetMapping (obj.GetType ()); + var pk = map.PK; + map.SetConnection(obj, null); + if (pk == null) { + throw new NotSupportedException ("Cannot delete " + map.TableName + ": it has no PK"); + } + var q = string.Format ("delete from \"{0}\" where \"{1}\" = ?", map.TableName, pk.Name); + return Execute (q, pk.GetValue (obj)); + } + + public void Dispose () + { + if (_open) { + SQLite3.Close (Handle); + Handle = IntPtr.Zero; + _open = false; + } + } + } + + public class PrimaryKeyAttribute : Attribute + { + } + public class AutoIncrementAttribute : Attribute + { + } + public class IndexedAttribute : Attribute + { + } + public class MaxLengthAttribute : Attribute + { + public int Value { get; private set; } + public MaxLengthAttribute (int length) + { + Value = length; + } + } + + public class TableMapping + { + public Type MappedType { get; private set; } + public string TableName { get; private set; } + + public Column[] Columns { get; private set; } + public Column PK { get; private set; } + Column _autoPk = null; + Column[] _insertColumns = null; + + string _insertSql = null; + + PropertyInfo _connectionProp = null; + + public TableMapping (Type type) + { + MappedType = type; + TableName = MappedType.Name; + var props = MappedType.GetProperties (BindingFlags.Public | BindingFlags.Instance); + var cols = new List(); + foreach (var p in props) { + if (p.CanWrite) { + if (p.PropertyType.IsSubclassOf(typeof(SQLiteConnection))) { + _connectionProp = p; + } + else { + cols.Add(new PropColumn (p)); + } + } + } + Columns = cols.ToArray(); + foreach (var c in Columns) { + if (c.IsAutoInc && c.IsPK) { + _autoPk = c; + } + if (c.IsPK) { + PK = c; + } + } + } + + public void SetConnection(object obj, SQLiteConnection conn) { + if (_connectionProp != null) { + _connectionProp.SetValue(obj, conn, null); + } + } + + public void SetAutoIncPK (object obj, long id) + { + if (_autoPk != null) { + _autoPk.SetValue (obj, Convert.ChangeType (id, _autoPk.ColumnType)); + } + } + + public Column[] InsertColumns { + get { + if (_insertColumns == null) { + _insertColumns = Columns.Where (c => !c.IsAutoInc).ToArray (); + } + return _insertColumns; + } + } + public Column FindColumn (string name) + { + var exact = Columns.Where (c => c.Name == name).FirstOrDefault (); + return exact; + } + + public string InsertSql (string extra) + { + if (_insertSql == null) { + var cols = InsertColumns; + _insertSql = string.Format ("insert {3} into \"{0}\"({1}) values ({2})", TableName, string.Join (",", (from c in cols + select "\"" + c.Name + "\"").ToArray ()), string.Join (",", (from c in cols + select "?").ToArray ()), extra); + } + return _insertSql; + } + public string InsertSql () + { + return InsertSql (""); + } + + public abstract class Column + { + public string Name { get; protected set; } + public Type ColumnType { get; protected set; } + public bool IsAutoInc { get; protected set; } + public bool IsPK { get; protected set; } + public bool IsIndexed { get; protected set; } + public bool IsNullable { get; protected set; } + public int MaxStringLength { get; protected set; } + public abstract void SetValue (object obj, object val); + public abstract object GetValue (object obj); + } + public class PropColumn : Column + { + PropertyInfo _prop; + public PropColumn (PropertyInfo prop) + { + _prop = prop; + Name = prop.Name; + ColumnType = prop.PropertyType; + IsAutoInc = Orm.IsAutoInc (prop); + IsPK = Orm.IsPK (prop); + IsIndexed = Orm.IsIndexed (prop); + IsNullable = !IsPK; + MaxStringLength = Orm.MaxStringLength (prop); + } + public override void SetValue (object obj, object val) + { + _prop.SetValue (obj, val, null); + } + public override object GetValue (object obj) + { + return _prop.GetValue (obj, null); + } + } + } + + + public static class Orm + { + public const int DefaultMaxStringLength = 140; + + public static string SqlDecl (TableMapping.Column p) + { + string decl = "\"" + p.Name + "\" " + SqlType (p) + " "; + + if (p.IsPK) { + decl += "primary key "; + } + if (p.IsAutoInc) { + decl += "autoincrement "; + } + if (!p.IsNullable) { + decl += "not null "; + } + + return decl; + } + + public static string SqlType (TableMapping.Column p) + { + var clrType = p.ColumnType; + if (clrType == typeof(Boolean) || clrType == typeof(Byte) || clrType == typeof(UInt16) || clrType == typeof(SByte) || clrType == typeof(Int16) || clrType == typeof(Int32)) { + return "integer"; + } else if (clrType == typeof(UInt32) || clrType == typeof(Int64)) { + return "bigint"; + } else if (clrType == typeof(Single) || clrType == typeof(Double) || clrType == typeof(Decimal)) { + return "float"; + } else if (clrType == typeof(String)) { + int len = p.MaxStringLength; + return "varchar(" + len + ")"; + } else if (clrType == typeof(DateTime)) { + return "datetime"; + } else if (clrType.IsEnum) { + return "integer"; + } else { + throw new NotSupportedException ("Don't know about " + clrType); + } + } + + public static bool IsPK (MemberInfo p) + { + var attrs = p.GetCustomAttributes (typeof(PrimaryKeyAttribute), true); + return attrs.Length > 0; + } + + public static bool IsAutoInc (MemberInfo p) + { + var attrs = p.GetCustomAttributes (typeof(AutoIncrementAttribute), true); + return attrs.Length > 0; + } + + public static bool IsIndexed (MemberInfo p) + { + var attrs = p.GetCustomAttributes (typeof(IndexedAttribute), true); + return attrs.Length > 0; + } + + public static int MaxStringLength (PropertyInfo p) + { + var attrs = p.GetCustomAttributes (typeof(MaxLengthAttribute), true); + if (attrs.Length > 0) { + return ((MaxLengthAttribute)attrs[0]).Value; + } else { + return DefaultMaxStringLength; + } + } + + } + + public class SQLiteCommand + { + SQLiteConnection _conn; + private List _bindings; + + public string CommandText { get; set; } + + internal SQLiteCommand (SQLiteConnection conn) + { + _conn = conn; + _bindings = new List (); + CommandText = ""; + } + + public int ExecuteNonQuery () + { + var r = SQLite3.Result.OK; + for (int i = 0; i < _conn.MaxExecuteAttempts; i++) { + var stmt = Prepare(); + r = SQLite3.Step (stmt); + SQLite3.Finalize(stmt); + if (r == SQLite3.Result.Error || r == SQLite3.Result.Constraint || r == SQLite3.Result.Corrupt) { + string msg = SQLite3.GetErrmsg (_conn.Handle); + throw SQLiteException.New (r, msg); + } else if (r == SQLite3.Result.Done) { + int rowsAffected = SQLite3.Changes (_conn.Handle); + return rowsAffected; + } else if (r == SQLite3.Result.Busy) { + // We will retry + System.Threading.Thread.Sleep(1000); + } + else { + throw SQLiteException.New(r, r.ToString()); + } + } + throw SQLiteException.New(r, r.ToString()); + } + + public IEnumerable ExecuteQuery () where T : new() + { + return ExecuteQuery (_conn.GetMapping (typeof(T))).Cast (); + } + + public IEnumerable ExecuteQuery (TableMapping map) + { + if (_conn.Trace) { + Console.WriteLine ("Executing Query: " + this); + } + + var stmt = Prepare (); + + var cols = new TableMapping.Column[SQLite3.ColumnCount (stmt)]; + for (int i = 0; i < cols.Length; i++) { + var name = Marshal.PtrToStringUni(SQLite3.ColumnName16 (stmt, i)); + cols[i] = map.FindColumn (name); + } + + while (SQLite3.Step (stmt) == SQLite3.Result.Row) { + var obj = Activator.CreateInstance (map.MappedType); + map.SetConnection(obj, _conn); + for (int i = 0; i < cols.Length; i++) { + if (cols[i] == null) + continue; + var val = ReadCol (stmt, i, cols[i].ColumnType); + cols[i].SetValue (obj, val); + } + yield return obj; + } + + SQLite3.Finalize (stmt); + } + + public T ExecuteScalar () + { + T val = default(T); + + var stmt = Prepare (); + if (SQLite3.Step (stmt) == SQLite3.Result.Row) { + val = (T)ReadCol (stmt, 0, typeof(T)); + } + SQLite3.Finalize (stmt); + + return val; + } + + public void Bind (string name, object val) + { + _bindings.Add (new Binding { + Name = name, + Value = val + }); + } + public void Bind (object val) + { + Bind (null, val); + } + + public override string ToString () + { + return CommandText; + } + + IntPtr Prepare () + { + var stmt = SQLite3.Prepare (_conn.Handle, CommandText); + BindAll (stmt); + return stmt; + } + + void BindAll (IntPtr stmt) + { + int nextIdx = 1; + foreach (var b in _bindings) { + if (b.Name != null) { + b.Index = SQLite3.BindParameterIndex (stmt, b.Name); + } else { + b.Index = nextIdx++; + } + } + foreach (var b in _bindings) { + if (b.Value == null) { + SQLite3.BindNull (stmt, b.Index); + } else { + var bty = b.Value.GetType (); + if (b.Value is Byte || b.Value is UInt16 || b.Value is SByte || b.Value is Int16 || b.Value is Int32) { + SQLite3.BindInt (stmt, b.Index, Convert.ToInt32 (b.Value)); + } else if (b.Value is Boolean) { + SQLite3.BindInt (stmt, b.Index, Convert.ToBoolean (b.Value) ? 1 : 0); + } else if (b.Value is UInt32 || b.Value is Int64) { + SQLite3.BindInt64 (stmt, b.Index, Convert.ToInt64 (b.Value)); + } else if (b.Value is Single || b.Value is Double || b.Value is Decimal) { + SQLite3.BindDouble (stmt, b.Index, Convert.ToDouble (b.Value)); + } else if (b.Value is String) { + SQLite3.BindText (stmt, b.Index, b.Value.ToString (), -1, new IntPtr (-1)); + } else if (b.Value is DateTime) { + SQLite3.BindText (stmt, b.Index, ((DateTime)b.Value).ToString ("yyyy-MM-dd HH:mm:ss"), -1, new IntPtr (-1)); + } else if (bty.IsEnum) { + SQLite3.BindInt (stmt, b.Index, Convert.ToInt32 (b.Value)); + } + + } + } + } + + class Binding + { + public string Name { get; set; } + public object Value { get; set; } + public int Index { get; set; } + } + + object ReadCol (IntPtr stmt, int index, Type clrType) + { + var type = SQLite3.ColumnType (stmt, index); + if (type == SQLite3.ColType.Null) { + return null; + } else { + if (clrType == typeof(Byte) || clrType == typeof(UInt16) || clrType == typeof(SByte) || clrType == typeof(Int16) || clrType == typeof(Int32)) { + return Convert.ChangeType (SQLite3.ColumnInt (stmt, index), clrType); + } else if (clrType == typeof(Boolean)) { + return ((Byte)Convert.ChangeType (SQLite3.ColumnInt (stmt, index), typeof(Byte)) == 1); + } else if (clrType == typeof(UInt32) || clrType == typeof(Int64)) { + return Convert.ChangeType (SQLite3.ColumnInt64 (stmt, index), clrType); + } else if (clrType == typeof(Single) || clrType == typeof(Double) || clrType == typeof(Decimal)) { + return Convert.ChangeType (SQLite3.ColumnDouble (stmt, index), clrType); + } else if (clrType == typeof(String)) { + var text = Marshal.PtrToStringUni (SQLite3.ColumnText16 (stmt, index)); + return text; + } else if (clrType == typeof(DateTime)) { + var text = Marshal.PtrToStringUni (SQLite3.ColumnText16 (stmt, index)); + return Convert.ChangeType (text, clrType); + } else if (clrType.IsEnum) { + return SQLite3.ColumnInt (stmt, index); + } else { + throw new NotSupportedException ("Don't know how to read " + clrType); + } + } + } + + } + + public class TableQuery : IEnumerable where T : new() + { + public SQLiteConnection Connection { get; private set; } + public TableMapping Table { get; private set; } + + Expression _where; + List _orderBys; + int? _limit; + int? _offset; + + class Ordering + { + public string ColumnName { get; set; } + public bool Ascending { get; set; } + } + + TableQuery (SQLiteConnection conn, TableMapping table) + { + Connection = conn; + Table = table; + } + + public TableQuery (SQLiteConnection conn) + { + Connection = conn; + Table = Connection.GetMapping (typeof(T)); + } + + public TableQuery Clone () + { + var q = new TableQuery (Connection, Table); + q._where = _where; + if (_orderBys != null) { + q._orderBys = new List (_orderBys); + } + q._limit = _limit; + q._offset = _offset; + return q; + } + + public TableQuery Where (Expression> predExpr) + { + if (predExpr.NodeType == ExpressionType.Lambda) { + var lambda = (LambdaExpression)predExpr; + var pred = lambda.Body; + var q = Clone (); + q.AddWhere (pred); + return q; + } else { + throw new NotSupportedException ("Must be a predicate"); + } + } + + public TableQuery Take (int n) + { + var q = Clone (); + q._limit = n; + return q; + } + + public TableQuery Skip (int n) + { + var q = Clone (); + q._offset = n; + return q; + } + + public TableQuery OrderBy (Expression> orderExpr) + { + return AddOrderBy (orderExpr, true); + } + + public TableQuery OrderByDescending (Expression> orderExpr) + { + return AddOrderBy (orderExpr, false); + } + + private TableQuery AddOrderBy (Expression> orderExpr, bool asc) + { + if (orderExpr.NodeType == ExpressionType.Lambda) { + var lambda = (LambdaExpression)orderExpr; + var mem = lambda.Body as MemberExpression; + if (mem != null && (mem.Expression.NodeType == ExpressionType.Parameter)) { + var q = Clone (); + if (q._orderBys == null) { + q._orderBys = new List (); + } + q._orderBys.Add (new Ordering { + ColumnName = mem.Member.Name, + Ascending = asc + }); + return q; + } else { + throw new NotSupportedException ("Order By does not support: " + orderExpr); + } + } else { + throw new NotSupportedException ("Must be a predicate"); + } + } + + private void AddWhere (Expression pred) + { + if (_where == null) { + _where = pred; + } else { + _where = Expression.AndAlso (_where, pred); + } + } + + private SQLiteCommand GenerateCommand () + { + var cmdText = "select * from \"" + Table.TableName + "\""; + var args = new List (); + if (_where != null) { + var w = CompileExpr (_where, args); + cmdText += " where " + w.CommandText; + } + if ((_orderBys != null) && (_orderBys.Count > 0)) { + var t = string.Join (", ", _orderBys.Select (o => "\"" + o.ColumnName + "\"" + (o.Ascending ? "" : " desc")).ToArray ()); + cmdText += " order by " + t; + } + if (_limit.HasValue) { + cmdText += " limit " + _limit.Value; + } + if (_offset.HasValue) { + cmdText += " offset " + _limit.Value; + } + return Connection.CreateCommand (cmdText, args.ToArray ()); + } + + class CompileResult + { + public string CommandText { get; set; } + public object Value { get; set; } + } + + private CompileResult CompileExpr (Expression expr, List queryArgs) + { + if (expr is BinaryExpression) { + var bin = (BinaryExpression)expr; + + var leftr = CompileExpr (bin.Left, queryArgs); + var rightr = CompileExpr (bin.Right, queryArgs); + + var text = "(" + leftr.CommandText + " " + GetSqlName (bin) + " " + rightr.CommandText + ")"; + return new CompileResult { CommandText = text }; + } else if (expr.NodeType == ExpressionType.Constant) { + var c = (ConstantExpression)expr; + var val = c.Value; + string t; + if (val is string) { + t = "'" + val.ToString ().Replace ("'", "''") + "'"; + } else { + t = val.ToString (); + } + return new CompileResult { + CommandText = t, + Value = c.Value + }; + } else if (expr.NodeType == ExpressionType.Convert) { + var u = (UnaryExpression)expr; + var ty = u.Type; + var valr = CompileExpr (u.Operand, queryArgs); + return new CompileResult { + CommandText = valr.CommandText, + Value = valr.Value != null ? Convert.ChangeType (valr.Value, ty) : null + }; + } else if (expr.NodeType == ExpressionType.MemberAccess) { + var mem = (MemberExpression)expr; + + if (mem.Expression.NodeType == ExpressionType.Parameter) { + // + // This is a column of our table, output just the column name + // + return new CompileResult { CommandText = "\"" + mem.Member.Name + "\"" }; + } else { + var r = CompileExpr (mem.Expression, queryArgs); + if (r.Value == null) { + throw new NotSupportedException ("Member access failed to compile expression"); + } + var obj = r.Value; + + if (mem.Member.MemberType == MemberTypes.Property) { + var m = (PropertyInfo)mem.Member; + var val = m.GetValue (obj, null); + queryArgs.Add (val); + return new CompileResult { + CommandText = "?", + Value = val + }; + } else if (mem.Member.MemberType == MemberTypes.Field) { + var m = (FieldInfo)mem.Member; + var val = m.GetValue (obj); + queryArgs.Add (val); + return new CompileResult { + CommandText = "?", + Value = val + }; + } else { + throw new NotSupportedException ("MemberExpr: " + mem.Member.MemberType.ToString ()); + } + } + } + throw new NotSupportedException ("Cannot compile: " + expr.NodeType.ToString ()); + } + + string GetSqlName (Expression expr) + { + var n = expr.NodeType; + if (n == ExpressionType.GreaterThan) + return ">"; else if (n == ExpressionType.GreaterThanOrEqual) + return ">="; else if (n == ExpressionType.LessThan) + return "<"; else if (n == ExpressionType.LessThanOrEqual) + return "<="; else if (n == ExpressionType.And) + return "and"; else if (n == ExpressionType.AndAlso) + return "and"; else if (n == ExpressionType.Or) + return "or"; else if (n == ExpressionType.OrElse) + return "or"; else if (n == ExpressionType.Equal) + return "="; else if (n == ExpressionType.NotEqual) + return "!="; + else + throw new System.NotSupportedException ("Cannot get SQL for: " + n.ToString ()); + } + + + #region IEnumerable implementation + public IEnumerator GetEnumerator () + { + return GenerateCommand ().ExecuteQuery ().GetEnumerator (); + } + #endregion + + #region IEnumerable implementation + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator () + { + return GetEnumerator (); + } + #endregion + } + + public static class SQLite3 + { + public enum Result : int + { + OK = 0, + Error = 1, + Internal = 2, + Perm = 3, + Abort = 4, + Busy = 5, + Locked = 6, + NoMem = 7, + ReadOnly = 8, + Interrupt = 9, + IOError = 10, + Corrupt = 11, + NotFound = 12, + TooBig = 18, + Constraint = 19, + Row = 100, + Done = 101 + } + + public enum ConfigOption : int + { + SingleThread = 1, + MultiThread = 2, + Serialized = 3 + } + + [DllImport("sqlite3", EntryPoint = "sqlite3_open")] + public static extern Result Open (string filename, out IntPtr db); + [DllImport("sqlite3", EntryPoint = "sqlite3_close")] + public static extern Result Close (IntPtr db); + [DllImport("sqlite3", EntryPoint = "sqlite3_config")] + public static extern Result Config (ConfigOption option); + + [DllImport("sqlite3", EntryPoint = "sqlite3_changes")] + public static extern int Changes (IntPtr db); + + [DllImport("sqlite3", EntryPoint = "sqlite3_prepare_v2")] + public static extern Result Prepare (IntPtr db, string sql, int numBytes, out IntPtr stmt, IntPtr pzTail); + public static IntPtr Prepare (IntPtr db, string query) + { + IntPtr stmt; + //Console.WriteLine ("Queryy={0}",query); + var r = Prepare (db, query, query.Length, out stmt, IntPtr.Zero); + if (r != Result.OK) { + throw SQLiteException.New (r, GetErrmsg (db)); + } + return stmt; + } + + [DllImport("sqlite3", EntryPoint = "sqlite3_step")] + public static extern Result Step (IntPtr stmt); + + [DllImport("sqlite3", EntryPoint = "sqlite3_finalize")] + public static extern Result Finalize (IntPtr stmt); + + [DllImport("sqlite3", EntryPoint = "sqlite3_last_insert_rowid")] + public static extern long LastInsertRowid (IntPtr db); + + [DllImport("sqlite3", EntryPoint = "sqlite3_errmsg16")] + public static extern IntPtr Errmsg (IntPtr db); + public static string GetErrmsg (IntPtr db) { + return Marshal.PtrToStringUni (Errmsg(db)); + } + + [DllImport("sqlite3", EntryPoint = "sqlite3_bind_parameter_index")] + public static extern int BindParameterIndex (IntPtr stmt, string name); + + [DllImport("sqlite3", EntryPoint = "sqlite3_bind_null")] + public static extern int BindNull (IntPtr stmt, int index); + [DllImport("sqlite3", EntryPoint = "sqlite3_bind_int")] + public static extern int BindInt (IntPtr stmt, int index, int val); + [DllImport("sqlite3", EntryPoint = "sqlite3_bind_int64")] + public static extern int BindInt64 (IntPtr stmt, int index, long val); + [DllImport("sqlite3", EntryPoint = "sqlite3_bind_double")] + public static extern int BindDouble (IntPtr stmt, int index, double val); + [DllImport("sqlite3", EntryPoint = "sqlite3_bind_text")] + public static extern int BindText (IntPtr stmt, int index, string val, int n, IntPtr free); + + [DllImport("sqlite3", EntryPoint = "sqlite3_column_count")] + public static extern int ColumnCount (IntPtr stmt); + [DllImport("sqlite3", EntryPoint = "sqlite3_column_name")] + public static extern IntPtr ColumnName (IntPtr stmt, int index); + [DllImport("sqlite3", EntryPoint = "sqlite3_column_name16")] + public static extern IntPtr ColumnName16(IntPtr stmt, int index); + [DllImport("sqlite3", EntryPoint = "sqlite3_column_type")] + public static extern ColType ColumnType (IntPtr stmt, int index); + [DllImport("sqlite3", EntryPoint = "sqlite3_column_int")] + public static extern int ColumnInt (IntPtr stmt, int index); + [DllImport("sqlite3", EntryPoint = "sqlite3_column_int64")] + public static extern long ColumnInt64 (IntPtr stmt, int index); + [DllImport("sqlite3", EntryPoint = "sqlite3_column_double")] + public static extern double ColumnDouble (IntPtr stmt, int index); + [DllImport("sqlite3", EntryPoint = "sqlite3_column_text")] + public static extern IntPtr ColumnText (IntPtr stmt, int index); + [DllImport("sqlite3", EntryPoint = "sqlite3_column_text16")] + public static extern IntPtr ColumnText16(IntPtr stmt, int index); + + public enum ColType : int + { + Integer = 1, + Float = 2, + Text = 3, + Blob = 4, + Null = 5 + } + } + +} diff --git a/TweetStation/TweetStation.csproj b/TweetStation/TweetStation.csproj new file mode 100644 index 0000000..a19da15 --- /dev/null +++ b/TweetStation/TweetStation.csproj @@ -0,0 +1,147 @@ + + + + Debug + iPhoneSimulator + 9.0.21022 + 2.0 + {7C7D3161-2DFB-4201-B70E-88ACD3976AA9} + {E613F3A2-FE9C-494F-B74E-F63BCB86FEA6};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} + Exe + TweetStation + MainWindow.xib + v3.5 + TweetStation + 3.0 + org.tirania.tweetstation + + + true + full + false + bin\iPhoneSimulator\Debug + DEBUG + prompt + 4 + x86 + None + True + -i18n=west + 3.2 + + + + none + false + bin\iPhoneSimulator\Release + prompt + 4 + x86 + None + False + 3.0 + + + true + full + false + bin\iPhone\Debug + DEBUG + prompt + 4 + x86 + iPhone Developer + True + 3.0 + + + none + false + bin\iPhone\Release + prompt + 4 + x86 + iPhone Developer: Miguel de Icaza (6YW2BSTNRQ) + False + 3.0 + + + + + + + + + + + + MainWindow.xib + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {3FFBFFF8-5560-4EDE-82E5-3FFDFBBA8A50} + MonoTouch.Dialog + + + + + + \ No newline at end of file diff --git a/TweetStation/UI/Composer.cs b/TweetStation/UI/Composer.cs new file mode 100644 index 0000000..97f0b88 --- /dev/null +++ b/TweetStation/UI/Composer.cs @@ -0,0 +1,303 @@ +// Composer.cs: +// Views and ViewControllers for composing messages +// +// Author: +// Miguel de Icaza (miguel@gnome.org) +// +using System; +using System.Drawing; +using System.Text; +using System.Web; +using MonoTouch.Foundation; +using MonoTouch.UIKit; +using MonoTouch.CoreLocation; +using SQLite; + +namespace TweetStation +{ + public class ComposerView : UIView { + const UIBarButtonItemStyle style = UIBarButtonItemStyle.Bordered; + internal UITextView textView; + Composer composer; + UIToolbar toolbar; + UILabel charsLeft; + internal UIBarButtonItem GpsButtonItem; + + public ComposerView (RectangleF bounds, Composer composer) : base (bounds) + { + this.composer = composer; + textView = new UITextView (RectangleF.Empty) { + Font = UIFont.SystemFontOfSize (18) + }; + textView.Changed += HandleTextViewChanged; + + charsLeft = new UILabel (RectangleF.Empty) { + Text = "140", + TextColor = UIColor.White, + BackgroundColor = UIColor.Clear, + TextAlignment = UITextAlignment.Right + }; + + toolbar = new UIToolbar (RectangleF.Empty); + GpsButtonItem = new UIBarButtonItem (UIImage.FromFile ("Images/gps.png"), style, InsertGeo); + + toolbar.SetItems (new UIBarButtonItem [] { + new UIBarButtonItem (UIBarButtonSystemItem.Trash, delegate { textView.Text = ""; } ) { Style = style }, + new UIBarButtonItem (UIBarButtonSystemItem.FlexibleSpace, null), + new UIBarButtonItem (UIBarButtonSystemItem.Camera, null, null) { Style = style }, + GpsButtonItem }, false); + + AddSubview (toolbar); + AddSubview (textView); + AddSubview (charsLeft); + } + + void HandleTextViewChanged (object sender, EventArgs e) + { + string text = textView.Text; + + var enabled = composer.sendItem.Enabled; + if (enabled ^ (text.Length != 0)) + composer.sendItem.Enabled = !enabled; + + var left = 140-text.Length; + if (left < 0) + charsLeft.TextColor = UIColor.Red; + else + charsLeft.TextColor = UIColor.White; + + charsLeft.Text = (140-text.Length).ToString (); + } + + internal void InsertGeo (object sender, EventArgs args) + { + GpsButtonItem.Enabled = false; + composer.RequestLocation (); + } + + internal void GeoDone () + { + GpsButtonItem.Enabled = true; + } + + internal void Reset (string text) + { + textView.Text = text; + HandleTextViewChanged (null, null); + } + + public override void LayoutSubviews () + { + Resize (Bounds); + } + + void Resize (RectangleF bounds) + { + textView.Frame = new RectangleF (0, 0, bounds.Width, bounds.Height-44); + toolbar.Frame = new RectangleF (0, bounds.Height-44, bounds.Width, 44); + charsLeft.Frame = new RectangleF (160, bounds.Height-44, 50, 44); + } + + public string Text { + get { + return textView.Text; + } + set { + textView.Text = value; + } + } + } + + /// + /// Composer is a singleton that is shared through the lifetime of the app, + /// the public methods in this class reset the values of the composer on + /// each invocation. + /// + public class Composer : UIViewController + { + ComposerView composerView; + UINavigationBar navigationBar; + UINavigationItem navItem; + internal UIBarButtonItem sendItem; + UIViewController previousController; + long InReplyTo; + string directRecipient; + CLLocationManager locationManager; + CLLocation location; + + public static readonly Composer Main = new Composer (); + + Composer () : base (null, null) + { + // Navigation Bar + navigationBar = new UINavigationBar (new RectangleF (0, 0, 320, 44)); + navItem = new UINavigationItem (""); + var close = new UIBarButtonItem ("Close", UIBarButtonItemStyle.Plain, CloseComposer); + navItem.LeftBarButtonItem = close; + sendItem = new UIBarButtonItem ("Send", UIBarButtonItemStyle.Plain, Post); + navItem.RightBarButtonItem = sendItem; + + navigationBar.PushNavigationItem (navItem, false); + + // Composer + composerView = new ComposerView (ComputeComposerSize (RectangleF.Empty), this); + + // Add the views + NSNotificationCenter.DefaultCenter.AddObserver ("UIKeyboardWillShowNotification", KeyboardWillShow); + + View.AddSubview (composerView); + View.AddSubview (navigationBar); + } + + public class MyCLLocationManagerDelegate : CLLocationManagerDelegate { + Composer parent; + public MyCLLocationManagerDelegate (Composer parent) + { + this.parent = parent; + } + + public override void UpdatedLocation (CLLocationManager manager, CLLocation newLocation, CLLocation oldLocation) + { + parent.location = newLocation; + parent.composerView.GeoDone (); + } + } + + internal void RequestLocation () + { + if (locationManager == null){ + locationManager = new CLLocationManager () { + DesiredAccuracy = CLLocation.AccuracyBest, + Delegate = new MyCLLocationManagerDelegate (this), + DistanceFilter = 1000f + }; + } + if (locationManager.LocationServicesEnabled) + locationManager.StartUpdatingLocation (); + } + + public override void ViewDidLoad () + { + base.ViewDidLoad (); + } + + public void ResetComposer (string caption, string initialText) + { + composerView.Reset (initialText); + InReplyTo = 0; + directRecipient = null; + location = null; + composerView.GpsButtonItem.Enabled = true; + navItem.Title = caption; + } + + void CloseComposer (object sender, EventArgs a) + { + if (locationManager != null) + locationManager.StartUpdatingLocation (); + + previousController.DismissModalViewControllerAnimated (true); + } + + void AppendLocation (StringBuilder content) + { + if (location == null) + return; + + // TODO: check if geo_enabled is set for the user, if not, open a browser to have the user change this. + content.AppendFormat ("&lat={0}&long={1}", location.Coordinate.Latitude, location.Coordinate.Longitude); + } + + void Post (object sender, EventArgs a) + { + var content = new StringBuilder (); + var account = TwitterAccount.CurrentAccount; + + if (directRecipient == null){ + content.AppendFormat ("status={0}&source=TweetStation", HttpUtility.UrlEncode (composerView.Text)); + AppendLocation (content); + if (InReplyTo != 0) + content.AppendFormat ("&in_reply_to_status_id={0}", InReplyTo); + account.Post ("http://twitter.com/statuses/update.json", content.ToString ()); + } else { + content.AppendFormat ("text={0}&user={1}", HttpUtility.UrlEncode (composerView.Text), HttpUtility.UrlEncode (directRecipient)); + AppendLocation (content); + account.Post ("http://twitter.com/direct_messages/new.json", content.ToString ()); + } + CloseComposer (sender, a); + } + + void KeyboardWillShow (NSNotification notification) + { + var kbdBounds = (notification.UserInfo.ObjectForKey (UIKeyboard.BoundsUserInfoKey) as NSValue).RectangleFValue; + + composerView.Frame = ComputeComposerSize (kbdBounds); + } + + RectangleF ComputeComposerSize (RectangleF kbdBounds) + { + var view = View.Bounds; + var nav = navigationBar.Bounds; + + return new RectangleF (0, nav.Height, view.Width, view.Height-kbdBounds.Height-nav.Height); + } + + void Activate (UIViewController parent) + { + previousController = parent; + composerView.textView.BecomeFirstResponder (); + parent.PresentModalViewController (this, true); + } + + public void NewTweet (UIViewController parent) + { + ResetComposer (Locale.GetText ("New Tweet"), ""); + + Activate (parent); + } + + public void ReplyTo (UIViewController parent, Tweet source) + { + ResetComposer (Locale.GetText ("Reply Tweet"), "@" + source.Screename + " "); + InReplyTo = source.Id; + directRecipient = null; + + Activate (parent); + } + + public void Quote (UIViewController parent, Tweet source) + { + ResetComposer (Locale.GetText ("Quote"), "RT @" + source.Screename + " " + source.Text); + + Activate (parent); + } + + public void Direct (UIViewController parent, string username) + { + ResetComposer (username == "" ? Locale.GetText ("Direct message") : Locale.Format ("Direct to {0}", username), ""); + directRecipient = username; + + Activate (parent); + } + } + + public class Draft { + static bool inited; + + static void Init () + { + if (inited) + return; + inited = true; + Database.Main.CreateTable (); + } + + [PrimaryKey] + public int Id { get; set; } + public long AccountId { get; set; } + public string Recipient { get; set; } + public long InReplyTo { get; set; } + public bool DirectMessage { get; set; } + public string Message { get; set; } + } +} diff --git a/TweetStation/UI/Conversation.cs b/TweetStation/UI/Conversation.cs new file mode 100644 index 0000000..caf8847 --- /dev/null +++ b/TweetStation/UI/Conversation.cs @@ -0,0 +1,92 @@ +// +// Conversation.cs: UI elements for showing conversations +// +// Author: +// Miguel de Icaza (miguel@gnome.org) +// +using System; +using System.Drawing; +using System.IO; +using System.Linq; +using MonoTouch.Dialog; +using MonoTouch.Foundation; +using MonoTouch.UIKit; + +namespace TweetStation +{ + // + // Displays a conversation + // + public class ConversationViewController : DialogViewController + { + Tweet source; + Section convSection; + + public ConversationViewController (Tweet source) : base (null, true) + { + this.source = source; + + convSection = new Section (); + ProcessConversation (source); + Root = new RootElement ("Conversation") { + convSection + }; + } + + /// + /// Loads as many tweets as possible from the database, and if they are missing + /// requests them from the service. + /// + void ProcessConversation (Tweet previous) + { + convSection.Add (new TweetElement (previous)); + + while (previous != null && previous.InReplyToStatus != 0) { + var lookup = Tweet.FromId (previous.InReplyToStatus); + if (lookup == null){ + QueryServer (previous.InReplyToStatus); + return; + } else + convSection.Add (new TweetElement (lookup)); + previous = lookup; + } + EndConversation (); + } + + void QueryServer (long reply) + { + Tweet.LoadFullTweet (reply, tweet => { + if (tweet == null){ + EndConversation (); + return; + } + + // Insert the results and continue processing. + ProcessConversation (tweet); + }); + } + + void EndConversation () + { + convSection.Add (new StringElement ("End of conversation")); + } + } + + // + // This MonoTouch.Dialog.Element will create a conversation DialogViewController. + // + public class ConversationRootElement : RootElement { + Tweet source; + + public ConversationRootElement (string caption, Tweet source) : base (caption) + { + this.source = source; + } + + protected override UIViewController MakeViewController () + { + return new ConversationViewController (source); + } + } +} + diff --git a/TweetStation/UI/DetailTweetViewController.cs b/TweetStation/UI/DetailTweetViewController.cs new file mode 100644 index 0000000..dc16cca --- /dev/null +++ b/TweetStation/UI/DetailTweetViewController.cs @@ -0,0 +1,164 @@ +// +// DetailTweetViewController.cs: +// Renders a full tweet, with the user profile information +// and useful operations for it +// +// Author: +// Miguel de Icaza (miguel@gnome.org) +// +// +using System; +using System.Drawing; +using MonoTouch.Dialog; +using MonoTouch.Foundation; +using MonoTouch.UIKit; +using System.IO; + +namespace TweetStation +{ + public class DetailTweetViewController : DialogViewController { + const int PadX = 4; + Tweet tweet; + static string [] buttons = new string [] { + Locale.GetText ("Reply"), + Locale.GetText ("Retweet"), + Locale.GetText ("Quote"), + Locale.GetText ("Direct") }; + + public DetailTweetViewController (Tweet tweet) : base (UITableViewStyle.Grouped, null, true) + { + this.tweet = tweet; + var handlers = new EventHandler [] { Reply, Retweet, Quote, Direct }; + var profileRect = new RectangleF (PadX, 0, View.Bounds.Width-30-PadX*2, 100); + var detailRect = new RectangleF (PadX, 0, View.Bounds.Width-30-PadX*2, 0); + + var shortProfileView = new ShortProfileView (profileRect, tweet.UserId, true); + shortProfileView.PictureTapped += delegate { PictureViewer.Load (this, tweet.UserId); }; + shortProfileView.Tapped += LoadFullProfile; + shortProfileView.UrlTapped += delegate { WebViewController.OpenUrl (this, User.FromId (tweet.UserId).Url); }; + + var main = new Section (shortProfileView){ + new DetailTweetView (detailRect, tweet, ContentHandler) + }; + if (tweet.InReplyToStatus != 0){ + var in_reply = new ConversationRootElement (Locale.Format ("In reply to: {0}", tweet.InReplyToUserName), tweet); + + main.Add (in_reply); + } + + Section replySection = new Section (); + if (tweet.Kind == TweetKind.Direct) + replySection.Add (new StringElement (Locale.GetText ("Direct Reply"), delegate { Direct (this, EventArgs.Empty); })); + else + replySection.Add (new UIViewElement (null, new ButtonsView (buttons, handlers), true)); + + Root = new RootElement (tweet.Screename){ + main, + replySection, + new Section () { + new TimelineElement (tweet.Screename, Locale.GetText ("User's timeline"), "http://api.twitter.com/1/statuses/user_timeline.json?skip_user=true&id=" + tweet.UserId, User.FromId (tweet.UserId)) + } + }; + } + + // + // Invoked by the TweetView when the content is tapped + // + void ContentHandler (string data) + { + Util.MainAppDelegate.Open (this, data); + } + + void LoadFullProfile () + { + ActivateController (new FullProfileView (tweet.UserId)); + } + + void DisplayUserTimeline () + { + + } + + void Reply (object sender, EventArgs args) + { + Composer.Main.ReplyTo (this, tweet); + } + + void Direct (object sender, EventArgs args) + { + Composer.Main.Direct (this, tweet.Screename); + } + + void Retweet (object sender, EventArgs args) + { + TwitterAccount.CurrentAccount.Post ("http://api.twitter.com/1/statuses/retweet/" + tweet.Id + ".json", ""); + + } + + void Quote (object sender, EventArgs args) + { + Composer.Main.Quote (this, tweet); + } + } + + public class DetailTweetView : UIView { + static UIImage off = UIImage.FromFileUncached ("Images/star-off.png"); + static UIImage on = UIImage.FromFileUncached ("Images/star-on.png"); + const int PadY = 4; + const int smallSize = 12; + TweetView tweetView; + UIButton buttonView; + + public DetailTweetView (RectangleF rect, Tweet tweet, TweetView.TappedEvent handler) : base (rect) + { + var tweetRect = rect; + if (tweet.Kind != TweetKind.Direct) + tweetRect.Width -= 30; + + tweetView = new TweetView (tweetRect, tweet.Text){ + BackgroundColor = UIColor.Clear, + }; + if (handler != null) + tweetView.Tapped += handler; + + AddSubview (tweetView); + + rect.Y = tweetView.Frame.Height + PadY; + rect.Height = smallSize; + AddSubview (new UILabel (rect) { + Text = Util.FormatTime (new TimeSpan (DateTime.UtcNow.Ticks - tweet.CreatedAt)) + " ago from " + tweet.Source, + TextColor = UIColor.Gray, + Font = UIFont.SystemFontOfSize (smallSize) + }); + + var f = Frame; + f.Y += PadY; + f.Height = tweetView.Frame.Height + PadY * 2 + smallSize + 2; + Frame = f; + + if (tweet.Kind != TweetKind.Direct){ + // Now that we now our size, center the button + buttonView = UIButton.FromType (UIButtonType.Custom); + buttonView.Frame = new RectangleF (tweetRect.X + tweetRect.Width, (f.Height-38)/2-4, 38, 38); + UpdateButtonImage (tweet); + + buttonView.TouchDown += delegate { + tweet.Favorited = !tweet.Favorited; + TwitterAccount.CurrentAccount.Post (String.Format ("http://api.twitter.com/1/favorites/{0}/{1}.json", tweet.Favorited ? "create" : "destroy", tweet.Id),""); + UpdateButtonImage (tweet); + tweet.Replace (Database.Main); + }; + + AddSubview (buttonView); + } + } + + void UpdateButtonImage (Tweet tweet) + { + var image = tweet.Favorited ? on : off; + + buttonView.SetImage (image, UIControlState.Normal); + buttonView.SetImage (image, UIControlState.Selected); + } + } +} diff --git a/TweetStation/UI/Favorites.cs b/TweetStation/UI/Favorites.cs new file mode 100644 index 0000000..cc26b15 --- /dev/null +++ b/TweetStation/UI/Favorites.cs @@ -0,0 +1,30 @@ +// +// The page that shows the various search options +// Search, Nearby, User, saved searches and trending topics +// +using System; +using System.Collections.Generic; +using MonoTouch.Foundation; +using MonoTouch.UIKit; +using MonoTouch.Dialog; +using System.Web; + +namespace TweetStation +{ + public class FavoritesViewController : BaseTimelineViewController { + string name; + + public FavoritesViewController (string name) : base (true) + { + this.name = name; + } + + protected override string TimelineTitle { + get { + return "Favorites"; + } + } + + } +} + diff --git a/TweetStation/UI/Follow.cs b/TweetStation/UI/Follow.cs new file mode 100644 index 0000000..69747eb --- /dev/null +++ b/TweetStation/UI/Follow.cs @@ -0,0 +1,14 @@ +using System; +using MonoTouch.Dialog; +using MonoTouch.UIKit; + +namespace TweetStation +{ + public class FollowElement : RootElement + { + public FollowElement (User u, string caption, string url) : base (caption) + { + } + } +} + diff --git a/TweetStation/UI/FullProfileView.cs b/TweetStation/UI/FullProfileView.cs new file mode 100644 index 0000000..bfe42e3 --- /dev/null +++ b/TweetStation/UI/FullProfileView.cs @@ -0,0 +1,211 @@ +using System; +using System.Drawing; +using System.IO; +using MonoTouch.Foundation; +using MonoTouch.Dialog; +using MonoTouch.UIKit; +using System.Web; +using System.Json; + +namespace TweetStation +{ + public class FullProfileView : DialogViewController + { + const string lookup = "http://api.twitter.com/1/users/lookup.json"; + const int PadX = 4; + StyledStringElement followButton, blockUnblockButton; + User user; + bool following, blocking; + + public FullProfileView (long id) : base (UITableViewStyle.Grouped, null, true) + { + user = User.FromId (id); + if (user == null) + Fetch ("?user_id=" + id, id.ToString ()); + else + CreateUI (); + } + + public FullProfileView (string name) : base (UITableViewStyle.Grouped, null, true) + { + user = User.FromName (name); + if (user == null) + Fetch ("?screen_name=" + name, name); + else + CreateUI (); + } + + void Fetch (string suffix, string diagMsg) + { + TwitterAccount.CurrentAccount.Download (new Uri (lookup + suffix), res => { ProcessUserReturn (res, diagMsg); }); + } + + void ProcessUserReturn (byte [] res, string diagMsg) + { + if (res == null){ + Root = Util.MakeError (diagMsg); + return; + } + user = User.LoadUsers (new MemoryStream (res)); + if (user == null) + Root = Util.MakeError (diagMsg); + else + CreateUI (); + } + + void CreateUI () + { + var profileRect = new RectangleF (PadX, 0, View.Bounds.Width-30-PadX*2, 100); + var shortProfileView = new ShortProfileView (profileRect, user.Id, false); + shortProfileView.PictureTapped += delegate { PictureViewer.Load (this, user.Id); }; + shortProfileView.UrlTapped += delegate { WebViewController.OpenUrl (this, user.Url); }; + + var main = new Section (shortProfileView){ + new StyledStringElement (user.Description) { + Lines = 0, + LineBreakMode = UILineBreakMode.WordWrap, + Font = UIFont.SystemFontOfSize (14) + } + }; + + var tweets = String.Format ("http://api.twitter.com/1/statuses/user_timeline.json?skip_user=true&id={0}", user.Id); + var favorites = String.Format ("http://api.twitter.com/version/favorites.json?id={0}", user.Id); + +#if false + followButton = new StyledStringElement (FollowText, ToggleFollow){ + Alignment = UITextAlignment.Center, + TextColor = UIColor.FromRGB (0x32, 0x4f, 0x85) + }; +#endif + var sfollow = new Section () { + new ActivityElement () + }; + + Root = new RootElement (user.Screenname){ + main, + new Section () { + new TimelineElement (user.Screenname, Locale.Format ("{0:#,#} tweets", user.StatusesCount), tweets, user), + new TimelineElement (user.Screenname, Locale.Format ("{0:#,#} favorites", user.FavCount), favorites, null), + new FollowElement (user, Locale.Format ("{0:#,#} friends", user.FriendsCount), "friends"), + new FollowElement (user, Locale.Format ("{0:#,#} followers", user.FollowersCount), "followers"), + }, + sfollow, + }; + + string url = String.Format ("http://api.twitter.com/1/friendships/show.json?target_id={0}&source_screen_name={1}", + user.Id, + HttpUtility.UrlEncode (TwitterAccount.CurrentAccount.Username)); + TwitterAccount.CurrentAccount.Download (new Uri (url), res => { + TableView.BeginUpdates (); + Root.Remove (sfollow); + if (res != null) + ParseFollow (res); + + TableView.EndUpdates (); + }); + } + + static string GetFollowText (bool following) + { + if (following) + return Locale.GetText ("Unfollow this user"); + else + return Locale.GetText ("Follow this user"); + } + + static string GetBlockText (bool blocking) + { + if (blocking) + return Locale.GetText ("Unblock this user"); + else + return Locale.GetText ("Block this user"); + } + + // + // Parses the return from twitter from the friendship show result + // we extract from here the follow status and the blocking status + // and insert the sections directly into our root + // + void ParseFollow (byte [] res) + { + try { + var root = JsonValue.Load (new MemoryStream (res)); + + // + // Follow/unfollow + // + var target = root ["relationship"]["target"]; + following = target ["followed_by"]; + + followButton = new StyledStringElement (GetFollowText (following), ToggleFollow){ + Alignment = UITextAlignment.Center, + TextColor = UIColor.FromRGB (0x32, 0x4f, 0x85), + Font = UIFont.BoldSystemFontOfSize (14) + }; + + var following_me = (bool) target ["following"]; + var caption = following_me ? Locale.Format ("{0} is following you", user.Screenname) : Locale.Format ("{0} is not following you", user.Screenname); + Root.Insert (2, new Section (null, caption) { followButton }); + + // + // Block/unblock + // + var source = root ["relationship"]["source"]; + var blocking = (bool) source ["blocking"]; + blockUnblockButton = new StyledStringElement (GetBlockText (blocking), ToggleBlock){ + + Alignment = UITextAlignment.Center, + TextColor = UIColor.FromRGB (0x32, 0x4f, 0x85), + }; + Root.Insert (3, new Section () { blockUnblockButton }); + } catch (Exception e) { + Console.WriteLine (e); + } + } + + void ToggleFollow () + { + var url = String.Format ("http://api.twitter.com/1/friendships/{0}/{1}.json", following ? "destroy": "create", user.Id); + following = !following; + TwitterAccount.CurrentAccount.Post (url, ""); + followButton.Caption = GetFollowText (following); + Root.Reload (followButton, UITableViewRowAnimation.Fade); + } + + void ToggleBlock () + { + string caption = blocking + ? Locale.Format ("Are you sure you want to unblock {0}", user.Screenname) + : Locale.Format ("Are you sure you want to block {0}", user.Screenname); + + var sheet = new UIActionSheet (caption, null, Locale.GetText ("Cancel"), blocking ? Locale.GetText ("Unblock") : Locale.GetText ("Block"), null); + sheet.Clicked += delegate(object sender, UIButtonEventArgs e) { + if (e.ButtonIndex != 0) + return; + + var url = String.Format ("http://api.twitter.com/1/blocks/{0}.json?user_id={1}", blocking ? "destroy" : "create", user.Id); + blocking = !blocking; + TwitterAccount.CurrentAccount.Post (url, ""); + blockUnblockButton.Caption = GetBlockText (blocking);; + Root.Reload (blockUnblockButton, UITableViewRowAnimation.Fade); + }; + + // You would think "View" is the right view to pass here, but + // the "Cancel" event wont get events because the View is covered + // by the tab bar, so it wonget get events. So we need to find + // the full root. + sheet.ShowInView (Util.MainAppDelegate.MainView); + } + } + + public class MyProfileElement : RootElement { + public MyProfileElement (string caption) : base (caption) {} + + public override void Selected (DialogViewController dvc, UITableView tableView, NSIndexPath path) + { + var full = new FullProfileView (TwitterAccount.CurrentAccount.Username); + dvc.ActivateController (full); + } + } +} + diff --git a/TweetStation/UI/PictureViewer.cs b/TweetStation/UI/PictureViewer.cs new file mode 100644 index 0000000..d405f00 --- /dev/null +++ b/TweetStation/UI/PictureViewer.cs @@ -0,0 +1,17 @@ +using System; +using MonoTouch.UIKit; + +namespace TweetStation +{ + public class PictureViewer : UIViewController + { + public PictureViewer () : base () + { + } + + public static void Load (UIViewController parent, long userId) + { + } + } +} + diff --git a/TweetStation/UI/SearchTab.cs b/TweetStation/UI/SearchTab.cs new file mode 100644 index 0000000..69b4262 --- /dev/null +++ b/TweetStation/UI/SearchTab.cs @@ -0,0 +1,269 @@ +// +// The page that shows the various search options +// Search, Nearby, User, saved searches and trending topics +// +using System; +using System.Collections.Generic; +using System.IO; +using System.Json; +using System.Linq; +using System.Net; +using MonoTouch.Foundation; +using MonoTouch.UIKit; +using MonoTouch.Dialog; +using System.Web; +using System.Drawing; + +namespace TweetStation +{ + // + // The main entry point for searches in the application, it + // dispatches to various nested views + // + public class SearchesViewController : DialogViewController { + TwitterAccount account; + Section savedSearches, trends, lists; + + public SearchesViewController () : base (null) {} + + public TwitterAccount Account { + get { + return account; + } + + set { + if (account == value) + return; + + account = value; + ReloadAccount (); + } + } + + void ReloadAccount () + { + lists = new Section ("Lists") { + new StringElement ("New list", delegate { EditList (null, new ListDefinition ()); }) + }; + + Root = new RootElement ("Search") { + new Section () { + new RootElement ("Search"), + new RootElement ("Nearby"), + new RootElement ("Go to User", delegate { return new SearchUser (); }) + }, + lists, + }; + } + + bool SearchResultsAreRecent { + get { + long lastTime; + return Int64.TryParse (Util.Defaults.StringForKey ("searchLoadedTime"), out lastTime) && (DateTime.UtcNow.Ticks - lastTime) < TimeSpan.FromMinutes (30).Ticks; + } + } + + public override void ViewWillAppear (bool animated) + { + base.ViewWillAppear (animated); + + if (savedSearches != null) + return; + + FetchLists (); + + if (SearchResultsAreRecent){ + InsertCachedSearchResults (); + FetchTrends (); + } else { + account.Download (new Uri ("http://api.twitter.com/1/saved_searches.json"), result => { + if (result != null) + LoadSearches (result); + }); + } + } + + void LoadSearches (byte [] result) + { + try { + var json = JsonValue.Load (new MemoryStream (result)); + + for (int i = 0; i < json.Count; i++) + Util.Defaults.SetString (json [i]["query"], "s-" + i); + + Util.Defaults.SetString (DateTime.UtcNow.Ticks.ToString (), "searchLoadedTime"); + InsertCachedSearchResults (); + } catch (Exception e){ + Console.WriteLine (e); + } + FetchTrends (); + } + + void InsertCachedSearchResults () + { + savedSearches = new Section ("Saved searches"){ + GetCachedResults () + }; + Root.Add (savedSearches); + } + + IEnumerable GetCachedResults () + { + for (int i = 0; i < 10; i++){ + var value = Util.Defaults.StringForKey ("s-" + i); + if (value == null) + yield break; + yield return new SearchElement (value, value); + } + } + + // + // Queues a request to fetch the trends, and adds a new section to the root + // + void FetchTrends () + { + account.Download (new Uri ("http://search.twitter.com/trends/current.json"), result => { + try { + var json = JsonValue.Load (new MemoryStream (result)); + var jroot = (JsonObject) json ["trends"]; + var jtrends = jroot.Values.FirstOrDefault (); + + trends = new Section ("Trends"); + + for (int i = 0; i < jtrends.Count; i++) + trends.Add ( new SearchElement (jtrends [i]["name"], jtrends [i]["query"])); + Root.Add (trends); + } catch (Exception e){ + Console.WriteLine (e); + } + }); + } + + // + // Queues a request to fetch the lists, and inserts + // the results into the existing section + // + void FetchLists () + { + account.Download (new Uri ("http://api.twitter.com/1/" + account.Username + "/lists.json"), res => { + if (res == null) + return; + + var json = JsonValue.Load (new MemoryStream (res)); + var jlists = json ["lists"]; + try { + int pos = 0; + foreach (JsonObject list in jlists){ + string name = list ["full_name"]; + string listname = list ["name"]; + string url = "http://api.twitter.com/1/" + account.Username + "/lists/" + listname + "/statuses.json"; + lists.Insert (pos++, UITableViewRowAnimation.Fade, new TimelineElement (name, name, url, null)); + } + } catch (Exception e){ + Console.WriteLine (e); + } + }); + } public enum Privacy { + Public, Private + } + + public class ListDefinition { + public string Name; + public string Description; + public Privacy Privacy; + } + + public void EditList (string originalName, ListDefinition list) + { + var editor = new DialogViewController (null, true); + var name = new EntryElement ("Name", null, list.Name); + name.Changed += delegate { + editor.NavigationItem.RightBarButtonItem.Enabled = !String.IsNullOrEmpty (name.Value); + }; + var description = new EntryElement ("Description", "optional", list.Description); + var privacy = new RootElement ("Privacy", new RadioGroup ("key", (int) list.Privacy)){ + new Section () { + new RadioElement ("Public"), + new RadioElement ("Private") + } + }; + editor.NavigationItem.SetLeftBarButtonItem (new UIBarButtonItem (UIBarButtonSystemItem.Cancel, delegate { + DeactivateController (true); + }), false); + + editor.NavigationItem.SetRightBarButtonItem (new UIBarButtonItem (UIBarButtonSystemItem.Save, delegate { + string url = String.Format ("http://api.twitter.com/1/{0}/lists{1}.json?name={2}&mode={3}{4}", + TwitterAccount.CurrentAccount.Username, + originalName == null ? "" : "/" + originalName, + HttpUtility.UrlEncode (name.Value), + privacy.RadioSelected == 0 ? "public" : "private", + description.Value == null ? "" : "&description=" + HttpUtility.UrlEncode (description.Value)); + TwitterAccount.CurrentAccount.Post (url, ""); + + }), false); + + editor.NavigationItem.RightBarButtonItem.Enabled = !String.IsNullOrEmpty (name.Value); + editor.Root = new RootElement ("New List") { + new Section () { name, description, privacy } + }; + ActivateController (editor); + } + } + + // + // A view controller that performs a searc + // + public class SearchViewController : BaseTimelineViewController { + string search; + + public SearchViewController (string search) : base (true) + { + this.search = search; + } + + protected override string TimelineTitle { + get { + return search; + } + } + + protected override void ResetState () + { + Root = Util.MakeProgressRoot (search); + TriggerRefresh (); + } + + public override void ReloadTimeline () + { + TwitterAccount.CurrentAccount.Download (new Uri ("http://search.twitter.com/search.json?q=" + HttpUtility.UrlEncode (search)), res => { + if (res == null){ + Root = Util.MakeError ("search"); + return; + } + var tweetStream = Tweet.TweetsFromSearchResults (new MemoryStream (res)); + + Root = new RootElement (search){ + new Section () { + from tweet in tweetStream select (Element) new TweetElement (tweet) + } + }; + ReloadComplete (); + }); + } + } + + public class SearchElement : RootElement { + string query; + + public SearchElement (string caption, string query) : base (caption) + { + this.query = query; + } + + protected override UIViewController MakeViewController () + { + return new SearchViewController (query) { Account = TwitterAccount.CurrentAccount }; + } + + } +} diff --git a/TweetStation/UI/SearchUser.cs b/TweetStation/UI/SearchUser.cs new file mode 100644 index 0000000..0a4829a --- /dev/null +++ b/TweetStation/UI/SearchUser.cs @@ -0,0 +1,87 @@ +// +// Search for a user +// +// Author: Miguel de Icaza (miguel@gnome.org) +// +using System; +using System.Collections.Generic; +using MonoTouch.Foundation; +using MonoTouch.UIKit; +using MonoTouch.Dialog; +using System.Drawing; +using System.Linq; + +namespace TweetStation +{ + public abstract class SearchDialog : DialogViewController { + SearchMirrorElement searchMirror; + Section entries; + + public SearchDialog () : base (UITableViewStyle.Plain, null) + { + EnableSearch = true; + } + + public override void OnSearchTextChanged (string text) + { + base.OnSearchTextChanged (text); + searchMirror.Text = text; + } + + public override void ViewWillAppear (bool animated) + { + base.ViewWillAppear (animated); + + searchMirror = new SearchMirrorElement (); + Section entries = new Section () { + searchMirror + }; + + Root = new RootElement (Locale.GetText ("Search")){ + entries, + }; + PopulateSearch (entries); + + StartSearch (); + PerformFilter (""); + } + + public abstract void PopulateSearch (Section entries); + } + + public class SearchUser : SearchDialog { + public SearchUser () + { + Console.WriteLine ("User"); + } + + public override void PopulateSearch (Section entries) + { + entries.Add (from x in Database.Main.Query ("SELECT Screenname from User ORDER BY Screenname") + select (Element) new StringElement (x.Screenname)); + } + } + + // + // Just a styled string element, but if the search string is not empty + // the Matches method always returns true + // + public class SearchMirrorElement : StyledStringElement { + string text; + public string Text { + get { return text; } + set { text = value; Value = Locale.Format ("Go to user '{0}'", text); } + } + + public SearchMirrorElement () : base ("") + { + } + + public override bool Matches (string test) + { + return !String.IsNullOrEmpty (text); + } + + } +} + diff --git a/TweetStation/UI/ShortProfile.cs b/TweetStation/UI/ShortProfile.cs new file mode 100644 index 0000000..feb2088 --- /dev/null +++ b/TweetStation/UI/ShortProfile.cs @@ -0,0 +1,120 @@ +using System; +using System.Drawing; +using MonoTouch.Dialog; +using MonoTouch.Foundation; +using MonoTouch.UIKit; + +namespace TweetStation +{ + public class ShortProfileView : UIView, IImageUpdated + { + const int userSize = 19; + const int followerSize = 13; + const int locationSize = 14; + const int urlSize = 14; + const int TextX = 95; + + static UIFont userFont = UIFont.BoldSystemFontOfSize (userSize); + static UIFont followerFont = UIFont.SystemFontOfSize (followerSize); + static UIFont locationFont = UIFont.SystemFontOfSize (locationSize); + static UIFont urlFont = UIFont.BoldSystemFontOfSize (urlSize); + + UIImageView profilePic; + UIButton url; + User user; + + public ShortProfileView (RectangleF rect, long userId, bool discloseButton) : base (rect) + { + BackgroundColor = UIColor.Clear; + + user = User.FromId (userId); + if (user == null){ + Console.WriteLine ("userid={0}", userId); + return; + } + + // Pics are 73x73, but we will add a border. + profilePic = new UIImageView (new RectangleF (10, 10, 75, 75)); + profilePic.BackgroundColor = UIColor.Clear; + + profilePic.Image = ImageStore.RequestProfilePicture (userId, user.PicUrl, this); + AddSubview (profilePic); + + url = UIButton.FromType (UIButtonType.Custom); + url.Font = urlFont; + url.Font = urlFont; + url.LineBreakMode = UILineBreakMode.TailTruncation; + url.HorizontalAlignment = UIControlContentHorizontalAlignment.Left; + url.TitleShadowOffset = new SizeF (0, 1); + + url.SetTitle (user.Url, UIControlState.Normal); + url.SetTitle (user.Url, UIControlState.Highlighted); + url.SetTitleColor (UIColor.FromRGB (0x32, 0x4f, 0x85), UIControlState.Normal); + url.SetTitleColor (UIColor.Red, UIControlState.Highlighted); + url.SetTitleShadowColor (UIColor.White, UIControlState.Normal); + url.Frame = new RectangleF (TextX, 70, rect.Width-TextX, urlSize); + + url.AddTarget (delegate { if (UrlTapped != null) UrlTapped (); }, UIControlEvent.TouchUpInside); + + AddSubview (url); + + if (discloseButton){ + var button = UIButton.FromType (UIButtonType.DetailDisclosure); + button.Frame = new RectangleF (290, 36, 20, 20); + AddSubview (button); + } + } + + public event NSAction PictureTapped; + public event NSAction UrlTapped; + public event NSAction Tapped; + + public override void TouchesBegan (NSSet touches, UIEvent evt) + { + if (user == null) + return; + + var touch = touches.AnyObject as UITouch; + var location = touch.LocationInView (this); + if (profilePic.Frame.Contains (location)){ + if (PictureTapped != null) + PictureTapped (); + } else { + if (Tapped != null) + Tapped (); + } + } + + public override void Draw (RectangleF rect) + { + // Perhaps we should never instantiate this view if the user is null. + if (user == null) + return; + + var w = rect.Width-TextX; + var context = UIGraphics.GetCurrentContext (); + + context.SaveState (); + context.SetRGBFillColor (0, 0, 0, 1); + context.SetShadowWithColor (new SizeF (0, -1), 1, UIColor.White.CGColor); + + DrawString (user.Name, new RectangleF (TextX, 12, w, userSize), userFont, UILineBreakMode.TailTruncation); + DrawString (user.Location, new RectangleF (TextX, 50, w, locationSize), locationFont, UILineBreakMode.TailTruncation); + + UIColor.DarkGray.SetColor (); + DrawString (user.FollowersCount + " followers", new RectangleF (TextX, 34, w, followerSize), followerFont); + + context.RestoreState (); + //url.Draw (rect); + } + + #region IImageUpdated implementation + public void UpdatedImage (long id) + { + var pic = ImageStore.GetLocalProfilePicture (id); + if (pic != null) + profilePic.Image = pic; + } + #endregion + } +} diff --git a/TweetStation/UI/Timeline.cs b/TweetStation/UI/Timeline.cs new file mode 100644 index 0000000..04ea352 --- /dev/null +++ b/TweetStation/UI/Timeline.cs @@ -0,0 +1,295 @@ +// +// Timeline.cs: classes for rendering timelines of tweets +// +using System; +using System.Linq; +using System.Collections.Generic; +using MonoTouch.UIKit; +using MonoTouch.Dialog; +using System.Drawing; +using System.IO; +using MonoTouch.Foundation; + +namespace TweetStation { + + public abstract class BaseTimelineViewController : DialogViewController + { + TwitterAccount account; + protected TweetKind kind; + + public BaseTimelineViewController (bool pushing) : base (null, pushing) + { + Style = UITableViewStyle.Plain; + NavigationItem.RightBarButtonItem = new UIBarButtonItem (UIBarButtonSystemItem.Compose, delegate { + if (kind == TweetKind.Direct){ + var sheet = new UIActionSheet (""); + sheet.AddButton (Locale.GetText ("New Tweet")); + sheet.AddButton (Locale.GetText ("Direct Message")); + sheet.AddButton (Locale.GetText ("Cancel")); + + sheet.CancelButtonIndex = 2; + sheet.Clicked += delegate(object sender, UIButtonEventArgs e) { + if (e.ButtonIndex == 2) + return; + + if (e.ButtonIndex == 0) + Composer.Main.NewTweet (this); + else + Composer.Main.Direct (this, ""); + }; + sheet.ShowInView (Util.MainAppDelegate.MainView); + } else { + Composer.Main.NewTweet (this); + } + }); + + RefreshRequested += delegate { + ReloadTimeline (); + }; + } + + public TwitterAccount Account { + get { + return account; + } + + set { + if (account == value) + return; + + account = value; + ReloadAccount (); + } + } + + // The title for the root element + protected abstract string TimelineTitle { get; } + + // Must reload the contents, the account has changed and create the view contents + protected abstract void ResetState (); + + // Reloads data from the server + public abstract void ReloadTimeline (); + + void ReloadAccount () + { + if (Root != null) + Root.Dispose (); + + ResetState (); + } + } + + public class TimelineViewController : BaseTimelineViewController { + Section mainSection; + string timelineTitle; + + public TimelineViewController (string title, TweetKind kind, bool pushing) : base (pushing) + { + timelineTitle = title; + this.kind = kind; + EnableSearch = true; + } + + protected override string TimelineTitle { + get { + return timelineTitle; + } + } + + // + // Fetches the tweets from the database up to @limit values + // and @lastId is the last known tweet that we had loaded in the + // view, if we find the value in the first @limit values, we know + // that the timeline is continuous + // + bool continuous; + IEnumerable FetchTweets (int limit, long lastId, int skip) + { + continuous = false; + foreach (var tweet in Database.Main.Query ( + "SELECT * FROM Tweet WHERE LocalAccountId = ? AND Kind = ? ORDER BY CreatedAt DESC LIMIT ? OFFSET ?", + Account.LocalAccountId, kind, limit, skip)){ + if (tweet.Id == lastId) + continuous = true; + yield return (Element) new TweetElement (tweet); + } + } + + // Gets the ID for the tweet in the tableview at @pos + long? GetTableTweetId (int pos) + { + var mainSection = Root [0]; + long lastId = 0; + if (mainSection.Elements.Count > pos){ + return (mainSection.Elements [pos] as TweetElement).Tweet.Id; + } else + return null; + } + + public override void ReloadTimeline () + { + long? since = null; + var res = Database.Main.Query ("SELECT Id FROM Tweet WHERE LocalAccountId = ? AND Kind = ? ORDER BY Id DESC LIMIT 1", Account.LocalAccountId, kind).FirstOrDefault (); + if (res != null){ + // This should return one overlapping value. + since = res.Id - 1; + } + + DownloadTweets (0, since, null); + } + + void DownloadTweets (int insertPoint, long? since, long? max_id) + { + if (kind != TweetKind.Home) + return; + + Account.ReloadTimeline (kind, since, max_id, count => { + if (count == -1){ + mainSection.Insert (insertPoint, new StringElement (Locale.Format ("Net failure on {0}", DateTime.Now))); + count = 1; + } else { + // If we find an overlapping value, the timeline is continous, otherwise, we offer to load more + + // If insertPoint == 0, this is a top load, otherwise it is a "Load more tweets" load, so we + // need to fetch insertPoint-1 to get the actual last tweet, instead of the message. + long lastId = GetTableTweetId (insertPoint == 0 ? 0 : insertPoint-1) ?? 0; + + continuous = false; + int newTweets = mainSection.Insert (insertPoint, UITableViewRowAnimation.None, FetchTweets (count, lastId, insertPoint)); + NavigationController.TabBarItem.BadgeValue = count.ToString (); + + if (!continuous){ + Element more = null; + more = new StringElement ("Load more tweets", delegate { + DownloadTweets (insertPoint + count, null, GetTableTweetId (insertPoint + count-1)-1); + mainSection.Remove (more); + }); + mainSection.Insert (insertPoint+count, UITableViewRowAnimation.None, more); + } + } + ReloadComplete (); + + // Only scroll to last unread if this was not an intermediate "Load more tweets" + if (insertPoint == 0) + TableView.ScrollToRow (NSIndexPath.FromRowSection (count-1, 0), UITableViewScrollPosition.Middle, false); + }); + } + + protected override void ResetState () + { + mainSection = new Section () { + FetchTweets (200, 0, 0) + }; + + Root = new RootElement (timelineTitle) { + UnevenRows = true + }; + Root.Add (mainSection); + SearchPlaceholder = Locale.Format ("Search {0}", TimelineTitle); + if (Util.NeedsUpdate ("update" + kind, TimeSpan.FromSeconds (120))) + TriggerRefresh (); + } + } + + // + // This version fo the BaseTimelineViewController does not store anything + // on the database, it loads the data directly from the network into memory + // + // Used for transient information display + // + public class StreamedTimelineViewController : BaseTimelineViewController { + const int PadX = 4; + ShortProfileView shortProfileView; + string title; + Uri url; + User reference; + bool loaded; + + public StreamedTimelineViewController (string title, Uri url, User reference) : base (true) + { + this.url = url; + this.title = title; + this.reference = reference; + + this.NavigationItem.Title = title; + EnableSearch = true; + } + + public StreamedTimelineViewController (string title, Uri url) : this (title, url, null) + { + } + + protected override string TimelineTitle { get { return title; } } + + protected override void ResetState () + { + if (reference != null){ + var profileRect = new RectangleF (PadX, 0, View.Bounds.Width-30-PadX*2, 100); + shortProfileView = new ShortProfileView (profileRect, reference.Id, true); + shortProfileView.PictureTapped += delegate { PictureViewer.Load (this, reference.Id); }; + shortProfileView.UrlTapped += delegate { WebViewController.OpenUrl (this, reference.Url); }; + shortProfileView.Tapped += delegate { ActivateController (new FullProfileView (reference.Id)); }; + TableView.TableHeaderView = shortProfileView; + } + } + + public override void ViewWillAppear (bool animated) + { + base.ViewWillAppear (animated); + if (loaded) + return; + SearchPlaceholder = "Search"; + loaded = true; + Root = Util.MakeProgressRoot (title); + TriggerRefresh (); + } + + // Reloads data from the server + public override void ReloadTimeline () + { + Account.Download (url, result => { + if (result == null){ + Root = new RootElement (title) { + new Section () { + new StringElement ("Unable to download the timeline") + } + }; + return; + } + ReloadComplete (); + + var tweetStream = Tweet.TweetsFromStream (new MemoryStream (result), reference); + + Root = new RootElement (title){ + new Section () { + from tweet in tweetStream select (Element) new TweetElement (tweet) + } + }; + }); + } + } + + public class TimelineElement : RootElement { + User reference; + string nestedCaption; + string url; + + public TimelineElement (string nestedCaption, string caption, string url, User reference) : base (caption) + { + this.nestedCaption = nestedCaption; + this.reference = reference; + this.url = url; + } + + protected override UIViewController MakeViewController () + { + return new StreamedTimelineViewController (nestedCaption, new Uri (url), reference) { + Account = TwitterAccount.CurrentAccount + }; + } + + } +} + + diff --git a/TweetStation/UI/TweetCell.cs b/TweetStation/UI/TweetCell.cs new file mode 100644 index 0000000..6b94293 --- /dev/null +++ b/TweetStation/UI/TweetCell.cs @@ -0,0 +1,261 @@ +// +// TweetCell.cs: +// +// This shows both how to implement a custom UITableViewCell and +// how to implement a custom MonoTouch.Dialog.Element. +// +// Author: +// Miguel de Icaza +// +using System; +using System.Drawing; +using MonoTouch.UIKit; +using MonoTouch.Foundation; +using MonoTouch.Dialog; + +namespace TweetStation +{ + // + // TweetCell used for the timelines. It is relatlively light, and + // does not do highlighting. This might work for the iPhone, but + // for the iPad we probably should just use TweetViews that do the + // highlighting of url-like things + // + public class TweetCell : UITableViewCell, IImageUpdated { + // Do these as static to reuse across all instances + const int userSize = 14; + const int textSize = 15; + const int timeSize = 10; + + + const int PicSize = 48; + const int PicXPad = 10; + const int PicYPad = 5; + + const int TextLeftStart = 2 * PicXPad + PicSize; + + const int TextHeightPadding = 4; + const int TextYOffset = userSize + 4; + const int MinHeight = PicSize + 2 * PicYPad; + const int TimeWidth = 46; + + static UIFont userFont = UIFont.BoldSystemFontOfSize (userSize); + static UIFont textFont = UIFont.SystemFontOfSize (textSize); + static UIFont timeFont = UIFont.SystemFontOfSize (timeSize); + + Tweet tweet; + UILabel userLabel, textLabel, timeLabel; + UIImageView imageView; + UIImageView retweetView; + + public TweetCell (IntPtr handle) : base (handle) { + Console.WriteLine (Environment.StackTrace); + } + + // Create the UIViews that we will use here, layout happens in LayoutSubviews + public TweetCell (UITableViewCellStyle style, NSString ident, Tweet tweet) : base (style, ident) + { + this.tweet = tweet; + SelectionStyle = UITableViewCellSelectionStyle.Blue; + + userLabel = new UILabel () { + TextAlignment = UITextAlignment.Left, + Font = userFont, + }; + + textLabel = new UILabel () { + Font = textFont, + TextAlignment = UITextAlignment.Left, + Lines = 0, + LineBreakMode = UILineBreakMode.WordWrap + }; + timeLabel = new UILabel () { + Font = timeFont, + TextColor = UIColor.LightGray, + TextAlignment = UITextAlignment.Right, + BackgroundColor = UIColor.Clear + }; + imageView = new UIImageView (new RectangleF (PicXPad, PicYPad, PicSize, PicSize)); + retweetView = new UIImageView (new RectangleF (PicXPad + 30, PicYPad + 30, 23, 23)); + UpdateCell (tweet); + + ContentView.Add (userLabel); + ContentView.Add (textLabel); + ContentView.Add (timeLabel); + ContentView.Add (imageView); + ContentView.Add (retweetView); + } + + // + // This method is called when the cell is reused to reset + // all of the cell values + // + public void UpdateCell (Tweet tweet) + { + this.tweet = tweet; + + userLabel.Text = tweet.Retweeter == null ? tweet.Screename : tweet.Screename + "→" + tweet.Retweeter; + textLabel.Text = tweet.Text; + timeLabel.Text = Util.FormatTime (new TimeSpan (DateTime.UtcNow.Ticks - tweet.CreatedAt)); + + var img = ImageStore.GetLocalProfilePicture (tweet.UserId); + if (img == null && tweet.UserId < 0){ + // For the negative user ids, we can try looking up by screename now + img = ImageStore.GetLocalProfilePicture (tweet.Screename); + } + + if (img == null) + ImageStore.QueueRequestForPicture (tweet.UserId, tweet.PicUrl, this); + else + tweet.PicUrl = null; + + imageView.Image = img == null ? ImageStore.DefaultImage : img; + + // If no retweet, hide our image. + if (tweet.Retweeter == null) + retweetView.Alpha = 0; + else { + retweetView.Alpha = 1; + img = ImageStore.GetLocalProfilePicture (tweet.RetweeterId); + if (img == null) + ImageStore.QueueRequestForPicture (tweet.RetweeterId, tweet.RetweeterPicUrl, this); + else + tweet.RetweeterPicUrl = null; + + retweetView.Image = img == null ? ImageStore.DefaultImage : img; + } + } + + public static float GetCellHeight (RectangleF bounds, Tweet tweet) + { + bounds.Height = 999; + + // Keep the same as LayoutSubviews + bounds.X = TextLeftStart; + bounds.Width -= TextLeftStart+TextHeightPadding; + + using (var nss = new NSString (tweet.Text)){ + var dim = nss.StringSize (textFont, bounds.Size, UILineBreakMode.WordWrap); + return Math.Max (dim.Height + TextYOffset + 2*TextHeightPadding, MinHeight); + } + } + + // + // Layouts the views, called before the cell is shown + // + + public override void LayoutSubviews () + { + base.LayoutSubviews (); + var full = ContentView.Bounds; + var tmp = full; + + tmp.Width -= TextLeftStart+TextHeightPadding+TimeWidth; + tmp.X = TextLeftStart; + tmp.Y = TextHeightPadding; + tmp.Height = userSize; + userLabel.Frame = tmp; + + tmp = full; + tmp.X = TextLeftStart; + tmp.Y = TextHeightPadding; + tmp.Height = timeSize; + tmp.Width -= TextLeftStart+TextHeightPadding; + timeLabel.Frame = tmp; + + tmp = full; + tmp.Y += TextYOffset; + tmp.Height -= TextYOffset; + tmp.X = TextLeftStart; + tmp.Width -= TextLeftStart+TextHeightPadding; + textLabel.Frame = tmp; + } + + void IImageUpdated.UpdatedImage (long onId) + { + // Discard notifications that might have been queued for an old cell + if (tweet == null || (tweet.UserId != onId && tweet.RetweeterId != onId)) + return; + + imageView.Alpha = 0; + // Discard the url string once the image is loaded, we wont be using it. + if (onId == tweet.UserId){ + imageView.Image = ImageStore.GetLocalProfilePicture (onId); + tweet.PicUrl = null; + } else { + retweetView.Image = ImageStore.GetLocalProfilePicture (onId); + tweet.RetweeterPicUrl = null; + } + + UIView.BeginAnimations (null, IntPtr.Zero); + UIView.SetAnimationDuration (0.5); + + imageView.Alpha = 1; + UIView.CommitAnimations (); + } + } + + // + // A MonoTouch.Dialog.Element that renders a TweetCell + // + public class TweetElement : Element, IElementSizing { + static NSString key = new NSString ("tweetelement"); + public Tweet Tweet; + + public TweetElement (Tweet tweet) : base (null) + { + Tweet = tweet; + } + + // Gets a cell on demand, reusing cells + public override UITableViewCell GetCell (UITableView tv) + { + var cell = tv.DequeueReusableCell (key) as TweetCell; + if (cell == null) + cell = new TweetCell (UITableViewCellStyle.Default, key, Tweet); + else + cell.UpdateCell (Tweet); + + return cell; + } + + public override void Selected (DialogViewController dvc, UITableView tableView, NSIndexPath path) + { + // For partial tweets we need to load the full tweet + if (Tweet.UserId < 0) + Tweet.LoadFullTweet (Tweet.Id, t => { + if (t == null) + return; + + Tweet = t; + Activate (dvc, t); + }); + else + Activate (dvc, Tweet); + } + + void Activate (DialogViewController dvc, Tweet source) + { + var profile = new DetailTweetViewController (source); + dvc.ActivateController (profile); + } + + public override bool Matches (string text) + { + try { + return Tweet.Screename.IndexOf (text, StringComparison.CurrentCultureIgnoreCase) != -1 || + Tweet.Text.IndexOf (text, StringComparison.InvariantCultureIgnoreCase) != -1 || + Tweet.Retweeter != null ? Tweet.Retweeter.IndexOf (text, StringComparison.CurrentCultureIgnoreCase) != -1 : false; + } catch (Exception e){ + return false; + } + } + + #region IElementSizing implementation + public float GetHeight (UITableView tableView, NSIndexPath indexPath) + { + return TweetCell.GetCellHeight (tableView.Bounds, Tweet); + } + #endregion + } +} diff --git a/TweetStation/UI/TweetView.cs b/TweetStation/UI/TweetView.cs new file mode 100644 index 0000000..8361eda --- /dev/null +++ b/TweetStation/UI/TweetView.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using MonoTouch.CoreGraphics; +using MonoTouch.Foundation; +using MonoTouch.UIKit; + +namespace TweetStation +{ + /// + /// A UIView that renders a tweet in full, including highlighted #hash, @usernames + /// and urls + /// + public class TweetView : UIView { + const int fontHeight = 17; + UIFont regular = UIFont.SystemFontOfSize (fontHeight); + UIFont bold = UIFont.BoldSystemFontOfSize (fontHeight); + + string text; + RectangleF lastRect; + List blocks; + public float Height { get; private set; } + Block highlighted = null; + + public TweetView (RectangleF frame, string text) : base (frame) + { + blocks = new List (); + + Update (text); + lastRect = RectangleF.Empty; + + var f = Frame; + f.Height = Height; + Frame = f; + } + + public void Update (string text) + { + this.text = text; + Height = Layout (); + } + + class Block { + public string Value; + public RectangleF Bounds; + public UIFont Font; + } + + //const int spaceLen = 4; + const int lineHeight = fontHeight + 4; + float Layout () + { + float max = Bounds.Width, segmentLength, lastx = 0, x = 0, y = 0; + int p = 0; + UIFont font = regular, lastFont = null; + string line = ""; + + blocks.Clear (); + while (p < text.Length){ + int sidx = text.IndexOf (' ', p); + if (sidx == -1) + sidx = text.Length-1; + + var segment = text.Substring (p, sidx-p+1); + if (segment.Length == 0) + break; + + var start = segment [0]; + if (start == '@' || start == '#' || segment.StartsWith ("http://", StringComparison.Ordinal)) + font = bold; + else + font = regular; + + segmentLength = StringSize (segment, font).Width; + + // If we would overflow the line. + if (x + segmentLength >= max){ + // Push the text we have so far, go to next line + if (line != ""){ + blocks.Add (new Block () { + Bounds = new RectangleF (lastx, y, x-lastx, lineHeight), + Value = line, + Font = lastFont ?? font, + }); + y += lineHeight; + lastx = 0; + } + + // Too long to fit even on its own line, stick it on its own line. + if (segmentLength >= max){ + var dim = StringSize (segment, font, new SizeF (max, float.MaxValue), UILineBreakMode.WordWrap); + blocks.Add (new Block () { + Bounds = new RectangleF (new PointF (0, y), dim), + Value = segment, + Font = lastFont ?? font + }); + y += dim.Height; + x = 0; + line = ""; + } else { + x = segmentLength; + line = segment; + } + p = sidx + 1; + lastFont = font; + } else { + // append the segment if the font changed, or if the font + // is bold (so we can make a tappable element on its own). + if (x != 0 && (font != lastFont || font == bold)){ + blocks.Add (new Block () { + Bounds = new RectangleF (lastx, y, x-lastx, lineHeight), + Value = line, + Font = lastFont + }); + lastx = x; + line = segment; + lastFont = font; + } else { + lastFont = font; + line = line + segment; + } + x += segmentLength; + p = sidx+1; + } + // remove duplicate spaces + while (p < text.Length && text [p] == ' ') + p++; + //Console.WriteLine ("p={0}", p); + } + if (line == "") + return y; + + blocks.Add (new Block () { + Bounds = new RectangleF (lastx, y, x-lastx, lineHeight), + Value = line, + Font = font + }); + + return y + lineHeight; + } + + public override void Draw (RectangleF rect) + { + if (rect != lastRect){ + Layout (); + lastRect = rect; + } + + var context = UIGraphics.GetCurrentContext (); + UIFont last = null; + + foreach (var block in blocks){ + var font = block.Font; + if (font != last){ + if (font == bold) + context.SetRGBFillColor (0.1f, 0.5f, 0.87f, 1); + else + context.SetRGBFillColor (0, 0, 0, 1); + last = font; + } + + // selected? + if (block == highlighted){ + context.FillRect (block.Bounds); + context.SetRGBFillColor (1, 1, 1, 1); + last = null; + } + DrawString (block.Value, block.Bounds, block.Font); + //context.SetRGBStrokeColor (1, 0, 1, 1); + //context.StrokeRect (block.Bounds); + } + } + + public override void TouchesBegan (NSSet touches, UIEvent evt) + { + Track ((touches.AnyObject as UITouch).LocationInView (this)); + } + + void Track (PointF pos) + { + foreach (var block in blocks){ + if (!block.Bounds.Contains (pos)) + continue; + + highlighted = block; + SetNeedsDisplay (); + return; + } + } + + public delegate void TappedEvent (string value); + + public event TappedEvent Tapped; + + public override void TouchesEnded (NSSet touches, UIEvent evt) + { + if (highlighted != null && highlighted.Font == bold){ + if (Tapped != null) + Tapped (highlighted.Value); + } + + highlighted = null; + SetNeedsDisplay (); + } + + public override void TouchesCancelled (NSSet touches, UIEvent evt) + { + highlighted = null; + SetNeedsDisplay (); + } + + public override void TouchesMoved (NSSet touches, UIEvent evt) + { + Track ((touches.AnyObject as UITouch).LocationInView (this)); + } + + } +} diff --git a/TweetStation/UI/Web.cs b/TweetStation/UI/Web.cs new file mode 100644 index 0000000..66bbd6e --- /dev/null +++ b/TweetStation/UI/Web.cs @@ -0,0 +1,73 @@ +// +// +// +using System; +using MonoTouch.UIKit; +using MonoTouch.Dialog; +using MonoTouch.Foundation; +using System.Drawing; + +namespace TweetStation +{ + public class WebViewController : UIViewController { + static WebViewController Main = new WebViewController (); + + UIToolbar toolbar; + UIBarButtonItem [] items; + UIWebView webView; + + WebViewController () + { + toolbar = new UIToolbar (); + items = new UIBarButtonItem [] { + new UIBarButtonItem (Locale.GetText ("Back"), UIBarButtonItemStyle.Bordered, (o, e) => { webView.GoBack (); }), + new UIBarButtonItem (Locale.GetText ("Forward"), UIBarButtonItemStyle.Bordered, (o, e) => { webView.GoForward (); }), + new UIBarButtonItem (UIBarButtonSystemItem.FlexibleSpace, null), + new UIBarButtonItem (UIBarButtonSystemItem.Refresh, (o, e) => { webView.Reload (); }), + new UIBarButtonItem (UIBarButtonSystemItem.Stop, (o, e) => { webView.StopLoading (); }) + }; + toolbar.Items = items; + + View.AddSubview (toolbar); + } + + public void SetupWeb () + { + webView = new UIWebView (){ + ScalesPageToFit = true + }; + webView.LoadStarted += delegate { Util.PushNetworkActive (); }; + webView.LoadFinished += delegate { Util.PopNetworkActive (); }; + + //webView.SizeToFit (); + View.AddSubview (webView); + } + + public override void ViewDidDisappear (bool animated) + { + base.ViewDidDisappear (animated); + webView.RemoveFromSuperview (); + webView.Dispose (); + webView = null; + } + + public override void ViewWillAppear (bool animated) + { + base.ViewWillAppear (animated); + Console.WriteLine ("Frame: {0}", View.Frame); + toolbar.Frame = new RectangleF (0, View.Frame.Height-44, View.Frame.Width, 44); + webView.Frame = new RectangleF (0, 0, View.Frame.Width, View.Frame.Height-44); + } + + public static void OpenUrl (DialogViewController parent, string url) + { + UIView.BeginAnimations ("foo"); + Main.HidesBottomBarWhenPushed = true; + Main.SetupWeb (); + Main.webView.LoadRequest (new NSUrlRequest (new NSUrl (url))); + parent.ActivateController (Main); + UIView.CommitAnimations (); + } + } +} + diff --git a/TweetStation/Utilities/ButtonsView.cs b/TweetStation/Utilities/ButtonsView.cs new file mode 100644 index 0000000..d4559cb --- /dev/null +++ b/TweetStation/Utilities/ButtonsView.cs @@ -0,0 +1,50 @@ +// +// ButtonsView.cs: A view that constructs evently spaced buttons +// +using System; +using System.Drawing; +using MonoTouch.UIKit; +using MonoTouch.Foundation; +using MonoTouch.Dialog; + +namespace TweetStation +{ + + public class ButtonsView : UIView { + EventHandler [] actions; + UIButton [] buttons; + const int buttonHeight = 44; + + public ButtonsView (string [] captions, EventHandler [] actions) : base (new Rectangle (0, 0, 320, buttonHeight)) + { + if (captions == null || actions == null) + throw new ArgumentNullException (); + + if (captions.Length != actions.Length) + throw new ArgumentException ("Mismatched array sizes between actions and captions"); + + buttons = new UIButton [captions.Length]; + for (int i = captions.Length; --i >= 0; ){ + var button = UIButton.FromType (UIButtonType.RoundedRect); + + button.Font = UIFont.BoldSystemFontOfSize (14); + button.SetTitle (captions [i], UIControlState.Normal); + AddSubview (button); + button.AddTarget (actions [i], UIControlEvent.TouchUpInside); + + buttons [i] = button; + } + } + + const int XPad = 0; + + public override void LayoutSubviews () + { + var cellWidth = (310-2*XPad) / buttons.Length; + + for (int i = 0; i < buttons.Length; i++){ + buttons [i].Frame = new RectangleF (XPad + i * cellWidth, 0, cellWidth-8, buttonHeight); + } + } + } +} diff --git a/TweetStation/Utilities/Graphics.cs b/TweetStation/Utilities/Graphics.cs new file mode 100644 index 0000000..0a42cb2 --- /dev/null +++ b/TweetStation/Utilities/Graphics.cs @@ -0,0 +1,37 @@ +// +// Utilities for dealing with graphics +// +using System; +using System.Drawing; +using MonoTouch.CoreGraphics; +using MonoTouch.UIKit; + +namespace TweetStation +{ + public static class Graphics + { + // Child proof the image by rounding the edges of the image + internal static UIImage RemoveSharpEdges (UIImage image) + { + if (image == null) + throw new ArgumentNullException ("image"); + + UIGraphics.BeginImageContext (new SizeF (48, 48)); + var c = UIGraphics.GetCurrentContext (); + + c.BeginPath (); + c.MoveTo (48, 24); + c.AddArcToPoint (48, 48, 24, 48, 4); + c.AddArcToPoint (0, 48, 0, 24, 4); + c.AddArcToPoint (0, 0, 24, 0, 4); + c.AddArcToPoint (48, 0, 48, 24, 4); + c.ClosePath (); + c.Clip (); + + image.Draw (new PointF (0, 0)); + var converted = UIGraphics.GetImageFromCurrentImageContext (); + UIGraphics.EndImageContext (); + return converted; + } + } +} diff --git a/TweetStation/Utilities/LRUCache.cs b/TweetStation/Utilities/LRUCache.cs new file mode 100644 index 0000000..90ee8fc --- /dev/null +++ b/TweetStation/Utilities/LRUCache.cs @@ -0,0 +1,89 @@ +// +// A simple LRU cache used for tracking the images +// +// Authors: +// Miguel de Icaza (miguel@gnome.org) +// +// +using System; +using System.Collections.Generic; + +public class LRUCache where TValue : class, IDisposable { + Dictionary> dict; + Dictionary, TKey> revdict; + LinkedList list; + int limit; + + public LRUCache (int limit) + { + list = new LinkedList (); + dict = new Dictionary> (); + revdict = new Dictionary, TKey> (); + + this.limit = limit; + } + + void Evict () + { + var last = list.Last; + var key = revdict [last]; + + dict.Remove (key); + revdict.Remove (last); + list.RemoveLast (); + last.Value.Dispose (); + } + + public void Purge () + { + foreach (var element in list) + element.Dispose (); + + dict.Clear (); + revdict.Clear (); + list.Clear (); + } + + public TValue this [TKey key] { + get { + LinkedListNode node; + + if (dict.TryGetValue (key, out node)){ + list.Remove (node); + list.AddFirst (node); + + return node.Value; + } + return null; + } + + set { + LinkedListNode node; + + if (dict.TryGetValue (key, out node)){ + // If we already have a key, move it to the front + list.Remove (node); + list.AddFirst (node); + + // Remove the old value + node.Value.Dispose (); + node.Value = value; + return; + } + + if (dict.Count >= limit) + Evict (); + + // Adding new node + node = new LinkedListNode (value); + list.AddFirst (node); + dict [key] = node; + revdict [node] = key; + } + } + + public override string ToString () + { + return "LRUCache dict={0} revdict={1} list={2}"; + } +} diff --git a/TweetStation/Utilities/Locale.cs b/TweetStation/Utilities/Locale.cs new file mode 100644 index 0000000..14a0ecc --- /dev/null +++ b/TweetStation/Utilities/Locale.cs @@ -0,0 +1,17 @@ +using System; + +namespace TweetStation +{ + public static class Locale + { + public static string GetText (string str) + { + return str; + } + + public static string Format (string fmt, params object [] args) + { + return String.Format (fmt, args); + } + } +} diff --git a/TweetStation/Utilities/Util.cs b/TweetStation/Utilities/Util.cs new file mode 100644 index 0000000..74be09c --- /dev/null +++ b/TweetStation/Utilities/Util.cs @@ -0,0 +1,181 @@ + +using System; +using System.IO; +using System.Text; +using System.Threading; +using MonoTouch.UIKit; +using MonoTouch.Foundation; +using MonoTouch.Dialog; + +namespace TweetStation +{ + public static class Util + { + /// + /// A shortcut to the main application + /// + public static UIApplication MainApp = UIApplication.SharedApplication; + public static AppDelegate MainAppDelegate = UIApplication.SharedApplication.Delegate as AppDelegate; + + public readonly static string BaseDir = Path.Combine (Environment.GetFolderPath (Environment.SpecialFolder.Personal), ".."); + + // + // Since we are a multithreaded application and we could have many + // different outgoing network connections (api.twitter, images, + // searches) we need a centralized API to keep the network visibility + // indicator state + // + static object networkLock = new object (); + static int active; + + public static void PushNetworkActive () + { + lock (networkLock){ + active++; + MainApp.NetworkActivityIndicatorVisible = true; + } + } + + public static void PopNetworkActive () + { + lock (networkLock){ + active--; + if (active == 0) + MainApp.NetworkActivityIndicatorVisible = false; + } + } + + public static DateTime LastUpdate (string key) + { + var s = Defaults.StringForKey (key); + if (s == null) + return DateTime.MinValue; + long ticks; + if (Int64.TryParse (s, out ticks)) + return new DateTime (ticks, DateTimeKind.Utc); + else + return DateTime.MinValue; + } + + public static bool NeedsUpdate (string key, TimeSpan timeout) + { + return DateTime.UtcNow - LastUpdate (key) > timeout; + } + + public static void RecordUpdate (string key) + { + Defaults.SetString (key, DateTime.UtcNow.Ticks.ToString ()); + } + + + public static NSUserDefaults Defaults = NSUserDefaults.StandardUserDefaults; + + const long TicksOneDay = 864000000000; + const long TicksOneHour = 36000000000; + const long TicksMinute = 600000000; + + public static string FormatTime (TimeSpan ts) + { + int v; + + if (ts.Ticks < TicksMinute){ + v = ts.Seconds; + if (v == 1) + return Locale.GetText ("1 second"); + else + return Locale.Format ("{0} seconds", v); + } else if (ts.Ticks < TicksOneHour){ + v = ts.Minutes; + if (v == 1) + return Locale.GetText ("1 minute"); + else + return Locale.Format ("{0} minutes", v); + } else if (ts.Ticks < TicksOneDay){ + v = ts.Hours; + if (v == 1) + return Locale.GetText ("1 hour"); + else + return Locale.Format ("{0} hours", v); + } else { + v = ts.Days; + if (v == 1) + return Locale.GetText ("1 day"); + else + return Locale.Format ("{0} days", v); + } + } + + public static string StripHtml (string str) + { + if (str.IndexOf ('<') == -1) + return str; + var sb = new StringBuilder (); + for (int i = 0; i < str.Length; i++){ + char c = str [i]; + if (c != '<'){ + sb.Append (c); + continue; + } + + for (i++; i < str.Length; i++){ + c = str [i]; + if (c == '"' || c == '\''){ + var last = c; + for (i++; i < str.Length; i++){ + c = str [i]; + if (c == last) + break; + if (c == '\\') + i++; + } + } else if (c == '>') + break; + } + } + return sb.ToString (); + } + + public static string CleanName (string name) + { + if (name.Length == 0) + return ""; + + bool clean = true; + foreach (char c in name){ + if (Char.IsLetterOrDigit (c) || c == '_') + continue; + clean = false; + break; + } + if (clean) + return name; + + var sb = new StringBuilder (); + foreach (char c in name){ + if (!Char.IsLetterOrDigit (c)) + break; + + sb.Append (c); + } + return sb.ToString (); + } + + public static RootElement MakeProgressRoot (string caption) + { + return new RootElement (caption){ + new Section (){ + new ActivityElement () + } + }; + } + + public static RootElement MakeError (string diagMsg) + { + return new RootElement ("Error"){ + new Section ("Error"){ + new MultilineElement ("Unable to retrieve the information for " + diagMsg) + } + }; + } + } +}