Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
4e87a76
Add Lichess dummy widget
r3econ Mar 20, 2026
8e65783
Add FeedKit dependency to the iOS widget target
r3econ Mar 20, 2026
f10db6c
Replace dummy content with real data
r3econ Mar 20, 2026
31cb26c
Fetch and show more items
r3econ Mar 20, 2026
af07476
Fix the daily feed
r3econ Mar 20, 2026
a985d74
Add image fetching
r3econ Mar 20, 2026
1e6cacf
Tweak the max number of posts
r3econ Mar 20, 2026
9f03429
Add logo
r3econ Mar 20, 2026
5ef327b
Refactor
r3econ Mar 20, 2026
16b3376
Wrap the title
r3econ Mar 20, 2026
d932dbb
Tweak the design
r3econ Mar 21, 2026
5b6a536
Add URL to the blog items that trigger a deeplink
r3econ Mar 21, 2026
f429656
Handle deeplink in the app
r3econ Mar 21, 2026
4a86240
Add readme
r3econ Mar 21, 2026
3f198c7
Simplify logic
r3econ Mar 21, 2026
aa64426
Improve the design
r3econ Mar 21, 2026
505fcbe
Rename the widget
r3econ Mar 21, 2026
461632d
Add user widgets
r3econ Mar 21, 2026
3f93af1
Tweak the design
r3econ Mar 21, 2026
be83a36
Make the content scale base on available space
r3econ Mar 21, 2026
7fef951
Fix the font sizes
r3econ Mar 21, 2026
e5ee514
Remove previews
r3econ Mar 21, 2026
7181bad
Update the placeholder logic
r3econ Mar 21, 2026
4fd1a69
Fix problem with the small widget
r3econ Mar 21, 2026
af6f649
Remove image from the small widget
r3econ Mar 21, 2026
5c2aac4
Bottom align the publication date
r3econ Mar 21, 2026
aa30674
Fix image sizing bug
r3econ Mar 21, 2026
4c1119f
Refactor
r3econ Mar 21, 2026
a23b2bb
Update placeholder
r3econ Mar 21, 2026
c9183af
Refactor
r3econ Mar 21, 2026
f2788d0
Improve architecture
r3econ Mar 21, 2026
37cf871
Simplify code
r3econ Mar 21, 2026
eeb5c91
Merge branch 'main' of https://github.com/r3econ/mobile into ios-lich…
r3econ Mar 22, 2026
176ea91
Reorganize folder structure
r3econ Mar 22, 2026
a4c5a7e
Simplify placeholders
r3econ Mar 22, 2026
4950581
Update placeholder names
r3econ Mar 22, 2026
5dc1d76
Remove news feed
r3econ Mar 22, 2026
ecf6e44
Add readme to the project
r3econ Mar 22, 2026
89c0cd7
Update project file
r3econ Mar 22, 2026
f2d512d
Remove not needed URL
r3econ Mar 22, 2026
52b8024
Show author in the community feed
r3econ Mar 23, 2026
c98af98
Improve date formatting
r3econ Mar 23, 2026
dedbb56
Extract date logic into an extension
r3econ Mar 23, 2026
a03ea42
Format
r3econ Mar 23, 2026
6e315b6
Fix deprecation warning
r3econ Mar 23, 2026
4328d47
Fix filenames
r3econ Mar 23, 2026
9cd6be6
Introduce kLichessUriScheme
r3econ Mar 24, 2026
efbd89e
Separate blog widget into 3 distinct widgets
r3econ Mar 25, 2026
b1fc597
Add test scheme
r3econ Mar 25, 2026
2a4cd29
Update placeholder logic
r3econ Mar 25, 2026
a44bb90
Add placeholder images
r3econ Mar 25, 2026
c1214c4
Update placeholder content
r3econ Mar 25, 2026
b2d4893
Update widget order
r3econ Mar 25, 2026
ed5dadd
Rename
r3econ Mar 25, 2026
928cc27
Refactor
r3econ Mar 25, 2026
d244d94
Simplify feed fetching
r3econ Mar 25, 2026
5f9adc4
Fix warning
r3econ Mar 25, 2026
cdb6f12
Extract layout constants
r3econ Mar 25, 2026
34c0e00
Improve error layout
r3econ Mar 26, 2026
f5ceab6
Update formatting
r3econ Mar 26, 2026
3325e52
Update extension readme
r3econ Mar 26, 2026
191d621
Add widget identifier to matchfile and fastfile
r3econ Mar 26, 2026
daba1a0
Update extension readme
r3econ Mar 26, 2026
69ff30e
Merge branch 'main' into ios-lichess-blog-feed-widget
r3econ Mar 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions ios/EXTENSIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# iOS Extensions

## Building extensions as a contributor

Each iOS app extension (e.g. the Lichess widgets) is a separate Xcode target with its own bundle ID. For the extension to be installed on a device, that bundle ID must be registered in the Lichess Apple Developer account with a matching provisioning profile.

As an external contributor you won't have access to that account, so `flutter run` will silently drop the extension from the bundle — you can add the app to your home screen but the widget won't appear in the widget catalog.

### Testing locally

You can still build and test extensions using your own Apple Developer account and iOS Simulator. A free account is enough for device testing.

1. Open `ios/Runner.xcworkspace` in Xcode.
2. Select the **Runner** target → **Signing & Capabilities** → set **Team** to your account and change the bundle ID to something you own (e.g. `com.yourname.lichess`).
3. Do the same for the extension target (e.g. **LichessWidgetsExtension**), using a matching sub-ID (e.g. `com.yourname.lichess.widget`).
4. Build and run from Xcode — the extension will be signed with your profile and work on your device.

These changes are local only and should not be committed.

### Merging new extensions

When a PR that adds a new extension is ready to merge, a Lichess org member with Apple Developer access needs to:

1. Register the new App ID (for the extension's bundle ID) in the Apple Developer portal.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're using fastlane match for code signing in release mode in this project. It would be nice to update these instructions to take that into account.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, definitely. I added fastlane instructions in daba1a0. They might require some fine-tuning once you create a build and see how fastlane handles the new widget extension. Hopefully it works with no changes 🚀

2. Create a provisioning profile for it.

## Deploying extensions via fastlane

Extensions have their own bundle ID (`org.lichess.mobileV2.<ExtensionName>`) and require a separate provisioning profile managed by `match`. The `Matchfile` and `Fastfile` list all extension bundle IDs alongside the main app.

### Adding a new extension to the fastlane setup

After registering the App ID in the developer portal (see above), run `match` once to create and store the provisioning profile:

```sh
cd ios
bundle exec fastlane match appstore --app_identifier org.lichess.mobileV2.<ExtensionName>
```

This will generate the profile, push it to the certificates repo, and set the correct `PROVISIONING_PROFILE_SPECIFIER` in the Xcode project. After that, `fastlane beta` handles signing for all targets automatically — including in CI.

Also add the new bundle ID to both `app_identifier` arrays in `fastlane/Matchfile` and the `sync_code_signing` call in `fastlane/Fastfile`.
70 changes: 70 additions & 0 deletions ios/LichessWidgets/Blog Feed Widget/BlogFeedFetcher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import FeedKit
import UIKit
import WidgetKit
internal import XMLKit

struct BlogFeedFetcher {
static var nextUpdateDate: Date {
Calendar.current.date(byAdding: .hour, value: 1, to: .now)!
}

private func fetchThumbnail(urlString: String, spec: BlogThumbnailSpec) async -> Data? {
guard let url = URL(string: urlString),
let (data, _) = try? await URLSession.shared.data(from: url),
let source = UIImage(data: data)
else { return nil }
let scale = UITraitCollection.current.displayScale
let size = CGSize(width: spec.width * scale, height: spec.height * scale)
return await source.byPreparingThumbnail(ofSize: size)?.jpegData(compressionQuality: 0.85)
}

func fetchEntry(feed: BlogFeedChoice, username: String?, family: WidgetFamily) async -> BlogFeedEntry {
let (items, error) = await fetchFeed(feed: feed, username: username, family: family)
return BlogFeedEntry(date: .now, feed: feed, username: username, items: items, error: error)
}

private func fetchFeed(feed: BlogFeedChoice,
username: String?,
family: WidgetFamily) async -> (items: [BlogFeedItem], error: String?) {
guard let urlString = feed.feedURL(username: username) else {
return ([], "Enter a username in widget settings")
}
do {
guard case .atom(let atomFeed) = try await Feed(urlString: urlString) else {
return ([], "Unexpected feed format")
}
let thumbSpec = family.thumbnailSpec
let items = await withTaskGroup(of: (Int, BlogFeedItem).self) { group in
for (index, entry) in (atomFeed.entries ?? []).prefix(family.maxItems).enumerated() {
group.addTask {
let thumbData: Data? = if let thumbSpec,
let thumbURL = entry.media?.thumbnails?.first?.attributes?.url {
await fetchThumbnail(urlString: thumbURL, spec: thumbSpec)
} else {
nil
}
let entryURL = entry.links?
.first(where: { $0.attributes?.rel == "alternate" })?
.attributes?.href
?? entry.links?.first?.attributes?.href
return (index, BlogFeedItem(
id: entry.id ?? "\(index)",
title: entry.title ?? "Untitled",
url: entryURL,
publishedDate: entry.published,
author: entry.authors?.first?.name,
thumbnailData: thumbData,
thumbnailImageName: nil
))
}
}
var results: [(Int, BlogFeedItem)] = []
for await result in group { results.append(result) }
return results.sorted { $0.0 < $1.0 }.map(\.1)
}
return (items, nil)
} catch {
return ([], error.localizedDescription)
}
}
}
121 changes: 121 additions & 0 deletions ios/LichessWidgets/Blog Feed Widget/BlogFeedPlaceholder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import Foundation
import WidgetKit

/// Static placeholder data shown in the widget gallery and while a widget loads.
enum BlogFeedPlaceholder {
static func entry(feed: BlogFeedChoice, username: String? = nil, family: WidgetFamily) -> BlogFeedEntry {
BlogFeedEntry(
date: .now,
feed: feed,
username: username,
items: Array(items(for: feed).prefix(family.maxItems)),
error: nil
)
}

private static func items(for feed: BlogFeedChoice) -> [BlogFeedItem] {
switch feed {
case .officialBlog: return officialBlogItems
case .communityBlog: return communityBlogItems
case .userBlog: return userBlogItems
}
}

// MARK: Official Blog

private static let officialBlogItems: [BlogFeedItem] = [
BlogFeedItem(id: "1",
title: "Lichess Mobile App Update",
url: nil,
publishedDate: daysAgo(1),
author: nil,
thumbnailData: nil,
thumbnailImageName: "OfficialBlogPlaceholderImage1"),
BlogFeedItem(id: "2",
title: "Queens' Online Chess Festival",
url: nil,
publishedDate: daysAgo(31),
author: nil,
thumbnailData: nil,
thumbnailImageName: "OfficialBlogPlaceholderImage2"),
BlogFeedItem(id: "3",
title: "Announcing the ChessMood 20/20 Grand Prix 2026",
url: nil,
publishedDate: daysAgo(35),
author: nil,
thumbnailData: nil,
thumbnailImageName: "OfficialBlogPlaceholderImage3"),
BlogFeedItem(id: "4",
title: "Streamer Arenas Announcement — February to July 2026",
url: nil,
publishedDate: daysAgo(39),
author: nil,
thumbnailData: nil,
thumbnailImageName: "OfficialBlogPlaceholderImage4"),
]

// MARK: Community Blog

private static let communityBlogItems: [BlogFeedItem] = [
BlogFeedItem(id: "1",
title: "How To Analyse Your Game Like a 2000-Rated Player",
url: nil,
publishedDate: daysAgo(0),
author: "VihaanRathodBhuj",
thumbnailData: nil,
thumbnailImageName: "CommunityBlogPlaceholderImage1"),
BlogFeedItem(id: "2",
title: "What a Figure Skater Can Teach You About Chess Improvement",
url: nil,
publishedDate: daysAgo(0),
author: "FM MattyDPerrine",
thumbnailData: nil,
thumbnailImageName: "CommunityBlogPlaceholderImage2"),
BlogFeedItem(id: "3", title: "Slow Growth in Chess: The Truth Most People Don't Want to Hear",
url: nil, publishedDate: daysAgo(2),
author: "IM nikhildixit",
thumbnailData: nil,
thumbnailImageName: "CommunityBlogPlaceholderImage3"),
BlogFeedItem(id: "4", title: "Everyone's favorite: A modern Kortchnoi seeking to change his history",
url: nil,
publishedDate: daysAgo(0),
author: "FM Reynold9402",
thumbnailData: nil,
thumbnailImageName: "CommunityBlogPlaceholderImage4"),
]

// MARK: User Blog

private static let userBlogItems: [BlogFeedItem] = [
BlogFeedItem(id: "1",
title: "Did you know Lichess can do this? Opening Explorer and Tablebase",
url: nil,
publishedDate: daysAgo(2),
author: nil,
thumbnailData: nil,
thumbnailImageName: "UserBlogPlaceholderImage1"),
BlogFeedItem(id: "2",
title: "Annotated: Sicilian Accelerated Dragon Deep Dive",
url: nil,
publishedDate: daysAgo(7),
author: nil,
thumbnailData: nil, thumbnailImageName: "UserBlogPlaceholderImage2"),
BlogFeedItem(id: "3",
title: "Tournament Report: City Open 2026",
url: nil,
publishedDate: daysAgo(14),
author: nil,
thumbnailData: nil, thumbnailImageName: "UserBlogPlaceholderImage3"),
BlogFeedItem(id: "4",
title: "Endgame Studies I Recommend",
url: nil,
publishedDate: daysAgo(21),
author: nil,
thumbnailData: nil,
thumbnailImageName: "UserBlogPlaceholderImage4"),
]

private static func daysAgo(_ days: Int) -> Date? {
Calendar.current.date(byAdding: .day, value: -days, to: .now)
}
}
17 changes: 17 additions & 0 deletions ios/LichessWidgets/Blog Feed Widget/CommunityBlogWidget.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import SwiftUI
import WidgetKit

struct CommunityBlogWidget: Widget {
let kind = "CommunityBlogWidget"

var body: some WidgetConfiguration {
StaticConfiguration(kind: kind,
provider: GenericBlogFeedProvider(feed: .communityBlog)) { entry in
BlogFeedWidgetEntryView(entry: entry)
.containerBackground(.background, for: .widget)
}
.configurationDisplayName("Community Blog")
.description("Latest posts from the Lichess community blog.")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}
30 changes: 30 additions & 0 deletions ios/LichessWidgets/Blog Feed Widget/GenericBlogFeedProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import SwiftUI
import WidgetKit

struct GenericBlogFeedProvider: TimelineProvider {
let feed: BlogFeedChoice
private let fetcher = BlogFeedFetcher()

func placeholder(in context: Context) -> BlogFeedEntry {
BlogFeedPlaceholder.entry(feed: feed, family: context.family)
}

func getSnapshot(in context: Context, completion: @escaping (BlogFeedEntry) -> Void) {
if context.isPreview { completion(placeholder(in: context)); return }
Task {
completion(await fetcher.fetchEntry(feed: feed,
username: nil,
family: context.family))
}
}

func getTimeline(in context: Context, completion: @escaping (Timeline<BlogFeedEntry>) -> Void) {
Task {
let entry = await fetcher.fetchEntry(feed: feed,
username: nil,
family: context.family)
let nextUpdate = BlogFeedFetcher.nextUpdateDate
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
}
}
}
25 changes: 25 additions & 0 deletions ios/LichessWidgets/Blog Feed Widget/Model/BlogFeedChoice.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation

enum BlogFeedChoice: String {
case officialBlog
case communityBlog
case userBlog

var displayName: String {
switch self {
case .officialBlog: return "Official Blog"
case .communityBlog: return "Community Blog"
case .userBlog: return "User Blog"
}
}

func feedURL(username: String?) -> String? {
switch self {
case .officialBlog: return "https://lichess.org/@/Lichess/blog.atom"
case .communityBlog: return "https://lichess.org/blog/community.atom"
case .userBlog:
guard let username, !username.isEmpty else { return nil }
return "https://lichess.org/@/\(username)/blog.atom"
}
}
}
17 changes: 17 additions & 0 deletions ios/LichessWidgets/Blog Feed Widget/Model/BlogFeedEntry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import WidgetKit

struct BlogFeedEntry: TimelineEntry {
let date: Date
let feed: BlogFeedChoice
let username: String?
let items: [BlogFeedItem]
let error: String?

/// Display name for the widget header.
var headerTitle: String {
if feed == .userBlog, let username, !username.isEmpty {
return "@\(username)"
}
return feed.displayName
}
}
12 changes: 12 additions & 0 deletions ios/LichessWidgets/Blog Feed Widget/Model/BlogFeedItem.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import Foundation

struct BlogFeedItem: Identifiable {
let id: String
let title: String
let url: String?
let publishedDate: Date?
let author: String?
let thumbnailData: Data?
/// Asset catalog image name, used for static placeholder items only.
let thumbnailImageName: String?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Foundation

struct BlogThumbnailSpec {
let width: CGFloat
let aspectRatio: CGFloat // height = width * aspectRatio
var height: CGFloat { width * aspectRatio }
}
17 changes: 17 additions & 0 deletions ios/LichessWidgets/Blog Feed Widget/OfficialBlogWidget.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import SwiftUI
import WidgetKit

struct OfficialBlogWidget: Widget {
let kind = "OfficialBlogWidget"

var body: some WidgetConfiguration {
StaticConfiguration(kind: kind,
provider: GenericBlogFeedProvider(feed: .officialBlog)) { entry in
BlogFeedWidgetEntryView(entry: entry)
.containerBackground(.background, for: .widget)
}
.configurationDisplayName("Official Blog")
.description("Latest posts from the Lichess official blog.")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}
24 changes: 24 additions & 0 deletions ios/LichessWidgets/Blog Feed Widget/UserBlogFeedProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import WidgetKit

struct UserBlogFeedProvider: AppIntentTimelineProvider {
private let fetcher = BlogFeedFetcher()

func placeholder(in context: Context) -> BlogFeedEntry {
BlogFeedPlaceholder.entry(feed: .userBlog, username: "ChessNoob2009", family: context.family)
}

func snapshot(for configuration: UserBlogFeedIntent, in context: Context) async -> BlogFeedEntry {
if context.isPreview { return placeholder(in: context) }
return await fetcher.fetchEntry(feed: .userBlog,
username: configuration.username,
family: context.family)
}

func timeline(for configuration: UserBlogFeedIntent, in context: Context) async -> Timeline<BlogFeedEntry> {
let entry = await fetcher.fetchEntry(feed: .userBlog,
username: configuration.username,
family: context.family)
let nextUpdate = BlogFeedFetcher.nextUpdateDate
return Timeline(entries: [entry], policy: .after(nextUpdate))
}
}
Loading